All findings · Lucene finding · LFV-064 · osq-score-overflow-nan

OSQ quantized vector scores become NaN when a large component overflows the correction terms

In the opt-in OSQ vector formats, a vector with one large but finite component (-1e19 in 16 dims) scores NaN against itself and the kNN query finds nothing; the float format gives 1.0. Four float32 products in the score overflow to +/-Infinity and cancel to NaN (10.4.0 to 10.6.0, main).

Summary

Priority
Low, rank 571Moved down from Medium: in the opt-in OSQ format with MIP or EUCLIDEAN, one vector component around 1e17-1e19 (for example a byte-swapped vector) silently makes all quantized scores NaN and returns 0 hits, but real embeddings never get there, so only corrupt or crafted vectors reach it.
Impact
silent wrong results
Since
10.4.0 (#15169, 2025-09-17)introduced 2025-09-17 · about 1 year
Affected
10.4.0 to 10.6.0 and main; opt-in OSQ formats only
Status
documented
Found by
a Z3 floating-point query on a model of the scorer, then a JUnit

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's opt-in OSQ vector formats store each vector as small integer codes plus a few float "correction terms", and rebuild the score from them at search time.

Background: how OSQ scores a vector

  1. At flush, OSQ subtracts the segment's centroid from each vector and maps every component into an interval [a, b] with 8-bit codes (the default). Per vector it stores a, b, the sum of the codes and, for EUCLIDEAN, the squared norm of the centred vector.
  2. A query is coded the same way. The score is rebuilt from the integer dot product of the two code arrays and four float32 products of the correction terms; their sum estimates the dot product of the centred vectors.
  3. Exhaustive kNN scores vectors in batches of 64 and keeps a batch only if its best score beats the current threshold.
Summary card of the bug: two 16-dim vectors in an OSQ field, doc 0 = (-1e19, 0, ..., 0); the four float32 terms of the score estimate become Infinity, -Infinity, -Infinity and Infinity and sum to NaN; the 64-vector batch with the NaN is dropped; expected doc 0 with score 1.0, actual NaN and 0 hits.
Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index two 16-dim EUCLIDEAN vectors with the opt-in Lucene104ScalarQuantizedVectorsFormat: doc 0 = (-1e19, 0, ..., 0), doc 1 = all zeros. Every component is finite, so KnnFloatVectorField accepts both.
  2. Search with doc 0's own vector, KnnFloatVectorQuery(k=1). You expect doc 0 with score 1.0, which is what the float format returns.
  3. Instead doc 0's quantized score is NaN. OSQ rebuilds the score from four float32 products of stored correction terms: +4.0e38, -3.75e38, -3.75e38 and +3.75e38. Each is above Float.MAX_VALUE (3.4e38), so they become +Inf, -Inf, -Inf and +Inf, and their sum, about 2.5e37 if computed exactly, comes out NaN.
  4. NaN slips past the score bounds (Math.max(NaN, 0f) is NaN). Exhaustive search keeps a 64-vector batch only if its best score beats the threshold, and NaN > x is false: the query returns 0 hits, losing doc 1 too. No error, even with assertions on.
  5. One outlier can hit a whole segment. A large positive outlier pulls the centroid up, so the other documents and an ordinary query all sit far below it: at 1024 dims, with 100 documents and one component of +1e20, 99 of 100 EUCLIDEAN scores were NaN and the query found nothing. With MAXIMUM_INNER_PRODUCT one component of 6e21 made all 301 scores of a segment NaN (assertions off).

Who is affected

Users of the opt-in, @lucene.experimental OSQ formats with EUCLIDEAN or MAXIMUM_INNER_PRODUCT whose vectors and queries both sit about sqrt(3.4e38 / dim) or more below the segment centroid (6e17 at 1024 dims): far beyond ordinary embeddings, but corrupt or synthetic vectors get there. A NaN silently hides up to 63 neighbours; one outlier can empty a segment. Priority low.

Mechanism, terms and numbers953 words

Lucene's opt-in OSQ formats (Lucene104ScalarQuantizedVectorsFormat and Lucene104HnswScalarQuantizedVectorsFormat) rebuild each quantized score in float32 from four products of per-vector correction terms (Lucene104ScalarQuantizedVectorScorer.java:290-291). For a vector with one large centred component the products are each larger than Float.MAX_VALUE while their exact sum is not: they overflow to +Infinity and -Infinity and the sum is NaN. In the repro (16 dims, doc 0 = (-1e19, 0, ..., 0)) doc 0 scores NaN against its own vector and KnnFloatVectorQuery returns 0 hits; the float format returns doc 0 with score 1.0. NaN passes the bounds added by #15411, and exhaustive kNN drops the whole 64-vector batch whose maximum is NaN. One large positive outlier pulls the centroid so far that the other documents and an ordinary query all score NaN (99 of 100 at 1024 dims). With MAXIMUM_INNER_PRODUCT the per-segment centroid terms themselves can overflow, and one outlier made every score of a 301-document segment NaN (assertions off). Doing the arithmetic in double fixes the EUCLIDEAN case. Every release with these formats, 10.4.0 to 10.6.0, is affected. The inputs are extreme (the stored vector and the query both about 6e17 or more below the segment centroid at 1024 dims), so the priority is low.

The mechanism (main 645d4fe14a20)

  1. Accepted input. KnnFloatVectorField checks only that the vector is non-null, non-empty and finite (non-zero for COSINE): KnnFloatVectorField.java:42-61, :105 via VectorUtil.checkFinite (VectorUtil.java:485-492). The query gets the same check (KnnFloatVectorQuery.java:97).
  2. What OSQ stores. With doc 0 = (-1e19, 0, ..., 0) and doc 1 = 0, the centroid is (-5e18, 0, ...). OptimizedScalarQuantizer.scalarQuantize (OptimizedScalarQuantizer.java:197-236) centres doc 0 to (-5e18, 0, ...), picks the interval [-5e18, 0] and stores the code sum 3825 (15 components at code 255) and the squared norm 2.5e37. All finite.
  3. The estimate (Lucene104ScalarQuantizedVectorScorer.java:283-291), all float32:
   float score =
       ax * ay * targetVectors.dimension() + ay * lx * x1 + ax * ly * y1 + lx * ly * qcDist;

For doc 0 against its own vector (ax = ay = -5e18, lx = ly = 5e18 / 255, x1 = y1 = 3825, qcDist = 975,375):

termexactfloat32
ax*ay*dim+4.0e38+Infinity
ay*lx*x1-3.75e38-Infinity
ax*ly*y1-3.75e38-Infinity
lx*ly*qcDist+3.75e38+Infinity
sum+2.5e37NaN
  1. The bounds pass NaN. EUCLIDEAN computes qAdd + xAdd - 2*score and returns 1 / (1f + Math.max(score, 0f)) (:294-301); computed in double from the same float32 inputs the distance is about -1.4e30 (rounding residue), which Math.max clamps to 0, so the score would be 1.0 (fix A does this: doc 0 scores 1.0); but Math.max(NaN, 0f) is NaN. MIP/COSINE add qAdd + xAdd - centroidDP and return scaleMaxInnerProductScore or Math.clamp(score, -1, 1) (:305-315), both NaN for NaN (VectorUtil.java:444-449).
  2. One NaN drops a batch. exhaustiveBulkScore collects a 64-ordinal batch (Lucene104ScalarQuantizedVectorsReader.java:87) only if scorer.bulkScore(...) > knnCollector.minCompetitiveSimilarity() (:390, :402). The default bulkScore returns the Math.max over the batch (RandomVectorScorer.java:47-54), NaN here, and NaN > x is false. The HNSW reader has the same test in its exhaustive branch (Lucene99HnswVectorsReader.java:396, :408), and the HNSW runs checked (2 and 100 documents) went through it: minCompetitiveSimilarity() was called only from lines 396 and 408, never from the graph searcher. Graph search tests each batch of neighbours the same way (HnswGraphSearcher.java:341-343); no run here exercised it with a NaN.
  3. The MIP leg. centroidDP = VectorUtil.dotProduct(c, c) (Lucene104ScalarQuantizedVectorsWriter.java:173-174) and the per-vector correction x.c (OptimizedScalarQuantizer.java:203, :235) are float32 and stored. Once c.c exceeds Float.MAX_VALUE it is stored as +Infinity; x.c is signed and becomes +Infinity or -Infinity once |x.c| exceeds it (|x||c| above Float.MAX_VALUE is needed for that, not enough). The score is then Infinity - Infinity. One outlier can do that for the whole segment. With -ea the writer's dotProduct call fails its finiteness assertion at flush instead.

Conditions (executed)

  • EUCLIDEAN: the stored vector and the query must both sit far below the segment centroid, each with a centred component of about -sqrt(Float.MAX_VALUE / dim) or less (the same coordinate or different ones): the four products grow with the lower ends ax, ay of the two quantization intervals, which are large and negative only then. In the runs a vector far above the centroid scored finitely. The study measured the onset at spikes of 5.95e17 at 1024 dims, 2.4e18 at 64 dims and 1.35e19 at 2 dims (a 32-document construction; the centred component is about 31/32 of the spike).
  • Which document is the outlier does not matter. A negative outlier queried with itself scores NaN (the repro). A positive outlier moves the centroid (the mean, Lucene104ScalarQuantizedVectorsWriter.java:815) up by spike / n, so the other documents and an ordinary query sit far below it: with 16 dims, doc 0 = (+1e19, 0, ..., 0), doc 1 = 0 and a zero query, doc 1 scores NaN and 0 hits come back (the float format: doc 1 at 1.0); with 1024 dims, 100 Gaussian documents and doc 0 [0] = +1e20, 99 of 100 scores are NaN for doc 7's vector and 0 hits come back, flat and HNSW, assertions on and off (the float format, assertions off: doc 7 at 1.0). With the spikes in different coordinates (doc 0 = (-1e19, 0, ...), doc 1 = (0, -1e19, 0, ...), doc 2 = 0, query = doc 1) doc 0 and doc 1 score NaN.
  • The spike needed grows with the segment size n: +5e19 in the 100-document case gave no NaN.
  • At 1024 dims with 100 documents and doc 0 [0] = -1e18, both OSQ formats returned top-10 = docs 64-73 for doc 0's vector; the float format returns docs 0-9 with doc 0 at 1.0.
Exposure and likelihood244 words
  • Who is exposed. Only the opt-in Lucene104 scalar-quantized formats, flat and HNSW; all five ScalarEncoding values fail the same way. The default codec uses the float Lucene99HnswVectorsFormat (Lucene104Codec.java:123). DOT_PRODUCT needs unit vectors and COSINE normalizes first, so neither reaches these magnitudes.
  • EUCLIDEAN. The stored vector and the query both sit far below the segment centroid (a centred component of about -sqrt(Float.MAX_VALUE / dim) or less in each). That happens to a negative outlier queried with itself, and, when one large positive outlier pulls the centroid up, to every other document and an ordinary query. Every float-path quantity can still be finite. No assertion fires in the OSQ path, even with -ea.
  • MAXIMUM_INNER_PRODUCT. c.c or |x.c| above Float.MAX_VALUE. One outlier is enough for the whole segment: an outlier with a component of 6e21, added to 300 Gaussian documents, made 301 of 301 quantized scores NaN, and an ordinary query returned 0 hits (assertions off). With -ea the flush fails on an assertion instead.
  • Effect. Silent wrong results: NaN scores, and in exhaustive search each NaN vector and up to 63 neighbours in its batch are missing. With a positive outlier that is nearly the whole segment: 1024 dims, 100 documents, one component of +1e20, 99 of 100 scores NaN and 0 hits.
  • Likelihood. Rare: the values are many orders of magnitude beyond ordinary embedding values, so in practice it takes corrupt or synthetic vectors (an assessment, not measured). Priority low.

Minimal repro

One JUnit test method in lucene/core, public API plus two test-framework helpers (LuceneTestCase.getOnlyLeafReader, TestUtil.alwaysKnnVectorsFormat): two 16-dim EUCLIDEAN documents in the flat OSQ format, ByteBuffersDirectory, one segment, one thread, no random() calls (the framework still randomizes locale and time zone). Same result with assertions on and off.

TestScalarQuantizedEuclideanOverflow.java (lucene/core, one test)62 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.codecs.lucene104;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloatVectorField;
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.TopDocs;
import org.apache.lucene.search.VectorScorer;
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 TestScalarQuantizedEuclideanOverflow extends LuceneTestCase {

  public void testLargeComponentGivesFiniteScore() throws Exception {
    float[] spike = new float[16];
    spike[0] = -1e19f;
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc =
          new IndexWriterConfig()
              .setCodec(TestUtil.alwaysKnnVectorsFormat(new Lucene104ScalarQuantizedVectorsFormat()));
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (float[] vector : new float[][] {spike, new float[16]}) {
          Document doc = new Document();
          doc.add(new KnnFloatVectorField("v", vector, VectorSimilarityFunction.EUCLIDEAN));
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        VectorScorer scorer = getOnlyLeafReader(reader).getFloatVectorValues("v").scorer(spike);
        assertEquals(0, scorer.iterator().nextDoc());
        float score = scorer.score();
        TopDocs hits = new IndexSearcher(reader).search(new KnnFloatVectorQuery("v", spike, 1), 1);
        // expected: doc 0 found with a finite score (1.0 with the float format); actual: NaN and 0 hits
        assertTrue(
            "expected doc 0 found with a finite score, but its score is " + score + " and the query found " + hits.scoreDocs.length + " hits",
            Float.isFinite(score) && hits.scoreDocs.length == 1 && hits.scoreDocs[0].doc == 0);
      }
    }
  }
}
Command
cp $REPRO/TestScalarQuantizedEuclideanOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene104/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene104.TestScalarQuantizedEuclideanOverflow -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false
Expected
Doc 0 found with a finite score (1.0 with the float format).
Observed
Score NaN and 0 hits: expected doc 0 found with a finite score, but its score is NaN and the query found 0 hits, assertions on and off.

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

