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.
- 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.
- 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.
- Exhaustive kNN scores vectors in batches of 64 and keeps a batch only if its best score beats the current threshold.
- Index two 16-dim EUCLIDEAN vectors with the opt-in
Lucene104ScalarQuantizedVectorsFormat: doc 0 = (-1e19, 0, ..., 0), doc 1 = all zeros. Every component is finite, soKnnFloatVectorFieldaccepts both. - 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. - 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. - 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, andNaN > xis false: the query returns 0 hits, losing doc 1 too. No error, even with assertions on. - 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).
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.
- Accepted input.
KnnFloatVectorFieldchecks only that the vector is non-null, non-empty and finite (non-zero for COSINE):KnnFloatVectorField.java:42-61,:105viaVectorUtil.checkFinite(VectorUtil.java:485-492). The query gets the same check (KnnFloatVectorQuery.java:97). - 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. - 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):
| term | exact | float32 |
|---|---|---|
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.5e37 | NaN |
- The bounds pass NaN. EUCLIDEAN computes
qAdd + xAdd - 2*scoreand returns1 / (1f + Math.max(score, 0f))(:294-301); computed in double from the same float32 inputs the distance is about -1.4e30 (rounding residue), whichMath.maxclamps to 0, so the score would be 1.0 (fix A does this: doc 0 scores 1.0); butMath.max(NaN, 0f)is NaN. MIP/COSINE addqAdd + xAdd - centroidDPand returnscaleMaxInnerProductScoreorMath.clamp(score, -1, 1)(:305-315), both NaN for NaN (VectorUtil.java:444-449). - One NaN drops a batch.
exhaustiveBulkScorecollects a 64-ordinal batch (Lucene104ScalarQuantizedVectorsReader.java:87) only ifscorer.bulkScore(...) > knnCollector.minCompetitiveSimilarity()(:390,:402). The defaultbulkScorereturns theMath.maxover the batch (RandomVectorScorer.java:47-54), NaN here, andNaN > xis 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. - 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 exceedsFloat.MAX_VALUEit is stored as +Infinity; x.c is signed and becomes +Infinity or -Infinity once |x.c| exceeds it (|x||c| aboveFloat.MAX_VALUEis needed for that, not enough). The score is then Infinity - Infinity. One outlier can do that for the whole segment. With-eathe writer'sdotProductcall fails its finiteness assertion at flush instead.
- 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 endsax,ayof 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
ScalarEncodingvalues fail the same way. The default codec uses the floatLucene99HnswVectorsFormat(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-eathe 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.
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.
/*
* 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);
}
}
}
}
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.
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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.4.0, 10.5.2 and 10.6.0 release jars |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (IEEE float32 arithmetic; Panama and scalar vector providers both fail) |
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
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- 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.
KnnFloatVectorFieldandKnnFloatVectorQuerycheck 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 byVectorSimilarityFunction.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.maxandMath.clampat:301and: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'sMath.maxandMath.clampreturn 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.
ScalarQuantizerthrows "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.
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.
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):
centroidDPand 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,lystill 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,forbiddenApisMainandforbiddenApisTestpass. - The study's 5-test class with this fix shadowed (assertions off): the 2 EUCLIDEAN tests and the dim-1 scan pass;
testMipSingleDocLargeMagnitudestill fails (NaN, 0 hits), andtestOneOutlierDocBlastRadiusstill 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).
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.
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.
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.
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.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub (apache/lucene issues, PRs, commits; downstream repositories) | 2026-10-30 | a 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 full | no report; #15408 overlaps most |
| Open apache/lucene PRs | 2026-10-30 | file lists of 114 open vector, quantization, kNN and scorer PRs | 16 touch the component files; none adds NaN or overflow handling to the OSQ score |
| Mailing lists, JIRA archive, web | 2026-10-30 | 32 list queries (dev@, java-user@, issues@, commits@), 6 JIRA-archive queries, 6 web queries | no report; the 2023 dev@ thread on #12281 / #12373 is about the float path |
| Study search | 2026-10-22 | about 35 GitHub searches | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the test fails, assertions on and off (gradle and the repro runner) |
| 10.6.0 | affected | the test fails, assertions on and off (release jars) |
| 10.4.0, 10.5.0, 10.5.1, 10.5.2 | affected | the 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 absent | no 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 | related | doc 0 scores NaN but is still returned (no batch threshold yet); outside this fix site |
with the fix (main 645d4fe14a20) | not affected | the test passes (doc 0 = 1.0) and so does the full lucene/core suite (8,898 tests, 322 skipped) |
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.
| Check | Verdict | What it found |
|---|---|---|
| study verifier | new bug | Own 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) | real | The 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 only | No command ran (0 Java runs, no source read); a separate run re-ran the repro: fails. |
| Second adjudication | real, high | Own 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 affected | History runs on 10.1.0 to 10.6.0 and main; prior-report search (the prior-report record). |
| this doc's runs | reproduced, fix validated | Gradle 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 corrections | Re-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 errors | OpenAI 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. |
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.
- PNG 519 KB
Finding record, Lucene and upstream sources, repro files and diagram notes564 words
- 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 fulllucene/coresuite. 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.
- 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-315a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java:197-236a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsWriter.java:173-174a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsReader.java:87, :373-408a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/hnsw/RandomVectorScorer.java:47-54a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:81, :396, :408a 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:97a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:444-449, :485-492a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java:341-343a 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:123a 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:97a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/VectorSimilarityFunction.java:35-39, :99-103a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:465-476a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorScorer.java:301, :314a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/quantization/ScalarQuantizer.java:92-96a 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)
- TestScalarQuantizedEuclideanOverflow.java (lucene/core, one test):
repro/LFV-064/src/org/apache/lucene/codecs/lucene104/TestScalarQuantizedEuclideanOverflow.java(62 lines)
Every value drawn comes from the code at 645d4fe14a20 or the diagram's executed probe.
assets/LFV-064/bug.png(519 KB)
