All findings · Lucene finding · LFV-1185 · seeded-knn-seed-weight-hit-without-vector

SeededKnnVectorQuery: a caller-built seed Weight whose hits lack a vector makes search throw

Built with its public constructor and your own seed Weight, SeededKnnVectorQuery makes IndexSearcher.search throw IllegalArgumentException when a seed hit has no vector, where fromFloatQuery with the same seed returns 2 hits. Releases 10.3.0 to 10.6.0 and main; the factories are not affected.

Summary

Priority
Low, rank 694The experimental SeededKnnVectorQuery constructor that takes a caller-built seed Weight makes search throw IllegalArgumentException, or an EOFException that looks like corruption, when seed hits lack a vector on a sparse vector field, in releases 10.3.0 through 10.6.0. The documented factories are unaffected, only some seed layouts trigger it, and the failure is loud.
Impact
minor
Since
10.3.0 (#14838, 2025), when the seed-Weight constructors became publicintroduced 2025-06-25 · about 1 year
Affected
10.3.0 to 10.6.0 and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of SeededKnnVectorQuery's 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 query turns seed hits into graph entry points by walking the field's vector iterator. That walk assumes every seed has a vector, which only the factory path guarantees.

Background: seeded kNN search

  1. SeededKnnVectorQuery runs a seed query (for example a lexical query) first and starts the HNSW graph search from its top-k hits instead of the graph's usual entry point.
  2. The graph works with vector ordinals, not doc ids: on a field that only some documents have, ordinals number the documents that have a vector, counting from 0 (doc 1998, the 1,000th such document, is ordinal 999 in the repro). The query maps each seed doc to its ordinal by advancing the field's vector iterator to it.
Diagram of the bug: the last documents of the repro's segment, vectors on the even ones only, doc 1998 the last vector document (ordinal 999). The caller's seed Weight matches doc 1997 (no vector) and doc 1998. The vector iterator advances from 1997 to 1998, then is asked to advance to 1998 again while already there, and runs off the end. The graph search was told to expect 2 entry points, receives 1, and throws IllegalArgumentException. The factory path filters the seeds on FieldExistsQuery first and keeps only doc 1998.
Seed hits 1997 (no vector) and 1998. Mapping seed 1997 moves the vector iterator to doc 1998 (ordinal 999); mapping seed 1998 then advances an iterator already on 1998, which runs it off the end. 1 of the 2 counted entry points arrives and the search 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 2,000 documents with the default codec and give only the even ones a 2-dimensional vector (1,000 vectors). The seed query seed:x matches doc 1997, which has no vector, and doc 1998, the last document with one.
  2. You build the seed's Weight with searcher.createWeight(seed, TOP_SCORES, 1f) and search new SeededKnnVectorQuery(knn k=2, seed, seedWeight). You expect 2 hits: SeededKnnVectorQuery.fromFloatQuery(knn, seed) returns 2 for the same seed.
  3. Instead search throws IllegalArgumentException: The number of entry points provided is less than the number of entry points requested, with assertions on and off.
  4. Why: the factory path keeps only seeds that have a vector; your Weight skips that filter. Mapping seeds to graph ordinals, seed 1997 moves the vector iterator to doc 1998, then seed 1998 calls advance(1998) on an iterator already there, which runs it off the end. 1 of the 2 counted entry points arrives.
  5. On a dense vector block the same code throws EOFException: read past EOF instead, which looks like index corruption, though IndexWriter wrote the index normally.

Who is affected

Only code that calls the experimental constructor SeededKnnVectorQuery(knnQuery, seed, seedWeight) with a Weight it built itself, on a vector field that only some documents have, in a segment large enough to get a graph. The factories and PatienceKnnVectorQuery are safe. The whole search call fails, with assertions on and off.

Mechanism, terms and numbers597 words

SeededKnnVectorQuery starts the HNSW search from the documents a seed query matches. Since 10.3.0 its constructors SeededKnnVectorQuery(KnnFloatVectorQuery|KnnByteVectorQuery, Query, Weight) are public and take the seed Weight from the caller. The factories pass null, and rewrite then builds the Weight with seed MUST + FieldExistsQuery(field) FILTER; a caller's Weight is kept as given, so its hits can include documents without a vector. The code that maps seed documents to graph ordinals (MappedDISI) assumes they all have one: it calls advance on the vector iterator with a target at or before its current document, which DocIdSetIterator leaves undefined, and the entry-point count includes seeds that map to no vector. In the repro (2,000 documents, a vector on the even ones, seed hits on docs 1997 and 1998) IndexSearcher.search throws IllegalArgumentException, with assertions on and off, where fromFloatQuery with the same seed returns 2 hits. On a DENSE block the failure is an EOFException: read past EOF.

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

  1. The filter is skipped. rewrite returns super.rewrite when the seed Weight is non-null (search/SeededKnnVectorQuery.java:119-123). Only for a null Weight does it call the package-private createSeedWeight (:136-146), which builds seed MUST + FieldExistsQuery(field) FILTER (+ the kNN filter as a FILTER).
  2. Seed hits are collected and counted. Per leaf, SeededCollectorManager.newCollector scores the seed Weight into a top-k collector (:315-335; k = 2, hits 1997 and 1998), wraps the field's vector iterator (an IndexedDISI for a sparse field) and the sorted seed docs in MappedDISI, and passes seedTopDocs.scoreDocs.length (2) as the number of entry points (:341-353).
  3. The mapping advances in place. MappedDISI.nextDoc (:254-260) and advance (:231-237) call indexedDISI.advance(newTarget) for every seed doc, with no check that newTarget > indexedDISI.docID(). Seed 1997 moves the vector iterator to doc 1998 (ordinal 999); seed 1998 then calls advance(1998) on an iterator already at 1998, which is undefined for target <= current (search/DocIdSetIterator.java:141-142). The SPARSE block reads forward from its cursor (codecs/lucene90/IndexedDISI.java:580-594), finds nothing later, and the iterator is exhausted.
  4. Too few entry points. SeededHnswGraphSearcher.fromEntryPoints (util/hnsw/SeededHnswGraphSearcher.java:37-55), called from HnswGraphSearcher.search (util/hnsw/HnswGraphSearcher.java:148-152), wants 2 ordinals, gets 1, and throws (:47-49).

Other layouts (supplementary public-API probes, not the repro; executed through gradle on main)

  • A seed after the last vector document (docs 0..999 have a vector, the only seed is doc 1000): the same IllegalArgumentException, because the count includes a seed that maps to nothing.
  • A DENSE block (docs 0..9899 have a vector except 9700..9799; seeds 9700, 9701, 9850; k = 3; run with assertions off only): EOFException: read past EOF ... [slice=_0_Lucene99HnswVectorsFormat_0.vec] [slice=docs] on MMapDirectory, IndexOutOfBoundsException on ByteBuffersDirectory. The factory with the same seeds returns 3 hits.
  • Other layouts finish, but the mapping yields wrong ordinals, and those become the graph search's entry points: the hunt's probe of the package-private MappedDISI (vectors on even docs) mapped seeds {1, 2, 10} to ordinals [1, 2, 5], so seed doc 2 (ordinal 1) came out as ordinal 2 (doc 4); by reading its code, the recommended fix gives [1, 5] (doc 1 has no vector and is dropped). The effect on results was not measured; not claimed.

Not affected

fromFloatQuery/fromByteQuery and a null Weight go through createSeedWeight; PatienceKnnVectorQuery.rewrite always rebuilds the seed Weight with it (search/PatienceKnnVectorQuery.java:297-312). A segment too small for an HNSW graph is not affected either (the default format skips the graph for small segments, codecs/lucene99/Lucene99HnswVectorsFormat.java:122-137), which is why the repro uses 1,000 vectors.

Exposure and likelihood112 words
  • Opt-in, experimental API. The Weight-taking constructors are public since 10.3.0; the class is @lucene.experimental (search/SeededKnnVectorQuery.java:40). Default codec and IndexWriterConfig suffice.
  • Loud, sometimes misleading. One such leaf fails the whole IndexSearcher.search. On a DENSE block the EOFException: read past EOF or IndexOutOfBoundsException can send an operator looking for index corruption in an index IndexWriter wrote normally.
  • No downstream caller found. Solr uses the factories; GitHub code search found no caller of the Weight-taking constructor outside Lucene.
  • Likelihood: rare. It needs the experimental constructor, an unfiltered seed Weight, a sparse vector field and a seed layout that pushes the mapping past a vector document. No public report was found since 10.3.0.

Minimal repro

One JUnit test method on the public API: 2,000 documents with a vector on the even ones, a seed Weight from IndexSearcher.createWeight over a term matching docs 1997 and 1998, then a seeded search for 2 hits. Deterministic, one test thread; it doubles as the regression test.

TestSeededKnnSeedWithoutVector.java (lucene/core, package org.apache.lucene.search, 1 test)60 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.Field;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestSeededKnnSeedWithoutVector extends LuceneTestCase {

  public void testSeedHitWithoutVector() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w =
          new IndexWriter(dir, new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()))) {
        for (int i = 0; i < 2000; i++) {
          Document doc = new Document();
          if (i % 2 == 0) {
            doc.add(new KnnFloatVectorField("v", new float[] {i, 1f}));
          }
          if (i == 1997 || i == 1998) {
            doc.add(new StringField("seed", "x", Field.Store.NO));
          }
          w.addDocument(doc);
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Query seed = new TermQuery(new Term("seed", "x"));
        Weight seedWeight = searcher.createWeight(seed, ScoreMode.TOP_SCORES, 1f);
        KnnFloatVectorQuery knn = new KnnFloatVectorQuery("v", new float[] {5f, 1f}, 2);
        // expected 2 hits (as fromFloatQuery returns); actual IllegalArgumentException
        TopDocs hits = searcher.search(new SeededKnnVectorQuery(knn, seed, seedWeight), 2);
        assertEquals(2, hits.scoreDocs.length);
      }
    }
  }
}
Command
cp $REPRO/TestSeededKnnSeedWithoutVector.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestSeededKnnSeedWithoutVector -Ptests.seed=DEADBEEF
# add -Ptests.asserts=false for production mode: the same IllegalArgumentException
Expected
searcher.search(...) returns 2 hits.
Observed
IllegalArgumentException: The number of entry points provided is less than the number of entry points requested at SeededHnswGraphSearcher.fromEntryPoints(SeededHnswGraphSearcher.java:48), assertions on and off.

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