More files, environment, full output229 words

The test sets the codec with TestUtil.alwaysKnnVectorsFormat(new Lucene104ScalarQuantizedVectorsFormat()) (default 8-bit encoding). Doc 0 = (-1e19, 0, ..., 0), doc 1 = all zeros; the query is doc 0's own vector. It asserts that FloatVectorValues.scorer(q).score() for doc 0 is finite and that KnnFloatVectorQuery(k=1) returns doc 0. With the float Lucene99HnswVectorsFormat the same test passes (score 1.0), and with a spike of -1e10 instead of -1e19 the OSQ test passes too. It also fails as a plain main() with a Lucene104Codec subclass and no test framework, and with both the Panama and the scalar vector provider. ./gradlew tidy rewraps the three lines longer than 100 columns (file lines 42, 55 and 57); nothing else changes.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.4.0, 10.5.2 and 10.6.0 release jars
jdkOpenJDK 25.0.3
osLinux aarch64 (IEEE float32 arithmetic; Panama and scalar vector providers both fail)
Command (environment)
cp $REPRO/TestScalarQuantizedEuclideanOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene104/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene104.TestScalarQuantizedEuclideanOverflow -Ptests.seed=DEADBEEF -Ptests.asserts=true
# also with -Ptests.asserts=false

