All findings · Lucene finding · LFV-065 · packed-nibble-odd-dimension

PACKED_NIBBLE quantized vectors with an odd dimension fail once raw vectors are dropped

With 4-bit PACKED_NIBBLE quantization and an odd dimension, dropping the full-precision vectors breaks every vector read: vectorValue() and full-precision rescoring throw vector dimensions differ: 3 != 2 * 2 for 3 dims, because 4 padded nibbles are unpacked into 3 slots. Opt-in and read-only, since 10.4.0; known from #16030, whose author fixed this line before the revert undid it.

Summary

Priority
Low, rank 1170PACKED_NIBBLE quantized vectors with an odd dimension fail once raw vectors are dropped, which no public API does, loud and contrived.
Impact
crash
Since
10.4.0 (2025-11, #15415)introduced 2025-11-13
Affected
10.4.0 to 10.6.0 and main; only the opt-in PACKED_NIBBLE encoding, odd dimensions, raw vectors dropped
Status
confirmed
Found by
non-formal: code reading while modelling quantization (study report: code review + exhaustive layout check)

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

The finding in 30 seconds

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.

Background: 4-bit packing and dropped raw vectors

  1. 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.
  2. PACKED_NIBBLE uses 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.
  3. Read-only searcher copies may delete the full-precision vectors to save disk. vectorValue() then rebuilds approximate floats from the codes: the "fallback".
A 3-dim vector {0.1, 0.2, 0.3} is quantized to 3 four-bit codes and packed into 2 bytes: byte 0 holds codes 0 and 2, byte 1 holds code 1 and a padding nibble. With the full-precision vectors dropped, vectorValue() unpacks the 2 bytes, which needs 4 slots, into a buffer of 3, and VectorUtil.int4Unpack throws 'vector dimensions differ: 3 != 2 * 2'. A 4-dim vector fills 4 slots exactly and reads back.
A 3-dim vector's 4-bit codes are stored as 4 nibbles in 2 bytes, one of them padding; the fallback reader unpacks them into a 3-slot buffer, and the unpack refuses: 3 != 2 * 2. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document with the 3-dim vector {0.1, 0.2, 0.3} using Lucene104ScalarQuantizedVectorsFormat(PACKED_NIBBLE). The 4-bit codes are stored two per byte, so 3 codes become 4 nibbles (one is padding) in 2 bytes.
  2. 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.
  3. You expect getFloatVectorValues("v").vectorValue(0) to return the dequantized vector, about {0.1, 0.2, 0.3}. A 4-dim vector does.
  4. 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.
  5. 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 throw ArrayIndexOutOfBoundsException instead.

Who is affected

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.

The code (main 645d4fe14a20, OffHeapScalarQuantizedFloatVectorValues.java)

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);
  • unpackNibbles is VectorUtil.int4Unpack (OffHeapScalarQuantizedVectorValues.java:191-193), which throws IllegalArgumentException("vector dimensions differ: " + unpacked.length + " != 2 * " + packed.length) unless unpacked.length == packed.length * 2 (VectorUtil.java:313-318). For odd d, 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); getDiscreteDimensions rounds 3 up to 4 and getDocPackedLength gives 2 bytes (:136-139, :161-166). The writer quantizes into new byte[encoding.getDiscreteDimensions(dim)] (Lucene104ScalarQuantizedVectorsWriter.java:190) and packs with packNibbles (: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), and ScalarQuantizedVectorValues.vectorValue delegates 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 own unpackNibbles). 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 20 PACKED_NIBBLE odd-dimension cases.
  • The data is fine: reading the stored codes into a padded buffer and dequantizing the first dim of 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 that CheckIndex rejects (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.

Minimal repro

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.

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

import org.apache.lucene.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);
        }
      }
    }
  }
}
Command
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 * 2 from VectorUtil.int4Unpack, with assertions on and off.

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

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/.vemf files 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's DIRECT_MONOTONIC_BLOCK_SHIFT). It cuts .vec to its header and footer and rewrites .vemf with the same header and field metadata but a vector data length of 0 and a vector count of 0; the quantized .veq/.vemq stay as written. The result is an index CheckIndex rejects, which upstream's own fallback tests allow for by switching CheckIndex off (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 ScalarEncoding import differs (it was nested in Lucene104ScalarQuantizedVectorsFormat).
  • As the regression test: this same method. Upstream it can live in TestLucene104ScalarQuantizedVectorsFormat and call that class's own simulateEmptyRawVectors(dir) (not run in that form). Let it check values too: the last line becomes assertArrayEquals(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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
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)

