All findings · Lucene finding · LFV-237 · knnvectorvalues-fromdisi-advance-ordinal

KnnVectorValues.fromDISI's advance() leaves index() stale, so on Lucene 10 sparse byte vectors in segments written by Lucene 9.4 read back as zeros or as another document's vector

On Lucene 10, sparse byte vectors in a segment written by Lucene 9.4 read back wrong after advance(): index() stays -1, so ByteKnnVectorFieldSource returns [0, 0] for every document that has a vector, and after a nextDoc() you get another document's vector. No error; kNN queries are fine.

Summary

Priority
Low, rank 370Only unmerged segments written by Lucene 9.4.0-9.4.2 with a sparse byte vector field reach it, every merge rewrites them, and kNN queries stay correct.
Impact
silent wrong results
Since
10.0.0 (#13779, 2024)introduced 2024-09-28 · about 2 years
Affected
10.0.0 to 10.6.0 (10.6.0 by reading) and main; only segments written by 9.4.0-9.4.2
Status
documented
Found by
bytecode delegation scan, then a JUnit on a 9.4.2-written index

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Lucene 10 reads Lucene 9.4 segments through backward-codecs. For a byte vector field that only some documents have, that reader's iterator updates the vector's ordinal (index()) only in nextDoc(). advance() moves to the document but leaves the ordinal behind.

Summary card. Title: KnnVectorValues.fromDISI: advance() leaves index() stale, so on Lucene 10, sparse byte vectors in segments written by Lucene 9.4 read back as zeros, or as another doc's vector. Example: a Lucene 9.4.2 segment, docs 0-5; only docs 0, 2, 4 have the byte vector v = [i, i+1] (ordinals 0, 1, 2); read on Lucene main. Step 1: the 9.4 sparse byte reader returns KnnVectorValues.fromDISI(disi), which keeps its own counter ord, starting at -1. Step 2: it.advance(2); docID() becomes 2, but index() stays -1: only nextDoc() does ord++. Step 3: values.vectorValue(it.index()), that is vectorValue(-1); lastOrd also starts at -1, so the never-filled buffer [0, 0] comes back, with no exception. Expected [0, 1], [2, 3], [4, 5] for docs 0, 2, 4; actual [0, 0] for all three. nextDoc() to doc 0, then advance(4): index() is still 0, so doc 4 reads doc 0's vector [0, 1], not [4, 5]. Controls and the fix (advance() stepping with slowAdvance) read every stored vector; affected 10.0.0 to 10.5.2 and main, only segments written by Lucene 9.4.0-9.4.2.
The 9.4 sparse byte reader's iterator counts the ordinal only in nextDoc(): advance(2) moves docID() to 2 but leaves index() at -1, so vectorValue(-1) returns [0, 0]; after a nextDoc(), doc 4 reads doc 0's [0, 1]. Select the still to open it at full size. Generated with AI, checked against the code.
  1. An index still has a segment written by Lucene 9.4.2: 6 documents, and only docs 0, 2 and 4 have a 2-dimension byte vector, [doc, doc + 1], so their ordinals (slots in the vector data) are 0, 1 and 2. You open it with Lucene 10.x or main.
  2. You read doc 2's vector the usual way: it = values.iterator(); it.advance(2); values.vectorValue(it.index()). ByteKnnVectorFieldSource does exactly this. You expect ordinal 1 and [2, 3].
  3. You get ordinal -1 and [0, 0]. This reader's iterator, built by KnnVectorValues.fromDISI, counts the ordinal only in nextDoc(); advance() moves to doc 2 but not the count, and ordinal -1 returns the reader's never-filled buffer. ByteKnnVectorFieldSource reads [0, 0] for docs 0, 2 and 4.
  4. Call nextDoc() first (doc 0, ordinal 0) and then advance(4): index() is still 0, so doc 4 reads doc 0's [0, 1] instead of [4, 5]. No exception, with assertions on or off.
  5. Lucene 9.12.3 reads the same index correctly. kNN queries, float vector fields and segments from 9.5 or later are fine, and a merge rewrites the segment correctly.

Who is affected

Lucene 10.0.0 to 10.6.0 (10.6.0 by reading) and main, when an index still holds an unmerged segment written by Lucene 9.4.0-9.4.2 with a byte vector field that some documents lack, read with advance() then index(): byte-vector function queries (ByteKnnVectorFieldSource) or application code. kNN queries, float fields and newer segments are fine. Rare, but silent and deterministic.

Mechanism, terms and numbers509 words

Lucene 10 still opens indexes created by Lucene 9 and reads their old segments through backward-codecs. For a byte vector field that only some documents have, in a segment written by Lucene 9.4.0, 9.4.1 or 9.4.2, the reader's iterator() returns the DocIndexIterator built by the protected helper KnnVectorValues.fromDISI. That iterator moves its ordinal only in nextDoc(); its advance() moves to the target document but leaves the ordinal where it was. So after advance(doc), index() is -1 or an earlier document's ordinal, and vectorValue(iterator.index()), the usual way to read one document's vector and exactly what the queries module's ByteKnnVectorFieldSource does, returns all zeros or another document's vector, silently. Lucene's kNN queries, the 9.4 float field and segments written by 9.5 or later are not affected, and a merge rewrites the segment correctly. Lucene 9.x reads the same index correctly: a 10.0.0 regression from #13779.

Terms

  • Ordinal: vectors of a field are stored densely, one slot per document that has a vector. A document's ordinal is its slot: with vectors on docs 0, 2 and 4, doc 2 is ordinal 1. vectorValue(ord) reads a slot.
  • DocIndexIterator: since 10.0 the way to walk a vector field (values.iterator()). It is a DocIdSetIterator plus index(), "the value index (aka "ordinal" or "ord") corresponding to the current doc" (KnnVectorValues.java:140).
  • Sparse field: not every document has a vector, so ordinal and doc id differ.
  • KnnVectorValues.fromDISI: a protected static helper that wraps a DocIdSetIterator of the documents with a value into a DocIndexIterator (KnnVectorValues.java:187-225).

The bug

  1. fromDISI's iterator keeps its own int ord = -1 (:194); index() returns it (:202-204). nextDoc() does ord++ (:211), but advance(target) is only return docsWithField.advance(target); (:216-218). After a forward advance() onto a document, index() is -1 or the ordinal of an earlier document.
  2. On main the only reader that returns this iterator is the Lucene 9.4 sparse byte reader: return fromDISI(disi); (backward-codecs lucene94/OffHeapByteVectorValues.java:194-197). Its float twin returns IndexedDISI.asDocIndexIterator(disi) (lucene94/OffHeapFloatVectorValues.java:188-191), whose index() is disi.index() and stays right after advance().
  3. vectorValue(ord) reads only when ord != lastOrd, and lastOrd starts at -1 (OffHeapByteVectorValues.java:39, :71-77). So on fresh values vectorValue(-1) returns the allocated but never-filled buffer: all zeros. A stale ordinal >= 0 returns that earlier document's vector. After a by-ordinal read, vectorValue(-1) seeks one vector before the field's data: another field's bytes on NIOFSDirectory, an IllegalArgumentException on MMapDirectory (executed).
  4. VectorFieldFunction.exists(doc) positions only with advance(doc) (VectorFieldFunction.java:44-58), and ByteKnnVectorFieldSource reads vectorValue(iterator.index()) (ByteKnnVectorFieldSource.java:70-76). So every document with a vector reads as zeros.

The numbers

6 documents, vectors [doc, doc + 1] on docs 0, 2, 4. advance(2): ordinal -1, [0, 0] (expected 1, [2, 3]). nextDoc() then advance(4): ordinal 0, [0, 1] (expected 2, [4, 5]). ByteKnnVectorFieldSource on the segment written by the 9.4.2 release: [0, 0] for docs 0, 2 and 4. fromDISI over docs {1, 3, 5, 7}: advance(5) leaves index() -1 (expected 2).

Exposure and likelihood384 words
  • Who is exposed. Applications on 10.0.0 to 10.5.2 or main whose index still holds a segment written by Lucene 9.4.0, 9.4.1 or 9.4.2 with a sparse byte vector field, not merged since, read through advance() then index(): the queries module's ByteKnnVectorFieldSource and ByteVectorSimilarityFunction function queries (every document that has a vector reads as the zero vector, so those documents' similarity scores are wrong and all equal, while documents without a vector get null and score 0 as before; COSINE gives NaN, which FunctionQuery turns into 0), application code that reads one document's vector this way (a committer's java-user@ answer of 2025-02 recommends iterator.advance(doc) then values.vectorValue(iterator.index()), shown for float vectors; the same pattern on these byte vectors hits the bug), and any KnnVectorValues subclass built on the protected fromDISI. Code that also calls nextDoc() gets another document's vector. The fixtures were written by 9.4.2; 9.4.0 and 9.4.1 write the same Lucene94 format and were not run.
  • Default vs opt-in. DirectoryReader.open reads 9.x-created indexes by default on 10.x and main (Version.MIN_SUPPORTED_MAJOR = 9, Version.java:308); the function-query path is the application's choice.
  • Scale. Any size, deterministic; 6 documents are enough. The advance()-then-index() reads throw no exception, with -ea and -da. Only the rarer sequence with a by-ordinal read first can throw: IllegalArgumentException on MMapDirectory, another field's bytes on NIOFSDirectory (executed).
  • Not hit. Lucene's exact, approximate and filtered kNN queries and DoubleValuesSource.similarityToQueryVector (executed: they read through scorer(), which uses IndexedDISI.index()); ByteVectorSimilarityValuesSource, ByteVectorSimilarityQuery and the join module's diversifying byte kNN query (code reading); the 9.4 float field (executed); dense 9.4 byte fields and segments written by 9.5 or later (code reading). Filter membership and deletions stay correct, and a merge rewrites the segment correctly, CheckIndex clean (executed).
  • How common. Rare: the Lucene94 vector format was current only from 9.4.0 (2022-09-30) to 9.5.0 (2023-01-30), byte vectors first appeared in 9.4.0, and every merge removes such segments. Downstream, by code reading only: Elasticsearch 8.6.0 used Lucene 9.4.2, and its byte-vector script doc values call advance() then index(); whether its current releases still open such indices was not checked.
  • On unreleased main, MIN_SUPPORTED_MAJOR was 10 from #15012 (2025-09-18) to #16741 (2026-10-15), so main refused 9.x-created indexes by default in that window; it is 9 again at a9779ef3c193.