More files, environment, full output243 words

TestSeededKnnSeedWithoutVector (one method, extends LuceneTestCase, package org.apache.lucene.search of the lucene/core tests, Apache license header). It is the hunt's minimal test, unchanged. It draws no random values itself (fixed default codec, ByteBuffersDirectory, default IndexWriterConfig, whose 16 MB buffer writes the 2,000 small documents as one segment at close; the test does not assert one segment); it failed with seeds DEADBEEF (gradle) and 5EED0B01 (standalone runner), with assertions on and off, and passes checkGoogleJavaFormat. The segment needs an HNSW graph, which the default format builds only from about 650 vectors (when numVectors > (int) (ln(numVectors) * 100), codecs/lucene99/Lucene99HnswVectorsWriter.java:712-722); the repro uses 1,000.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 10.3.0, 10.4.0, 10.5.2, 10.6.0: fail; 10.2.x: not reachable through public API; 10.1.0, 9.12.3: class absent
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (only this platform was run)
Command (environment)
cp $REPRO/TestSeededKnnSeedWithoutVector.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestSeededKnnSeedWithoutVector -Ptests.seed=DEADBEEF

Observed output

# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF (assertions on; -Ptests.asserts=false gives the same exception and frames)
TestSeededKnnSeedWithoutVector > testSeedHitWithoutVector FAILED
    java.lang.IllegalArgumentException: The number of entry points provided is less than the number of entry points requested
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:4B0DFB083D2B873B]:0)
        at org.apache.lucene.util.hnsw.SeededHnswGraphSearcher.fromEntryPoints(SeededHnswGraphSearcher.java:48)
        at org.apache.lucene.util.hnsw.HnswGraphSearcher.search(HnswGraphSearcher.java:151)
        at org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsReader.search(Lucene99HnswVectorsReader.java:380)
        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.SeededKnnVectorQuery.approximateSearch(SeededKnnVectorQuery.java:155)
        at org.apache.lucene.search.AbstractKnnVectorQuery.getLeafResults(AbstractKnnVectorQuery.java:254)
        ...
        at org.apache.lucene.search.SeededKnnVectorQuery.rewrite(SeededKnnVectorQuery.java:122)
        at org.apache.lucene.search.IndexSearcher.rewrite(IndexSearcher.java:873)
        ...
        at org.apache.lucene.search.IndexSearcher.search(IndexSearcher.java:624)
        at org.apache.lucene.search.TestSeededKnnSeedWithoutVector.testSeedHitWithoutVector(TestSeededKnnSeedWithoutVector.java:55)

