- A float16 value is stored in a
short. The largest finite one is 65504;Float.floatToFloat16maps 65519 to 65504 and anything of magnitude 65520 or more to Infinity. Lucene104ScalarQuantizedVectorsFormatstores each vector twice: the raw float16 values, and a compressed copy of small integers (1 to 8 bits per component) with an interval [lower, upper] measured from the centroid, the field's mean vector in that segment.- A read-only index may drop the raw copy to save space; upstream's tests do it by rewriting the vector files. Reads then rebuild each component as centroid + lower + q x step, "with some precision loss".
- Index two FLOAT16 vectors,
(65504, -65504, 0)and(0, 0, 0), EUCLIDEAN, withLucene104ScalarQuantizedVectorsFormat(default 8-bit encoding). 65504 is the largest finite float16 value, and the field accepts it. - Drop the raw vectors after the writer closes, as the format's own tests do, so reads rebuild each vector from its compressed copy. You expect doc 0 back as roughly
(65504, -65504, 0). - Instead
vectorValue(0)returns(65472, -Infinity, 85.625). The quantization interval reaches 42.8 past the data, so the second component rebuilds as -65546.8, andFloat.floatToFloat16turns anything of magnitude 65520 or more into Infinity. - Whatever uses the rebuilt vector sees an infinite distance. In an 8-dimensional variant, full-precision rescoring of doc 0's own vector gives doc 0 the score 0.0 and ranks the zero vector first; with assertions on,
VectorUtil.squareDistancefails its finiteness check. No error with assertions off.
Only unreleased main (and branch_11x): a FLOAT16 field on the opt-in scalar-quantized format whose raw vectors were removed by rewriting index files, with EUCLIDEAN or MAXIMUM_INNER_PRODUCT and components at or near +-65504 (tens of thousands for 1-2 bit encodings). vectorValue() and full-precision rescoring return wrong values; plain kNN search is unaffected.
Mechanism, terms and numbers678 words
Lucene104ScalarQuantizedVectorsFormat quantizes FLOAT16 vectors (since #16473). For read-only indexes whose raw float16 vectors were dropped to save storage, getFloat16VectorValues returns OffHeapScalarQuantizedFloat16VectorValues, which rebuilds each vector "with some precision loss relative to the original float16 vectors". It calls OptimizedScalarQuantizer.deQuantize(byte[], short[], ...), which narrows xi + centroid[h] with Float.floatToFloat16 and no clamp (OptimizedScalarQuantizer.java:294). The optimized interval can reach a little past the data, so a component at the fp16 limit rebuilds just beyond it and becomes Infinity: (65504, -65504, 0) reads back as (65472, -Infinity, 85.625) with the default 8-bit encoding. Full-precision rescoring then gives the document an infinite EUCLIDEAN distance (score 0.0), so an exact match can rank last; with -ea, VectorUtil.squareDistance trips its finiteness assert. The FLOAT32 sibling view stays finite on the same data. It needs the opt-in quantized format, raw vectors dropped by a file rewrite (as upstream's tests do), EUCLIDEAN or MAXIMUM_INNER_PRODUCT and components near +-65504 (tens of thousands for 1-2 bit encodings); plain kNN search is unaffected. Unreleased main and branch_11x only; saturating to +-65504 before narrowing fixes it.
- Dequantized view:
OffHeapScalarQuantizedFloat16VectorValues(@lucene.internal). Javadoc: "Used for read-only indexes whose raw float16 vectors have been dropped to save storage: only the scalar-quantized bytes remain, sovectorValue(int)reconstructs float16 values by dequantizing them, with some precision loss relative to the original float16 vectors." (OffHeapScalarQuantizedFloat16VectorValues.java:43-45). - Dropped raw vectors: an empty
.vecfile and a vector length of 0 in.vemf, written afterIndexWritercloses, asTestLucene104ScalarQuantizedVectorsFormat#simulateEmptyRawVectorsdoes (:614). The FLOAT32 view's javadoc names that method as the way to remove full-precision vectors (OffHeapScalarQuantizedFloatVectorValues.java:48-51).
Lucene104ScalarQuantizedVectorsReader.getFloat16VectorValues: when the raw float16 vectors have size 0 (:327), it returns the dequantized view paired with the quantized values (:331-343).- At index time
OptimizedScalarQuantizer.scalarQuantizecentres the vector on the centroid (:205), clamps the initial interval to the centred vector's [min, max] (:217-218), thenoptimizeIntervals(:219, defined at:336) can move an end past [min, max]. - The view's
vectorValue(:105-127) callsdeQuantize(byte[], short[], ...)(:117-123), which computesxi = q * step + lowerInterval(OptimizedScalarQuantizer.java:293) andFloat.floatToFloat16((float) (xi + centroid[h]))with no clamp (:294). Its javadoc: "narrowing each reconstructed value to float16" (:272-273). The view is its only production caller. - A rebuilt value of magnitude 65520 or more becomes +-Infinity.
VectorUtil.squareDistance(short[], short[])asserts a finite result (VectorUtil.java:144-151, assert:149); with assertions off, EUCLIDEAN scores the infinite distance as 1 / (1 + Infinity) = 0.0. - Affected readers:
Float16VectorValues#vectorValueand#rescoreron the view, soRescoreTopNQuery.createFullPrecisionRescorerQuery(Query, short[], String, int)(RescoreTopNQuery.java:302-306) and, by code reading, the queries module'sFloat16KnnVectorFieldSource(:71). Plain kNN search scores in quantized space and was executed unaffected.
doc 0 (65504, -65504, 0) | doc 1 (0, 0, 0) | |
|---|---|---|
| centroid | (32752, -32752, 0) | same |
| optimized interval (centred) | [-32794.797, 32709.172] (data range [-32752, 32752]) | same |
| quantized bytes | [255, 0, 128] | [0, 255, 128] |
| float reconstruction | (65461.168, -65546.8, 85.625) | (-42.797, -42.831, 85.625) |
| float16 read-back | (65472, -Infinity, 85.625) | (-42.8125, -42.84375, 85.625) |
The gradle runs of the repro print only its message (-Infinity) with assertions on and off; the whole read-back above was printed by probes with -ea and -da.
Consequences executed on 645d4fe14a20 by probes and larger tests (not part of the repro). On the repro's two documents, rescoring doc 0's own vector with RescoreTopNQuery returns [doc=1 1.17e-10, doc=0 0.0] (-da). Default encoding, EUCLIDEAN, docs [65504, -65504, 65504, -65504, 1000, -1000, 0, 0], its negation and the zero vector: [doc=2 5.83e-11, doc=0 0.0, doc=1 0.0]. 1-bit encoding (HNSW variant), (60000, 49984, 0, 0), (20000, -49984, 0, 0), (40000, 0, 0, 0): doc 0 reads back as (Infinity, 33824, 1171, 1171) and ranks last, [doc=2 3.45e-10, doc=1 1.01e-10, doc=0 0.0]. In the controls with raw vectors kept, doc 0 ranks first. With -ea the rescoring stops at VectorUtil.java:149.
Exposure and likelihood314 words
- Who is hit. Applications on unreleased main and
branch_11xthat index FLOAT16 vectors withLucene104ScalarQuantizedVectorsFormatorLucene104HnswScalarQuantizedVectorsFormat(both executed) and then drop the raw vectors for a read-only searcher index. NoIndexWriterAPI does that today; open #15630 / #13158 would make the writer produce such files. - Similarities. EUCLIDEAN and MAXIMUM_INNER_PRODUCT (both executed). COSINE vectors are scaled to unit length before quantization (
Lucene104ScalarQuantizedVectorsWriter.java:903-905), so they never come near the limit. DOT_PRODUCT is documented to need unit-length vectors (VectorSimilarityFunction.java:53-57), but nothing enforces that; it was not tested. - How large. There is no simple threshold: it depends on the optimized interval end, the centroid and the code a component gets. What the probes saw: with the default 8-bit encoding, 40 of 40 random 22-dimensional segments with about 25% of components at exactly +-65504 overflowed, and 0 of 200 with gaussian values of sigma 15000 capped at 60000 did. With 1 and 2 bits, values in the tens of thousands were enough (60000 at 1 bit, -50944 at 2 bits). All 5 encodings overflow on a witness at +-65504. Values exactly at 65504 arise, for example, when an application saturates float32 values while converting them to fp16.
- What happens.
vectorValue()returns an infinite component with no error. Full-precision rescoring gives the document an infinite EUCLIDEAN distance (score 0.0), so an exact match can rank last; under MAXIMUM_INNER_PRODUCT the scores became NaN or +Infinity and moved such documents into the top 3. With-eathe rescore ends in anAssertionError. - What does not happen. No index damage, no hang, no change to the inner query's match set; plain kNN search returns identical hits and scores with raw vectors kept and dropped.
- Scale. Opt-in on every axis. A single affected document is enough, in a segment that also holds other vectors; a one-document segment reads back exactly, because its centroid is the vector itself.
One JUnit test: index (65504, -65504, 0) and (0, 0, 0) with the default scalar-quantized format, drop the raw vectors with a copy of the format test's simulateEmptyRawVectors, and check that vectorValue(0)[1] is finite. Deterministic, single thread, about 2 s; the 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 java.io.IOException;
import org.apache.lucene.codecs.CodecUtil;
import org.apache.lucene.codecs.lucene95.OrdToDocDISIReaderConfiguration;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloat16VectorField;
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.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestFloat16DequantizedViewOverflow extends LuceneTestCase {
public void testDequantizedValuesAreFinite() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
IndexWriterConfig iwc =
new IndexWriterConfig()
.setCodec(
TestUtil.alwaysKnnVectorsFormat(new Lucene104ScalarQuantizedVectorsFormat()))
.setUseCompoundFile(false);
try (IndexWriter w = new IndexWriter(dir, iwc)) {
for (float[] v : new float[][] {{65504f, -65504f, 0f}, {0f, 0f, 0f}}) {
short[] h = new short[v.length];
for (int i = 0; i < v.length; i++) {
h[i] = Float.floatToFloat16(v[i]);
}
Document doc = new Document();
doc.add(new KnnFloat16VectorField("v", h, VectorSimilarityFunction.EUCLIDEAN));
w.addDocument(doc);
}
}
simulateEmptyRawVectors(dir);
try (DirectoryReader r = DirectoryReader.open(dir)) {
short[] v = r.leaves().get(0).reader().getFloat16VectorValues("v").vectorValue(0);
float y = Float.float16ToFloat(v[1]);
assertTrue("expected a finite approximation of -65504, got " + y, Float.isFinite(y));
}
}
}
private static void simulateEmptyRawVectors(Directory dir) throws IOException {
for (String file : dir.listAll()) {
if (file.endsWith(".vec")) {
byte[] header;
try (IndexInput in = dir.openInput(file, IOContext.DEFAULT)) {
header = CodecUtil.readIndexHeader(in);
}
dir.deleteFile(file);
try (IndexOutput out = dir.createOutput(file, IOContext.DEFAULT)) {
out.writeBytes(header, header.length);
CodecUtil.writeFooter(out);
}
} else if (file.endsWith(".vemf")) {
byte[] header;
int field, encoding, similarity, dim;
long offset;
try (IndexInput in = dir.openInput(file, IOContext.DEFAULT)) {
header = CodecUtil.readIndexHeader(in);
field = in.readInt();
encoding = in.readInt();
similarity = in.readInt();
offset = in.readVLong();
in.readVLong();
dim = in.readVInt();
}
dir.deleteFile(file);
try (IndexOutput out = dir.createOutput(file, IOContext.DEFAULT)) {
out.writeBytes(header, header.length);
out.writeInt(field);
out.writeInt(encoding);
out.writeInt(similarity);
out.writeVLong(offset);
out.writeVLong(0);
out.writeVInt(dim);
out.writeInt(0);
OrdToDocDISIReaderConfiguration.writeStoredMeta(16, out, null, 0, 0, null);
out.writeInt(-1);
CodecUtil.writeFooter(out);
}
}
}
}
}
cp TestFloat16DequantizedViewOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene104/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene104.TestFloat16DequantizedViewOverflow -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
- Expected
- A finite approximation of -65504 for doc 0's second component (the field accepted only finite values).
- Observed
expected a finite approximation of -65504, got -Infinity, with assertions on and off.
More files, environment, full output264 words
One JUnit class, one test method, public API only (it sits in org.apache.lucene.codecs.lucene104 like the format's own tests but uses no package-private member), ByteBuffersDirectory, an explicit codec, nothing randomized. 2 documents of 3 dimensions is the smallest default-encoding input found that overflows (1-dimensional vectors, single documents and 2-dimensional pairs read back finitely). The helper is a condensed copy of TestLucene104ScalarQuantizedVectorsFormat#simulateEmptyRawVectors (16 is DIRECT_MONOTONIC_BLOCK_SHIFT), because that method lives in core's test sources.
As the regression test: this same test, for example as a new method in TestLucene104ScalarQuantizedVectorsFormat, which already has simulateEmptyRawVectors(dir). With the fix it passes with assertions on and off, and :lucene:core:check -x test passes with the test file added. It compiles only where the float16 API exists (main from #16383) and fails from #16473 on.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); no release has the fp16 dequantized view |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp TestFloat16DequantizedViewOverflow.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene104/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene104.TestFloat16DequantizedViewOverflow -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on (the same with -Ptests.asserts=false)
TestFloat16DequantizedViewOverflow > testDequantizedValuesAreFinite FAILED
java.lang.AssertionError: expected a finite approximation of -65504, got -Infinity
at __randomizedtesting.SeedInfo.seed([DEADBEEF:8E7A13E97D1A25A2]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.assertTrue(Assert.java:42)
at org.apache.lucene.codecs.lucene104.TestFloat16DequantizedViewOverflow.testDequantizedValuesAreFinite(TestFloat16DequantizedViewOverflow.java:60)
# probe (not part of the repro): the values behind it
centroid=[32752.0, -32752.0, 0.0]
ord=0 interval=[-32794.797, 32709.172] quantized=[255, 0, 128] floatReconstruction=[65461.168, -65546.8, 85.625] fp16ReadBack=[65472.0, -Infinity, 85.625]
ord=1 interval=[-32794.797, 32709.172] quantized=[0, 255, 128] floatReconstruction=[-42.796875, -42.83142, 85.625] fp16ReadBack=[-42.8125, -42.84375, 85.625]
floatToFloat16(65519.0) = 65504.0 floatToFloat16(65520.0) = Infinity
# with the saturating fix: the test passes with assertions on and off- The view promises the stored vectors back "with some precision loss"; upstream's test of this mode asserts the quantization error bound.
- The field accepts only finite values, and
VectorUtil.squareDistanceasserts a finite result. - The FLOAT32 view rebuilds the same data finitely and ranks the exact match first.
The full argument260 words
- The view's contract. "reconstructs float16 values by dequantizing them, with some precision loss relative to the original float16 vectors" (
OffHeapScalarQuantizedFloat16VectorValues.java:43-45; the "relative to the original" wording came from a review suggestion by msokolov in #16473). - Upstream tests assert an error bound in this mode.
testReadQuantizedFloat16VectorWithEmptyRawVectors"asserts they stay within the quantization error bound" (TestLucene104ScalarQuantizedVectorsFormat.java:444-449, added by #16473); the shared base test says formats "maintain accuracy within expected error bounds even when the original raw vector file is empty" (BaseKnnVectorsFormatTestCase.java:2187-2189). Both use small random vectors, far from 65504: the fp16 test unit-length vectors (TestLucene104ScalarQuantizedVectorsFormat.java:461), the base test unit-length vectors for COSINE and otherwise components in [0, 1) (BaseKnnVectorsFormatTestCase.java:2202-2204,:1903-1915). - Finite in, finite out.
KnnFloat16VectorFieldrejects non-finite values (checkFiniteFloat16,KnnFloat16VectorField.java:72);VectorUtil.squareDistance(short[], short[])asserts a finite result (:149). - The FLOAT32 sibling. On the finder's 1-bit data with raw vectors dropped,
OffHeapScalarQuantizedFloatVectorValuesrebuilds doc 0 as[73820.7, 33820.707, 1171.291, 1171.291]and ranks it first; on the repro's two documents it reads doc 0 back as[65461.168, -65546.8, 85.625], finite. For this data the difference that matters is that it keeps the float reconstruction, while the FLOAT16 view narrows it without saturating. - A supported mode.
CHANGES.txt: #14792 (introduced the fallback), #15476 ("Enforce fallback support for float vector retrieval in quantized KNN vector formats"), #16415 ("Fix scoring on scalar-quantized indexes whose raw float vectors have been dropped", 10.5.1). - No sign of intent. None of the 41 #16473 PR texts mentions overflow, infinity, 65504, saturation, clamping, range or finiteness.
The devil's-advocate check266 words
The reviews by a reviewer from another model family (OpenAI GPT-5.6) voted not real on these grounds, while reproducing the bug and agreeing on the cause and the fix: (a) deQuantize(byte[], short[], ...) is a public static method documented as "narrowing each reconstructed value to float16", and TestOptimizedScalarQuantizer#testDeQuantizeFloat16MatchesFloat asserts equality with Float.floatToFloat16 of the float overload, so Infinity is the helper's specified result; (b) the trigger state exists only after rewriting index files ("The raw vector file is emptied below, which CheckIndex rightly rejects", BaseKnnVectorsFormatTestCase.java:2275), the view is @lucene.internal, and SECURITY.md calls modified index files undefined behaviour; (c) 65504 is extreme.
Why it fails: (a) only decides where the clamp goes; the helper's only production caller is the view, whose contract is an approximation, and the helper's own test (values in [0, 1)) passes with the clamp. (b) The quantized data is exactly what IndexWriter wrote; only the raw-vector files are rewritten (.vec emptied, its metadata .vemf set to 0 vectors; .veq and .vemq untouched), which is the mode's purpose, and upstream tests, documents and bug-fixes that mode. The quoted CheckIndex comment is in a FLOAT32 test: on the repro's index CheckIndex reports no problem before or after the drop, and its vector test covers 0 fields, because at main it skips FLOAT16 vector fields (CheckIndex.java:2864-2867; see LFV-089). (c) The values are legal input the field accepts, and the FLOAT32 path handles them. Verdict: a defect; a maintainer may still call it hardening of an opt-in internal mode, which is why fix A pairs the clamp with a javadoc sentence.
Recommended A. Saturate to +-65504 in deQuantize(byte[], short[], ...), with a javadoc sentence
Saturate the rebuilt value to +-65504 before narrowing in OptimizedScalarQuantizer.deQuantize(byte[], short[], ...), and say so in its javadoc (+7/-2 lines). With it the repro passes with assertions on and off, the 6 closest core test classes (149 tests) pass, and :lucene:core:check -x test passes.
// the largest finite float16 value
private static final float FLOAT16_MAX = 65504f;
...
double xi = (double) (quantized[h] & 0xFF) * step + lowerInterval;
double value = Math.clamp(xi + centroid[h], -FLOAT16_MAX, FLOAT16_MAX);
dequantized[h] = Float.floatToFloat16((float) value);All options and trade-offs4 options ยท 641 words
Option A was validated through gradle in a scratch worktree of 645d4fe14a20; B, C and D were not run.
A. Saturate to +-65504 in deQuantize(byte[], short[], ...), with a javadoc sentence (validated by the checks) (recommended)
Clamp xi + centroid[h] to [-65504, 65504] before Float.floatToFloat16, and add to the javadoc: "The quantization interval can reach slightly past the range of the original values, so reconstructed values beyond the finite float16 range are saturated to +/-65504 instead of becoming infinite." Full diff:
--- a/lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java
+++ b/lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java
@@ -53,6 +53,8 @@
{-3.611f, 3.611f},
{-3.922f, 3.922f}
};
+ // the largest finite float16 value
+ private static final float FLOAT16_MAX = 65504f;
// the default lambda value
private static final float DEFAULT_LAMBDA = 0.1f;
// the default optimization iterations allowed
@@ -270,7 +272,9 @@
* Dequantizes a quantized byte vector back to float16 values.
*
* <p>Behaves as {@link #deQuantize(byte[], float[], byte, float, float, float[])}, narrowing each
- * reconstructed value to float16.
+ * reconstructed value to float16. The quantization interval can reach slightly past the range of
+ * the original values, so reconstructed values beyond the finite float16 range are saturated to
+ * +/-65504 instead of becoming infinite.
*
* @param quantized the quantized byte vector to dequantize
* @param dequantized the output array to store dequantized float16 bit patterns
@@ -291,7 +295,8 @@
double step = (upperInterval - lowerInterval) / nSteps;
for (int h = 0; h < quantized.length; h++) {
double xi = (double) (quantized[h] & 0xFF) * step + lowerInterval;
- dequantized[h] = Float.floatToFloat16((float) (xi + centroid[h]));
+ double value = Math.clamp(xi + centroid[h], -FLOAT16_MAX, FLOAT16_MAX);
+ dequantized[h] = Float.floatToFloat16((float) value);
}
return dequantized;
}// the largest finite float16 value
private static final float FLOAT16_MAX = 65504f;
...
double xi = (double) (quantized[h] & 0xFF) * step + lowerInterval;
double value = Math.clamp(xi + centroid[h], -FLOAT16_MAX, FLOAT16_MAX);
dequantized[h] = Float.floatToFloat16((float) value);Trade-offs
- A value at the limit rebuilds as 65504 instead of Infinity: an error no larger than the float reconstruction's own (42.8 in the repro).
- In-range values are unchanged bit for bit, so the helper's equality test still passes.
- It changes the documented behaviour of a public static method of an
@lucene.experimentalclass (hence the javadoc sentence); the view is its only production caller. - Cost: one clamp per component on the fallback read path. No on-disk change; existing indexes are fixed on read.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seedDEADBEEF, 2026-11-01: without the fix the repro fails with assertions on and off; with it, it passes with assertions on and off. - With the fix:
TestOptimizedScalarQuantizer(7),TestLucene104ScalarQuantizedVectorsFormat(65),TestLucene104HnswScalarQuantizedVectorsFormat(64),TestLucene104ScalarQuantizedMergeScorer(9),TestQuantizedVectorValuesPrefetch(3) and the repro: 149 tests, 0 failures, 4 skipped (two nightlytestRecall, two empty-raw base tests the HNSW test class opts out of). ./gradlew :lucene:core:check -x testwith the fix and the repro as a new test file: BUILD SUCCESSFUL (google-java-format, ecj lint, forbidden APIs, licenses; error-prone skipped).- Earlier, the same clamp shadowed into the stock jars made the finder's, the independent reproducer's and the cross-model reviewers' tests pass. The full Lucene test suite was not run.
In OffHeapScalarQuantizedFloat16VectorValues.vectorValue, dequantize into a float[] scratch with the float overload and narrow with saturation.
Trade-offs
Keeps the helper's documented plain narrowing; costs a dimension-sized float scratch per view and duplicates the narrowing loop. Not validated.
Constrain the interval so that centroid + lower and centroid + upper stay within +-65504.
Trade-offs
Changes the quantized encoding and quantized scores of new segments and does not fix existing indexes. Not recommended, not validated.
A javadoc note on the view.
Trade-offs
Keeps the wrong rescoring order and the assertion failure, and contradicts the view's "precision loss" wording and the error-bound tests. Not recommended.
No release has this code; until a fix lands, a service on main that drops raw FLOAT16 vectors should not trust vectorValue() or full-precision rescoring for vectors with components near the fp16 limit.
Formal: a Z3 floating-point query over the quantizer's float16 read path showed that a legal float16 component inside the quantization interval can rebuild to a non-finite value, at 1, 4 and 8 bits. A probe of the real quantizer gave a witness, and a JUnit test on a real index confirmed it with assertions on and off.
The method in full143 words
- Method: floating-point SMT precondition chaining (Z3), the hunt's formal tier, in the hunt unit on optimized scalar quantization. The obligation "the fp16 reconstruction of a legal in-interval fp16 component is finite" was refuted for 1, 4 and 8 bits (its violation is satisfiable); two more queries proved that fp16 narrowing overflows iff |y| >= 65520 and that a clamp keeps values finite.
- Witness: a real-jar probe of
scalarQuantize+deQuantize(short[])over random fp16 vectors hit +-Infinity for 1, 2, 4, 7 and 8 bits; then a JUnit test on a real index with the raw vectors dropped (read-back and rescoring) failed 2/2 with-eaand-da, and the clamp made it pass. - Later: an independent reproducer, an intended-behaviour devil's advocate, cross-model reviews and an adjudication checked it (2026-10-25); the test was reduced to one method (2026-10-29) and re-verified on main
645d4fe14a20.
No prior report found in GitHub issues, PRs and review threads, commits, the JIRA archive, the mailing lists, downstream projects or the web (searched 2026-10-25, re-checked 2026-11-01). Open PR #16790 touches the quantizer but leaves the narrowing line unchanged.
Searches4 searches
Closest items, none a report of this bug: #16473 (introduced the code; its tests use unit-length vectors or values in [0, 1)), #16383 (non-finite fp16 query targets led to checkFiniteFloat16, the input side), #12373 (closed FP32 input-magnitude limit; the fix here is not an input limit), #12281 (finite-vector rule), #16415 and #15576 (FLOAT32 dropped-raw view bugs fixed as bugs), #15630 and #13158 (open work toward writer-produced empty raw vectors), #16533 and #16725 (open fp16 work). Downstream precedent, not a report: OpenSearch k-NN #1493 chose clip-to-max for fp16 because infinite components mean a vector is never returned. Related findings on this site: LFV-085, the same dropped-raw fallback but a different defect and fix (rescorer().bulk() reads quantized bytes as float32 vectors; known: dweiss described it on #16030, which was reverted for an unrelated reason), and LFV-089 (CheckIndex skips FLOAT16 vector fields). Gaps: a search shows only that no report was found; private channels were not searched.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs, commits and review threads | 2026-10-25 | five scripted prior-report searches (48 issue/PR and 9 commit queries); 62 distinct manual read-only searches; the changed files of all 412 open PRs; the review threads of #16473, #16383, #15549, #16710, #16628, #16687, #16696 and #15630 read directly | no report; no open PR touches the view, and #16790 leaves the narrowing line unchanged |
| Mailing lists, JIRA archive, downstream, web | 2026-10-25 | 100 list queries over dev@, java-user@, issues@ and commits@; 16 JIRA-archive queries; 6 downstream queries (Elasticsearch, OpenSearch, OpenSearch k-NN, Solr); 15 web queries | no report (the only list hits are mirrors of #16383; JIRA: an unrelated 2016 HalfFloatPoint item) |
| Re-check: apache/lucene issues and PRs | 2026-11-01 | 12 read-only searches sorted by creation date (OffHeapScalarQuantizedFloat16VectorValues, "deQuantize float16", "float16 infinity quantized", "fp16 dequantize overflow", "floatToFloat16 clamp", "simulateEmptyRawVectors float16"; issues and PRs); the changed files of all 99 PRs updated since 2026-10-25 | nothing new: 0 issue hits, one known PR (#16710); only #16790 touches the quantizer, still without the narrowing line |
| Upstream main and branches | 2026-11-01 | file contents at upstream main 2f424fd428d (2026-10-30) and branch_11x 603af870375 | not fixed; both files unchanged since #16473 apart from a reverted rewrite that kept the line |
Introduced by #16473 (50c1e6acb7f, merged 2026-08-26), which added float16 scalar quantization and the dropped-raw float16 view; not a regression. No release has it: 10.6.0, 10.5.2 and 9.12.3 lack float16 vectors. branch_11x carries identical files, so 11.0.0 would ship it if nothing changes.
Versions tested6 rows
Introduced by #16473 "Add scalar quantization support for Fp16 vector encoding" (GitHub Pulkitg64; reviewed and approved by msokolov), commit 50c1e6acb7f on main, 2026-08-26. git blame and git log -S attribute the narrowing line and the view to it; its parent 091a987a14d has no fp16 dequantized view. Not a regression: both are first-version code. #16628 (55fd2e75fb2, 2026-09-28) only added the RescoreTopNQuery fp16 entry point; the wrong order was already reachable through Float16VectorValues#rescorer. #16030 rewrote the quantizer and was reverted, keeping the line.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (standalone and gradle); passes with fix A |
main 50c1e6acb7f (#16473), 8d999e9487d, 55fd2e75fb2, 7cecf795236, ea57360912ed, a9779ef3c193 | introducing commit and later | all fail with -ea (infinite read-back; rescoring trips VectorUtil.java:149); -da also at 50c1e6acb7f, 55fd2e75fb2 and ea57360912ed: rescoring ranks the exact match last |
upstream main 2f424fd428d, branch_11x 603af870375 | read | identical files, not executed; branch_11x contains 50c1e6acb7f |
parent 091a987a14d | not affected | no fp16 dequantized view: the view tests are skipped |
| 10.5.2, 9.12.3 | not affected | no float16 vector API: the repro does not compile |
10.6.0, branch_10_6 | read | no KnnFloat16VectorField, no FLOAT16 encoding, no fp16 view; 10.6.0 does not contain 50c1e6acb7f |
Confirmed (medium-high) by an adjudication: an independent reproducer and an intended-behaviour devil's advocate said real; reviews by a reviewer from another model family (OpenAI GPT-5.6) reproduced it and agreed on cause and fix but voted not real on contract grounds. Re-verified on main 645d4fe14a20; this doc's gradle run validated the fix.
Checks and verdicts303 words
All checks agree on the root cause and that the clamp fixes it. The dissents are about the contract (the helper's javadoc and the file-rewrite trigger) and are answered in the devil's-advocate check.
| Check | Verdict | What it found |
|---|---|---|
| cross-model review, refute lens (OpenAI GPT-5.6) | not real, high confidence | Reproduced it with its own witness (source 64000, float reconstruction 75063.82, float16 Infinity), agreed on the root cause and the fix; held the inputs out of contract. |
| independent reproducer | real | Own repro and witness: all 5 encodings x EUCLIDEAN and MAXIMUM_INNER_PRODUCT, with -ea and with -da; with -da also 10/10 iterations and the scalar vectorization provider; raw-kept and quarter-scale controls pass; the fix passes. |
| cross-model review, intended lens (OpenAI GPT-5.6) | not real, medium confidence | Reproduced it and ran the fix against the upstream suites (7/7, 64 run, 0 failed); called it works-as-designed (helper javadoc, CheckIndex-rejected state; CheckIndex in fact reports this index clean), adding that maintainers may accept the clamp as robustness. |
| intended-behaviour devil's advocate | real, medium-high | The FLOAT32 view stays finite on the same data and ranks the exact match first; upstream supports and bug-fixes the dropped-raw mode; asked for a javadoc change with the clamp. |
| adjudication | confirmed, medium-high | Sided with the independent reproducer and the devil's advocate (real), with a written answer to the intended-lens review; re-ran the reproducer's test with a new seed (fails under -ea and -da, passes with the fix). |
| main refreshes | still reproduces | Re-run on main a9779ef3c193 and 645d4fe14a20 with the same failures; reduced to a one-method test on 2026-10-29. |
| brief validation (gradle) | fix A validated | Repro fails on stock main with assertions on and off and passes with fix A; 149 tests in 6 core classes and the core module's static checks pass. |
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 462 KB
Finding record, Lucene and upstream sources, repro files and diagram notes894 words
- Registry title
- FLOAT16 vectors read back from a quantized index whose raw vectors were dropped can contain +-Infinity, so full-precision rescoring ranks an exact match last
- Area
- codecs / vectors
- Priority
- Low, rank 1168: FLOAT16 vectors read back from a quantized index with raw vectors dropped can be +-Infinity, which needs file surgery plus values near the fp16 limit, in unreleased code.
- Impact class
- silent wrong results
- Impact note
- Users of unreleased main and branch_11x (11.0.0) with a FLOAT16 field on the opt-in Lucene104 scalar-quantized format whose raw vectors were dropped (the read-only fallback, made by rewriting index files after IndexWriter closes), EUCLIDEAN or MAXIMUM_INNER_PRODUCT, and components near the fp16 limit (default 8-bit encoding: at or within about one quantization step of +-65504; 1-2 bit: tens of thousands are enough); ordinary scale; plain kNN search is unaffected.
- Summary
- You index finite FLOAT16 vectors with large components (for example 60000) in the Lucene104 scalar-quantized format, then drop the raw vectors the way the format's own tests do (simulateEmptyRawVectors), so reads go through the dequantized fp16 view. You expect getFloat16VectorValues().vectorValue() to return an approximation of each stored vector, and full-precision rescoring with a document's own vector to rank that document first. Instead OptimizedScalarQuantizer.deQuantize(byte[], short[], ...) narrows a slightly out-of-range float reconstruction to fp16 without saturating, so 60000 comes back as Infinity, and rescoring gives the exact match the score 0.0 and ranks it last (with -ea, an AssertionError in VectorUtil.squareDistance).
- Introduced
- #16473 (50c1e6acb7f) [read: git log -S/-G and git blame on the narrowing line and the view; exec: the history repro fails at 50c1e6acb7f, and its parent 091a987a14d has no fp16 dequantized view] (2026-08-26)
- First affected version
- unreleased (main and branch_11x, 11.0.0-SNAPSHOT; no release tag contains 50c1e6acb7f)
- Affected versions
- main from 50c1e6acb7f (2026-08-26) through 645d4fe14a2 [exec: 50c1e6acb7f, 8d999e9487d, 55fd2e75fb2, 7cecf795236, ea57360912ed and 645d4fe14a2 (2026-10-29, -ea and -da) fail]; 10.5.2 and 9.12.3 not affected (no fp16 vector API, the repro does not compile) [exec]; branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b have no FLOAT16 encoding and no fp16 deQuantize [read 2026-10-29]; branch_11x 603af870375 contains 50c1e6acb7f and has both files unchanged [read 2026-11-01]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- formal: Z3
- Upstream and prior reports
- none found (2026-10-25: 5 scripted prior-report searches with 48 GitHub issue/PR, 9 commit, 6 downstream, 100 mailing-list and 16 JIRA-archive queries; 62 manual read-only GitHub searches; file lists of all 412 open PRs; review threads of 8 fp16 PRs; 15 web queries); related prior items: #16473 (introduced the code), #16383 (input-side checkFiniteFloat16), #16415 and #15576 (FLOAT32 dropped-raw view bugs fixed as bugs), #15630 / #13158 (open work toward writer-produced empty raw vectors); open #16790 and #16871 touch nearby code but not the narrowing line
- Lucene commit tested
- main
645d4fe14a20: the minimal repro with assertions on and off standalone and through gradle; the saturating fix in a scratch worktree with 6 core test classes (149 tests) and:lucene:core:check -x test; a probe printing the interval, bytes and float reconstruction; the finder's and the independent reproducer's rescoring tests. Main50c1e6acb7f(#16473),8d999e9487d,55fd2e75fb2,7cecf795236,ea57360912ed,a9779ef3c193: fail. Parent091a987a14d: no fp16 view. 10.5.2 and 9.12.3: no fp16 API (the repro does not compile). 10.6.0 andbranch_10_6: no fp16 view (read). Upstream main2f424fd428dandbranch_11x603af870375: identical files (read).
- Finding record
lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java:269-297 (javadoc 'narrowing each reconstructed value to float16'; unclamped Float.floatToFloat16 at :294)lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloat16VectorValues.java:39-48 (view javadoc 'with some precision loss relative to the original float16 vectors', @lucene.internal), :106-127 (vectorValue -> deQuantize)lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsReader.java:327-344 (dropped-raw FLOAT16 branch)lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:144-151 (squareDistance(short[], short[]) asserts a finite result)lucene/core/src/java/org/apache/lucene/document/KnnFloat16VectorField.java:69-73 (checkFiniteFloat16 on field values)lucene/core/src/test/org/apache/lucene/util/quantization/TestOptimizedScalarQuantizer.java:81 (testDeQuantizeFloat16MatchesFloat, plain narrowing on in-range data)lucene/core/src/test/org/apache/lucene/codecs/lucene104/TestLucene104ScalarQuantizedVectorsFormat.java:614 (simulateEmptyRawVectors); lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseKnnVectorsFormatTestCase.java:2275 ('CheckIndex rightly rejects')SECURITY.md:28, 30-34, 48-50 (threat model)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsReader.java:307-347 (dropped-raw FLOAT16 branch :327-344)lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloat16VectorValues.java:39-48 (javadoc), :105-127 (vectorValue -> deQuantize :117-123), :239, :247 (scorer)lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java:40 (@lucene.experimental), :187-237 (scalarQuantize; centring :205, min/max :206-207, initial interval :217-218, optimizeIntervals :219, :336), :270-297 (deQuantize(byte[], short[], ...); narrowing :294)lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloatVectorValues.java:39-51 (javadoc naming simulateEmptyRawVectors), :130, :143 (float[] overload)lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:144-151 (squareDistance(short[], short[]) asserts a finite result)lucene/core/src/java/org/apache/lucene/search/RescoreTopNQuery.java:302-306; lucene/core/src/java/org/apache/lucene/search/FullPrecisionFloat16VectorSimilarityValuesSource.java:79-87lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/Float16KnnVectorFieldSource.java:71lucene/core/src/test/org/apache/lucene/codecs/lucene104/TestLucene104ScalarQuantizedVectorsFormat.java:614 (simulateEmptyRawVectors)
- Who is affected
lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsWriter.java:903-905 (COSINE scaled to unit length); lucene/core/src/java/org/apache/lucene/index/VectorSimilarityFunction.java:53-57 (DOT_PRODUCT javadoc)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloat16VectorValues.java:43-45, :47lucene/core/src/test/org/apache/lucene/codecs/lucene104/TestLucene104ScalarQuantizedVectorsFormat.java:444-449lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseKnnVectorsFormatTestCase.java:2187-2189, 2275lucene/core/src/java/org/apache/lucene/document/KnnFloat16VectorField.java:72; lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:149lucene/core/src/test/org/apache/lucene/util/quantization/TestOptimizedScalarQuantizer.java:81-113lucene/CHANGES.txt (GITHUB#14792, GITHUB#15476 in 10.4.0; GITHUB#16415 in 10.5.1; GITHUB#16473 in 11.0.0)
- Prior reports
a Lucene clone (2026-11-01): git rev-parse <ref>:<path> of OptimizedScalarQuantizer.java and OffHeapScalarQuantizedFloat16VectorValues.java at origin/main, origin/branch_11x and 645d4fe14a20
- History
a Lucene clone (2026-11-01): git rev-parse <ref>:<path> for releases/lucene/10.6.0, origin/branch_10_6, origin/branch_11x, origin/main; git merge-base --is-ancestor 50c1e6acb7f releases/lucene/10.6.0 (false), origin/branch_11x (true)a Lucene main checkout: git blame -L 294,294 OptimizedScalarQuantizer.java; git blame -L 43,45 OffHeapScalarQuantizedFloat16VectorValues.java (50c1e6acb7f2)
- TestFloat16DequantizedViewOverflow.java (JUnit, one test):
repro/LFV-084/src/org/apache/lucene/codecs/lucene104/TestFloat16DequantizedViewOverflow.java(107 lines)
Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.
assets/LFV-084/bug.png(462 KB)