Observed output

TestScalarQuantizedEuclideanOverflow > testLargeComponentGivesFiniteScore FAILED
    java.lang.AssertionError: expected doc 0 found with a finite score, but its score is NaN and the query found 0 hits
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:658935A03473404D]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.assertTrue(Assert.java:42)
        at org.apache.lucene.codecs.lucene104.TestScalarQuantizedEuclideanOverflow.testLargeComponentGivesFiniteScore(TestScalarQuantizedEuclideanOverflow.java:56)
:lucene:core:test (FAILURE): 1 test, 1  failure

# the same with -Ptests.asserts=false, and on the 10.4.0, 10.5.2 and 10.6.0 release jars (-ea and -da)
# with the recommended fix: BUILD SUCCESSFUL, 1 test, 0 failures

Why it looks unintended

  • The input is legal: the field and the query check only that components are finite, and #12373, a 1e17 component cap, was declined.
  • EUCLIDEAN scores are documented as "in (0, 1]"; the float format returns 1.0 for the same vectors.
  • The scorer already bounds out-of-range quantized scores for extreme inputs (#15411); NaN slips through those bounds.
The full argument254 words
  • Legal input. KnnFloatVectorField and KnnFloatVectorQuery check only finiteness; no magnitude limit is documented in the field, VectorSimilarityFunction, the OSQ format or the quantizer. MAXIMUM_INNER_PRODUCT "does not require normalization of the inputs. Should be used when the embedding vectors store useful information within the vector magnitude" (VectorSimilarityFunction.java:99-103). #12373 proposed rejecting components of 1e17 and more and was closed unmerged: "there's no consensus to impose this limit" (msokolov).
  • The documented range. EUCLIDEAN maps the squared distance to a "similarity score in (0, 1]" (VectorUtil.java:465-476, used by VectorSimilarityFunction.java:35-39); the float format returns 1.0 for the same vectors. The float path has its own limit: its squared distance overflows once two vectors differ by more than about 1.8e19 in a coordinate, and then it scores 0 with assertions off and trips its finiteness assertion with -ea (VectorUtil.java:135). The repro stays below that (largest squared distance 1e38).
  • Maintainers bound quantized scores on purpose. #15411 (mccullocht, merged 2025-11-07) added Math.max and Math.clamp at :301 and :314, saying out-of-bounds values are "more likely when the inputs are 'extreme' in the sense that they are very far from the segment-level centroid". Java's Math.max and Math.clamp return NaN for NaN.
  • An exact self-match. In #12281 uschindler wrote "A query searching for the exact same vector (cosine=1) should return a valid score". The repro is that case for EUCLIDEAN.
  • The older quantizer guards against it. ScalarQuantizer throws "Scalar quantizer does not support infinite or NaN values" (ScalarQuantizer.java:92-96, #13366); OSQ has no such guard.
The devil's-advocate check176 words

Strongest case: the values are absurd for any embedding model. On #15408 benwtrent called that issue's extreme test values "pretty...extreme". On #15784 msokolov, asking whether a graph-search collector should also account for NaN, answered "no -- that's a matter for checking valid inputs". The OSQ formats and the scorer are @lucene.experimental (Lucene104ScalarQuantizedVectorsFormat.java:97, Lucene104HnswScalarQuantizedVectorsFormat.java:47, Lucene104ScalarQuantizedVectorScorer.java:39) and lossy by design. So maintainers may answer "outside the practical range".

Why it fails: the input checks Lucene has (the "valid inputs" check) accept these vectors, and the one proposal to reject them, #12373, was declined (it predates OSQ, so it says nothing about OSQ itself). The float format handles the tested inputs. The quantized path does not fail loudly: it returns NaN silently, with no assertion for EUCLIDEAN even with -ea, and takes other documents with it, up to 63 of a batch or, with a positive outlier, nearly the whole segment. A fix in the scorer is small and needs no format change. Verdict of every checker that ran code: a defect, low priority.

Suggested fix

Recommended Compute the score in double inside quantizedScore

Do the arithmetic in quantizedScore in double (+13/-8, one file, no format change). The repro passes with doc 0 at 1.0; so do the closest suites (592 tests, 19 skipped) and all of lucene/core (8,898 tests, 322 skipped). It fixes EUCLIDEAN only: the MIP single-document case stays NaN, its overflowed terms already stored as float32 Infinity.

double score =
    (double) ax * ay * targetVectors.dimension()
        + (double) ay * lx * x1
        + (double) ax * ly * y1
        + (double) lx * ly * qcDist;
if (similarityFunction == EUCLIDEAN) {
  score =
      (double) queryCorrections.additionalCorrection()
          + indexCorrections.additionalCorrection()
          - 2 * score;
  return (float) (1 / (1 + Math.max(score, 0)));
}
// MIP/COSINE: the same (double) cast for the correction; then
// MIP: scaleMaxInnerProductScore((float) score); COSINE: (float) Math.clamp(score, -1, 1)
All options and trade-offs4 options · 900 words

One fix was built and run in a scratch worktree of 645d4fe14a20 with gradle. Three alternatives were considered and not built; B and C address the MIP leg, D only limits the damage.

Compute the score in double inside quantizedScore (validated by the checks) (recommended)

The four products and their sum, the EUCLIDEAN distance and the MIP/COSINE correction are computed in double. The EUCLIDEAN 1 / (1 + max(d, 0)) and the COSINE Math.clamp also run in double and their results are cast to float; for MIP the score is cast to float just before scaleMaxInnerProductScore, which takes a float. The stored corrections and the format are unchanged.

double score =
    (double) ax * ay * targetVectors.dimension()
        + (double) ay * lx * x1
        + (double) ax * ly * y1
        + (double) lx * ly * qcDist;
if (similarityFunction == EUCLIDEAN) {
  score =
      (double) queryCorrections.additionalCorrection()
          + indexCorrections.additionalCorrection()
          - 2 * score;
  return (float) (1 / (1 + Math.max(score, 0)));
}
// MIP/COSINE: the same (double) cast for the correction; then
// MIP: scaleMaxInnerProductScore((float) score); COSINE: (float) Math.clamp(score, -1, 1)

Trade-offs

  • Fixes the repro (doc 0 now scores 1.0, like the float format), the study's 64-dim EUCLIDEAN spike (doc 0 = 1.0, 3 hits), its 130-document batch test (hits start at doc 0 again), and the positive-outlier and different-coordinate cases (the float format's scores). With one +1e20 component at 1024 dims it removes all NaN and 10 hits come back, but the ranking stays coarse (docs 1-10 tie at 1.0; the float format ranks doc 7 first): with the centroid pulled 1e18 away each vector's 8-bit interval spans about 1e18. That is quantization loss, not this NaN.
  • Does not fix the MIP single-document case ({3e19, 1}: still NaN, 0 hits): centroidDP and the per-vector x.c are stored as float32 Infinity. In the 301-document MIP outlier case the 300 ordinary documents no longer score NaN but 0.0, and the outlier stays NaN, so the ranking is still wrong.
  • Ordinary inputs: the same formula over the same float inputs (the stored corrections, and lx, ly still computed in float), rounded once at the end instead of after each float32 step. Scores usually change only in the last bits; where the terms cancel (as in #15408) today's float32 result can be off by more, and the fix's result differs from it by that much, nearer the formula's exact value. No reindexing.
  • Cost: about 15 scalar double operations per scored vector (8 multiplications and 3 additions for the estimate, 3 to 6 more for the correction and the final step) plus the float-to-double conversions, outside the SIMD dot product of the codes; not benchmarked. In #12281 double math was rejected for the per-component SIMD loops; this is not in a loop, but expect the question.
  • The same float32 expression in the read-only BBQ format (backward_codecs/lucene102/Lucene102BinaryFlatVectorsScorer.java:183) is left unchanged.

What the checks validated

  • gradle, stock + test: fails with assertions on and off; + this fix: passes with assertions on and off.
  • + this fix: the closest suites (codecs.lucene104.*, util.quantization.*, codecs.lucene99.*, TestRescoreTopNQuery*, TestFullPrecision*, TestKnn*, TestVectorSimilarity*, TestKnnGraph, TestHnswMergeAbort, TestFilterReadersForwardVectorPrefetch, TestColumnBatchVectorColumn): 32 suites, 592 tests, 19 skipped, 0 failures, with seed DEADBEEF and assertions on and with seed C0FFEE and assertions off.
  • + this fix: the full :lucene:core:test, 8,898 tests, 322 skipped, 0 failures (assertions on); checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass.
  • The study's 5-test class with this fix shadowed (assertions off): the 2 EUCLIDEAN tests and the dim-1 scan pass; testMipSingleDocLargeMagnitude still fails (NaN, 0 hits), and testOneOutlierDocBlastRadius still fails: its MIP half leaves the outlier at NaN and the rest at 0.0, and its EUCLIDEAN half (no NaN, on stock either) overlaps the float top-10 in 1 of 10, on stock as with the fix (quantization loss, not this NaN). Not run: other modules, nightly tests, benchmarks.
  • The resolution probe (production codec, no test framework) with this fix shadowed, assertions on and off: P1 (the repro's inputs: doc 0 = 1.0), P2 and P3 (the float format's scores), P4 (no NaN), P5 (hits 0-4, as with the float format).

Store the MIP correction so that it cannot overflow (not built)

The MIP score adds q.c + x.c - c.c, and for vectors near the centroid the last two cancel. Storing (x - c).c per vector, instead of x.c next to a per-segment c.c, keeps that cancellation before anything is stored. The study's Z3 model found that re-associating as qAdd + (xAdd - cDP) makes the single-document dim-1 score exact when |x| < 2^64 and q - x is finite.

Trade-offs

Changes what the format stores (a new format version, back-compat for old segments), and the query-side q.c can still overflow for an extreme query. Neither built nor run.

Reject such vectors when indexing into OSQ (not built)

Check that the correction products stay finite, in the spirit of the older ScalarQuantizer guard (#13366).

Trade-offs

The centroid is known only at flush, so the error would come at flush, not at addDocument; a field-level cap like #12373 was declined. It turns a silent wrong result into an error, for MIP too.

Keep one NaN from dropping a whole batch (not built)

Make the exhaustive batch test ignore NaN scores, for example with a NaN-skipping maximum.

Trade-offs

Defence in depth only: the 63 neighbours come back, but the outlier's own score stays NaN.

Downstream note

A service that indexes user-supplied vectors into an OSQ field can have one outlier vector make nearly every quantized score of its segment NaN (EUCLIDEAN with assertions on and off, MAXIMUM_INNER_PRODUCT with assertions off), so it may want to cap component magnitudes itself.

How it was found

A Z3 floating-point query on a bit-precise model of the quantized MAXIMUM_INNER_PRODUCT score of a one-document segment asked whether a finite float score can come with a NaN quantized score; Z3 found such a vector in 23 s. The study's verifier then reproduced it end to end and widened it to EUCLIDEAN with every float quantity finite.

The method in full120 words

Method: SMT (Z3) floating-point queries on a bit-precise model of quantizedScore for a dim-1, single-document MIP segment, built from the code of main 7cecf79523 (study experiment SMT-6, query M1, 23 s: witness x = 1.73 x 2^78). Query M2 proved that the quantized score is NaN only if x*x overflows or q - x or q*x is not finite. The study's independent verifier wrote its own 5-test JUnit, reproduced it on main and 10.5.2, found the EUCLIDEAN form (64 dims, one component -1e19, every float quantity finite), the 64-vector batch drop and the 301/301 MIP blast radius, and measured the thresholds per dimension. On 2026-10-29 a small sweep (2-16 dims, 2-4 documents) cut it down to the one-method test above.

Prior reports, history and checks

Prior reports

None found in the recorded searches: 103 tool queries and 50+ manual searches on 2026-10-30 (GitHub, mailing lists, JIRA archive, downstream, web). Closest: open #15408 (same scorer formula on extreme vectors, but rounding, not overflow) and its partial fix #15411; #12281 and #12373 discussed float-path overflow and declined a component cap.

Searches4 searches

The follow-up rates it partially_known under the hunt's strict rule (a partial overlap counts) because of #15408; the registry keeps novelty new, since nobody has reported this NaN. A new issue that links #15408, or a comment there, both fit.

Related, not reports of this bug: #15408 (open; float32 rounding gives a negative squared distance for vectors far from the centroid; benwtrent: "We just need to fix some math in the scorer"); #15411 (merged; the bounds that NaN passes); #12281 (merged; float-path NaN for large vectors, only checkFinite adopted); #12373 (closed unmerged; a 1e17 component cap); #13366 (merged; the old ScalarQuantizer rejects NaN/Inf); #15540 and #15209 (other NaN causes in vector code); #16665 (a COSINE merge-scorer bug in the same reader: clamped scores and lower recall, not NaN). Open PRs touching the files (#15257, #16790 and others) add no NaN or overflow handling.

Search caveats: GitHub search reads the first page (50 results) per query; the JIRA archive predates OSQ; private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub (apache/lucene issues, PRs, commits; downstream repositories)2026-10-30a scripted prior-report search (48 issue/PR, 6 commit, 5 downstream queries) and 32 manual queries, e.g. NaN quantized, OSQ overflow, checkFinite, 1E17, "corrective terms", BBQ overflow; #15408, #15411, #12281, #12373 read in fullno report; #15408 overlaps most
Open apache/lucene PRs2026-10-30file lists of 114 open vector, quantization, kNN and scorer PRs16 touch the component files; none adds NaN or overflow handling to the OSQ score
Mailing lists, JIRA archive, web2026-10-3032 list queries (dev@, java-user@, issues@, commits@), 6 JIRA-archive queries, 6 web queriesno report; the 2023 dev@ thread on #12281 / #12373 is about the float path
Study search2026-10-22about 35 GitHub searchesno report

History

Introduced in 10.4.0 by #15169 (594eeb7e637, 2025-09-17), which created the scorer with this float32 formula, copied from the 10.2 binary-quantized scorer; #15021 (the batch maximum and the HNSW batch test) and #15474 (the flat OSQ reader's 64-vector batches), both first released in 10.4.0, turn one NaN into missing hits. 10.4.0 to 10.6.0 and main fail; 10.3.2 has no such format.

Versions tested6 rows

#15169 "Add a new codec to implement OSQ for 4 and 8 bit quantized vectors" (mccullocht, co-author benwtrent; 594eeb7e637 on main, backport 07033dcd84cc, 2025-09-17) created Lucene104ScalarQuantizedVectorScorer. Git copy detection pairs it with Lucene102BinaryFlatVectorsScorer.java, which has had the same float32 expression since #14078 (benwtrent, 8856a5f4eb7, 2025-03-04, 10.2.0). #15411 (7ceee5d5f92, 2025-11-07) added the bounds NaN passes; #15021 "integrate into new bulk scorers" (mccullocht, 0421a8de771 on main, backport 66d7ed8aaa2, 2025-09-10, first released in 10.4.0) made bulkScore return the batch maximum and added the batch test to the HNSW reader's exhaustive branch and to graph search; #15474 "Use bulk scoring new osq" (benwtrent, 683f59f66b9, 2025-12-08) added the 64-ordinal batches to the flat OSQ reader. Not fixed upstream.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe test fails, assertions on and off (gradle and the repro runner)
10.6.0 affectedthe test fails, assertions on and off (release jars)
10.4.0, 10.5.0, 10.5.1, 10.5.2 affectedthe test fails, assertions on and off; the 1-bit encoding fails the same way; the MIP leg fails on 10.4.0 (assertions off)
10.3.2 API absentno Lucene104 format (the repro does not compile); the older int7 scalar-quantized format scores the same input correctly
10.2.0 to 10.3.2, BBQ formats relateddoc 0 scores NaN but is still returned (no batch threshold yet); outside this fix site
with the fix (main 645d4fe14a20) not affectedthe test passes (doc 0 = 1.0) and so does the full lucene/core suite (8,898 tests, 322 skipped)

Independent checks

The study's verifier, the verify step and a second adjudication reproduced it, each with their own runs; the cross-model refute review returned a procedural "not real", and a separate run of the repro failed as claimed.

Checks and verdicts326 words

The reviewer from another model family (OpenAI GPT-5.6) could not execute code; its only substantive point (the values are outside a practical range) it rated weak itself. The second adjudication comes from the same model family as the finder.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier new bugOwn 5-test JUnit on main and 10.5.2; widened it to EUCLIDEAN with every float quantity finite; batch drop and 301/301 MIP blast radius; thresholds per dimension.
verify (current main) realThe minimal repro fails on main, 10.5.2 and 10.4.0 (-ea, -da); float-format and small-spike controls pass; the term diagnostic shows Inf - Inf; a double shadow fixes it.
cross-model review, refute lens not real, low, procedural onlyNo command ran (0 Java runs, no source read); a separate run re-ran the repro: fails.
Second adjudication real, highOwn 1024-dim probe (flat and HNSW: docs 0-63 missing), a production-codec main(), 10.6.0, both vector providers, two seeds; proposed a negative-spike and an extreme-query condition, which the fact check later found too narrow.
Follow-up 10.4.0 first affectedHistory runs on 10.1.0 to 10.6.0 and main; prior-report search (the prior-report record).
this doc's runs reproduced, fix validatedGradle on stock and with the fix (the repro, the closest suites with two seeds, the full lucene/core suite, lint); the study test with the fix shadowed.
fact check, code-running real; 9 correctionsRe-ran the gradle command, the release jars and its own production-codec probe (all five encodings; positive outliers empty a segment for EUCLIDEAN too). Its corrections are folded into this doc.
fact check, another model family substantively sound; 5 errorsOpenAI GPT-5.6 read the sources and the saved runs without running code. Its one high-severity point (fix A gives a tiny score, not 1.0) rested on a flawed recomputation; an executed run with fix A gives 1.0. The other points are folded in or answered.

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 notes564 words

Finding record

LFV-064, snapshot of 2026-11-06

Registry title
Quantized MIP/EUCLIDEAN scores become NaN when OSQ correction terms overflow
Area
codecs / vectors
Priority
Low, rank 571: Moved down from Medium: in the opt-in OSQ format with MIP or EUCLIDEAN, one vector component around 1e17-1e19 (for example a byte-swapped vector) silently makes all quantized scores NaN and returns 0 hits, but real embeddings never get there, so only corrupt or crafted vectors reach it.
Impact class
silent wrong results
Impact note
Users of the opt-in OSQ format with MAXIMUM_INNER_PRODUCT or EUCLIDEAN and vector components around 1e17-1e19 (one outlier is enough); released.
Summary
With the OSQ format and MAXIMUM_INNER_PRODUCT or EUCLIDEAN, you index vectors with components around 1e17-1e19. You expect finite scores, as the float path gives. Instead the correction terms overflow to inf - inf, the quantized score is NaN, and NaN drops whole 64-document batches (one outlier made 301 of 301 scores NaN).
Introduced
#15169 (594eeb7e637) [read: the formula came with the OSQ 4/8-bit codec] (2025-09-17)
First affected version
unknown (not yet bisected; 10.5.2 executed)
Affected versions
10.5.2 and main [exec]
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 could only read the code (its low-confidence “not real” is procedural); not yet triaged by a person.
Found by
formal: Z3
Upstream and prior reports
none found (study searches 2026-10-22); related overflow discussion in #12281 / #12373
Lucene commit tested
main 645d4fe14a20 (commit of 2026-10-28): the repro through gradle in a scratch worktree, assertions on and off, stock and with the recommended fix; with the fix, the closest suites (two seeds) and the full lucene/core suite. The repro runner on main and the 10.4.0, 10.5.2 and 10.6.0 release jars; the follow-up's history runs on 10.1.0 to 10.6.0. Upstream main on 2026-10-30 (716d6f216f52) has not changed the cited files.

Lucene and upstream sources

Finding record
  • SECURITY.md:28 (threat model)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorScorer.java:283-291, :294-301, :305-315
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java:197-236
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsWriter.java:173-174
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsReader.java:87, :373-408
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/hnsw/RandomVectorScorer.java:47-54
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:81, :396, :408
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/KnnFloatVectorField.java:42-61, :105; lucene/core/src/java/org/apache/lucene/search/KnnFloatVectorQuery.java:97
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:444-449, :485-492
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java:341-343
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsWriter.java:815 (centroid = mean)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104Codec.java:123
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsFormat.java:97, Lucene104HnswScalarQuantizedVectorsFormat.java:47 (@lucene.experimental)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/document/KnnFloatVectorField.java:42-61, :105; lucene/core/src/java/org/apache/lucene/search/KnnFloatVectorQuery.java:97
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/VectorSimilarityFunction.java:35-39, :99-103
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:465-476
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorScorer.java:301, :314
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/quantization/ScalarQuantizer.java:92-96
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsFormat.java:97, Lucene104HnswScalarQuantizedVectorsFormat.java:47, Lucene104ScalarQuantizedVectorScorer.java:39 (@lucene.experimental)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:135 (squareDistance finiteness assertion)
  • github.com/apache/lucene/pull/15784#discussion_r2987551984 (msokolov, 2026-03-25, read-only)

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

Repro files

About the diagrams

Every value drawn comes from the code at 645d4fe14a20 or the diagram's executed probe.

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.