The dequantizing read view sizes its unpack buffer as new byte[dimension]. For an odd dimension PACKED_NIBBLE stores one padding nibble, so VectorUtil.int4Unpack needs dimension + 1 slots and throws. The writer and the quantized scorer size the same buffer with the padded length; only the two dequantizing views do not.
- A scalar-quantized vector field keeps two copies of each vector: the full-precision floats (
.vec) and small integer codes with a few correction values (.veq). kNN search scores with the codes. PACKED_NIBBLEuses 4-bit codes, two per byte. An odd dimension is rounded up: 3 codes take 4 nibbles in 2 bytes, byte 0 holding codes 0 and 2, byte 1 holding code 1 and a padding nibble.- Read-only searcher copies may delete the full-precision vectors to save disk.
vectorValue()then rebuilds approximate floats from the codes: the "fallback".
- Index one document with the 3-dim vector
{0.1, 0.2, 0.3}usingLucene104ScalarQuantizedVectorsFormat(PACKED_NIBBLE). The 4-bit codes are stored two per byte, so 3 codes become 4 nibbles (one is padding) in 2 bytes. - Drop the full-precision vectors the way Lucene's own test helper
simulateEmptyRawVectors()does; the reader's javadoc names it for read-only indexes. The quantized codes stay on disk. - You expect
getFloatVectorValues("v").vectorValue(0)to return the dequantized vector, about{0.1, 0.2, 0.3}. A 4-dim vector does. - You get
IllegalArgumentException: vector dimensions differ: 3 != 2 * 2. The reader unpacks the 2 bytes into a 3-slot buffer, but unpacking needs 2 × 2 = 4 slots. - Every vector of the field fails, and so does a search with a full-precision rescore query (
RescoreTopNQuery.createFullPrecisionRescorerQuery); plain kNN search still works. Any odd dimension fails, up to 1023 (1023 != 2 * 512); with assertions off, 10.4.0 to 10.5.2 throwArrayIndexOutOfBoundsExceptioninstead.
Only users of the opt-in Lucene104 scalar-quantized formats with the non-default PACKED_NIBBLE encoding, an odd dimension, and full-precision vectors removed by rewriting files (an index CheckIndex rejects), as for read-only searcher replicas. Then no vector of that field can be read or rescored. Loud, no wrong results, data intact; one document is enough.
Mechanism, terms and numbers453 words
Lucene104ScalarQuantizedVectorsFormat and its HNSW variant can rebuild approximate float vectors from the quantized codes when a read-only index has had its full-precision vectors dropped (the fallback added by #15415 in 10.4.0). With ScalarEncoding.PACKED_NIBBLE, 4-bit codes are packed two per byte and an odd dimension is padded with one nibble: a 3-dim vector is 2 bytes, 4 nibbles.
The dequantizing view allocates its unpack buffer as new byte[dimension] (3), but VectorUtil.int4Unpack needs exactly twice the packed length (4), so every read of an odd-dimension field throws, and full-precision rescoring throws with it. The writer and the quantized scorer already size this buffer with the padded length, the data on disk is intact, and #16030's author fixed these lines on purpose ("it would occasionally compute the wrong size and tests would fail"); the fix was lost when #16030 was reverted.
int docPackedLength = encoding.getDocPackedLength(dimension); // :91, (3 + 1) / 2 = 2 bytes
...
this.unpackedByteVectorValue = new byte[dimension]; // :96, 3 slots
...
case PACKED_NIBBLE -> // :123-124
OffHeapScalarQuantizedVectorValues.unpackNibbles(byteValue, unpackedByteVectorValue);unpackNibblesisVectorUtil.int4Unpack(OffHeapScalarQuantizedVectorValues.java:191-193), which throwsIllegalArgumentException("vector dimensions differ: " + unpacked.length + " != 2 * " + packed.length)unlessunpacked.length == packed.length * 2(VectorUtil.java:313-318). For oddd,d != 2 * ((d + 1) / 2), so every odd dimension fails and every even one is exact.- The layout:
PACKED_NIBBLE(1, (byte) 4, 4)(QuantizedByteVectorValues.java:39);getDiscreteDimensionsrounds 3 up to 4 andgetDocPackedLengthgives 2 bytes (:136-139,:161-166). The writer quantizes intonew byte[encoding.getDiscreteDimensions(dim)](Lucene104ScalarQuantizedVectorsWriter.java:190) and packs withpackNibbles(:551;OffHeapScalarQuantizedVectorValues.java:182-188): for dim 3, byte 0 holds codes 0 and 2, byte 1 holds code 1 and the padding nibble. - The route: when the raw float vectors have size 0, the reader returns this view (
Lucene104ScalarQuantizedVectorsReader.java:278-297), andScalarQuantizedVectorValues.vectorValuedelegates to it (:769-771). The float16 twin has the same lines (OffHeapScalarQuantizedFloat16VectorValues.java:92,:135-136; reader:326-344; main and branch_11x only). - Everyone else pads: the writer (
Lucene104ScalarQuantizedVectorsWriter.java:190, 243, 431, 942) and the quantized scorer (Lucene104ScalarQuantizedVectorScorer.java:226-230,new byte[OptimizedScalarQuantizer.discretize(values.dimension(), 2)]before its ownunpackNibbles). So indexing, kNN search and merges work for odd dimensions while raw vectors are present. - The other encodings are fine: the 1-bit and 2-bit unpackers stop at the output length (
OptimizedScalarQuantizer.java:460-471,:527-551) and 8-bit / 7-bit need no unpack (OffHeapScalarQuantizedFloatVectorValues.java:129-139). An end-to-end matrix of 5 encodings x dims 1..40 fails in exactly the 20PACKED_NIBBLEodd-dimension cases. - The data is fine: reading the stored codes into a padded buffer and dequantizing the first
dimof them works for odd dims 1..41 (worst absolute error 0.076).
Exposure and likelihood234 words
- Who. Read-only searcher copies, for example NRT segment-replication replicas, that drop the full-precision vectors to save disk (the use case behind #14792 and the open issue #13158), with the Lucene104 scalar-quantized format,
PACKED_NIBBLE, and an odd dimension. - Opt-in. The default codec does not quantize; the Lucene104 scalar-quantized formats default to
UNSIGNED_BYTE(Lucene104ScalarQuantizedVectorsFormat.java:144-147) and are@lucene.experimental(:97). Every odd dimension from 1 to 1023 is legal (maximum 1024,:168-170), and odd dimensions index, search and read fine while raw vectors are present. - Reachability. No Lucene API writes the dropped-raw state; it takes file rewriting like
TestLucene104ScalarQuantizedVectorsFormat.simulateEmptyRawVectors()(:614-682), and the result is an index thatCheckIndexrejects (BaseKnnVectorsFormatTestCase.java:2274-2276). Two upstream efforts would make it easy to create: the open PR #15630 proposes writer support for empty full-precision vector files, and #16030 added a write-side mode that drops the float vectors (DATA_BLIND_WITHOUT_FLOATS; on main and branch_10x 2026-09-18 to 2026-09-25, never released; its feature request #16029 is still open). - Effect. Every
vectorValue(ord)of the field throws, and so do a search with a full-precision rescore query (RescoreTopNQuery.createFullPrecisionRescorerQuery) and, on main, the float16 view. Plain quantized kNN search works. Deterministic, the same with assertions on and off; no index damage, and a fixed reader reads the same files. - Scale. One document; the flat and the HNSW format, dense and sparse fields, dims 1 to 1023.
One JUnit test, deterministic: one document with a 3-dim vector in the flat Lucene104ScalarQuantizedVectorsFormat(PACKED_NIBBLE), the raw vectors dropped with a private copy of Lucene's own simulateEmptyRawVectors() test helper, then vectorValue(0). The try/catch only labels the failure.
/*
* 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.codecs.CodecUtil;
import org.apache.lucene.codecs.lucene95.OrdToDocDISIReaderConfiguration;
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.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;
import org.apache.lucene.util.quantization.QuantizedByteVectorValues.ScalarEncoding;
public class TestPackedNibbleOddDimensionWithoutRawVectors extends LuceneTestCase {
public void testVectorValueWithOddDimension() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
IndexWriterConfig iwc =
new IndexWriterConfig()
.setCodec(
TestUtil.alwaysKnnVectorsFormat(
new Lucene104ScalarQuantizedVectorsFormat(ScalarEncoding.PACKED_NIBBLE)))
.setUseCompoundFile(false);
try (IndexWriter w = new IndexWriter(dir, iwc)) {
Document doc = new Document();
doc.add(new KnnFloatVectorField("v", new float[] {0.1f, 0.2f, 0.3f}));
w.addDocument(doc);
}
simulateEmptyRawVectors(dir);
try (DirectoryReader reader = DirectoryReader.open(dir)) {
float[] v;
try {
v = getOnlyLeafReader(reader).getFloatVectorValues("v").vectorValue(0);
} catch (RuntimeException e) {
// expected: the dequantized 3-dim vector; actual: IllegalArgumentException from int4Unpack
throw new AssertionError("expected the dequantized 3-dim vector, got " + e, e);
}
assertEquals(3, v.length);
}
}
}
private static void simulateEmptyRawVectors(Directory dir) throws Exception {
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 $REPRO/TestPackedNibbleOddDimensionWithoutRawVectors.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene104/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene104.TestPackedNibbleOddDimensionWithoutRawVectors -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same exception)
- Expected
vectorValue(0)returns the dequantized 3-dim vector (about{0.1, 0.2, 0.3}), as it does for a 4-dim vector.- Observed
IllegalArgumentException: vector dimensions differ: 3 != 2 * 2fromVectorUtil.int4Unpack, with assertions on and off.
More files, environment, full output415 words
- Smallest input. One document, dimension 3 (odd, so the 2 packed bytes hold codes in both halves plus one padding nibble; dimension 1 fails too), no compound file so the raw
.vec/.vemffiles can be rewritten,ByteBuffersDirectory, one thread; the same result with assertions on and off and with any seed. - The drop helper is a copy of
TestLucene104ScalarQuantizedVectorsFormat.simulateEmptyRawVectors()(:614-682; block shift 16 is that class'sDIRECT_MONOTONIC_BLOCK_SHIFT). It cuts.vecto its header and footer and rewrites.vemfwith the same header and field metadata but a vector data length of 0 and a vector count of 0; the quantized.veq/.vemqstay as written. The result is an indexCheckIndexrejects, which upstream's own fallback tests allow for by switchingCheckIndexoff (BaseKnnVectorsFormatTestCase.java:2274-2276). - Control (not part of the repro): the same test with a 4-dim vector passes on main, 10.4.0 and 10.6.0.
- Releases. The same file fails on 10.5.0, 10.5.1, 10.5.2 and 10.6.0; on 10.4.0 only the
ScalarEncodingimport differs (it was nested inLucene104ScalarQuantizedVectorsFormat). - As the regression test: this same method. Upstream it can live in
TestLucene104ScalarQuantizedVectorsFormatand call that class's ownsimulateEmptyRawVectors(dir)(not run in that form). Let it check values too: the last line becomesassertArrayEquals(new float[] {0.1f, 0.2f, 0.3f}, v, 1f / 16), since a length check alone would also pass a broken fix that returns any 3 values. That line also fails before and passes with fix A and with fix B.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp $REPRO/TestPackedNibbleOddDimensionWithoutRawVectors.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene104/
cd $LUCENE
./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene104.TestPackedNibbleOddDimensionWithoutRawVectors -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same exception)
# gradle, main 645d4fe14a20, seed DEADBEEF, assertions on
TestPackedNibbleOddDimensionWithoutRawVectors > testVectorValueWithOddDimension FAILED
java.lang.AssertionError: expected the dequantized 3-dim vector, got java.lang.IllegalArgumentException: vector dimensions differ: 3 != 2 * 2
at __randomizedtesting.SeedInfo.seed([DEADBEEF:BF7D0792B33E1E60]:0)
at org.apache.lucene.codecs.lucene104.TestPackedNibbleOddDimensionWithoutRawVectors.testVectorValueWithOddDimension(TestPackedNibbleOddDimensionWithoutRawVectors.java:57)
Caused by:
java.lang.IllegalArgumentException: vector dimensions differ: 3 != 2 * 2
at org.apache.lucene.util.VectorUtil.int4Unpack(VectorUtil.java:315)
at org.apache.lucene.codecs.lucene104.OffHeapScalarQuantizedVectorValues.unpackNibbles(OffHeapScalarQuantizedVectorValues.java:192)
at org.apache.lucene.codecs.lucene104.OffHeapScalarQuantizedFloatVectorValues.vectorValue(OffHeapScalarQuantizedFloatVectorValues.java:124)
at org.apache.lucene.codecs.lucene104.Lucene104ScalarQuantizedVectorsReader$ScalarQuantizedVectorValues.vectorValue(Lucene104ScalarQuantizedVectorsReader.java:770)
at org.apache.lucene.codecs.lucene104.TestPackedNibbleOddDimensionWithoutRawVectors.testVectorValueWithOddDimension(TestPackedNibbleOddDimensionWithoutRawVectors.java:54)
1 test completed, 1 failed
# gradle, assertions off (-Ptests.asserts=false): the same exception
# 10.6.0, assertions on and off: the same IllegalArgumentException
# 10.4.0, 10.5.0, 10.5.1, 10.5.2, assertions off: ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3
# (at OffHeapScalarQuantizedVectorValues.unpackNibbles); assertions on: java.lang.AssertionError from unpackNibbles
# the same test with the 4-dim vector {0.1, 0.2, 0.3, 0.4}: passes (main, 10.4.0, 10.6.0)
# with fix A or fix B: BUILD SUCCESSFUL- The reader's javadoc promises dequantized vectors for read-only indexes whose full-precision vectors were dropped; #15476 made this a format contract.
- No javadoc limits parity; Lucene104's writer and scorer handle odd
PACKED_NIBBLEdimensions with the padded length. - The fallback tests skip odd dims only via a
dim % 2guard inherited from Lucene99, which rejects odd 4-bit dims.
The full argument408 words
- Documented capability.
OffHeapScalarQuantizedFloatVectorValues.java:39-54: it dequantizes stored vectors "back to float vectors with some precision loss"; "This class is used for read-only indexes where full-precision float vectors have been dropped from the index to save storage space. Full-precision vectors can be removed from an index using a method as implemented inTestLucene104ScalarQuantizedVectorsFormat.simulateEmptyRawVectors()." - Format contract.
BaseKnnVectorsFormatTestCase.java:132(supportsFloatVectorFallback());:2187-2189: formats must "properly dequantize vectors and maintain accuracy within expected error bounds even when the original raw vector file is empty or corrupted";:2255-2260: scores must not change once the raw file is emptied. These tests run for the flat format, which the repro uses;TestLucene104HnswScalarQuantizedVectorsFormatopts out (supportsFloatVectorFallback()returns false,:491-493), so the HNSW route is shown by execution only (the study's tests and a dim-1023 probe). CHANGES: #15415 under 10.4.0 New Features (lucene/CHANGES.txt:1378), #15476 (:1527), #16415 under 10.5.1 Bug Fixes (:894). - This encoding is in scope. The fallback has an explicit
PACKED_NIBBLEbranch (OffHeapScalarQuantizedFloatVectorValues.java:123-124), and the Lucene104 fallback tests pick the encoding at random from all values (TestLucene104ScalarQuantizedVectorsFormat.java:81-86). - Odd dimensions are in scope. No javadoc, CHANGES or MIGRATE entry limits parity, and upstream's own Lucene104 search and write/read tests draw dims from 4..64 (
TestLucene104ScalarQuantizedVectorsFormat.java:98,:291). - Why tests miss it.
BaseKnnVectorsFormatTestCase.java:2196-2199,:2266-2269(if (dim % 2 == 1) dim++);TestLucene104ScalarQuantizedVectorsFormat.java:452,:529(dim = 2 * random().nextInt(1, 33)). The guard first appeared in the Lucene99 fallback test of #14792; the Lucene99 writer rejects odd dimensions for 4 bits ("is not supported for odd vector dimensions", test copyLucene99ScalarQuantizedVectorsWriter.java:198-204). #15415 copied the guard and #15476 moved it into the shared base class, which still serves Lucene99. - The PR author called the old size wrong. #16030 (mccullocht, merged 2026-09-18) changed both views to
new byte[encoding.getDiscreteDimensions(dimension)]and thedeQuantizeloop bound todequantized.length. In review, kaivalnp asked about the buffer line (2026-09-10): "I didn't get why this had to change? Is it required independently of this PR?"; mccullocht replied (2026-09-17): "This is an independent change -- it would occasionally compute the wrong size and tests would fail." #16030 was reverted on 2026-09-25 (c72bc6abf18), which restored the bug. The revert commit gives no reason; in the PR discussion dweiss named flaky CI tests and merge inputs whose wrappers defeat the data-blind mode's raw-vector detection (2026-09-23), and mccullocht agreed ("Rolling back is fine"); neither concerns this buffer.
The devil's-advocate check209 words
Strongest case for "not a bug" (a cross-model reviewer, OpenAI GPT-5.6, who reproduced it and agreed on the cause): no production API drops raw vectors, only a test hook does; CheckIndex "rightly rejects" such an index (BaseKnnVectorsFormatTestCase.java:2275); the class is @lucene.internal, the format @lucene.experimental; and the fallback tests deliberately use even dimensions.
Why it fails: the committers designed the fallback knowing this. msokolov, reviewing #14792: "it provides the ability to read an index that we don't actually provide any way to create!", and they asked for the usage note that became the class javadoc ("full precision vectors can be removed from an index using a method like shown in the tests ... should be treated as a read-only index"). benwtrent on #14792: "When the vec file isn't present, the format should still return a FloatVectorValues, and when folks call float[] getVector(int ord), they get the de-quantized representation of the vector." mikemccand backed #15476 as "the test infra to confirm future KNN Codecs on 10.x also preserve this capability". Every upstream fallback test turns CheckIndex off and still requires correct reads; the failure comes through public LeafReader#getFloatVectorValues and RescoreTopNQuery; and the even-dimension guard comes from Lucene99's write-side restriction, not from a Lucene104 limit.
Recommended A. Pad the buffer, dequantize only the real dimensions (recommended; #16030's lines)
Size the unpack buffer with the padded length, new byte[encoding.getDiscreteDimensions(dimension)], in both dequantizing views, and let OptimizedScalarQuantizer.deQuantize loop over the output length: #16030's lines (its author's "independent change"), lost in its revert. Validated with gradle: the test fails before and passes after; the lucene104 and quantization suites pass (199 tests, 0 failures).
// OffHeapScalarQuantizedFloatVectorValues.java:96 and OffHeapScalarQuantizedFloat16VectorValues.java:92
- this.unpackedByteVectorValue = new byte[dimension];
+ this.unpackedByteVectorValue = new byte[encoding.getDiscreteDimensions(dimension)];
// OptimizedScalarQuantizer.deQuantize, float[] and short[] overloads (:262, :292)
- for (int h = 0; h < quantized.length; h++) {
+ // The quantized input may hold rounded-up dimensions for packed encodings; the output length
+ // defines how many are real.
+ for (int h = 0; h < dequantized.length; h++) {All options and trade-offs2 options · 595 words
Two validated options, both applied in a scratch worktree of main 645d4fe14a20 and run with gradle; both also make the study's 5-encoding x 40-dimension matrix read back every vector. Not recommended: rejecting odd dimensions for PACKED_NIBBLE at write time (breaks a configuration that works with raw vectors present; existing segments still fail), or only enlarging the buffer (moves the failure into deQuantize, which would index dequantized[3] and centroid[3] of length 3). Test gap: the fallback tests could draw odd dimensions for the Lucene104 formats; the dim % 2 guard is needed only by Lucene99.
A. Pad the buffer, dequantize only the real dimensions (recommended; #16030's lines) (validated by the checks)
Allocate the unpack buffer with the padded length in both views, and loop deQuantize over the output length. These are the lines of #16030 by mccullocht (the deQuantize comment is theirs; the comment on the buffer line is not from #16030), without that PR's unrelated COSINE assertion changes, and the "wrong size" diagnosis is theirs too.
// OffHeapScalarQuantizedFloatVectorValues.java:96 and OffHeapScalarQuantizedFloat16VectorValues.java:92
- this.unpackedByteVectorValue = new byte[dimension];
+ this.unpackedByteVectorValue = new byte[encoding.getDiscreteDimensions(dimension)];
// OptimizedScalarQuantizer.deQuantize, float[] and short[] overloads (:262, :292)
- for (int h = 0; h < quantized.length; h++) {
+ // The quantized input may hold rounded-up dimensions for packed encodings; the output length
+ // defines how many are real.
+ for (int h = 0; h < dequantized.length; h++) {Trade-offs
- Four changed lines plus comments; no extra copy; matches the writer and the scorer.
- Changes the loop bound of a public static helper. Lucene's own callers pass equal lengths; an outside caller passing an output longer than its input would now get an
ArrayIndexOutOfBoundsExceptioninstead of a partly filled array. - The 1-bit and 2-bit encodings also get the padded buffer; their unpackers fill it from the padded stored bytes and only the first
dimensioncodes are read (the matrix check below covers them).
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seed DEADBEEF: the minimal test fails before the change and passes after it, with assertions on and off. - Seed DEADBEEF:
TestLucene104ScalarQuantizedVectorsFormat(65 tests, 1 skipped),TestLucene104HnswScalarQuantizedVectorsFormat(64, 3 skipped),TestOptimizedScalarQuantizer(7): 0 failures. - Seed 5EED1234: every test class in
org.apache.lucene.codecs.lucene104.*andorg.apache.lucene.util.quantization.*: 199 tests, 6 skipped, 0 failures. - Standalone, the three patched files shadowed in: the study's 5 dropped-raw tests pass (float32 read, full-precision rescore, the float16 twin, two controls; 3 of them fail without the fix), and its matrix of 5 encodings x dims 1..40 reads back all 200 cases.
- Not run: the whole
:lucene:core:test,check, or the backward-codecs suites.
Keep unpackedByteVectorValue = new byte[dimension] and add a PACKED_NIBBLE-only padded scratch in both views; deQuantize stays as it is. This is the study verifier's fix sketch, with the per-call allocation hoisted into a field.
// in both views
// PACKED_NIBBLE only: int4Unpack also writes the padding nibble of an odd dimension
private final byte[] paddedNibbles;
...
this.paddedNibbles =
encoding == ScalarEncoding.PACKED_NIBBLE
? new byte[encoding.getDiscreteDimensions(dimension)]
: null;
...
case PACKED_NIBBLE -> {
OffHeapScalarQuantizedVectorValues.unpackNibbles(byteValue, paddedNibbles);
System.arraycopy(paddedNibbles, 0, unpackedByteVectorValue, 0, dimension);
}Trade-offs
- Touches only the two package-private views; no public method changes behaviour.
- One extra copy of
dimensionbytes per read, only on thePACKED_NIBBLEfallback path, and one more small array per view.
What the checks validated
- Gradle, seed DEADBEEF: the minimal test,
TestLucene104ScalarQuantizedVectorsFormatandTestLucene104HnswScalarQuantizedVectorsFormat: 130 tests, 4 skipped, 0 failures. - Standalone: the study's 5 dropped-raw tests pass and its 200-case matrix reads back every vector.
Relevant to downstream products that strip full-precision vectors from read-only replicas and use PACKED_NIBBLE with odd dimensions: their vector reads and full-precision rescoring fail with an exception; no data is damaged.
Non-formal. While modelling quantization, a study AI tool read the dequantizing views and saw the buffer size; upstream tests forcing even dimensions pointed at it. An independent verifier then built all 5 encodings x dims 1..40 (200 indexes), dropped the raw vectors and read every vector back: exactly the 20 PACKED_NIBBLE odd-dimension cases fail.
The method in full126 words
- The study's quantization unit found it by code reading while modelling; its report lists "code review + exhaustive layout check", and its own notes credit the even-dimension test guard as the pointer. No solver step produced it.
- An independent verifier wrote its own drop helper and 7 tests on the HNSW format: dim 3 fails for
vectorValue()and for a search with aRescoreTopNQuery.createFullPrecisionRescorerQueryquery, the float16 twin fails, the dim 4 control and plain kNN pass, the 200-index matrix isolatesPACKED_NIBBLEx odd dims, and the stored codes decode correctly with a padded buffer (odd dims 1..41). - The hunt then minimised it to the one-method test, re-ran it on main and on every release with the code, and tie-broke a cross-model review (section "Independent checks").
Known: on #16030 the PR author, mccullocht, answered a review question from kaivalnp about this line: "This is an independent change -- it would occasionally compute the wrong size and tests would fail." The fix was lost when #16030 was reverted; nothing tracks it since. No other report found (2026-10-22, 2026-11-01).
Searches4 searches
The prior report. A public review thread on #16030, anchored on OffHeapScalarQuantizedFloatVectorValues.java:96 (the hunk that replaces new byte[dimension] with new byte[encoding.getDiscreteDimensions(dimension)]): kaivalnp asked (2026-09-10) "I didn't get why this had to change? Is it required independently of this PR?", and mccullocht replied (2026-09-17) "This is an independent change -- it would occasionally compute the wrong size and tests would fail." It names the defect and the fix but not the trigger or the symptom (odd PACKED_NIBBLE dimensions, raw vectors dropped, every read throws). #16030 was reverted on 2026-09-25, which restored the line; no issue or PR has tracked it since. The earlier searches read #16030's description and conversation but not its inline review comments; the independent fact check found the thread.
Closest otherwise, none this bug: #15576 / #15578 (a sibling fallback bug for the DIBIT encoding, fixed in its discretization, not the buffer); #15415 (introduced the code; its test forces even dims); #15476 and #16415 (same state, even dims); #16631 (vectorized int4Unpack, which turned the error into an IllegalArgumentException); #16473 (added the float16 twin); #13872 / #13321 (2024: the Lucene99 4-bit format rejects odd dims by design, padding was suggested); #15630 (open: writer support for empty full-precision files); a downstream project's test-only change forcing even dims for its own 4-bit codec with raw vectors present (different codec and symptom).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene, incl. migrated JIRA) | 2026-10-22 and 2026-11-01 | 17 study queries; 50 issue/PR searches (class and method names, the error message, odd/odd-sized/4-bit/dropped raw phrasings, a sweep since 2026-10-15); the changed files of all 409 open PRs; 30+ related threads read in full | #16030's review thread on the buggy line (the PR author calls the old size wrong); no other report |
| Downstream projects | 2026-11-01 | 7 queries over four downstream search projects | one unrelated test-only change in another codec |
| Mailing lists (dev@, java-user@, issues@, commits@, builds@) | 2026-11-01 | 20 scripted queries and 30 manual queries | no report |
| JIRA archive and web | 2026-11-01 | 4 JIRA queries (0 results), 6 web queries | nothing relevant |
Introduced by #15415 (ba455f2bae1, 2025-11-13), which added the dequantizing fallback in 10.4.0. Fails on 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main; 10.3.2 has no lucene104 codec. #16631 turned the error into an IllegalArgumentException; #16030 fixed it on purpose for a week before its revert. Not fixed upstream.
Versions tested6 rows
- Introduced:
ba455f2bae1"Add fallback support to Lucene104ScalarQuantizedVectorsFormat getFloatVectorValues when there are no full-precision vectors present (#15415)" (2025-11-13), shipped through its branch_10x copyeb0173245c8, which is in exactly the tags 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0. The commit created the view withnew byte[dimension]and thePACKED_NIBBLEunpack. - Error form: before #16631 (2026-09-09) the unpack was a plain loop:
AssertionErrorwith assertions on,ArrayIndexOutOfBoundsExceptionwith them off. Since thenVectorUtil.int4Unpackchecks lengths and throwsIllegalArgumentException. - Float16 twin: added by #16473 (
50c1e6acb7f, 2026-08-26) with the same allocation; on main and branch_11x, not in 10.6.0. - A week with the fix: #16030 (
cab6cfcb604, 2026-09-18) padded the buffer in both views; the revertc72bc6abf18(2026-09-25) restored the bug. 10.6.0 contains both. - Upstream: no commit after
645d4fe14a20touches the two views,OffHeapScalarQuantizedVectorValuesorint4Unpack(upstream main checked 2026-11-01), and no open PR touches the two views.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails with assertions on and off (gradle and standalone); passes with fix A or B |
| 10.6.0 | affected | fails with assertions on and off: IllegalArgumentException |
| 10.5.0, 10.5.1, 10.5.2 | affected | fail: ArrayIndexOutOfBoundsException (assertions off), AssertionError (on) |
| 10.4.0 | first release | fails like 10.5.x (import-adjusted copy of the test) |
| 10.3.2 | not affected | no codecs/lucene104 package: the test does not compile |
| 4-dim vector, same steps | not affected | passes on main, 10.4.0 and 10.6.0 |
An independent study verifier, the current-main reproducer, a tie-break and this doc's gradle runs call it real. A cross-model reviewer (OpenAI GPT-5.6) reproduced it and agreed on the cause but voted not real because no Lucene API creates the dropped-raw state; the tie-break settled that from the PR history.
Checks and verdicts271 words
Gap: the cross-model reviewer did not see the upstream PR discussions the tie-break relies on, and it was not re-run after the tie-break.
| Check | Verdict | What it found |
|---|---|---|
| study verifier | real | Own drop helper and 7 tests on the HNSW format; 3 fail on main (float32 read, rescore, float16); the matrix isolates PACKED_NIBBLE x odd dims. |
| reverify on 645d4fe14a20 | still reproduces | Study tests, 7 run, 3 failed, assertions on and off. |
| minimal repro | verified | Fails on main with assertions on and off and two seeds; even-dim control passes; fix sketch passes. |
| current-main verification | real | Main and 10.5.2 fail with assertions on and off; the fix sketch passes. |
| cross-model review, refute lens | not real (contract), high | OpenAI GPT-5.6: reproduced, root cause agreed; judged the dropped-raw state out of contract. |
| Second adjudication | real | PR history settles the contract; 10.4.0, 10.5.2, 10.6.0 run; own probe: dim 1023 fails, dim 1024 passes. |
| doc gradle runs | fails before, passes with A and B | Scratch worktree of 645d4fe14a20. Fix A: the lucene104 and quantization packages pass (199 tests). Fix B: the minimal test and the two Lucene104 scalar-quantized format test classes pass (130 tests). |
| independent fact check | real; a prior report found | Re-ran the repro (gradle and standalone, assertions on and off, three seeds), fix A and fix B; found the #16030 review thread (see Prior reports). |
| cross-model fact check | mechanism agreed; 2 wording errors | OpenAI GPT-5.6: agreed on the mechanism and the runs; its corrections (the rescore factory only builds the query; the .vemf file is rewritten, not emptied) are applied. |
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 447 KB
Finding record, Lucene and upstream sources, repro files and diagram notes518 words
- Registry title
- PACKED_NIBBLE quantized vectors with an odd dimension fail once raw vectors are dropped
- Area
- codecs / vectors
- Priority
- Low, rank 1170: PACKED_NIBBLE quantized vectors with an odd dimension fail once raw vectors are dropped, which no public API does, loud and contrived.
- Impact class
- crash
- Impact note
- Users of the opt-in PACKED_NIBBLE encoding with an odd dimension who drop raw vectors (e.g. searcher replicas); released since 10.4.0.
- Summary
- You use PACKED_NIBBLE scalar quantization with an odd vector dimension and drop the raw vectors. You expect vectorValue() and full-precision rescoring to work from the quantized data. Instead every vectorValue() call and every rescore throws (IllegalArgumentException on 10.6.0 and main; on 10.4.0 to 10.5.2 ArrayIndexOutOfBoundsException with assertions off).
- Introduced
- #15415 (ba455f2bae1) (2025-11-13)
- First affected version
- 10.4.0 [exec]
- Affected versions
- 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0 [exec, -ea and -da]; main 645d4fe14a20 [exec 2026-10-29 and 2026-11-01, -ea and -da]; 10.3.2 and older not affected (no lucene104 codec) [exec 10.3.2]; branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b: OffHeapScalarQuantizedFloatVectorValues.java identical to 10.5.2 [read]
- Status
- confirmed (independent verifiers agreed)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that disputed it (the study's other checks rated it real); not yet triaged by a person.
- Found by
- non-formal: code reading; also reached by exhaustive check
- Upstream and prior reports
- known: #16030 review thread on OffHeapScalarQuantizedFloatVectorValues.java:96 (kaivalnp asked 2026-09-10 why the buffer line changed; mccullocht, the PR author, 2026-09-17: 'This is an independent change -- it would occasionally compute the wrong size and tests would fail'); the fix was lost in the revert c72bc6abf18 (2026-09-25); no issue or PR has tracked it since; no other report found (searches 2026-10-22 and 2026-11-01)
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test through gradle with assertions on and off, before and after each fix, in a scratch worktree (2026-11-01), and standalone (2026-10-29 and 2026-11-01); releases 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0 standalone with assertions on and off; 10.3.2 has no lucene104 codec.
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloatVectorValues.java:91, :96, :122-124, :129-139 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedVectorValues.java:182-188, :191-193 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:313-318 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/quantization/QuantizedByteVectorValues.java:39, :136-139, :161-166 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsReader.java:278-297, :326-344, :769-771 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloat16VectorValues.java:92, :135-136 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsWriter.java:190, :243, :431, :551, :942; lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorScorer.java:226-230 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java:460-471, :527-551 (at 645d4fe14a20)
- Who is affected
lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsFormat.java:97, :144-147, :168-170 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/codecs/lucene104/TestLucene104ScalarQuantizedVectorsFormat.java:614-682 (at 645d4fe14a20)lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseKnnVectorsFormatTestCase.java:2274-2276 (at 645d4fe14a20)
- Minimal repro
lucene/core/src/test/org/apache/lucene/codecs/lucene104/TestLucene104ScalarQuantizedVectorsFormat.java:614-682 (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloatVectorValues.java:39-54, :123-124 (at 645d4fe14a20)lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseKnnVectorsFormatTestCase.java:132, :2187-2189, :2196-2199, :2255-2260, :2266-2269, :2275 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/codecs/lucene104/TestLucene104ScalarQuantizedVectorsFormat.java:81-86, :98, :291, :452, :529 (at 645d4fe14a20)lucene/CHANGES.txt:894, :1378, :1527 (at 645d4fe14a20)lucene/backward-codecs/src/test/org/apache/lucene/backward_codecs/lucene99/Lucene99ScalarQuantizedVectorsWriter.java:198-204 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/codecs/lucene104/TestLucene104HnswScalarQuantizedVectorsFormat.java:491-493 (at 645d4fe14a20)
- Suggested fix
lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java:253-266, :283-297 (at 645d4fe14a20)
- Diagrams
lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedFloatVectorValues.java:91, :96, :123-124; lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:313-318; lucene/core/src/java/org/apache/lucene/codecs/lucene104/OffHeapScalarQuantizedVectorValues.java:182-188 (at 645d4fe14a20)
- TestPackedNibbleOddDimensionWithoutRawVectors.java:
repro/LFV-065/src/org/apache/lucene/codecs/lucene104/TestPackedNibbleOddDimensionWithoutRawVectors.java(106 lines)
assets/LFV-065/bug.png(447 KB)