Minimal repro

The test shows that on main, a sparse byte vector in a segment written by Lucene 9.4.2 reads back as zeros when the iterator is positioned with advance(doc) and the vector is read with vectorValue(iterator.index()) (KnnVectorValues.fromDISI's advance() leaves index() at -1).

TestLucene94SparseByteVectorAdvance.java (JUnit test)66 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.backward_codecs.lucene94;

import java.io.ByteArrayInputStream;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Base64;
import org.apache.lucene.index.ByteVectorValues;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.KnnVectorValues;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestLucene94SparseByteVectorAdvance extends LuceneTestCase {

  private static final String INDEX_ZIP_BASE64 =
      "UEsDBBQAAAAIAAAAXV3C/Pf5lQAAACkBAAAGAAAAXzAuY2Zls7+eIy7uU5qcmpdqaeCcn1uQX5qX4ppXUpSZWswA" +
      "BMs2aPRrLnmkzvtOwPaqceRFBnaFeKhyE4+84vKw1OSS/KJit/yi3MSSeAO9stQKAwYIeA6lWfTSUnIlGCGcuVBB" +
      "QqYk74BqKEIypcKACcJxINKU3AKohiNIppRYMEM4AQjBvNwOqOAaqOABjckvGGDWRDLUAwBQSwMEFAAAAAgAAABd" +
      "XSQ264xjAQAARAQAAAYAAABfMC5jZnOzv54jLuJTmpyal2pp4JyfW5BfmpfikliSyAAEyzZo9GsueaTO+07A9qpx" +
      "5EWgkD1QvVwORL2JR15xeVhqckl+UbFbflFuYolnXkpqBVAVI7pOaR9cWuINGAYBOKAx+QWMfdc+/R7Yn+KwcHHL" +
      "TM1JKQZ7zjcVHDQYHmRoaGBlAkpwATGIxuIrBzgLJmkGF1FwxJCEKWdkZEJ23f5ZvJcZIPEgizMeoBFIYjQwMoHt" +
      "Y/hfz8Dw/z+ypXuyZpZBWCB7xbCEi2cKKN4xUwyyKdzai+PxuxtX6BKbfEKYglvAvmAE4jCoIA9cmjMJyhBgxIwe" +
      "BBCAGgDC//+jhkS1Ms9RWDjIwMIhGOie1BRIaLglFuMMfkjyAJuMbKbVCvaDIPOEYL4Em+SZl5ZfjDVMGRnBkfEf" +
      "CpjkA1KLwFq88/JQAkevuDQtLbOC0QC3ijQwJYkzfEGJGdmtSlvPzEDmJ836HAIAUEsDBBQAAAAIAAAAXV3rg84B" +
      "+AAAADoBAAAFAAAAXzAuc2mzv54jLuxTmpyal2ppEJyanpuaV+KZl5bPAATLNmj0ay55pM77TsD2qnHkRQZOoCAL" +
      "EDMBMSMyByzAwZcDNkevLLWoODM/j9VSz0TPiK04v7QoOZU1Lae0OIOzJDM3tbgkMbeA19Dc0tjI2MgADESyEssS" +
      "9YpK80DyMP08RqZ6BnrG2pa6PiHB3GAVZal5KflF7KF52Xn55XlM+cWsPpl5pRXs+cV6iUXJGeyJINLMhAvIhxoC" +
      "U8vMFm+gl5yWygqkijMhnGJGebjPS/KLUlPcMlNzUord8otyE0v0cvNTUrmcXIND4oMDXF1dGA5oTH7BAAXiy9Xi" +
      "AFBLAwQUAAAACAAAAF1d1R0COl0AAACaAAAACgAAAHNlZ21lbnRzXzGzv54jzlGcmp6bmldSzMDAwLVsg0a/5pJH" +
      "6rzvBGyvGkdeYTTkZGHiZIAAFkYgwQgUYIo3QFN4kcOnNDk1L9XS5D8UgDT8RwMgMUY0nZchZh/QmPwCag1Dy81i" +
      "ZgBQSwECFAMUAAAACAAAAF1dwvz3+ZUAAAApAQAABgAAAAAAAAAAAAAAgAEAAAAAXzAuY2ZlUEsBAhQDFAAAAAgA" +
      "AABdXSQ264xjAQAARAQAAAYAAAAAAAAAAAAAAIABuQAAAF8wLmNmc1BLAQIUAxQAAAAIAAAAXV3rg84B+AAAADoB" +
      "AAAFAAAAAAAAAAAAAACAAUACAABfMC5zaVBLAQIUAxQAAAAIAAAAXV3VHQI6XQAAAJoAAAAKAAAAAAAAAAAAAACA" +
      "AVsDAABzZWdtZW50c18xUEsFBgAAAAAEAAQA0wAAAOADAAAAAA==";

  public void testAdvanceThenIndex() throws Exception {
    Path path = createTempDir();
    TestUtil.unzip(new ByteArrayInputStream(Base64.getDecoder().decode(INDEX_ZIP_BASE64)), path);
    try (Directory dir = FSDirectory.open(path);
        DirectoryReader reader = DirectoryReader.open(dir)) {
      ByteVectorValues values = reader.leaves().get(0).reader().getByteVectorValues("v");
      KnnVectorValues.DocIndexIterator it = values.iterator();
      assertEquals(1, it.advance(1));
      // index written by 9.4.2, doc 0 has no vector: expected doc 1's vector [1, 2], got [0, 0]
      assertEquals("[1, 2]", Arrays.toString(values.vectorValue(it.index())));
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-237/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/backward-codecs/build/libs/lucene-backward-codecs-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.backward_codecs.lucene94.TestLucene94SparseByteVectorAdvance
Expected vs actual
Expected doc 1's vector [1, 2]; actual [0, 0] (expected:<[[1, 2]]> but was:<[[0, 0]]>), with -ea and -da.

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

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => org.junit.ComparisonFailure: expected:<[[1, 2]]> but was:<[[0, 0]]>
       org.junit.Assert.assertEquals(Assert.java:117)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.backward_codecs.lucene94.TestLucene94SparseByteVectorAdvance.testAdvanceThenIndex(TestLucene94SparseByteVectorAdvance.java:64)

Why it looks unintended

  • DocIndexIterator#index() is "the value index (aka "ordinal" or "ord") corresponding to the current doc", and advance(target) "behaves as if" it called nextDoc() until the target.
  • The same commit gave the 9.4 float reader an iterator that stays right after advance(); Lucene's own ByteKnnVectorFieldSource relies on advance() then index().
  • Lucene 9.x reads the same index correctly: a 10.0.0 regression from #13779.
The full argument302 words
  • The contracts. KnnVectorValues.java:140: index() returns "the value index (aka "ordinal" or "ord") corresponding to the current doc". DocIdSetIterator.java:144-153: when target > current, advance() "behaves as if written" as a nextDoc() loop, which would move fromDISI's ordinal. The fromDISI javadoc (KnnVectorValues.java:187-190) has no nextDoc-only restriction, and MIGRATE.md makes DocIndexIterator the iteration API, "a DISI that also provides an index() method" (MIGRATE.md:1727).
  • The code around it. createSparseIterator() in the same class implements advance() with slowAdvance(target) (KnnVectorValues.java:262-264). Iterators that cannot advance throw UnsupportedOperationException (KnnVectorsWriter.java:464-466, :562-564, :676-678).
  • History. #13779 (6053e1e3137) gave the 9.4 float reader IndexedDISI.asDocIndexIterator and the 9.4 byte reader fromDISI in one commit; asDocIndexIterator even carries // can we replace with fromDISI? (IndexedDISI.java:429). Before it, the 9.4 sparse byte reader read vectorValue(disi.index()), correct after advance(). Inside the PR, commit d43785d replaced the inherited slowAdvance(target) default, which kept the ordinal right, with the forwarding advance() after a review comment about performance; the author wrote "I added implementations for advance()". Nobody discussed index() after advance(), and #13778 says the refactor "shouldn't introduce functional changes".
  • Precedent. #14992 / #15553: KnnVectorsWriter.MergedByteVectorValues counted its ordinal in vectorValue() instead of nextDoc(), unlike its float twin, so stepping its iterator without reading every vector broke the next read (its advance() throws). A reviewer called the byte/float difference "an oversight"; fixed in 10.4.0. The same kind of mistake (ordinal bookkeeping that does not follow the iterator), different class and fix.
  • Why tests missed it. BaseKnnVectorsFormatTestCase#testAdvance indexes float vectors only and checks docID(), not index() (BaseKnnVectorsFormatTestCase.java:1997-2042); no test calls fromDISI.
  • Legal trigger. The fixtures use the public 9.4.2 KnnVectorField(BytesRef, BYTE) API and are opened with DirectoryReader.open; VectorFieldFunction calls advance(doc) only for doc > docID().
The devil's-advocate check135 words

The strongest case for "works as designed": the vector-values API is @lucene.experimental (KnnVectorValues.java:29), #13779 explicitly allowed partial implementations, fromDISI is a protected helper whose core callers only call nextDoc(), and keeping the ordinal after a long skip costs a linear walk.

Why it fails: experimental means the API may change, not that it may return a wrong ordinal; Lucene signals partial support by throwing, while fromDISI implements advance() and answers wrongly; its first version in the PR inherited slowAdvance and was correct; the same commit used the ordinal-correct adapter for the float twin; Lucene's own queries module calls advance() then index() on this path; and 9.x read the same index correctly. The cross-model lenses, the independent reproducer, the devil's advocate and the adjudication rated the argument weak. Verdict: not intended, a defect.

Suggested fix

Recommended A + B: asDocIndexIterator in the 9.4 byte reader, and slowAdvance in fromDISI

Two one-line changes: the Lucene 9.4 sparse byte reader returns IndexedDISI.asDocIndexIterator(disi), as its float twin does (fast skipping kept), and fromDISI's advance() steps with slowAdvance(target), so the helper keeps index() right for any caller. The repro passes with assertions on and off, and 7 nearby test classes (1,116 tests) pass.

// lucene94/OffHeapByteVectorValues.java, SparseOffHeapVectorValues
public DocIndexIterator iterator() {
  return IndexedDISI.asDocIndexIterator(disi); // was: fromDISI(disi)
}

// KnnVectorValues.fromDISI
public int advance(int target) throws IOException {
  // step with nextDoc() so that ord stays the ordinal of the current doc
  return slowAdvance(target); // was: docsWithField.advance(target)
}
All options and trade-offs5 options ยท 478 words

Fixes A, B and A + B were applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF); option C and the hardening were not run.

A + B: asDocIndexIterator in the 9.4 byte reader, and slowAdvance in fromDISI (validated by the checks) (recommended)

A: lucene94/OffHeapByteVectorValues.java SparseOffHeapVectorValues.iterator() returns IndexedDISI.asDocIndexIterator(disi), the adapter its float twin uses (OffHeapFloatVectorValues.java:188-191; IndexedDISI is already imported). B: KnnVectorValues.fromDISI's advance(target) returns slowAdvance(target), like createSparseIterator() in the same class (KnnVectorValues.java:262-264).

// lucene94/OffHeapByteVectorValues.java, SparseOffHeapVectorValues
public DocIndexIterator iterator() {
  return IndexedDISI.asDocIndexIterator(disi); // was: fromDISI(disi)
}

// KnnVectorValues.fromDISI
public int advance(int target) throws IOException {
  // step with nextDoc() so that ord stays the ordinal of the current doc
  return slowAdvance(target); // was: docsWithField.advance(target)
}

Trade-offs

A keeps IndexedDISI's fast advance() on the only reachable reader. B makes the protected helper honour the advance() contract for any KnnVectorValues subclass; it does not touch nextDoc(), so nextDoc-only callers see no change. After A its remaining callers on main are write-side (BufferingKnnVectorsWriter) and the sandbox Faiss writer. No benchmark was run.

What the checks validated

  • A + B: the repro passes 3 of 3 with -Ptests.asserts=true and =false.
  • Closest suites with A + B: 8 test classes, 1,119 tests (18 skipped), 0 failures: TestLucene90HnswVectorsFormat, TestLucene91HnswVectorsFormat, TestLucene92HnswVectorsFormat, TestLucene94HnswVectorsFormat and TestBasicBackwardsCompatibility in backward-codecs, TestSimpleTextKnnVectorsFormat in codecs and TestKnnVectorSimilarityFunctions in queries, plus the repro.
  • checkGoogleJavaFormat (core, backward-codecs) and ecjLint (backward-codecs) pass; fix.diff and fix-with-test.diff apply cleanly to a clean a9779ef3c193 tree.
  • A + B shadowed onto the stock main jars: the finder's 9.4.2-release JUnit passes 3 of 3 and the independent reproducer's passes 9 of 9.

A only: asDocIndexIterator in the 9.4 sparse byte reader (validated by the checks)

Closes every reachable path on main and keeps fast skipping.

Trade-offs

Leaves fromDISI itself wrong for third-party subclasses.

What the checks validated

The two reader tests pass; testFromDISIAdvanceKeepsOrdinal still fails (gradle, -Ptests.asserts=true).

B only: slowAdvance in fromDISI (validated by the checks)

One line in core; fixes the helper and therefore the 9.4 reader.

Trade-offs

The 9.4 sparse byte iterator's advance() becomes linear in the documents with a vector that it skips. For ByteKnnVectorFieldSource, which visits documents in order, that is about the same work; for sparse random targets it is slower.

What the checks validated

All 3 repro tests pass (gradle); in the hunt it also passed the reviewer's 5 tests, TestKnnVectorSimilarityFunctions (11 of 11) and the 10-test security probe.

C: fromDISI's advance() throws UnsupportedOperationException (with A)

Like the merged-values iterators in KnnVectorsWriter.

Trade-offs

Fails loudly instead of silently, but breaks any third-party caller that advances today. Not run.

Optional hardening: reject a negative ordinal in the 9.4 reader's vectorValue

Stops the read one vector before the field's data after a by-ordinal read.

Trade-offs

Only if it costs nothing on hot paths. Not run.

Downstream note

Downstream code that reads byte vectors with advance() then index() (script doc values, for example) gets zero vectors on such segments, and code that also calls nextDoc() can score a document with another document's vector (in a probe, one hidden by an access filter).

How it was found

A static scan of the bytecode of every method in the stock jars flagged that in the iterator fromDISI returns, nextDoc() is customised while advance() just forwards. A search for readers that return this iterator found the Lucene 9.4 sparse byte reader, and a JUnit on a 9.4.2-written index confirmed the wrong reads.

The method in full171 words

Method: bytecode delegation-completeness scan (semi-formal tier: a tool's structural finding, then witnessed by execution), in a hunt unit on filter readers and delegating wrappers, 2026-10-26, main ea57360912ed.

  • The scanner interprets every method body of the stock main jars (6,946 classes, 1,005 wrapper classes) and flags a class in which one method of a group that must agree is customised while another is passed through unchanged. For KnnVectorValues$3, the iterator fromDISI returns, nextDoc() is customised (it moves the ordinal) and advance() is a pure forward.
  • A grep for fromDISI callers found the one read-side user, the Lucene 9.4 sparse byte reader, and a JUnit on an index written by the Lucene 9.4.2 release jars confirmed the zero and wrong vectors.
  • The scanner checks a structural rule and proves nothing about vector values, so this is not a find by a model checker, SMT solver or prover. The unit's differential wrapper harness and random interleaving tests missed it, because their indexes are written by main, whose readers never return fromDISI.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26): two tool runs of 59 queries each over GitHub, the mailing lists, JIRA and the web, 74 manual GitHub searches, and the changed files of 413 open and 311 closed-unmerged PRs. Closest: #14992 / #15553, the same kind of mistake in MergedByteVectorValues, fixed in 10.4.0.

Searches4 searches

Verdict new: 25 hits read and classified, none the same bug. fromDISI has 0 hits in GitHub issue and PR search. Keyword search cannot prove absence, and private channels cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; dev@, java-user@, issues@, commits@; LUCENE JIRA archive; the web2026-10-26the hunt's prior-report tool, two specs of 59 queries each (class names, then symptoms such as "stale ordinal", "zero vector"), plus a lists-and-JIRA rerun; 14 web searchesno report; related only: #13779, #14992 / #15553, #13872, #13105
GitHub (apache/lucene, downstream repositories, global)2026-10-2674 manual searches: fromDISI, DocIndexIterator, ByteKnnVectorFieldSource, VectorFieldFunction, OffHeapByteVectorValues, SparseOffHeapVectorValues, Lucene94Hnsw*, "KnnVectorValues advance"no report
open and closed-unmerged apache/lucene PRs2026-10-26changed files of all 413 open PRs and of 311 closed-unmerged ones (since 2024-09); diffs read for every PR touching these filesnone changes fromDISI or the 9.4 sparse iterator() except #13872 (closed unmerged), incidentally, while removing copy()
JIRA (LUCENE, SOLR); local dump of all apache/lucene issues and PRs; git history of main, branch_10x, branch_10_62026-10-267 JQL queries; 23 regexes; git log -S / -Gno report; SOLR-17007 is the earlier #13105 (segment with no vectors)

History

Introduced by #13779 (6053e1e3137, merged 2024-09-28), first released in 10.0.0. Executed: every 10.x release from 10.0.0 to 10.5.2 and main fail; 9.8.0, 9.11.1 and 9.12.0 to 9.12.3 read the same 9.4.2 index correctly. Not fixed upstream (main, branch_10x and branch_10_6 re-read on 2026-10-27).

Versions tested5 rows

Introduced by 6053e1e31378378f6d310a05ea6d7dcdfc45f48b, #13779 "First-class random access API for KnnVectorValues" (@msokolov, addresses #13778), merged 2024-09-28 for 10.0.0. It added fromDISI, switched the 9.4 sparse byte reader's iterator() to it and gave the 9.4 float reader IndexedDISI.asDocIndexIterator. The forwarding advance() was written in the PR's commit d43785d (2024-09-15). git log -L over the method finds only 6053e1e3137; both defect lines are the same at all 12 10.x release tags through 10.5.2 and on main. #13779 was never backported to 9.x. No commit-level bisect was run; releases were executed on both sides. Upstream re-read (read-only) on 2026-10-27: main at 20ced71c1e6e, branch_10x and branch_10_6 still contain both defect lines, and on branch_10_6 these files changed after #13779 only in ways that leave the lines alone; 10.6.0 (released 2026-10-29) was not executed.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedpaste-ready JUnit 3 of 3 fail through gradle, assertions on and off; the 9.4.2-release JUnit 3 of 3 fail
main ea57360912ed affected3 of 3 fail, -ea and -da
10.0.0 to 10.5.2 (12 releases) affected3 of 3 fail on each; -da too on 10.0.0
9.8.0, 9.11.1, 9.12.0 to 9.12.3 not affecteda 9.x-API variant reads the same 9.4.2 index correctly through ByteKnnVectorFieldSource
main a9779ef3c193 + fix A + B fixthe three JUnits pass (3 of 3, 3 of 3, 9 of 9)

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing; reviews by a model from another family (OpenAI GPT-5.6), one executed, rated it real; an adjudication confirmed it, and this doc's gradle runs reproduce it on a9779ef3c193 and validate the fix.

Checks and verdicts297 words

Every executing check wrote or re-ran its own JUnit on stock main; every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateJUnit on a 9.4.2-release index: 3 of 3 fail on main (-ea, -da), 10.0.0 and 10.5.2; the 9.x-API variant passes on 9.12.3; fix B passes 3 of 3.
cross-model review, refute lens real, highAn independent reviewer from another model family (OpenAI GPT-5.6), executed: wrote its own 9.4.2 index and tests (advance(4) leaves index() -1; ByteKnnVectorFieldSource returns zeros; after nextDoc(), advance(10) gives index() 0 instead of 2); controls pass; with fix B its 5 tests and TestKnnVectorSimilarityFunctions (11 of 11) pass.
independent reproducer realOwn 9-test JUnit on its own 12-document 9.4.2 index: 5 of 9 fail on main, 10.5.2 and 10.0.0, 4 controls pass; doc 5 reads doc 1's vector; fix A leaves the 2 direct fromDISI tests failing, fix B passes 9 of 9.
cross-model review, intended lens real, mediumThe same model family, reading only (it could not run Java); rated the intended argument weak. A separate run re-ran the repro: 3 of 3 fail.
intended-behaviour devil's advocate real, highNo intended, documented or misuse argument survives; re-ran the repro on main -ea, -da and 10.5.2 -da (3 of 3 fail) and the 9.x variant on 9.12.3 (passes).
adjudication confirmed, highRe-ran the independent reproduction's test (5 of 9 fail, -ea and -da) and the finder's with -da (3 of 3 fail).
this doc reproduced, fix validatedPaste-ready gradle JUnit on a9779ef3c193: 3 of 3 fail with assertions on and off; fixes A, B and A + B run; the finder's and the independent reproduction's JUnits with A + B pass.

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 notes1,505 words

Finding record

LFV-237, snapshot of 2026-11-06

Registry title
KnnVectorValues.fromDISI's advance() leaves index() stale, so on Lucene 10 sparse byte vectors in segments written by Lucene 9.4 read back as zeros or as another document's vector
Area
index / vector values iteration (KnnVectorValues.fromDISI) with backward-codecs / Lucene 9.4 sparse byte vector reader
Priority
Low, rank 370: Only unmerged segments written by Lucene 9.4.0-9.4.2 with a sparse byte vector field reach it, every merge rewrites them, and kNN queries stay correct.
Impact class
silent wrong results
Impact note
Applications on Lucene 10.0.0 to 10.6.0 (10.6.0 by reading) or main whose index still holds an unmerged segment written by Lucene 9.4.0-9.4.2 with a sparse byte vector field (some documents have no vector), and that read it through a consumer that calls advance() and then index(), such as the queries module's ByteKnnVectorFieldSource / ByteVectorSimilarityFunction function queries or application code that reads one document's vector that way, silently get the all-zeros vector for every document that has one (wrong function-query scores and ranking, no exception on this path), while application code that also calls nextDoc() on the same iterator gets another document's vector, with the default directory, -ea and -da, at any index size; Lucene's kNN queries and DoubleValuesSource.similarityToQueryVector, float vector fields, and segments written by 9.5 or later are not affected (executed for kNN, similarityToQueryVector and the 9.4 float field; code reading for the rest), and a merge rewrites the segment correctly (executed).
Summary
You open, on Lucene 10, an index whose segment was written by Lucene 9.4.2 with a byte vector field that only some documents have, move the field's iterator to a document with advance(doc) and read vectorValue(iterator.index()), which is what ByteKnnVectorFieldSource does, and expect that document's own vector, because DocIndexIterator.index() is documented as the ordinal of the current document and DocIdSetIterator.advance() must behave like repeated nextDoc() calls. Instead every document that has a vector reads as all zeros: the iterator that KnnVectorValues.fromDISI builds, which this one reader returns, counts the ordinal only in nextDoc(), and its advance() moves to the document without moving the ordinal, so index() stays at -1 (the reader then hands back its never-filled buffer); after earlier nextDoc() calls, index() is an earlier document's ordinal and the read returns that document's vector. Lucene 9.x reads the same index correctly; the defect came with the 10.0 vector-values API change #13779.
Introduced
6053e1e31378378f6d310a05ea6d7dcdfc45f48b (#13779 'First-class random access API for KnnVectorValues', msokolov, addresses #13778, merged 2024-09-28, milestone 10.0.0) [read: git log -S fromDISI, git log -L over the method, git log -G and git blame in the full-history clone; no commit-level bisect]: it added fromDISI and switched the Lucene 9.4 sparse byte reader's iterator() to fromDISI(disi), while the Lucene 9.4 float reader got IndexedDISI.asDocIndexIterator(disi) in the same commit; before it, the 9.4 sparse reader was itself the iterator and read vectorValue(disi.index()), which is correct. Inside the PR, commit d43785d (2024-09-15, 'no default advance(); default cost() unsupported') replaced the inherited slowAdvance(target) default, which kept the ordinal right, with the forwarding advance() after a review comment called the default a possible performance trap [read: PR commit list in the prior-report record]. Release-level execution on both sides: 9.12.0 passes (9.x-API variant), 10.0.0 fails (2024-09-28 (merge of #13779; the forwarding advance() was written in its commit d43785d on 2024-09-15))
First affected version
10.0.0 [exec] (the first release with #13779, tagged 2024-10-14); no 9.x release has KnnVectorValues, DocIndexIterator or fromDISI, and the 9.x reader reads the same 9.4.2 index correctly [exec: 9.8.0, 9.11.1, 9.12.0, 9.12.1, 9.12.2, 9.12.3]
Affected versions
every 10.x release through 10.5.2 (10.0.0, 10.1.0, 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2) and main ea57360912ed [exec: the finder repro fails 3 of 3 on each, with -ea and -da on 10.0.0 and main; the independent reproduction's own test fails 5 of 9 on 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da)]; not affected: 9.x [exec: a 9.x-API variant passes on 9.8.0, 9.11.1 and 9.12.0 to 9.12.3]; the source of both defect lines is unchanged at all 12 10.x release tags through 10.5.2 [read]; branch_10x has the same fromDISI and lucene94 iterator(), and on branch_10_6 the two files changed after #13779 only through #15722 and #13837, which leave these lines alone [read through the upstream contents and commits APIs], so 10.6.0 (released 2026-10-29) is very likely affected (not executed); not fixed on main. Only segments written by Lucene 9.4.0, 9.4.1 or 9.4.2 reach the defect on the stock read path (the only releases with the Lucene94 vector format; byte vectors first appeared in 9.4.0, LUCENE-10577); fixtures were written with 9.4.2 only [exec], 9.4.0 and 9.4.1 by format [read]. On unreleased main, MIN_SUPPORTED_MAJOR was 10 between #15012 (2025-09-18) and #16741 (2026-10-15), so main refused 9.x-created indexes by default in that window [read]; it is 9 at ea57360912ed
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal
Upstream and prior reports
none found (2026-10-26, the prior-report record: three scripted prior-report searches, spec A with 59 queries and spec B with 59 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the LUCENE JIRA archive and 14 web searches, plus a lists-and-JIRA-only rerun of spec B; 74 manual GitHub searches (29 in apache/lucene, 38 downstream and global, 7 on open items), 7 own JIRA queries over SOLR and LUCENE, the changed-file lists of all 413 open apache/lucene PRs and of the 311 closed-unmerged ones created since 2024-09-01 or closed after 2024-09-28 (diffs read for every PR touching the files), git history of main, branch_10x and branch_10_6, a regex pass over a local dump of all apache/lucene issues and PRs, and 24 threads read; 25 hits recorded, none the same bug; 'fromDISI' has 0 hits in GitHub issue and PR search); not fixed upstream (main at ea57360912ed, the tested commit, and branch_10x have the same fromDISI and the same lucene94 iterator(); no CHANGES entry covers it; no open PR changes fromDISI or that iterator()); closest items, not reports: #14992 / #15553 (MergedByteVectorValues counted its ordinal in vectorValue() instead of nextDoc(), unlike its float twin, so stepping its iterator without reading every vector broke the next read; its advance() throws; called an oversight and fixed in 10.4.0 (62c97771f2e) in KnnVectorsWriter, a different class and fix site), #13872 (closed unmerged 2024-12-01; it happened to replace fromDISI(disi) in the lucene94 sparse byte iterator() with IndexedDISI.asDocIndexIterator while removing copy(), and never mentions advance(), ordinals or this defect), #13779 (introducing PR; nobody in its 24 comments, 19 reviews or 37 review comments mentions index() after advance()); a committer's java-user@ answer (2025-02, 'How to retrieve vectors from the IndexReader') recommends advance() then vectorValue(iterator.index()) to read one document's vector
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): the paste-ready backward-codecs JUnit through gradle in a clean worktree, stock (assertions on and off), with fix A, with fix B and with A + B (assertions on and off), plus 7 nearby test classes (1,116 tests) with A + B (2026-10-27); the finder's and the independent reproducer's JUnits on indexes written by the Lucene 9.4.2 release jars through the hunt's classpath runner, stock and with A + B. Hunt runs on main ea57360912ed, on 9.8.0, 9.11.1, 9.12.0 to 9.12.3 and on every 10.x release through 10.5.2; the main refresh to a9779ef3c193 reproduces with identical messages.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/KnnVectorValues.java:134-142 (DocIndexIterator; index() 'the value index (aka "ordinal" or "ord") corresponding to the current doc' at :140-141), :187-190 (fromDISI javadoc), :191-225 (fromDISI: ord++ in nextDoc at :211, advance returns docsWithField.advance(target) at :217)
  • lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene94/OffHeapByteVectorValues.java:39 (lastOrd = -1), :71-82 (vectorValue cache and readValue seek to ord * byteSize), :194-197 (sparse iterator() returns fromDISI(disi)); lucene94/OffHeapFloatVectorValues.java:188-191 (iterator() returns IndexedDISI.asDocIndexIterator(disi))
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/VectorFieldFunction.java:45-57 (exists() positions with advance(doc)); ByteKnnVectorFieldSource.java:67-75 (vectorValue(iterator.index())); lucene/core/src/java/org/apache/lucene/codecs/lucene90/IndexedDISI.java:428-456 (asDocIndexIterator, comment '// can we replace with fromDISI?' at :429); lucene/core/src/java/org/apache/lucene/util/Version.java:308 (MIN_SUPPORTED_MAJOR = 9); grep of lucene: outside tests and sandbox, fromDISI is called only by lucene94/OffHeapByteVectorValues.java:196 and BufferingKnnVectorsWriter.java:353, :399 and :445 (write side)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/index/KnnVectorValues.java (140, 187-225)
  • lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene94/OffHeapByteVectorValues.java (39, 56-57, 71-82, 194-197)
  • lucene/backward-codecs/src/java/org/apache/lucene/backward_codecs/lucene94/OffHeapFloatVectorValues.java (188-191); lucene/core/src/java/org/apache/lucene/codecs/lucene90/IndexedDISI.java (424-456)
  • lucene/queries/src/java/org/apache/lucene/queries/function/valuesource/VectorFieldFunction.java (44-58), ByteKnnVectorFieldSource.java (66-82)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java (136-176); KnnVectorValues.java (29, 140, 187-190, 262-264); KnnVectorsWriter.java (464-466, 562-564, 676-678); IndexedDISI.java (429); lucene/MIGRATE.md (1727)
  • lucene/test-framework/src/java/org/apache/lucene/tests/index/BaseKnnVectorsFormatTestCase.java (1997-2042)

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

Repro files

About the diagrams

Made in parallel with this doc from a probe run on main a9779ef3c193 over the finder's index written by the Lucene 9.4.2 release jars (stock, -ea and -da, with the slowAdvance fix, and 10.0.0 and 10.5.2); the values on screen come from the probe. The alt text follows the summary card in the scene source.

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.