All findings · Lucene finding · LFV-046 · cosine-tiny-norm-validation

COSINE vectors whose float32 norm underflows to zero pass validation, then score +Infinity or NaN and break OSQ segments

Lucene rejects the all-zero COSINE vector but accepts {1e-30, 0}, whose float32 norm is also 0 (1e-30 x 1e-30 rounds to 0). The document scores +Infinity or NaN: it takes the top hit, can hide up to 63 other documents and, with OSQ quantization, breaks its segment and merges.

Summary

Priority
Low, rank 311Only a contrived COSINE vector with every component below about 2.6e-23 triggers it; real embeddings never produce one, and upstream treats such vectors as input validation (#15540).
Impact
silent wrong results
Since
9.0.0 (#366, 2021; not a regression)introduced 2021-10-11 · about 5 years
Affected
9.0.0 to 10.5.2 (33 releases) and main
Status
documented
Found by
Z3 floating-point queries (formal)

AI-assisted page: this finding was first signalled by a formal tool's own output and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Lucene computes COSINE norms in float32. A vector whose components are all at most about 2.6e-23 gets a norm of exactly 0, so it scores like the zero vector, but the field only rejects vectors whose components are all exactly 0.

Diagram of the bug: KnnFloatVectorField accepts the COSINE vector {1e-30, 0} because it is finite and not all zeros; in float32, 1e-30 x 1e-30 = 1e-60 rounds to 0, so its squared norm is 0; its COSINE score against {0.6, 0.8} is +Infinity and against {0, 1} is NaN, so it takes the top hit above the identical vector (1.0), a NaN hides its batch of up to 64 vectors, and with the OSQ format the segment returns no hits and forceMerge fails.
From the accepted vector {1e-30, 0} to its float32 norm of 0, and to the +Infinity and NaN scores that follow in search and in the OSQ segment. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index three COSINE vectors with the default codec: exact {0.6, 0.8}, near {0.8, 0.6} and tiny {1e-30, 0}. KnnFloatVectorField accepts tiny: it is finite and not all zeros.
  2. Search for {0.6, 0.8} with k=3. You expect exact first, with score 1.0.
  3. Instead tiny comes first, with score +Infinity. Lucene computes the norm in float32, where 1e-30 x 1e-30 = 1e-60 rounds to 0, so the cosine is 6e-31 / 0.
  4. Against {0, 1} the score is 0 / 0 = NaN, and one NaN makes the search skip its whole batch of up to 64 vectors: a 4-document segment returns 0 hits for {0, 1}, and CheckIndex at level 2 or higher reports the index as broken.
  5. With the opt-in OSQ (scalar-quantized) format, the same vector makes the segment's centroid NaN: kNN returns 0 of 3 documents, and forceMerge throws and closes the IndexWriter.

Who is affected

Every float32 COSINE field. On the default codec one such document scores +Infinity against any query with a positive dot product, tops the hits when reached, and can hide up to 63 others; on opt-in OSQ it poisons its segment and blocks merges. Ordinary embeddings should not produce it; buggy pipelines or untrusted input can.

Mechanism, terms and numbers1,081 words

KnnFloatVectorField rejects a COSINE vector only when every component is exactly 0, but every in-tree Java COSINE scorer and the quantized writer compute the squared norm in float32. For a vector whose components are all at most about 2.6e-23 (2^-75), such as {1e-30, 0}, each square rounds to 0, so the vector is accepted yet has a computed norm of exactly 0, like the zero vector the check is meant to keep out. Its COSINE score is then dot / 0: +Infinity, NaN or 0. On the default codec it takes the top hit above the identical vector (score 1.0), a NaN in a batch of up to 64 scored vectors makes the search skip the whole batch (a 4-document segment returns 0 hits), and CheckIndex at level 2 or higher (its logical integrity checks; the default level 1 checks only checksums) reports an index built only from accepted vectors as broken. On the opt-in Lucene104 scalar-quantized (OSQ) formats it turns the segment's centroid into NaN: every quantized score in the segment is NaN (a 3-document segment returns no hits), and every merge that includes it throws and closes the IndexWriter until the document is deleted. A slightly larger one-component vector has a subnormal, badly rounded norm, so its scores can exceed 1 (1.1103). A tiny query vector returns 0 hits too. The float32 norms date from the commit that added COSINE, so every release since 9.0.0 is affected.

Terms

  • float32 squared norm: x1*x1 + x2*x2 + ... computed in float, as all of Lucene's in-tree Java COSINE code does. The smallest positive float is 2^-149 (about 1.4e-45), so a square below about 7e-46 rounds to 0.
  • Subnormal: a float below Float.MIN_NORMAL (2^-126, about 1.2e-38). It has fewer significant bits, so a norm in that range is badly rounded.
  • OSQ: the opt-in Lucene104 scalar-quantized vector formats (Lucene104HnswScalarQuantizedVectorsFormat, Lucene104ScalarQuantizedVectorsFormat). For COSINE they normalize each vector and quantize it around a per-segment centroid.

The bug

  1. Validation checks exact zeros only. KnnFloatVectorField rejects COSINE vectors with VectorUtil.isZeroVector (KnnFloatVectorField.java:53-56, :146-150, :172-175), true only if every component == 0 (VectorUtil.java:515-523). Columnar addBatch does the same (ColumnValidation.java:364-381). Query targets get only checkFinite (KnnFloatVectorQuery.java:97).
  2. The norms are float32. DefaultVectorUtilSupport.cosine (:79-117) and PanamaVectorUtilSupport.cosine (:177-199) accumulate fma(x, x, norm) in float and return (float) (sum / Math.sqrt((double) norm1 * (double) norm2)); so do the off-heap bulk scorers (MemorySegmentBulkVectorOps, java25, :180-206). (The optional native provider's external lucene_cosineFloat was not checked.) If every |x_i| <= 2^-75, the norm is exactly 0 and the result is sum / 0: +Infinity, -Infinity or NaN. For a one-component vector {x, 0} with 2^-75 < x < 2^-63 the norm is subnormal and badly rounded, up or down, so scores can exceed 1 (1.1103) or fall below the true value (0.947 for {4.735e-23, 0}).
  3. The score transform passes them through. COSINE.compare is normalizeToUnitInterval(cosine(v1, v2)) (VectorSimilarityFunction.java:84-86), which is Math.max((1 + value) / 2, 0) (VectorUtil.java:461-463). The finiteness assert at VectorUtil.java:105 is off in production.
  4. One NaN hides a batch. RandomVectorScorer.bulkScore returns the batch maximum via Math.max, NaN if any score is NaN (RandomVectorScorer.java:47-54). The exhaustive search scores 64 vectors at a time and keeps a batch only if that maximum is > minCompetitiveSimilarity() (Lucene99HnswVectorsReader.java:81, :396, :408), which is false for NaN; HNSW expansion uses the same test (HnswGraphSearcher.java:341-343). At level 2 or higher (CheckIndex.java:1114, :1141; the command-line default is level 1, checksums only), CheckIndex runs kNN searches with stored vectors and fails when one returns no hit (CheckIndex.java:3112-3137).
  5. OSQ centroid. As each document is added, Lucene104ScalarQuantizedVectorsWriter.FieldWriter.addValue calls accumulate (:841-843), which divides the vector by (float) Math.sqrt(dotProduct(v, v)) = 0 and adds the Infinity or NaN to the in-memory centroid sums (:767-780). At flush, computeCentroid (:786-798) averages and normalizes them; l2normalize throws only when the squared norm == 0, false for NaN (DefaultVectorUtilSupport.java:479-498), so a NaN centroid is written with the segment and every quantized score in it is NaN (0 hits in the 3-document repro; the study's 2,001-document HNSW segment returned 1 hit, scored NaN). CheckIndex at level 2 or higher fails on the OSQ-HNSW segment; the flat OSQ format passes, because CheckIndex does not search flat formats (CheckIndex.java:3106-3110).
  6. OSQ merge. The merge re-normalizes each raw COSINE vector (NormalizedFloatVectorValues.java:51, via Lucene104ScalarQuantizedVectorsWriter.java:317-318); for the tiny vector that throws IllegalArgumentException("Cannot normalize a zero-length vector") (DefaultVectorUtilSupport.java:483), and IndexWriter treats this merge exception as tragic and closes (IndexWriter.java:5109-5113; only a MergeAbortedException on a non-external merge is swallowed, :5043-5053). A buffered uncommitted document is lost; a second writer fails the same way; deleting the document lets the merge succeed (executed probe).

The numbers

Main ea57360912ed, assertions off, one segment each:

what you doexpectedgot
new KnnFloatVectorField("v", {1e-30f, 0f}, COSINE)rejected like {0, 0}accepted
COSINE.compare of {0.6, 0.8}, {0, 1} with tiny; {1, 0} with {4.57e-23, 0}in [0, 1]Infinity, NaN, 1.1103277
default codec, docs {0.6, 0.8}, {0.8, 0.6}, tiny; kNN {0.6, 0.8}, k=3exact firsttiny=Infinity exact=1.0 near=0.98
default codec, docs {0, 1}, tiny, {1, 0}, {0.7071, 0.7071}; kNN {0, 1}, k=4{0, 1} first0 hits
49 random unit 64-d docs + 64 x Float.MIN_VALUE; CheckIndex at level 2cleanfailed to search k nearest neighbors
OSQ, docs {1, 0}, {0, 1}, tiny; kNN {0.6, 0.8}, k=33 hits0 hits
OSQ, segments {1, 0}, {0, 1} and tiny; forceMerge(1)succeedsCannot normalize a zero-length vector; writer closed
query side: 3 docs, kNN with target tiny, k=33 hits or an error0 hits
columnar addBatch with a COSINE {1e-30, 0} (main; its exact-zero check shipped in 10.6.0)rejectedaccepted

In the study's larger runs: in one 3,000-document 16-d segment, a document with all components 1e-30 was the top hit, scored +Infinity, for 86 of 200 random queries (91 had a positive dot product with it); one Float.MIN_VALUE document added to a 200-document segment cut exhaustive recall@10 from 1.0 to 0.7 (in a 3,000-document HNSW graph: 0.600 without, 0.603 with, no loss); one document with a single 1e-25 component made all 2,001 quantized scores of its OSQ segment NaN (kNN returned 1 hit, scored NaN).

Exposure and likelihood336 words
  • Who is exposed. Every float32 COSINE field with the default codec (Lucene99 HNSW float), through the public KnnFloatVectorField constructor and default settings; fields that opt into the Lucene104 OSQ formats get the worse effects. Not exposed: byte vectors (integer arithmetic, so the exact-zero check is complete), FLOAT16 fields (the smallest float16 squared, 2^-48, is a normal float32), and DOT_PRODUCT, EUCLIDEAN or MAXIMUM_INNER_PRODUCT fields, which do not divide by the norm.
  • The trigger. Every component at most about 2.6e-23 for +Infinity/NaN, or a norm below about 1e-19 for finite scores above 1. It is not expected from ordinary normalized embeddings (no survey of real models was done). Plausible sources: a buggy upstream pipeline, a computation meant to give zero that leaves rounding residue, an application that adds a tiny epsilon to get past the "zero vector not allowed" error, or input an untrusted user controls.
  • Scale. One document is enough, at any index size. It scores +Infinity for every query whose dot product with it is positive and ranks first whenever the search reaches it: always in exhaustive search; for 86 of the 91 such queries (out of 200) in the verifier's 3,000-document HNSW graph. The NaN hiding affects the exhaustive search of small segments most (batches of 64, so up to 63 other documents); in a 3,000-document HNSW graph the verifier saw no recall loss (recall@10 0.600 without, 0.603 with).
  • What you see. Nothing at index time and nothing at search time: wrong top hits (+Infinity or 1.1103 beat an exact 1.0), missing hits, and, at CheckIndex level 2 or higher, a CheckIndex error on an index built only from accepted input. On OSQ: a segment whose quantized scores are all NaN (no hits in the small repro), and an IndexWriter that closes on the first merge that includes it (buffered uncommitted documents lost); a second writer failed the same way, and deleting the document unblocked the merge.
  • Existing indexes. Validation cannot repair segments already on disk; deleting the document lets the OSQ merge succeed again.

Minimal repro

The test shows that KnnFloatVectorField accepts the non-zero COSINE vector {1e-30, 0}, whose float32 squared norm underflows to 0, and that it then scores +Inf, so a kNN query for {0.6, 0.8} on the default codec ranks it above the identical vector.

TestCosineTinyNormTopHit.java (JUnit test)59 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.document;

import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.VectorSimilarityFunction;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.KnnFloatVectorQuery;
import org.apache.lucene.search.ScoreDoc;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestCosineTinyNormTopHit extends LuceneTestCase {

  public void testIdenticalVectorIsTopHit() throws Exception {
    VectorSimilarityFunction cosine = VectorSimilarityFunction.COSINE;
    Document tiny = new Document();
    try {
      tiny.add(new KnnFloatVectorField("v", new float[] {1e-30f, 0f}, cosine));
    } catch (IllegalArgumentException rejectedLikeTheZeroVector) {
      return;
    }
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        Document exact = new Document();
        exact.add(new KnnFloatVectorField("v", new float[] {0.6f, 0.8f}, cosine));
        w.addDocument(exact);
        w.addDocument(tiny);
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        ScoreDoc top =
            new IndexSearcher(r)
                .search(new KnnFloatVectorQuery("v", new float[] {0.6f, 0.8f}, 2), 2)
                .scoreDocs[0];
        // expected: doc 0 {0.6, 0.8} on top; actual: doc 1 {1e-30, 0} on top, score +Inf
        assertEquals("top hit (score " + top.score + ")", 0, top.doc);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-046/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.document.TestCosineTinyNormTopHit
Expected vs actual
Expected: the identical vector (doc 0) is the top hit, or the tiny vector is rejected like the zero vector. Actual: doc 1 is the top hit with score Infinity (-da: top hit (score Infinity) expected:<0> but was:<1>; -ea: Lucene's own assert Float.isFinite(r) in VectorUtil.cosine fires).

All files of this repro: repro/LFV-046/

More files, environment, full output36 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError
       org.apache.lucene.util.VectorUtil.cosine(VectorUtil.java:105)
       org.apache.lucene.index.VectorSimilarityFunction$3.compare(VectorSimilarityFunction.java:85)
       org.apache.lucene.codecs.hnsw.DefaultFlatVectorScorer$FloatVectorScorer.score(DefaultFlatVectorScorer.java:246)
       org.apache.lucene.util.hnsw.RandomVectorScorer.bulkScore(RandomVectorScorer.java:50)
       org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsReader.search(Lucene99HnswVectorsReader.java:408)
       org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsReader.search(Lucene99HnswVectorsReader.java:324)
       org.apache.lucene.codecs.perfield.PerFieldKnnVectorsFormat$FieldsReader.search(PerFieldKnnVectorsFormat.java:329)
       org.apache.lucene.index.CodecReader.searchNearestVectors(CodecReader.java:294)
       org.apache.lucene.search.KnnFloatVectorQuery.approximateSearch(KnnFloatVectorQuery.java:118)
       [...]

Why it looks unintended

  • COSINE scores are documented to lie in [0, 1], and VectorUtil.cosine asserts finiteness; here they are +Infinity, NaN and 1.1103.
  • The field already rejects the zero vector for COSINE; {1e-30, 0} has the same computed norm, 0.
  • Committers asked to check that a COSINE vector's length is non-zero (#15540), by input validation (#16099).
The full argument210 words
  • The contracts. VectorSimilarityFunction.COSINE (VectorSimilarityFunction.java:76-81): "The similarity score is normalised to assure it is positive." normalizeToUnitInterval (VectorUtil.java:452-459): "Converts a dot product or cosine similarity value to a normalized score in the [0, 1] range ... @return a normalized score between 0 and 1". VectorUtil.cosine asserts Float.isFinite(r) (VectorUtil.java:105).
  • The existing check exists for this reason. KnnFloatVectorField throws "zero vector not allowed with cosine similarity function" (:55, :174; :149 says "... cosine similarity configured in field type"); TestField.testKnnFieldZeroVectors and testKnnFieldSetVectorValueZeroVectors (TestField.java:738-812) assert it. No test covers a non-zero vector whose norm underflows, and no javadoc, CHANGES.txt or MIGRATE.md entry states a minimum magnitude.
  • Upstream intent. On #15540 (open, type:bug) mikemccand asked: "Could we, if VectorSimilarity.COSINE vector distance is being used, validate that the vector's length is non-zero, and throw a clear exception up front, and prevent the vector from being indexed (so index does not become corrupt)?"; benwtrent: "I agree, cosine should be validated." The tiny vector's computed length is zero. On #16099 benwtrent requested changes: "this is a bad idea, adding unnecessary overhead to production paths for something that is data input validation and is obviously incorrect data."
  • Precedent. #12700 / #12727 treated even slightly negative vector scores as a bug.
The devil's-advocate check293 words

The strongest case for "intended" or "won't fix": the input is garbage that ordinary embedding models are not expected to produce; KnnFloatVectorField is marked @lucene.experimental (KnnFloatVectorField.java:38); Lucene's threat model leaves sanitizing input to the application (SECURITY.md line 28); the overflow mirror image, a 1e17 cap on float components (#12373), was closed for "no consensus to impose this limit"; COSINE is documented as the discouraged path ("the preferred way ... is to normalize all vectors to unit length, and instead use DOT_PRODUCT"); normalizeToUnitInterval calls its input "typically" in [-1, 1]; and a maintainer may object to rejecting mathematically non-zero vectors at a Float.MIN_NORMAL threshold.

Why it fails: Lucene already chose to validate COSINE inputs that cannot be scored (#15751, #16098, #16708), and the committers on #15540 asked for exactly "the vector's length is non-zero"; the check implements "all components are 0" instead, which is not what the arithmetic needs. The damage is not confined to the bad document: it hides up to 63 others, makes every quantized score of an OSQ segment NaN, blocks merges and makes CheckIndex (level 2 or higher) report corruption of an index built only from accepted input. #12373 was a fixed component cap for every similarity on the overflow side; this needs a COSINE-only check on the computed norm that costs one comparison for an ordinary vector, and rejecting only a computed norm of exactly 0 is a valid narrower choice. @lucene.experimental allows API changes; it does not set aside the documented score range. "Typically in [-1, 1]" does not cover +Infinity or NaN, and the code asserts finiteness itself. A cross-model refute review rated the intended-behaviour argument weak; the later cross-model fact check rated it moderate, not enough to make the behaviour correct. Verdict: a defect.

Suggested fix

Recommended Reject COSINE vectors whose float32 squared norm would be zero or subnormal (validated)

Reject a COSINE vector whose squared norm is zero or subnormal, not only an all-zero one: a new VectorUtil.isZeroOrSubnormalNorm, used by KnnFloatVectorField and columnar addBatch (+27/-9 lines). The 6 index-side tests pass; 985 nearby tests and the core static checks pass. It does not cover query vectors or repair existing segments.

  public static boolean isZeroOrSubnormalNorm(float[] v) {
    double squaredNorm = 0;
    for (float value : v) {
      if (Math.abs(value) >= 0x1p-63f) {
        return false; // value * value >= Float.MIN_NORMAL on its own
      }
      squaredNorm += (double) value * value;
    }
    return squaredNorm < Float.MIN_NORMAL; // false for NaN
  }
All options and trade-offs4 options · 832 words

Validation is the layer upstream asked for (#15540, #16099). One option was built and validated in a scratch worktree at ea57360912ed (removed afterwards); the others are untested here or were tested by the study as shadow patches.

Reject COSINE vectors whose float32 squared norm would be zero or subnormal (validated) (recommended)

+27/-9 lines in VectorUtil.java, KnnFloatVectorField.java and ColumnValidation.java; git apply --check is clean at ea57360912ed. The new public VectorUtil.isZeroOrSubnormalNorm(float[]) replaces isZeroVector in the three KnnFloatVectorField COSINE checks and the exact-zero test in ColumnValidation.checkFloatVectorValue. The error messages are unchanged; isZeroVector is unchanged.

  public static boolean isZeroOrSubnormalNorm(float[] v) {
    double squaredNorm = 0;
    for (float value : v) {
      if (Math.abs(value) >= 0x1p-63f) {
        return false; // value * value >= Float.MIN_NORMAL on its own
      }
      squaredNorm += (double) value * value;
    }
    return squaredNorm < Float.MIN_NORMAL; // false for NaN
  }

Trade-offs

  • Threshold. Rejecting only a norm of exactly 0 removes +Infinity and NaN but keeps subnormal norms, which still score above 1 ({4.57e-23, 0} vs {1, 0}: 1.1103). < Float.MIN_NORMAL on the double sum guarantees an exact squared norm of at least Float.MIN_NORMAL for every accepted COSINE vector (not quite a normal float32 norm; see Precision). It rejects slightly more than OSQ needs ({2^-74, 0} works on OSQ today).
  • Cost. The loop stops at the first component with |x| >= 2^-63, usually the first one; only vectors that are zero or tiny in every leading component are summed. Off the search path. No benchmark was run.
  • Precision. The sum is in double; the codecs' float32 fma sum rounds each square to a multiple of 2^-149 below 2 * Float.MIN_NORMAL, so it can be lower by up to about half a unit of 2^-149 per component. Executed counterexample: a 1,024-d vector the patch accepts (exact squared norm Float.MIN_NORMAL + 0.0078 x 2^-149) has a float32 squared norm of Float.MIN_NORMAL - 502 x 2^-149 and scores 1.000015 against a unit query in its direction. Small, but not zero; a margin such as < 2 * Float.MIN_NORMAL would close it (not built or tested).
  • API. Adds one public VectorUtil method (ColumnValidation is in another package). TestField and TestColumnBatchVectorColumn check the substrings "zero vector not allowed" and "zero vector at batch doc N", so a rewording that keeps them ("... zero or near-zero vector not allowed ...") needs no test change.
  • Scoring unchanged. VectorUtil.cosine and COSINE.compare called directly with a tiny vector still return +Infinity, NaN or scores above 1; the patched scoring test passes because it skips inputs the field rejects.
  • Limits. Does not repair segments already on disk (the argument of #16100), and does not cover query vectors (option B).

What the checks validated

  • Stock: the 7 repro tests and the new columnar case fail (28 tests with TestColumnBatchVectorColumn, 8 failed), assertions on and off.
  • Patched: 28 tests, 1 failed, assertions on and off; the failure is the query-side test, which this option does not address.
  • Patched, closest suites (org.apache.lucene.document.*, codecs.lucene104.*, codecs.lucene99.*, TestVectorUtil, TestKnnFloatVectorQuery, TestVectorSimilarityValuesSource, TestKnnGraph, TestCheckIndex): 985 tests, 0 failures, 41 skipped, assertions on and off (78 test classes match these filters, counted in the source tree).
  • Patched, broader core suites *Knn*, *Vector*, *Hnsw*, *Quantiz*, assertions on: 1,508 tests, 0 failures, 55 skipped.
  • :lucene:core:check -x test passes (google-java-format, ecjLint, forbiddenApis).
  • The study's variant of the same idea (float32 fma sum, a helper inside KnnFloatVectorField) passed the 6 index-side tests through the harness, and an independent reviewer's own test and TestField's testKnnField* tests.

Also validate COSINE query vectors (untested)

Reject a COSINE query target with a zero or subnormal norm (or return no hits on purpose). The query does not know the field's similarity when it is built, so the check has to run per segment where the FieldInfo is known: in KnnFloatVectorQuery.approximateSearch and createVectorScorer, or once in CodecReader.searchNearestVectors (which misses exact search).

Trade-offs

Also changes today's answer for an exact-zero query {0, 0} (0 hits on the default codec; an IllegalArgumentException from the OSQ scorer). Query-time zero vectors were mentioned on #15540, so this part is partly known and needs a decision upstream.

Make the scoring and quantization code robust instead (not recommended)

Recompute in double when a float32 norm is below Float.MIN_NORMAL in cosine, guard the zero divisor in OSQ accumulate, and normalize in double in l2normalize.

Trade-offs

The study built this as three shadow patches: it fixed the default-provider runs but 0 of 5 index-side tests with the Panama provider, because every vector provider (default, Panama, off-heap bulk scorers, native) needs its own patch. It touches hot paths, which upstream rejected for the exact-zero case (#16099).

Make the batch maximum NaN-safe (hardening, untested)

Ignore NaN when RandomVectorScorer.bulkScore and the bulk scorers compute the batch maximum, so one NaN cannot hide 63 other documents.

Trade-offs

Limits the blast radius on the default codec only: the bad document still scores +Infinity or NaN, and OSQ is unaffected.

Downstream note

The only gates on vector values are the exact-zero check and checkFinite, so an application that indexes caller-supplied embeddings in a COSINE field accepts such a vector today; in a test, one such document became the top hit of most queries whose dot product with it was positive.

How it was found

Z3 floating-point queries on the COSINE and quantization code refuted the claim that every vector the field accepts has a positive float32 norm, with a witness in 10.7 s. Independent AI verifiers then reproduced it end to end, one on the default codec and one on OSQ.

The method in full193 words

Method: SMT solving with Z3's floating-point theory (the study's "formal" tier). 2026-10-22 study, run 3, experiment "quantization-smt" (main 7cecf79523).

  • The proof obligation "accumulate divides by a positive divisor for every vector KnnFloatVectorField accepts" was refuted, with a witness in 10.7 s; "VectorUtil.cosine is finite for non-zero finite inputs" was refuted too; Z3 proved that no x > 2^-75 has fl(x*x) == 0, which gives the exact threshold.
  • The default-codec effect was found as a side effect of the OSQ probe; the OSQ case came from chaining preconditions (the field's checks, then accumulate).
  • AI verifiers wrote their own JUnit repros without reading the finder's tests: one for the default codec (12 tests, including the top-hit rate and CheckIndex), one for OSQ (10 tests, including the merge and a threshold scan over every float in [2^-80, 2^-60]). Both reproduced on main and 10.5.2. A follow-up merged the two into one root cause with a fix matrix and found the query side.
  • This hunt re-ran it on ea57360912ed, ran a cross-model refute review (OpenAI GPT-5.6), traced its history across 33 releases, searched for prior reports and validated a fix.

Prior reports, history and checks

Prior reports

Not reported as such: 272 queries across GitHub, commits, mailing lists, the JIRA archive, downstream projects and the web (2026-10-26). Open #15540 covers the same family for exact-zero vectors, and its fix is the very check this input passes, so a follow-up there may fit better than a new issue.

Searches5 searches

No issue, PR, commit, mailing-list thread, JIRA entry or downstream report describes a non-zero COSINE vector whose float32 norm underflows, the +Infinity or above-1 scores it gets, the NaN batch maximum, or the OSQ NaN centroid it causes. The overlap is with exact-zero vectors: open #15540 and its fix #15751 (extended by #16098 and #16708), the closed OSQ zero-vector PR #16100, and query-time zero vectors mentioned on #15540. The mirror image, float32 overflow of the norm, was discussed and a component cap declined (#12281, #12373). The prior-report follow-up therefore rates it "partially known".

Downstream precedent: Elasticsearch's dense-vector mapper computes the squared magnitude in float32 and rejects COSINE when its square root is 0, so it rejects the underflow-to-zero case but not subnormal norms (read, not run).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, plus Elasticsearch, OpenSearch and Solr)2026-10-26117 GraphQL phrasings (titles, bodies, comments), e.g. cosine underflow, subnormal, denormal, tiny vector, near-zero, small norm, zero magnitude, cosine NaN/Infinity, isZeroVector, "Cannot normalize a zero-length vector", "failed to search k nearest neighbors", Float.MIN_VALUE, MIN_NORMAL; plus the scripted prior-report search's GitHub queriesno report of the underflow case; #15540, #15751, #16098, #16708/#16732, #16099, #16100, #15621, #12281, #12373 (exact zeros or overflow)
Items read in full2026-10-2656 issues and PRs with comments and reviewsnone of the 10 comments on #15540 mentions tiny or underflowing vectors; benwtrent on #16099: fix it by input validation
Open PRs2026-10-26the changed files of all 413 open apache/lucene PRs; the diffs of the 13 relevant onesnone changes the validation to a computed norm or guards the OSQ zero divisor
Mailing lists and JIRA archive2026-10-26100 queries over dev@, java-user@, issues@ and commits@; 14 JIRA-archive queries; SOLR-17736 and SOLR-16977 readno report; a 2023 dev@ thread on vector math precision (overflow only)
Commits and web2026-10-26a commit-message grep of the full history; 14 web queriesno fix; a related report in another engine (Qdrant), different mechanism

History

Latent since COSINE was added (#366, merged 2021-10-11), so not a regression: all 33 releases from 9.0.0 to 10.5.2 and main give the same wrong scores. The 0-hit searches and the CheckIndex failure start in 10.4.0; the quantized merge failure in 10.2.0.

Versions tested7 rows

Introduced by #366, "LUCENE-10146: Add VectorSimilarityFunction.COSINE" (jtibshirani), merged 2021-10-11 as f4861159c3c: cosine with float32 squared norms, while the vector field checked no values at all. Later checks never covered this input: #1076 (9.4.0, encoding), #12281 (9.7.0, null, empty and non-finite), #15751 and #16098 (10.5.0, exact zeros), #16708 (exact zeros in addBatch; on unreleased main, and as backport #16732 on branch_10x/branch_10_6, released in 10.6.0). 10.4.0, before the zero check, fails the same 7 tests.

The query-side symptoms changed over time: 9.0.0-9.2.0 and 9.9.0-10.0.0 returned a hit with doc ID 2147483647 in a 3- or 4-document index; 9.3.0-9.8.0 and 10.1.0 returned 2 of 3 documents scored +Infinity for the tiny query; 10.2.0-10.3.2 ranked the NaN-scored tiny document first; from 10.4.0 both queries return 0 hits and CheckIndex fails, which fits #15021 (bulk scorers keep a batch only if its maximum beats the threshold; read, not isolated by a run).

Versions and branches executed, with results
Version or commitVerdictResult
8.11.4 no vector APIthe repro does not compile
9.0.0 first affectedscores +Infinity/NaN/1.1103 and the tiny top hit; the tiny and orthogonal queries return doc ID 2147483647
9.1.0 to 9.8.0, 9.9.0 to 10.1.0 (22 releases) affectedthe same scores and top hit; query symptoms vary (invalid doc ID, or 2 of 3 hits scored +Infinity)
10.2.0 to 10.3.2 (6 releases) affectedplus the merge failure with the binary-quantized format (its segment still returns its hits; their scores were not checked); the NaN-scored tiny doc ranks first
10.4.0, 10.5.0, 10.5.1, 10.5.2 affectedall 7 tests fail: 0 hits, CheckIndex failure (level 2), OSQ 0 hits and merge failure
main ea57360912ed affectedall 7 fail through gradle and the harness, assertions on and off, Panama and scalar providers
main + the fix fix Athe 6 index-side tests pass; the query-side test still fails (not covered)

Independent checks

Independent study verifiers and a cross-model refute review (OpenAI GPT-5.6) say real, each with its own test; the doc's test fails 7 of 7 on main with assertions on and off and with both vector providers, and a small-norm control passes 7 of 7.

Checks and verdicts472 words

The study's verifiers ran on main 7cecf79523 and 10.5.2; the hunt's checks ran on ea57360912ed.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier, default codec new_bug, reproducedOwn 12-test JUnit, 6 configurations (assertions on and off, Panama and scalar, mmap): +Infinity top hit, 86 of 200 queries in a 3,000-doc graph, NaN orthogonal query with 0 hits, recall 1.0 to 0.7, CheckIndex failure, scores above 1.
Study verifier, OSQ new_bug, reproducedOwn 10-test JUnit on main and 10.5.2: NaN centroid, all scores NaN, 0 hits, tragic merge; deleting the document unblocks the merge; threshold scan: NaN or Infinity exactly for x <= 2^-75. Its "CheckIndex clean" result ran CheckIndex with no level set, which skips the vector checks; at level 2 or higher the OSQ-HNSW segment fails ("failed to search k nearest neighbors") and only the flat OSQ format passes, as CheckIndex does not search flat formats.
Study follow-up: one root cause merged #5 and #7; query side foundFix matrix: validation removes every index-side symptom of both, in all modes; local robustness fixes need a patch per vector provider; a tiny query gives 0 hits (default) or an exception (OSQ).
Candidate re-run on ea57360912ed real7 of 7 fail on main (assertions on and off, Panama and scalar), 10.5.2 and 10.4.0; the 1e-10 control passes 7 of 7; the study's validation fix passes the 6 index-side tests.
Refute review (OpenAI GPT-5.6) real, high confidenceIts own contract test (2 tests: 1 fails with +Infinity, the other is its 1e-10 control and passes) gives the same result with assertions on and off and with the scalar provider; its reject-or-score test fails unpatched (assertions off) and passes with the validation fix, as do TestField's testKnnField* tests. Intended-behaviour argument: weak.
History and fix validation 33 releases affected; fix validated37 history runs; this doc's test through gradle on stock and patched main, the closest suites (985 tests) and broader vector suites (1,508 tests) with the fix, and the core static checks.
Fact check of this doc (independent AI tool) real; 7 text errors, fixedRe-ran the gradle command (7 of 7 fail, assertions on and off), the fix (28 tests, 1 failure: the query side) and probes; found 7 text errors (CheckIndex level, history counts, overclaims), all fixed.
Cross-model fact check (OpenAI GPT-5.6) real; 9 text errors, fixedRead-only review: the bug is real and well evidenced. Its main finding, a 1,024-d vector the patch accepts whose float32 norm is still subnormal, was executed (score 1.000015) and is now stated in the fix trade-offs.
Diagram check 2 label errors, fixed and re-renderedEvery on-screen value matched a probe re-run; the step-5 heading (when accumulate runs) and one summary-card bullet (the 2^-63 bound holds for {x, 0} only) were corrected.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes653 words

Finding record

LFV-046, snapshot of 2026-11-06

Registry title
COSINE vectors whose float32 squared norm underflows pass validation and give Inf/NaN scores or poison OSQ segments
Area
codecs / vectors
Priority
Low, rank 311: Only a contrived COSINE vector with every component below about 2.6e-23 triggers it; real embeddings never produce one, and upstream treats such vectors as input validation (#15540).
Impact class
silent wrong results
Impact note
Default codec (Lucene99 HNSW float) and the opt-in Lucene104 OSQ format with COSINE; needs a pathological vector (all components <= about 2.6e-23), including tiny query vectors; released.
Summary
You index, or query with, a non-zero COSINE float vector whose float32 squared norm is 0 or subnormal. You expect it to be rejected like an all-zero vector or to score sensibly. Instead it is accepted: on the default codec it scores +Inf/NaN, a NaN batch maximum hides 64 neighbouring documents and CheckIndex falsely fails; on OSQ one such vector makes the segment centroid NaN, so kNN returns 0 hits and every merge fails.
Introduced
#366 (f4861159c3c), which added COSINE: latent, not a regression [exec] (2021-10-11)
First affected version
9.0.0 [exec]
Affected versions
9.0.0 to 10.5.2 (33 releases) and main [exec]; the 0-hit searches and the CheckIndex failure from 10.4.0, the quantized merge failure from 10.2.0
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
Found by
formal: Z3
Upstream and prior reports
none found for the underflow variant (study searches 2026-10-22); same family as open #15540 (exact-zero vectors, fixed by #15751); the study's report §7 recommends a follow-up on #15540
Lucene commit tested
main ea57360912ed (commit of 2026-10-24, tested 2026-10-26) through gradle and through a standalone JUnit harness, assertions on and off, with the Panama and the scalar vector providers; the Maven Central jars of 10.5.2 and 10.4.0; all 33 releases from 9.0.0 to 10.5.2 with adapted copies of the test (8.11.4 has no vector API); a small-norm control on main; the fix in a scratch worktree at ea57360912ed (the test, the closest suites with assertions on and off, broader vector suites and :lucene:core:check -x test), removed afterwards. The study's verifiers ran on main 7cecf79523 and 10.5.2. Between 7cecf79523 and ea57360912ed the validation, cosine, OSQ writer, bulk-score, HNSW-search, CheckIndex and IndexWriter files are unchanged; the only change on the vector read path, #16883, adds DataAccessHint overloads to the vector readers and does not touch scoring.

Lucene and upstream sources

Finding record
  • SECURITY.md:28 (threat model)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/KnnFloatVectorField.java:53-56, 146-150, 172-175
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:105, 206-207, 461-463, 515-523; lucene/core/src/java25/.../MemorySegmentBulkVectorOps.java:180-206; NativeVectorUtilSupport.java:403-407
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/column/ColumnValidation.java:364-381
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/KnnFloatVectorQuery.java:97
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/internal/vectorization/DefaultVectorUtilSupport.java:79-117, 479-498; lucene/core/src/java25/.../PanamaVectorUtilSupport.java:177-199, 1617-1621
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/VectorSimilarityFunction.java:76-86
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/hnsw/RandomVectorScorer.java:47-54; util/hnsw/HnswGraphSearcher.java:341-343
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:81, 385-412; index/CheckIndex.java:1114, 1141, 3106-3110, 3112-3137, 4742-4754, 4837
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsWriter.java:146, 317-318, 767-798, 841-843; codecs/lucene104/NormalizedFloatVectorValues.java:49-53; index/IndexWriter.java:5030-5055, 5109-5113
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/VectorSimilarityFunction.java (DOT_PRODUCT, EUCLIDEAN, MAXIMUM_INNER_PRODUCT compare: no norm division)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/VectorSimilarityFunction.java:76-81; util/VectorUtil.java:105, 452-463
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/document/KnnFloatVectorField.java:38, 55, 149, 174; lucene/core/src/test/org/apache/lucene/document/TestField.java:738-812
  • Lucene main ea57360912ed: lucene/CHANGES.txt:881 (GITHUB#15751), :1192 (GITHUB#16098), :519-520 (GITHUB#16708)
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/KnnFloatVectorQuery.java:97-120; index/CodecReader.java:276-288

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

The diagrams (bug.png) were made separately for this doc from an executed probe on main ea57360912ed, checked by an independent diagram check and re-rendered after its two label fixes. The alt text describes the bug at the level of the steps above.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.