# with the recommended fix: 1 test, 0 failures (assertions off); the repro, 2 probes and 4 suites: 160 tests, 0 failures

Why it looks unintended

  • The public constructors state no precondition; the class javadoc says the format may ignore the seed, not that search may fail.
  • The helper that adds the FieldExistsQuery filter is package-private; passing null loses a subclass's overrides.
  • Benjamin Trent's #13635 review named the invariant: seed docs must have the vector field, or the doc-to-ordinal translation fails.
The full argument212 words
  • No stated precondition. SeededKnnVectorQuery(KnnFloatVectorQuery|KnnByteVectorQuery, Query, Weight) (search/SeededKnnVectorQuery.java:85-105) has no javadoc. The class javadoc says "The underlying format is free to ignore the provided seed" (:33).
  • The caller cannot reuse the safe path. createSeedWeight is package-private (:136). With a null Weight, rewrite returns a new plain SeededKnnVectorQuery (:124-133), so a subclass loses its overrides; #14838 ("Make it possible to extend Patience/Seeded knn queries") made the constructors public for subclassing. A caller can rebuild the filter with public API (FieldExistsQuery), but nothing says they must.
  • Lucene's own contracts. KnnSearchStrategy.Seeded documents its iterator and count as "valid entry points" (search/knn/KnnSearchStrategy.java:125-141), and MappedDISI makes the advance call DocIdSetIterator leaves undefined (search/DocIdSetIterator.java:141-142).
  • The invariant was named in review (as Benjamin Trent pointed out on #13635, 2024-08-06): the seed docs must have the vector field, or "the down stream translation from docId -> vector ordinal completely fails". createSeedWeight's FieldExistsQuery filter (#14084) answers it; the public constructor (#14838) bypasses it, and its PR and review do not discuss what the Weight may match.
  • Tests. Upstream tests build the query only with a null Weight; none passes a caller-built one. #13634, the feature request, asks that an unusable seed fall back to normal graph traversal.
The devil's-advocate check105 words

The strongest counter-argument: the Weight argument was the query's internal, already-rewritten state; #14838 exposed it only for subclassing, and every in-tree caller passes null or createSeedWeight's output.

Why it fails. Nothing documents the precondition, the only helper that meets it is package-private, null loses the subclass, and the failure is Lucene's own undefined advance call, surfacing as an internal IllegalArgumentException or a corruption-like EOFException. Even if maintainers call it a precondition, javadoc plus a check is warranted. The cross-model refute review rated the intended-behaviour case moderate and could not refute the bug; the independent reproducer, the devil's-advocate verifier and the adjudication rejected it.

Suggested fix

Recommended Map only seeds that have a vector, advance forward only, and count those

Map only seed docs that land on a vector, advancing the vector iterator only forward, and pass that count as the number of entry points; with no such seed, search as if unseeded. The repro passes; with it the repro, two layout probes and 4 seeded and patience suites run 160 tests, 0 failures.

for (int doc = seedDocIds.nextDoc(); doc != NO_MORE_DOCS; doc = seedDocIds.nextDoc()) {
  int vectorDoc =
      indexIterator.docID() < doc ? indexIterator.advance(doc) : indexIterator.docID();
  if (vectorDoc == NO_MORE_DOCS) break;
  if (vectorDoc == doc) {
    seedOrds[numSeedOrds++] = new ScoreDoc(indexIterator.index() + ctx.docBase, 0f);
  }
}
if (numSeedOrds == 0) return delegateCollector;
// seedDocs = TopDocsDISI over the numSeedOrds ordinals; Seeded(seedDocs, numSeedOrds, ...)
All options and trade-offs3 options ยท 518 words

The recommended change was applied in a scratch worktree at 5adff78bb168 and checked through gradle: the repro without it (fails, assertions on and off) and with it (passes, assertions off), then the repro, the two layout probes (seed after the last vector document; DENSE block) and TestSeededKnnFloatVectorQuery, TestSeededKnnByteVectorQuery, TestPatienceFloatVectorQuery, TestPatienceByteVectorQuery (160 tests, 0 failures, assertions on), and checkGoogleJavaFormat. An earlier run of the same change also passed 78 upstream seeded-query tests. Options B and C were not run. A guard on advance alone (newTarget > indexedDISI.docID()) fixes the repro's layout but not a seed after the last vector document, because the count still includes it. Maintainers may prefer B or C. The regression test is the one repro test, unchanged.

Map only seeds that have a vector, advance forward only, and count those (validated by the checks) (recommended)

In SeededCollectorManager.newCollector (search/SeededKnnVectorQuery.java:347-354), walk the sorted seed docs once, advance the vector iterator only when it is behind the seed, keep the ordinal only when the vector doc equals the seed doc, pass that number to KnnSearchStrategy.Seeded, and return the plain collector when no seed has a vector (+23/-4).

for (int doc = seedDocIds.nextDoc(); doc != NO_MORE_DOCS; doc = seedDocIds.nextDoc()) {
  int vectorDoc =
      indexIterator.docID() < doc ? indexIterator.advance(doc) : indexIterator.docID();
  if (vectorDoc == NO_MORE_DOCS) break;
  if (vectorDoc == doc) {
    seedOrds[numSeedOrds++] = new ScoreDoc(indexIterator.index() + ctx.docBase, 0f);
  }
}
if (numSeedOrds == 0) return delegateCollector;
// seedDocs = TopDocsDISI over the numSeedOrds ordinals; Seeded(seedDocs, numSeedOrds, ...)

Trade-offs

Local to the seeded query; covers the repro, the seed-after-last-vector and the DENSE layouts; like the factory path it drops seeds without a vector, but only after the top-k seed collection, so a caller's Weight can give fewer than k entry points where the FieldExistsQuery filter would have collected the next-best seeds that have one. The mapping becomes eager, one pass over at most k seed docs per leaf. MappedDISI stays for the optimistic re-entry collector (search/AbstractKnnVectorQuery.java:463-469), whose seeds are kNN hits and always have a vector.

What the checks validated

Without it the repro fails with assertions on and off and the two probes fail 3 of 5 (their factory controls pass); with it the repro passes with assertions off, and the repro, the probes and the 4 suites run 160 tests, 0 failures; :lucene:core:checkGoogleJavaFormat passes.

Filter a caller's seed like the internal path

When a Weight is given, still require FieldExistsQuery(field) (and the kNN filter), for example by building the Weight from seed with createSeedWeight, as PatienceKnnVectorQuery.rewrite already does.

Trade-offs

Simple, but it discards the caller's Weight, which is what the constructor is for, and leaves MappedDISI's undefined advance in place. Not run.

Document and check the precondition

Javadoc on both constructors (the Weight must match only documents with a vector in the field, and the kNN filter), make createSeedWeight protected so subclasses can build such a Weight, and fail with a clear message.

Trade-offs

Cheapest, but it keeps the trap, and the corruption-like EOFException on a DENSE block stays unless the check covers it. Not run.

Downstream note

Downstream, no caller of the Weight-taking constructor was found: Solr uses the factories, and OpenSearch k-NN's copy of MappedDISI is fed kNN hits, which always have a vector.

How it was found

A proof-obligation audit of SeededKnnVectorQuery's contract: mapping seeds to graph ordinals is correct only if every seed hit has a vector, and only the factory path discharges that obligation. A one-test public-API JUnit then witnessed the exception.

The method in full118 words

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

  • The finder listed what the seed-to-ordinal mapping needs from its inputs: sorted seed docs that each have a vector, so that every advance moves forward and the count equals the number of ordinals produced. The internal path discharges this with FieldExistsQuery; the public Weight-taking constructor does not.
  • The finder then wrote a public-API JUnit test that fails on main with assertions on and off and on the 10.6.0 and 10.3.0 jars; a final repro check kept the minimal version as the one repro.
  • An independent reproducer, an executed cross-model review, a devil's-advocate verifier and an adjudication reproduced it ("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 Lucene mailing lists, Elasticsearch, OpenSearch, Solr or the web (searched 2026-11-03). Nearest item: Benjamin Trent's #13635 review comment stating the invariant, before the public constructor existed.

Searches3 searches

No matching public report was found in the documented searches. Related, not reports: #13635 (closed; the design review that named the invariant), #14084 and #14170 (origin of MappedDISI and the entry-point count), #14838 (made the constructors public), #14195/#14197 (same exception text, a docBase bug fixed in 10.2.0, all seeds had vectors), #13634 (feature request: fall back when the seed is unusable), #12465 (a backward IndexedDISI call treated as caller misuse), #16952 and #16204 (open; edit the same file, not this code), OpenSearch k-NN SeededMappedDISI (a copy fed kNN hits). Limits: keyword search; GitHub search does not index PR review comments (read directly for the seeded-query PRs); 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)2026-11-03, re-checked the same daythree scripted prior-report searches (165 queries) plus an earlier one (51), 18 manual issue/PR searches and 3 commit searches on the class, MappedDISI, seedWeight, the exception text and the triggersno report; #13635, #14084, #14170, #14838 and #14195/#14197 are the nearest items
Open PRs2026-11-03the changed files of all 409 open apache/lucene PRs; the 27 updated since 2026-11-01 re-checked#16952 and #16204 edit the file; neither touches the mapping, the count or the constructors
Downstream code and trackers, mailing lists, web2026-11-03GitHub code search in Elasticsearch, OpenSearch and Solr; dev@, java-user@, issues@ and commits@; 7 web searchesno report and no downstream caller of the Weight-taking constructor

History

Reachable through public API since #14838 (2b47cd3cd69, 2025-06-25; first release 10.3.0), which made the seed-Weight constructors public; the unguarded mapping dates from #14084 (2025-01-15). Every release run from 10.3.0 to 10.6.0 fails. Not fixed upstream.

Versions tested7 rows

Public exposure: #14838 "Make it possible to extend Patience/Seeded knn queries" (Tommaso Teofili): main 2b47cd3cd69, branch_10x 52597fa4cac, 2025-06-25, first in releases/lucene/10.3.0. It made SeededKnnVectorQuery(KnnFloatVectorQuery|KnnByteVectorQuery, Query, Weight) public with no javadoc.

Root code: #14084 "Add two new Seeded Knn queries for seeded vector search" (Benjamin Trent, 34f0453283a, 2025-01-15) added MappedDISI with the unguarded indexedDISI.advance(newTarget) and the count seedTopDocs.scoreDocs.length; #14170 (e4321619bba, 2025-02-03) moved them into SeededKnnVectorQuery, the count now passed to KnnSearchStrategy.Seeded instead of SeededKnnCollector. git blame -w -M -C -C and git log -L show the guard never existed. In 10.2.x the code sits behind a package-private constructor: latent, not claimed. Not new in 10.6. Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) has no commit to the file since 2025-08-24.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle and the standalone runner: fails with assertions on and off; passes with the recommended fix
10.6.0, 10.5.2 affectedthe same IllegalArgumentException, assertions on and off
10.4.0, 10.3.0 affectedthe same IllegalArgumentException (10.3.0 also through an out-of-package copy of the test)
10.3.1, 10.3.2, 10.5.0, 10.5.1 by readingthe tags have both public constructors and both unguarded calls
10.2.2 not affectednot reachable through public API: an out-of-package copy does not compile (constructor package-private)
10.2.0, 10.2.1 by readingthe same package-private constructor (10.2.0 also ran a same-package copy, which fails: latent, not claimed)
10.1.0, 9.12.3 not affectedSeededKnnVectorQuery does not exist (compile error)

Independent checks

A finder, an independent reproducer, an executed cross-model review (OpenAI GPT-5.6, refute lens), a devil's-advocate verifier, an adjudication and the audit re-run reproduced it and agree on the root cause; a final repro check confirmed it. This doc's gradle runs reproduced it and validated the fix.

Checks and verdicts250 words

The cross-model review ran on main ea57360912ed, whose SeededKnnVectorQuery.java, SeededHnswGraphSearcher.java and IndexedDISI.java are byte-identical to 5adff78bb168; its test was re-run on 5adff78bb168 (fails; its factory control passes). All other main runs used 5adff78bb168; the release runs used each release's own jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOwn test fails on main (assertions on and off), 10.6.0 and 10.3.0; a supporting probe shows the DENSE EOFException and silent wrong ordinals.
independent reproducer realOwn test (seeds 999 without a vector and 1000) fails on main, 10.6.0, 10.5.2 and 10.3.0; the factory control passes; an advance guard fixes it; probes show the seed-after-last and DENSE failures.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own 4,096-document test fails with assertions on and off; all-vector-seed and factory controls pass; the intended-behaviour case rated moderate.
devil's-advocate verifier realThe strongest intended case (internal state exposed for subclassing) rejected; contrast test: the factory, a null Weight and a FieldExistsQuery-filtered Weight each return 2 hits.
adjudication confirmed, LowRe-ran the finder's test on main with assertions on and off: fails at fromEntryPoints:48.
audit re-run, final repro check reproduced; confirmed, LowThe one-test repro fails on main with assertions on and off and passes with the fix.
this doc reproduced; fix validatedGradle in a scratch worktree: fails without the fix (assertions on and off), passes with it; 160 tests, 0 failures with the fix.

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

Finding record

LFV-1185, snapshot of 2026-11-06

Registry title
SeededKnnVectorQuery built with its public seed-Weight constructor makes IndexSearcher.search throw IllegalArgumentException (or EOFException on a DENSE block) when a seed hit has no vector, because MappedDISI advances the vector iterator to a doc at or before its current one and the entry-point count includes seeds without a vector (since 10.3.0)
Area
search / kNN vector queries (SeededKnnVectorQuery seed-to-ordinal mapping: MappedDISI and the entry-point count)
Priority
Low, rank 694: The experimental SeededKnnVectorQuery constructor that takes a caller-built seed Weight makes search throw IllegalArgumentException, or an EOFException that looks like corruption, when seed hits lack a vector on a sparse vector field, in releases 10.3.0 through 10.6.0. The documented factories are unaffected, only some seed layouts trigger it, and the failure is loud.
Impact class
minor
Impact note
Only applications on 10.3.0 through 10.6.0 or main that call the @lucene.experimental public constructor SeededKnnVectorQuery(KnnFloatVectorQuery|KnnByteVectorQuery, Query, Weight) with a seed Weight they built themselves (for example IndexSearcher.createWeight over a lexical query) on a vector field that only some documents have are hit: when seed hits without a vector push the seed-to-ordinal mapping past the segment's last vector document, or a seed lies after it, IndexSearcher.search throws IllegalArgumentException, and on a DENSE IndexedDISI block it can throw EOFException 'read past EOF' instead, which looks like index corruption; -ea and -da, default codec and IndexWriterConfig, no scale needed; the fromFloatQuery/fromByteQuery factories, a null Weight and PatienceKnnVectorQuery filter the seed on FieldExistsQuery and are not affected.
Summary
You index 2,000 documents with a float vector on the even ones only, build a seed Weight with IndexSearcher.createWeight for a term query that matches doc 1997 (no vector) and doc 1998 (the last vector doc), and search new SeededKnnVectorQuery(new KnnFloatVectorQuery("v", q, 2), seed, seedWeight) for 2 hits. You expect 2 hits, which SeededKnnVectorQuery.fromFloatQuery returns for the same seed, and the class javadoc only says the format may ignore a seed. Instead search throws IllegalArgumentException 'The number of entry points provided is less than the number of entry points requested': the caller's Weight skips the FieldExistsQuery filter that the internal path adds, MappedDISI maps doc 1997 to vector doc 1998 and then calls advance(1998) on an iterator already at 1998 (undefined for a DocIdSetIterator), which runs it off the end, while the entry-point count still counts both seeds.
Introduced
2b47cd3cd69b83c9798bfa109ba05b2666947b8e on main (branch_10x 52597fa4cac): apache/lucene#14838 'Make it possible to extend Patience/Seeded knn queries' (Tommaso Teofili, approved by Benjamin Trent), which made the Weight-taking constructors SeededKnnVectorQuery(KnnFloatVectorQuery|KnnByteVectorQuery, Query, Weight) public with no javadoc; that is the first point where public API can hand MappedDISI a seed Weight whose hits lack a vector [exec: an out-of-package copy of the repro does not compile on 10.2.2 and fails on 10.3.0]. The defective code is older: the unguarded indexedDISI.advance(newTarget) in MappedDISI and the entry-point count seedTopDocs.scoreDocs.length come from 34f0453283a45acac366539d08d47a5e8939204a (#14084 'Add two new "Seeded" Knn queries for seeded vector search', Benjamin Trent; branch_10x bf7c98a0a1b), moved unchanged into SeededKnnVectorQuery by e4321619bba (#14170) [read: git blame -w -M -C -C and git log -L on main; the guard never existed] (2025-06-25 (author date = commit date of 2b47cd3cd69, #14838; branch_10x commit 2025-06-25); root code 2025-01-15 (author date of 34f0453283a, #14084))
First affected version
10.3.0 [exec: the out-of-package copy of the repro fails on 10.3.0 (-da) and the repro fails there with -ea; on 10.2.2 the out-of-package copy does not compile because the Weight-taking constructor is package-private] [read: git tag --contains 52597fa4cac starts at releases/lucene/10.3.0]
Affected versions
10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT); not fixed anywhere (upstream main 1c2072f0284, 2026-11-02, has no commit to SeededKnnVectorQuery.java since 310ef63d827, 2025-08-24, and MappedDISI.advance is still unguarded). Executed, fail [exec]: main with -ea and -da (finder, the independent reproduction, the intended-behaviour check, adjudication, a separate re-run), 10.6.0 (-ea and -da), 10.5.2 (-ea and -da), 10.4.0 (-da), 10.3.0 (-ea, and -da through the out-of-package copy). 10.3.1, 10.3.2, 10.5.0 and 10.5.1 by reading (every release tag from 10.3.0 to 10.6.0 has both public constructors and the two unguarded advance calls). Not affected through public API [exec]: 10.2.0 to 10.2.2 hold the same code behind a package-private constructor (a same-package copy of the test fails on 10.2.0 and 10.2.2: latent, not claimed); 10.1.0 and 9.12.3 have no SeededKnnVectorQuery (compile error). Not new in 10.6. All runs on Linux aarch64 with JDK 25.0.3.
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: proof-obligation audit
Upstream and prior reports
none found (2026-11-03: three scripted prior-report searches of 55 queries each (identifiers and the exception text; symptoms and triggers; history) plus the finder's run of 51, over GitHub issues, PRs and commits on apache/lucene, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; manual GitHub issue, commit and code searches, reads of the bodies, comments and review comments of #13634, #13635, #14084, #14170, #14195, #14197, #14838, #14688, #14226, #12465 and others, the changed files of all 409 open apache/lucene PRs (only #16952 and #16204 edit SeededKnnVectorQuery.java, neither touches MappedDISI, the entry-point count or the constructors), the 27 open PRs updated since 2026-11-01, and 7 web queries; the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches agree). Re-checked the same day: 'SeededKnnVectorQuery', 'MappedDISI', 'seedWeight' and the exception text return only the known items, no item mentioning seeded queries was updated since 2026-11-01 except #16145 (prefetch backoff, unrelated), the only open PR touching SeededKnnVectorQuery.java is #16952, and upstream main 1c2072f0284 is unchanged at the fix site. Nearest items, none of them this bug: #13635 (closed, the first design; Benjamin Trent's review comment that seed docs must have the vector field or the docId to ordinal translation fails, answered by the FieldExistsQuery filter in createSeedWeight; no caller-supplied Weight existed then), #14084 and #14170 (origin of MappedDISI and the entry-point count), #14838 (made the constructors public; no word on what the Weight must match), #14195/#14197 (the same exception text from a docBase bug, fixed in 10.2.0), #13634 (feature request: unusable seeds should fall back to normal graph traversal), #12465 (a backward IndexedDISI call treated as caller misuse), #16952 (open, seed bulk-scoring bound), and opensearch-project/k-NN SeededMappedDISI (a copy of MappedDISI fed kNN hits, which always have a vector)
Lucene commit tested
main 5adff78bb168 (2026-11-02) through gradle on 2026-11-03 in a scratch worktree: the repro with assertions on and off without the fix (fails), with the recommended fix (passes), and the repro, two supplementary layout probes and TestSeededKnnFloatVectorQuery, TestSeededKnnByteVectorQuery, TestPatienceFloatVectorQuery, TestPatienceByteVectorQuery with the fix (160 tests, 0 failures), plus checkGoogleJavaFormat. The hunt ran the test through a standalone runner on main (-ea, -da) and on the 10.6.0, 10.5.2, 10.4.0 and 10.3.0 jars (fail), on 10.2.x (not reachable through public API) and on 10.1.0 and 9.12.3 (class absent). Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/SeededKnnVectorQuery.java:30-41 (at 5adff78bb168) (class javadoc: 'NOTE: The underlying format is free to ignore the provided seed'; cites Lexically-Accelerated Dense Retrieval; @lucene.experimental)
  • lucene/core/src/java/org/apache/lucene/search/SeededKnnVectorQuery.java:85-105 (at 5adff78bb168) (public constructors SeededKnnVectorQuery(KnnFloatVectorQuery|KnnByteVectorQuery, Query, Weight), no javadoc, no precondition on the Weight's hits)
  • lucene/core/src/java/org/apache/lucene/search/SeededKnnVectorQuery.java:119-133 (at 5adff78bb168) (rewrite keeps a non-null seedWeight as it is) and :135-146 (package-private createSeedWeight adds FieldExistsQuery(field) and the kNN filter as FILTER clauses)
  • lucene/core/src/java/org/apache/lucene/search/SeededKnnVectorQuery.java:228-261 (at 5adff78bb168) (MappedDISI.advance and nextDoc call indexedDISI.advance(newTarget) with no check that newTarget > indexedDISI.docID())
  • lucene/core/src/java/org/apache/lucene/search/SeededKnnVectorQuery.java:340-353 (at 5adff78bb168) (the vector iterator wrapped in MappedDISI; numberOfEntryPoints = seedTopDocs.scoreDocs.length)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/SeededHnswGraphSearcher.java:37-55 (at 5adff78bb168) (fromEntryPoints throws IllegalArgumentException when the iterator ends before numEps ordinals)
  • lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:136-142 (at 5adff78bb168) (advance is undefined for target <= current)
  • lucene/core/src/java/org/apache/lucene/search/knn/KnnSearchStrategy.java:107-140 (at 5adff78bb168) (Seeded: 'Iterator of valid entry points', 'Number of valid entry points')
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/IndexedDISI.java:459, (at 5adff78bb168) :578-600, :649-690 (SPARSE and DENSE advanceWithinBlock read forward from the cursor)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/SeededKnnVectorQuery.java:119-123 (at 5adff78bb168) (rewrite keeps a non-null seed Weight), :136-146 (createSeedWeight adds FieldExistsQuery(field) and the kNN filter as FILTER clauses), :231-237 and :254-260 (MappedDISI.advance/nextDoc call indexedDISI.advance(newTarget) unguarded), :315-335, :341-353 (seed collection, MappedDISI, numberOfEntryPoints = seedTopDocs.scoreDocs.length)
  • lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:136-142 (at 5adff78bb168) (advance undefined for target <= current)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene90/IndexedDISI.java:459-470, (at 5adff78bb168) :580-594 (advance; SPARSE advanceWithinBlock reads forward from the cursor)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/SeededHnswGraphSearcher.java:37-55 (at 5adff78bb168) (fromEntryPoints throws at :47-49); util/hnsw/HnswGraphSearcher.java:148-152
  • lucene/core/src/java/org/apache/lucene/search/PatienceKnnVectorQuery.java:297-312; (at 5adff78bb168) codecs/lucene99/Lucene99HnswVectorsFormat.java:122-137
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/SeededKnnVectorQuery.java:31-41 (at 5adff78bb168) (class javadoc, @lucene.experimental), :85-105 (public constructors)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/SeededKnnVectorQuery.java:31-41, (at 5adff78bb168) :85-105, :124-133, :136-146
  • lucene/core/src/java/org/apache/lucene/search/knn/KnnSearchStrategy.java:125-141; (at 5adff78bb168) search/DocIdSetIterator.java:141-142
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/SeededKnnVectorQuery.java:341-356; (at 5adff78bb168) lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java:463-469; (at 5adff78bb168) search/PatienceKnnVectorQuery.java:297-312
Diagrams
  • lucene/core/src/java/org/apache/lucene/search/SeededKnnVectorQuery.java:119-146, (at 5adff78bb168) :254-260, :341-353
  • lucene/core/src/java/org/apache/lucene/search/DocIdSetIterator.java:141-142; (at 5adff78bb168) codecs/lucene90/IndexedDISI.java:580-594; util/hnsw/SeededHnswGraphSearcher.java:37-55

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 follows the repro's steps on main 5adff78bb168 (SeededKnnVectorQuery.java:119-146, :254-260, :352, DocIdSetIterator.java:141-142, IndexedDISI.java:580-594, SeededHnswGraphSearcher.java:47-49).

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.