Observed output

# 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

Why it looks unintended

  • 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_NIBBLE dimensions with the padded length.
  • The fallback tests skip odd dims only via a dim % 2 guard 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 in TestLucene104ScalarQuantizedVectorsFormat.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; TestLucene104HnswScalarQuantizedVectorsFormat opts 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_NIBBLE branch (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 copy Lucene99ScalarQuantizedVectorsWriter.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 the deQuantize loop bound to dequantized.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.

Suggested fix

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 ArrayIndexOutOfBoundsException instead 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 dimension codes 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.* and org.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.

B. Unpack into a separate padded scratch and copy the real codes (validated by the checks)

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 dimension bytes per read, only on the PACKED_NIBBLE fallback path, and one more small array per view.

What the checks validated

  • Gradle, seed DEADBEEF: the minimal test, TestLucene104ScalarQuantizedVectorsFormat and TestLucene104HnswScalarQuantizedVectorsFormat: 130 tests, 4 skipped, 0 failures.
  • Standalone: the study's 5 dropped-raw tests pass and its 200-case matrix reads back every vector.

Downstream note

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.

How it was found

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 a RescoreTopNQuery.createFullPrecisionRescorerQuery query, the float16 twin fails, the dim 4 control and plain kNN pass, the 200-index matrix isolates PACKED_NIBBLE x 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").

Prior reports, history and checks

Prior reports

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).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, incl. migrated JIRA)2026-10-22 and 2026-11-0117 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 projects2026-11-017 queries over four downstream search projectsone unrelated test-only change in another codec
Mailing lists (dev@, java-user@, issues@, commits@, builds@)2026-11-0120 scripted queries and 30 manual queriesno report
JIRA archive and web2026-11-014 JIRA queries (0 results), 6 web queriesnothing relevant

History

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 copy eb0173245c8, 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 with new byte[dimension] and the PACKED_NIBBLE unpack.
  • Error form: before #16631 (2026-09-09) the unpack was a plain loop: AssertionError with assertions on, ArrayIndexOutOfBoundsException with them off. Since then VectorUtil.int4Unpack checks lengths and throws IllegalArgumentException.
  • 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 revert c72bc6abf18 (2026-09-25) restored the bug. 10.6.0 contains both.
  • Upstream: no commit after 645d4fe14a20 touches the two views, OffHeapScalarQuantizedVectorValues or int4Unpack (upstream main checked 2026-11-01), and no open PR touches the two views.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (gradle and standalone); passes with fix A or B
10.6.0 affectedfails with assertions on and off: IllegalArgumentException
10.5.0, 10.5.1, 10.5.2 affectedfail: ArrayIndexOutOfBoundsException (assertions off), AssertionError (on)
10.4.0 first releasefails like 10.5.x (import-adjusted copy of the test)
10.3.2 not affectedno codecs/lucene104 package: the test does not compile
4-dim vector, same steps not affectedpasses on main, 10.4.0 and 10.6.0

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier realOwn 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 reproducesStudy tests, 7 run, 3 failed, assertions on and off.
minimal repro verifiedFails on main with assertions on and off and two seeds; even-dim control passes; fix sketch passes.
current-main verification realMain and 10.5.2 fail with assertions on and off; the fix sketch passes.
cross-model review, refute lens not real (contract), highOpenAI GPT-5.6: reproduced, root cause agreed; judged the dropped-raw state out of contract.
Second adjudication realPR 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 BScratch 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 foundRe-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 errorsOpenAI 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.

Diagram file

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

Sources and provenance

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

Finding record

LFV-065, snapshot of 2026-11-06

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.

Lucene and upstream sources

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)

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

Repro files

About the diagrams

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