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.
SeededKnnVectorQueryruns 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.- 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.
- 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:xmatches doc 1997, which has no vector, and doc 1998, the last document with one. - You build the seed's
Weightwithsearcher.createWeight(seed, TOP_SCORES, 1f)and searchnew SeededKnnVectorQuery(knn k=2, seed, seedWeight). You expect 2 hits:SeededKnnVectorQuery.fromFloatQuery(knn, seed)returns 2 for the same seed. - Instead
searchthrowsIllegalArgumentException: The number of entry points provided is less than the number of entry points requested, with assertions on and off. - 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. - On a dense vector block the same code throws
EOFException: read past EOFinstead, which looks like index corruption, thoughIndexWriterwrote the index normally.
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 filter is skipped.
rewritereturnssuper.rewritewhen the seed Weight is non-null (search/SeededKnnVectorQuery.java:119-123). Only for a null Weight does it call the package-privatecreateSeedWeight(:136-146), which buildsseed MUST + FieldExistsQuery(field) FILTER(+ the kNN filter as a FILTER). - Seed hits are collected and counted. Per leaf,
SeededCollectorManager.newCollectorscores the seed Weight into a top-k collector (:315-335; k = 2, hits 1997 and 1998), wraps the field's vector iterator (anIndexedDISIfor a sparse field) and the sorted seed docs inMappedDISI, and passesseedTopDocs.scoreDocs.length(2) as the number of entry points (:341-353). - The mapping advances in place.
MappedDISI.nextDoc(:254-260) andadvance(:231-237) callindexedDISI.advance(newTarget)for every seed doc, with no check thatnewTarget > indexedDISI.docID(). Seed 1997 moves the vector iterator to doc 1998 (ordinal 999); seed 1998 then callsadvance(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. - Too few entry points.
SeededHnswGraphSearcher.fromEntryPoints(util/hnsw/SeededHnswGraphSearcher.java:37-55), called fromHnswGraphSearcher.search(util/hnsw/HnswGraphSearcher.java:148-152), wants 2 ordinals, gets 1, and throws (:47-49).
- 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]onMMapDirectory,IndexOutOfBoundsExceptiononByteBuffersDirectory. 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.
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 andIndexWriterConfigsuffice. - Loud, sometimes misleading. One such leaf fails the whole
IndexSearcher.search. On a DENSE block theEOFException: read past EOForIndexOutOfBoundsExceptioncan send an operator looking for index corruption in an indexIndexWriterwrote 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.
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.
/*
* 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);
}
}
}
}
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 requestedatSeededHnswGraphSearcher.fromEntryPoints(SeededHnswGraphSearcher.java:48), assertions on and off.
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (only this platform was run) |
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
# 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- 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
FieldExistsQueryfilter is package-private; passingnullloses 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.
createSeedWeightis package-private (:136). With anullWeight,rewritereturns a new plainSeededKnnVectorQuery(: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.Seededdocuments its iterator and count as "valid entry points" (search/knn/KnnSearchStrategy.java:125-141), andMappedDISImakes theadvancecallDocIdSetIteratorleaves 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'sFieldExistsQueryfilter (#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
nullWeight; 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.
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.
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.
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, 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.
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
advancemoves forward and the count equals the number of ordinals produced. The internal path discharges this withFieldExistsQuery; 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").
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-03, re-checked the same day | three 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 triggers | no report; #13635, #14084, #14170, #14838 and #14195/#14197 are the nearest items |
| Open PRs | 2026-11-03 | the 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, web | 2026-11-03 | GitHub code search in Elasticsearch, OpenSearch and Solr; dev@, java-user@, issues@ and commits@; 7 web searches | no report and no downstream caller of the Weight-taking constructor |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main 5adff78bb168 | affected | gradle and the standalone runner: fails with assertions on and off; passes with the recommended fix |
| 10.6.0, 10.5.2 | affected | the same IllegalArgumentException, assertions on and off |
| 10.4.0, 10.3.0 | affected | the 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 reading | the tags have both public constructors and both unguarded calls |
| 10.2.2 | not affected | not reachable through public API: an out-of-package copy does not compile (constructor package-private) |
| 10.2.0, 10.2.1 | by reading | the 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 affected | SeededKnnVectorQuery does not exist (compile error) |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Own 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 | real | Own 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 | real | The 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, Low | Re-ran the finder's test on main with assertions on and off: fails at fromEntryPoints:48. |
| audit re-run, final repro check | reproduced; confirmed, Low | The one-test repro fails on main with assertions on and off and passes with the fix. |
| this doc | reproduced; fix validated | Gradle in a scratch worktree: fails without the fix (assertions on and off), passes with it; 160 tests, 0 failures with the fix. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 561 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,517 words
- 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 andTestSeededKnnFloatVectorQuery,TestSeededKnnByteVectorQuery,TestPatienceFloatVectorQuery,TestPatienceByteVectorQuerywith the fix (160 tests, 0 failures), pluscheckGoogleJavaFormat. 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.
- 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-152lucene/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-146lucene/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-353lucene/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
- TestSeededKnnSeedWithoutVector.java (lucene/core, package org.apache.lucene.search, 1 test):
repro/LFV-1185/TestSeededKnnSeedWithoutVector.java(60 lines)
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).
assets/LFV-1185/bug.png(561 KB)
