All findings · Lucene finding · LFV-1183 · acceptdocs-reuses-bitset-shorter-than-maxdoc

A kNN filter backed by a bit set shorter than maxDoc makes the search throw instead of returning hits

A kNN query whose filter hands Lucene a bit set shorter than the segment (10 bits for 100 documents) throws ArrayIndexOutOfBoundsException instead of returning hits: on a segment with no deletions, AcceptDocs reuses the filter's own set as the accept bits without checking its length (9.3.0 to 10.6.0, main).

Summary

Priority
Low, rank 692On the default codec, a kNN query whose custom filter iterates a bit set shorter than the segment's maxDoc throws instead of returning hits, in every release since 9.3.0. It needs a filter built on the internal BitSetIterator (or DocsWithFieldSet) over a compact set, on segments with no deletions where every document has the vector, and it fails loudly and never returns a wrong hit.
Impact
minor
Since
9.3.0 (2022, #951)introduced 2022-06-27 · about 4 years
Affected
9.3.0 through 10.6.0 and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of AcceptDocs' contract, witnessed by an executable repro

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

The filter itself is fine. The shortcut that reuses its bit set as the accept bits skips the length check that every other path applies.

Background: how a filtered kNN search uses its filter

  1. For each segment, a kNN query with a filter first turns the filter into accept bits: one bit per document id, from 0 to maxDoc - 1, saying whether that document may be returned.
  2. The vector search (a walk over the HNSW graph, or a plain loop over all vectors for small segments and small filters) checks a vector's bit before it collects it.
Diagram of the bug: a segment of 100 documents and the filter's 10-bit set, used as the segment's accept bits. The vector search checks the bit of vector after vector; past bit 9 it reads beyond the set's 10 bits. With assertions on, FixedBitSet.get fails there with an AssertionError; with them off, bits 10 to 63 still read as 0 from the set's one 64-bit word, and bit 64 throws ArrayIndexOutOfBoundsException. A 100-bit set would cover every vector and return doc 9.
The filter's 10-bit set is reused as the accept bits of a 100-document segment. The search checks the bit of each vector: 0 to 9 are inside the set, then it reads past the set's end and FixedBitSet.get throws. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You index 100 documents with one-dimensional vectors {0} to {99}: one segment, no deletions. Your own filter query matches docs 0 to 9 through a BitSetIterator over a FixedBitSet of 10 bits, not 100.
  2. You run KnnFloatVectorQuery("v", {50}, k=1, filter) and expect doc 9, the nearest of the 10 allowed documents. The same filter on a 100-bit set returns it.
  3. Before searching, Lucene turns the filter into accept bits for the segment. With no deletions it takes a shortcut and reuses the filter's 10-bit set as is, without checking that it covers all 100 documents.
  4. The vector search asks the accept bits about every vector, 0 to 99. Vector 10 is past the end: AssertionError: index=10, numBits=10 with assertions on. With them off, vectors 10 to 63 still read as not allowed, and vector 64 throws ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1.

Who is affected

Applications that give KnnFloatVectorQuery or KnnByteVectorQuery their own filter built on a compact bit set (sized to its last match), on segments with no deletions where every document has the vector. No in-tree query builds such a set. Default codec and settings; the request fails loudly; no wrong hit was seen.

Mechanism, terms and numbers681 words

Before a filtered kNN search, AbstractKnnVectorQuery turns the filter into per-segment accept bits through AcceptDocs.fromIteratorSupplier. When the segment has no deletions and the filter's iterator is a BitSetIterator, AcceptDocs.createBitSet returns that iterator's own BitSet without checking its length (AcceptDocs.java:95-97 at 5adff78bb168). Its two other branches build exactly maxDoc bits, and the class's other factory, fromLiveDocs, rejects bits whose length is not maxDoc. The HNSW reader then reads the accept bits for vector ordinals up to the segment's vector count, so a shorter set is read past its end: AssertionError with assertions on, ArrayIndexOutOfBoundsException with them off once an ordinal passes the set's last 64-bit word. No in-tree query builds such a set; a custom filter does, on a segment with no deletions where every document has the vector. It fails loudly; no wrong hit was seen. A one-line guard (reuse the set only when its length is maxDoc) fixes it.

The mechanism (main 5adff78bb168, under lucene/core/src/java/org/apache/lucene/)

  1. The filter's own iterator reaches AcceptDocs. The filter is ANDed with FieldExistsQuery(field) and rewritten (search/AbstractKnnVectorQuery.java:88-97); when every document has the vector, FieldExistsQuery rewrites to match-all and drops out, and the filter scorer's iterator goes unwrapped into AcceptDocs.fromIteratorSupplier(.., liveDocs, reader.maxDoc()) (:257-268).
  2. The shortcut reuses the set unchecked. createBitSet: if (liveDocs == null && iterator instanceof BitSetIterator bitSetIterator) { // If we already have a BitSet and no deletions, reuse the BitSet; return bitSetIterator.getBitSet(); } (search/AcceptDocs.java:95-97). This is the fix site. The other branches build new FixedBitSet(maxDoc) (:102) or BitSet.of(.., maxDoc) (:109-111), and BitsAcceptDocs, behind fromLiveDocs, throws IllegalArgumentException("Bits length = .. != maxDoc = ..") (:124-128). bits() hands the reused set out as is (:187-191).
  3. The reader reads every ordinal. accepted = acceptDocs.bits() (codecs/lucene99/Lucene99HnswVectorsReader.java:370); the exhaustive loop tests acceptedOrds.get(i) for every i < scorer.maxOrd() (:388-389), and the graph branch passes the same bits to HnswGraphSearcher, which tests every visited ordinal (:379-381). Ordinals equal doc ids here because every document has a vector.
  4. The read runs off the set. FixedBitSet.get asserts index < numBits (util/FixedBitSet.java:286). With assertions off it reads word index >> 6 (:287-291): ordinals 10 to 63 read the set's only word and correctly return false; ordinal 64 reads word 1 of a 1-word array and throws.

When it happens

  • All of these are needed: a filter whose scorer iterates a BitSetIterator over a set shorter than maxDoc; no deletions in the segment (otherwise the copying branch builds maxDoc bits); every document has the vector field (otherwise the FieldExistsQuery conjunction wraps the iterator); the filter not served from the query cache, whose sets are maxDoc long.
  • No in-tree query builds such a set: point, spatial and join queries, DocIdSetBuilder, LRUQueryCache and QueryBitSetProducer size theirs to maxDoc. Custom code can, with the @lucene.internal BitSetIterator, BitDocIdSet or DocIdSetBuilder, or with the public, untagged DocsWithFieldSet.iterator() (index/DocsWithFieldSet.java:101-104), which returns a BitSetIterator once its documents are not exactly 0 to n-1, over a set that grows with the documents added rather than to maxDoc; a custom filter built on it fails the same way (executed). Both are unusual custom-filter constructions.
  • Sets longer than maxDoc are not read past their end here (by reading; not tested). With assertions off, a short set throws only once the reader reaches an ordinal past its last 64-bit word, and the reads below that are correct, so no wrong hit was seen.
  • In the repro, the 100-vector segment gets no HNSW graph (#14963, 10.4.0: graphs are skipped for tiny segments), so the reader takes the exhaustive loop. On bigger segments, or before 10.4.0, the graph walk reads the bits for the ordinals it visits: with assertions on it fails at the first visited ordinal past the set, with them off only at one past the set's last 64-bit word (on 10.0.0 and 10.3.0 the repro's walk visits ordinal 54, so it fails with assertions on and passes with them off).
Exposure and likelihood123 words
  • Who. Code with its own filter Query whose scorer iterates a BitSetIterator over a set sized to its matches, or built for a smaller doc range, instead of the segment's maxDoc. A downstream vector-search plugin built such shorter-than-maxDoc BitSetIterators in 2022 (LUCENE-10674, #1068).
  • Default vs opt-in. Default codec, IndexWriterConfig and searcher; the trigger is the application's filter code.
  • Effect. Loud: that request throws from IndexSearcher.search and returns nothing. No corruption, and no wrong, extra or leaked hit was seen: by reading, the reads below the failing ordinal return the filter's own answer.
  • Scale. None needed: 100 documents, one segment, single thread, deterministic.
  • Likelihood. Rare: no in-tree query reaches it, and no public report of this crash was found since 9.3.0.

Minimal repro

One JUnit test method using public classes only (FixedBitSet, BitSetIterator and the protected Weight.DefaultScorerSupplier are tagged @lucene.internal): 100 one-dimensional vectors in one segment, a filter whose scorer iterates a 10-bit FixedBitSet, KnnFloatVectorQuery with k=1. It expects doc 9, so it doubles as the regression test. Fixed input, single thread.

TestKnnFilterBitSetShorterThanMaxDoc.java (lucene/core, package org.apache.lucene.search, 1 test)87 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.search;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
import org.apache.lucene.util.BitSetIterator;
import org.apache.lucene.util.FixedBitSet;

public class TestKnnFilterBitSetShorterThanMaxDoc extends LuceneTestCase {

  private static final class FirstTenDocsQuery extends Query {
    @Override
    public Weight createWeight(IndexSearcher searcher, ScoreMode scoreMode, float boost) {
      return new ConstantScoreWeight(this, boost) {
        @Override
        public ScorerSupplier scorerSupplier(LeafReaderContext context) {
          FixedBitSet bits = new FixedBitSet(10);
          bits.set(0, 10);
          return new DefaultScorerSupplier(
              new ConstantScoreScorer(score(), scoreMode, new BitSetIterator(bits, 10)));
        }

        @Override
        public boolean isCacheable(LeafReaderContext ctx) {
          return false;
        }
      };
    }

    @Override
    public String toString(String field) {
      return "FirstTenDocsQuery";
    }

    @Override
    public void visit(QueryVisitor visitor) {}

    @Override
    public boolean equals(Object other) {
      return sameClassAs(other);
    }

    @Override
    public int hashCode() {
      return classHash();
    }
  }

  public void testFilterBitSetShorterThanMaxDoc() throws Exception {
    try (Directory dir = newDirectory();
        IndexWriter w =
            new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
      for (int i = 0; i < 100; i++) {
        Document doc = new Document();
        doc.add(new KnnFloatVectorField("v", new float[] {i}));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        Query query = new KnnFloatVectorQuery("v", new float[] {50}, 1, new FirstTenDocsQuery());
        // expected: doc 9; actual: AIOOBE (-da) or AssertionError (-ea) from FixedBitSet.get
        assertEquals(9, new IndexSearcher(reader).search(query, 1).scoreDocs[0].doc);
      }
    }
  }
}
Command
cp $REPRO/TestKnnFilterBitSetShorterThanMaxDoc.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestKnnFilterBitSetShorterThanMaxDoc -Ptests.seed=DEADBEEF
# fails the same way with -Ptests.asserts=false (ArrayIndexOutOfBoundsException instead of AssertionError)
Expected
search returns doc 9, the nearest of the 10 filtered documents.
Observed
search throws ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1 (assertions off) or AssertionError: index=10, numBits=10 (on) from FixedBitSet.get.

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

More files, environment, full output294 words

TestKnnFilterBitSetShorterThanMaxDoc (one method, extends LuceneTestCase, package org.apache.lucene.search of the lucene/core tests, no package-private member, Apache license header; it does use the @lucene.internal FixedBitSet, BitSetIterator and protected Weight.DefaultScorerSupplier). Its input is fixed; LuceneTestCase still randomizes the directory, so the command pins a seed (it also failed with three other seeds). It passes checkGoogleJavaFormat. It fails with assertions on and off on main, and passes unchanged with the recommended fix. The release-jar runs and the first gradle pass (the 89-test suite and the unpatched run with assertions off) used an earlier copy of this test that differs only in one comment line; the current copy was re-run unpatched with assertions on and patched with them on and off, and an independent fact check ran it unpatched with assertions on and off (both fail).

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 10.4.0, 10.5.2, 10.6.0: fail (release jars, assertions off; 10.5.2 and 10.6.0 also on); 10.0.0, 10.3.0: fail with assertions on only
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (only this platform was run)
Command (environment)
cp $REPRO/TestKnnFilterBitSetShorterThanMaxDoc.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestKnnFilterBitSetShorterThanMaxDoc -Ptests.seed=DEADBEEF

Observed output

# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF, assertions on
TestKnnFilterBitSetShorterThanMaxDoc > testFilterBitSetShorterThanMaxDoc FAILED
    java.lang.AssertionError: index=10, numBits=10
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:5C2E948DDFDC87E4]:0)
        at org.apache.lucene.util.FixedBitSet.get(FixedBitSet.java:286)
        at org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsReader.search(Lucene99HnswVectorsReader.java:389)
        at org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsReader.search(Lucene99HnswVectorsReader.java:324)
        at org.apache.lucene.codecs.perfield.PerFieldKnnVectorsFormat$FieldsReader.search(PerFieldKnnVectorsFormat.java:329)
        at org.apache.lucene.index.CodecReader.searchNearestVectors(CodecReader.java:294)
        at org.apache.lucene.search.KnnFloatVectorQuery.approximateSearch(KnnFloatVectorQuery.java:118)
        at org.apache.lucene.search.AbstractKnnVectorQuery.getLeafResults(AbstractKnnVectorQuery.java:290)
        ...
        at org.apache.lucene.search.IndexSearcher.search(IndexSearcher.java:624)

# same, -Ptests.asserts=false
TestKnnFilterBitSetShorterThanMaxDoc > testFilterBitSetShorterThanMaxDoc FAILED
    java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
        at org.apache.lucene.util.FixedBitSet.get(FixedBitSet.java:291)
        at org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsReader.search(Lucene99HnswVectorsReader.java:389)
        ...

# release jars (repro harness, seed DEADBEEF)
10.6.0, assertions off: ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1 at Lucene99HnswVectorsReader.search:377
10.4.0, assertions off: same, at Lucene99HnswVectorsReader.search:365
10.3.0, assertions on: AssertionError: index=54, numBits=10 at HnswGraphSearcher.searchLevel:295 (passes with assertions off)

# with the fix: passes, assertions on and off

Why it looks unintended

  • AcceptDocs rejects accept bits whose length is not maxDoc on its other path, and its other branches build exactly maxDoc bits; only this shortcut skips the check.
  • No document requires a filter's set to cover the segment; BitSetIterator works with any length.
  • Upstream already fixed this same input in another class (LUCENE-10674, 9.4.0).
The full argument184 words
  • The class enforces the length on its other path. fromLiveDocs rejects bits of the wrong length (AcceptDocs.java:124-128), the two copying branches of createBitSet build exactly maxDoc bits (:102, :109-111), and upstream TestAcceptDocs asserts bits().length() == maxDoc for both factories (lucene/core/src/test/org/apache/lucene/search/TestAcceptDocs.java:57-59).
  • No length requirement anywhere. No javadoc, CHANGES or MIGRATE entry says a filter's BitSetIterator must cover the segment. BitSetIterator caches bits.length() and returns NO_MORE_DOCS past it (util/BitSetIterator.java:72, :94-95).
  • Core accepts shorter sets elsewhere. BitSetConjunctionDISI uses the minimum length of its sets (search/ConjunctionDISI.java:244-250). LUCENE-10674 (#1068 / #11709, fixed in 9.4.0) fixed that class for exactly this input, built by a downstream plugin; a maintainer called such sets "not typical" (2022-09-09), and the fix was merged with a test.
  • The same filter works everywhere else. On main, the 10-bit filter gives count 10, a plain search 10 hits, and 10 hits in a conjunction with FieldExistsQuery (independent reproducer's controls).
  • History. Before #951 (9.1.0, 9.2.0) the filter was always collected into a new FixedBitSet(maxDoc); #951's description and review do not discuss the set's length.
The devil's-advocate check148 words

The case for intended: BitSetIterator and FixedBitSet are @lucene.internal, so a short backing set is outside the public input domain (the executed cross-model refute review, which still reproduced the crash and agreed on the root cause and the fix). Also: Bits.get says out-of-range indexes are undefined, "just don't do it!" (util/Bits.java:30-31).

The answer (a judgement call): the tag withholds API-stability guarantees; it does not make a valid DocIdSetIterator an illegal filter, and the public, untagged DocsWithFieldSet.iterator() reaches the same crash. The Bits.get rule binds the caller, and here the caller is Lucene's own reader, fed bits that AcceptDocs built against its own invariant. #1068 shows upstream already treated this input class as a bug. On this reading, these points lower the likelihood rather than the validity, and the devil's-advocate verifier and the adjudication rejected the objection; a maintainer may still weigh the contract objection differently.

Suggested fix

Recommended Reuse the set only when it covers the segment (fix A)

In AcceptDocs.createBitSet, reuse the filter's set only when it has exactly maxDoc bits; otherwise the existing copy builds a full-size set (one-line guard). The repro passes with assertions on and off, and it and the 3 closest suites (89 tests) pass.

-    if (liveDocs == null && iterator instanceof BitSetIterator bitSetIterator) {
+    if (liveDocs == null
+        && iterator instanceof BitSetIterator bitSetIterator
+        && bitSetIterator.getBitSet().length() == maxDoc) {
All options and trade-offs3 options ยท 369 words

Option A was built and checked through gradle on 5adff78bb168; B and C were not built.

Reuse the set only when it covers the segment (fix A) (validated by the checks) (recommended)

Add bitSetIterator.getBitSet().length() == maxDoc to the shortcut's condition in AcceptDocs.createBitSet (AcceptDocs.java:95). A short set falls through to the existing copying branches, which build a maxDoc-sized set.

-    if (liveDocs == null && iterator instanceof BitSetIterator bitSetIterator) {
+    if (liveDocs == null
+        && iterator instanceof BitSetIterator bitSetIterator
+        && bitSetIterator.getBitSet().length() == maxDoc) {

Trade-offs

  • In-tree filters, whose sets are maxDoc long, keep the zero-copy shortcut; the cost is one length comparison per segment, plus a copy for sets of any other length.
  • Matches the class's own invariant (fromLiveDocs, TestAcceptDocs).
  • With ==, a set longer than maxDoc is copied too. That is fine while its set bits are below maxDoc, as a filter's matches must be; a set with a bit at or past maxDoc would throw in the copy (BitSetIterator.intoBitSet, util/BitSetIterator.java:126-128), where today's shortcut never reads it. Not tested.

What the checks validated

  • gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on and off) and passes with it (on and off).
  • With the fix: the repro (1), TestAcceptDocs (5), TestKnnFloatVectorQuery (43) and TestKnnByteVectorQuery (40): 89 tests, 0 failures. :lucene:core:checkGoogleJavaFormat passes with the fix and the test in the tree. The whole core module was not run.
  • The finder's earlier run of the same guard also passed its larger graph-walk probe (2000 vectors, a 1000-bit set).

The same guard with >= instead of ==

Reuse the set when getBitSet().length() >= maxDoc.

Trade-offs

Also keeps the shortcut for oversized sets, which the readers do not read past their end (by reading), but bits().length() could then differ from maxDoc, unlike every other AcceptDocs. Not built.

Bounds-check in the readers instead

Make the HNSW reader treat ordinals at or past acceptedOrds.length() as not accepted.

Trade-offs

Adds a check to the hot per-ordinal loop and must be repeated in every KnnVectorsReader; the public AcceptDocs.bits() would still hand short bits to outside readers. Not recommended; not built.

Downstream note

A product that builds its kNN filters from compact per-segment bit sets gets a failed request instead of hits; query text or documents cannot choose a filter set's length through any in-tree query.

How it was found

A proof-obligation audit of AcceptDocs' contract: the accept bits of a segment must cover all of its documents, as fromLiveDocs enforces. Of the ways createBitSet builds them, one, the reuse shortcut, never checks; a one-test JUnit then witnessed the crash.

The method in full122 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder stated the obligation for AcceptDocs: bits() of an AcceptDocs for a segment covers [0, maxDoc), as fromLiveDocs enforces and as the HNSW reader assumes. Two branches of createBitSet discharge it by construction; the reuse shortcut discharges it only if the filter's set is maxDoc long, which nothing checks.
  • The finder then wrote a JUnit test that fails on main with assertions on and off and on 10.2.0 to 10.6.0, a larger graph-walk probe for older releases, and the one-line guard.
  • An independent reproducer, an executed cross-model review, a devil's-advocate verifier and an adjudication reproduced it with their own tests ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits (with the JIRA archive), the mailing lists, downstream trackers or the web (searched 2026-11-03). Closest: LUCENE-10674 (#1068), the same input breaking a different class, fixed in 9.4.0.

Searches3 searches

No public report of this bug was found. Related, not reports: #1068 / #11709 (LUCENE-10674, fixed in 9.4.0; the issue page still shows open), where a BitSetIterator over a set shorter than maxDoc broke BitSetConjunctionDISI; its fix does not touch AcceptDocs, so it is the precedent that such sets are valid input. #951 introduced the shortcut; #15011 moved it into AcceptDocs and added the length check to fromLiveDocs only; #16588 is the last change to the file. #12414, #12857, #16586 and #15561 concern the same method or class, about deletions, performance or cost. Open #16768 is about LiveDocs sets longer than maxDoc in another class; open #16866 and #14996 touch the accept-docs path but leave the shortcut unchanged. Limits: keyword search; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), downstream repositories, the web2026-11-03, re-checked the same daythree 51-query scripted prior-report searches (836 hits, every high and medium one triaged), 28 direct GitHub searches on apache/lucene and 19 downstream, 17 web queriesno report; #1068 / #11709, #951, #15011, #16588 are the nearest items
Open PRs2026-11-03the changed files of all 409 open apache/lucene PRs; 29 touch the relevant files, 7 patches read#16866 and #14996 touch the accept-docs path; neither changes the shortcut
Mailing lists (dev@, java-user@, issues@, commits@)2026-11-0320 list queries per scripted prior-report search and a separate 20-query list searchnothing on this defect; only LUCENE-10606 had more than low overlap

History

Introduced by #951 (LUCENE-10606, 2022; first release 9.3.0), which reused the filter's own set instead of copying it into a maxDoc-sized one. Every release from 9.3.0 to 10.6.0 and main have it; not fixed upstream. For 9.3.0 to 9.8.0 this rests on the code history and a larger graph-walk probe.

Versions tested9 rows

Introduced by #951 (LUCENE-10606, "For KnnVectorQuery, optimize case where filter is backed by BitSetIterator", Kaival Parikh): 03846b468e5 on main and its branch_9x cherry-pick e055e95d3ef, authored and merged 2022-06-27; first release 9.3.0. It replaced the per-segment collector, which always built a new FixedBitSet(maxDoc), with the reuse shortcut.

Later commits only moved the code (#12004, 9.5.0, into AbstractKnnVectorQuery; #15011, 10.3.0, into AcceptDocs, adding the length check to fromLiveDocs only) or changed which loop reads the bits (#14160, 10.2.0: small filters take the exhaustive loop; #14963, 10.4.0: segments as small as the repro's get no graph). Every release tag from 9.3.0 to 10.6.0 has the unguarded shortcut. Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) still has it at AcceptDocs.java:95-97.

The repro's tiny input passes on some older releases because their search reads few bits: on 10.0.0 and 10.3.0 it passes with assertions off, so the graph walk read no ordinal at or past 64, the end of the set's only word. The repro uses the 10.x Weight API; for 9.x the hunt ran an adapted 200-document variant, which passes on 9.3.0, 9.7.0 and 9.8.0 (9.4 to 9.6 not run) because, by code reading, the walk runs out of its visit budget and falls back to an exact search that never reads the bits (not instrumented). The larger graph-walk probe covers those releases.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: the repro fails with assertions on and off; passes with fix A
10.6.0, 10.5.2 affectedthe repro fails, assertions on and off (exhaustive loop)
10.4.0 affectedthe repro fails (assertions off; exhaustive loop)
10.0.0, 10.3.0 affectedthe repro fails with assertions on only (graph walk); the 200-document variant fails with them off
10.1.0, 10.2.0 affectedthe finder's test fails on 10.2.0; the graph-walk probe fails on 10.1.0 (assertions on and off)
9.9.0, 9.10.0, 9.12.3 affectedthe 200-document variant fails (assertions off; graph walk)
9.3.0, 9.8.0 introduced in 9.3.0the graph-walk probe fails (9.3.0 assertions on and off); 9.3.0 passes with the guard shadowed in
9.1.0, 9.2.0 not affectedthe probe passes: the filter was always copied into a maxDoc-sized set
9.0.0 no APIno kNN filter yet

Independent checks

A finder, an independent reproducer, a devil's-advocate verifier, an adjudication, an audit and a final repro check reproduced it and agree on the root cause; an executed cross-model review (OpenAI GPT-5.6) reproduced it too but argued the input is out of contract. This doc's gradle runs validated the fix.

Checks and verdicts289 words

The cross-model refute review ran on main ea57360912ed, whose AcceptDocs, AbstractKnnVectorQuery and Lucene99HnswVectorsReader are byte-identical to 5adff78bb168. Every other check on main ran on 5adff78bb168; the release runs used each release's own jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproduced, LowOwn test fails on main (assertions on and off) and on 10.2.0 to 10.6.0; a graph-walk probe fails on 9.12.3, 10.0.0 and 10.1.0; with the guard, 88 upstream tests pass.
cross-model review, refute lens (OpenAI GPT-5.6, executed) not real (contract grounds only)Own 129-document test with a 7-bit set fails (on and off), maxDoc-sized and range controls pass; agrees on the root cause and that the fix fixes it, but argues @lucene.internal puts short sets out of contract. Re-run on 5adff78bb168: same results; a DocsWithFieldSet.iterator() filter fails too.
independent reproducer real, LowOwn 200-document test fails on main (on and off), 10.6.0, 10.5.2, 10.0.0 and 9.12.3; the maxDoc-sized control passes; passes with the guard.
devil's-advocate verifier (with a cross-model intended lens, OpenAI GPT-5.6) real, LowNo document requires a maxDoc-long set; AcceptDocs enforces it on fromLiveDocs only; #1068 precedent. The cross-model intended lens: real, Low.
adjudication confirmed, high, LowRe-run fails (on and off) at Lucene99HnswVectorsReader.search:389; the @lucene.internal objection rejected; likelihood rare.
separate re-run, audit and final repro check reproduced; confirmed, LowRe-runs fail (on and off) and pass with the guard; the minimal repro fails (on and off) and passes with the guard.
this doc reproduced; fix A validatedgradle without and with the guard (89 tests, 0 failures, formatting check passes); the repro on five release jars and with three more seeds on main.

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,599 words

Finding record

LFV-1183, snapshot of 2026-11-06

Registry title
A kNN query whose filter iterates a BitSetIterator over a bit set shorter than the segment's maxDoc throws ArrayIndexOutOfBoundsException (AssertionError with -ea) instead of returning the filtered hits, because AcceptDocs reuses that set as the accept bits without checking its length (since 9.3.0)
Area
core / search: filtered kNN accept docs (AcceptDocs.createBitSet behind KnnFloatVectorQuery/KnnByteVectorQuery filters, read by Lucene99HnswVectorsReader and HnswGraphSearcher)
Priority
Low, rank 692: On the default codec, a kNN query whose custom filter iterates a bit set shorter than the segment's maxDoc throws instead of returning hits, in every release since 9.3.0. It needs a filter built on the internal BitSetIterator (or DocsWithFieldSet) over a compact set, on segments with no deletions where every document has the vector, and it fails loudly and never returns a wrong hit.
Impact class
minor
Impact note
Hits only applications that give KnnFloatVectorQuery or KnnByteVectorQuery a custom filter Query whose scorer iterates a BitSetIterator (@lucene.internal; the public DocsWithFieldSet.iterator() returns one too) over a bit set shorter than the segment's maxDoc, on a segment with no deletions in which every document has the vector field: with the default codec, IndexWriterConfig and searcher, -ea and -da alike, IndexSearcher.search throws from FixedBitSet.get instead of returning hits [exec]; it fails loudly, never with a wrong hit; no scale is needed (100 documents); no in-tree query builds such a set; releases 9.3.0 through 10.6.0 and main are affected.
Summary
You run a KnnFloatVectorQuery with a custom filter whose scorer iterates a BitSetIterator over a FixedBitSet sized to its own matches (10 bits for docs 0..9) on a 100-document segment with no deletions, where every document has a vector. You expect the top k vectors among the filter's matches (here doc 9, with k = 1), as with the same filter backed by a maxDoc-sized set. Instead AcceptDocs.createBitSet hands the filter's 10-bit set unchanged to the HNSW reader as the accept bits (its other branches build maxDoc bits, and its fromLiveDocs entry point rejects bits whose length is not maxDoc), the reader tests it for every vector ordinal, and the search throws ArrayIndexOutOfBoundsException (AssertionError 'index=10, numBits=10' with -ea) from FixedBitSet.get.
Introduced
03846b468e52126582c09816f7e85e98aee9a405 on main and its branch_9x cherry-pick e055e95d3ef404368c1accea95d88f1cf5b48c80 (the commit in the 9.3.0 release): #951 (LUCENE-10606, 'For KnnVectorQuery, optimize case where filter is backed by BitSetIterator', by kaivalnp), which replaced the per-segment collector (always a new FixedBitSet(maxDoc)) by reusing the filter scorer's own BitSetIterator set when the segment has no deletions, without checking the set's length [read: git log -S in the history clone; exec: the finder's HNSW-path probe passes on 9.1.0 and 9.2.0, fails on 9.3.0 with -ea and -da, and passes on 9.3.0 with the length guard shadowed into KnnVectorQuery.createBitSet]. Later commits only moved the code (72968d30ba1, #12004, into AbstractKnnVectorQuery, 9.5.0; 310ef63d827, #15011, into AcceptDocs, 10.3.0, which added the length == maxDoc check to the fromLiveDocs path only) or changed where the bits are read (0614fe9ebd7, #14160, 10.2.0: small filtered counts take the exhaustive loop, which reads the accept bits for every ordinal). (2022-06-27 (author date of 03846b468e5, 12:22:52 +0530; merged 2022-06-27T06:52:53Z); first release 9.3.0)
First affected version
9.3.0 [exec: the finder's HNSW-path probe fails on 9.3.0 with -ea and -da and passes there with the length guard shadowed in; 9.1.0 and 9.2.0 pass, because KnnVectorQuery then always collected the filter into a new FixedBitSet(maxDoc)]. The independent reproduction's 200-document variant of the repro first fails on 9.9.0 [exec: it passes on 9.2.0, 9.3.0, 9.7.0 and 9.8.0; by reading, on those versions the tiny graph's search runs out of its visit limit before level 0 and falls back to an exact search that never reads the accept bits; not instrumented]. 9.0.0 has no kNN filter [read].
Affected versions
Every release from 9.3.0 (2022) through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT); never fixed. [exec, JDK 25, Linux aarch64] the independent reproduction's 200-document variant of the repro fails on main with -ea (AssertionError 'index=10, numBits=10') and -da (ArrayIndexOutOfBoundsException 'Index 1 out of bounds for length 1'), both at FixedBitSet.get <- Lucene99HnswVectorsReader.search:389 (the independent reproduction, the intended-behaviour check, the adjudication, the history step and the re-run of 2026-11-03); on 10.6.0, 10.5.2 (-ea and -da), 10.3.0 and 10.2.2 in the exhaustive loop, and on 10.0.0, 9.12.3 (-ea and -da), 9.10.0 and 9.9.0 in the graph walk (HnswGraphSearcher.searchLevel), all -da unless stated; it passes on 9.2.0, 9.3.0, 9.7.0 and 9.8.0. The finder's test fails on 10.2.0 to 10.6.0 and main and passes on 10.0.0 and 10.1.0; the finder's HNSW-path probe (2000 random 4-d vectors, a 1000-bit filter set) fails on 9.3.0 (-ea, -da), 9.8.0, 9.12.3, 10.0.0 and 10.1.0 (-ea, -da) and passes on 9.1.0 and 9.2.0. [read] every release tag from 9.3.0 to 10.6.0 has the unguarded shortcut. Not new in 10.6 (10.5.2 fails). Not fixed upstream: main 1c2072f0284 (2026-11-02) still has the unguarded shortcut at AcceptDocs.java:95-97; the last commit on AcceptDocs.java is #16588 (1a65ceae18e, author date 2026-09-10), which 5adff78 contains.
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (the adjudication ruled it real), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-03, on top of the finder's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): three scripted prior-report searches of 51 queries each (apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 836 hits, 6 high and 41 medium, all triaged), the finder's earlier 31-query run and the intended-behaviour check's 20-query lists run, 28 GitHub GraphQL searches on apache/lucene and 19 on downstream repositories, 3 Solr JIRA queries, 17 web queries, the changed files of all 409 open apache/lucene PRs (29 touch the relevant files; the patches of 7 of them read), and the commit history of AcceptDocs.java and AbstractKnnVectorQuery.java on main (author dates); the same-day recheck found no issue or PR about AcceptDocs and BitSetIterator, createBitSet and maxDoc, or a kNN filter ArrayIndexOutOfBoundsException, the only PR mentioning AcceptDocs updated since 2026-11-01 is #16866 (it does not touch createBitSet), and none of the PRs created since 2026-11-01 is about it. Related history, not reports: #951 (LUCENE-10606, 2022: introduced the shortcut; its review never discusses the set's length), #15011 (2025: moved it into AcceptDocs and added the length check to fromLiveDocs only), #16588 (2026: last change to AcceptDocs.java, reuse branch unchanged), #1068 / #11709 (LUCENE-10674, 2022: the same input class broke BitSetConjunctionDISI, a different consumer; fixed there in 9.4.0, AcceptDocs not covered), #12414, #12857, #16586 and #15561 (same method or class, about deletions, performance or cost), and open #16768 (LiveDocs backing sets longer than maxDoc, another class and the opposite direction).
Lucene commit tested
main 5adff78bb168 (2026-11-02) through gradle on 2026-11-03 in a scratch worktree, with assertions on and off, without and with the fix (the repro, TestAcceptDocs, TestKnnFloatVectorQuery, TestKnnByteVectorQuery, checkGoogleJavaFormat), and the repro with three more seeds; the repro on the release jars of 10.0.0, 10.3.0, 10.4.0, 10.5.2 and 10.6.0; older releases (9.0.0 to 9.12.3) through the hunt's other executed tests. Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/AcceptDocs.java:93-114 (at 5adff78bb168) (createBitSet: 'if (liveDocs == null && iterator instanceof BitSetIterator bitSetIterator) { // If we already have a BitSet and no deletions, reuse the BitSet; return bitSetIterator.getBitSet(); }' at :95-97 with no length check; the other branches build new FixedBitSet(maxDoc) at :102 or BitSet.of(.., maxDoc) at :109-111), :124-128 (BitsAcceptDocs, the fromLiveDocs path, throws IllegalArgumentException 'Bits length = .. != maxDoc = ..'), :88-91 (fromIteratorSupplier, @lucene.experimental public factory), :177-191 (DocIdSetIteratorAcceptDocs.bits() returns the reused set)
  • lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java:88-99 (at 5adff78bb168) (the filter is ANDed with FieldExistsQuery(field); when every document has the vector the conjunction rewrites to the filter itself, so the filter's own iterator reaches AcceptDocs), :256-268 (AcceptDocs.fromIteratorSupplier over the filter scorer's iterator, liveDocs, reader.maxDoc())
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:368-389 (at 5adff78bb168) (accepted = acceptDocs.bits(); acceptedOrds = scorer.getAcceptOrds(accepted); the exhaustive loop tests acceptedOrds.get(i) for every ord i < scorer.maxOrd() at :388-389; the graph branch passes acceptedOrds to HnswGraphSearcher.search)
  • lucene/core/src/java/org/apache/lucene/util/BitSetIterator.java:23-31 (at 5adff78bb168) (public, @lucene.internal); lucene/core/src/java/org/apache/lucene/index/DocsWithFieldSet.java:29 (at 5adff78bb168) and :101-104 (public, untagged; iterator() returns new BitSetIterator(set, cardinality) over a set sized to the largest added doc); lucene/core/src/java/org/apache/lucene/search/ConjunctionDISI.java:226-251 (at 5adff78bb168) (BitSetConjunctionDISI uses the minimum length of its bit sets)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java:88-97, (at 5adff78bb168) :257-268
  • lucene/core/src/java/org/apache/lucene/search/AcceptDocs.java:93-114 (at 5adff78bb168) (createBitSet; the shortcut at :95-97), :124-128 (BitsAcceptDocs length check), :187-191 (bits())
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:368-389 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:285-292 (at 5adff78bb168) (get)
  • lucene/core/src/java/org/apache/lucene/search/FieldExistsQuery.java:116-160 (at 5adff78bb168) (a vector field present in every document of every leaf rewrites to MatchAllDocsQuery, :135-139 and :159-160), search/BooleanQuery.java:387-394 (a match-all FILTER clause next to another FILTER clause is removed)
  • lucene/core/src/java/org/apache/lucene/index/DocsWithFieldSet.java:46-62, (at 5adff78bb168) :101-104
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:137 (at 5adff78bb168) (HNSW_GRAPH_THRESHOLD = 100) and Lucene99HnswVectorsWriter.java:716-722 (shouldCreateGraph); #14963 (4a7c9c2780b on main, cherry-pick 9b957e807aa in releases/lucene/10.4.0)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/AcceptDocs.java:95-114, (at 5adff78bb168) :124-128
  • lucene/core/src/test/org/apache/lucene/search/TestAcceptDocs.java:57-59 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/util/BitSetIterator.java:23-31 (at 5adff78bb168) (@lucene.internal), :72, :94-95
  • lucene/core/src/java/org/apache/lucene/search/ConjunctionDISI.java:226-251 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/util/Bits.java:27-34 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/index/DocsWithFieldSet.java:29, (at 5adff78bb168) :101-104 (public, untagged)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:388-389 (at 5adff78bb168) (the per-ordinal loop option C would touch)
Diagrams
  • lucene/core/src/java/org/apache/lucene/search/AcceptDocs.java:95-97; (at 5adff78bb168) codecs/lucene99/Lucene99HnswVectorsReader.java:388-389; util/FixedBitSet.java:285-292

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

Repro files

About the diagrams

The diagram illustrates the repro's segment and filter (main 5adff78bb168, AcceptDocs.java:95-97, Lucene99HnswVectorsReader.java:388-389, FixedBitSet.java:286-291).

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.