A deleted document stays in its segment until a merge removes it. Searches skip it, but RescoreTopNQuery picks its top n before that check happens.
f:a with v = doc id, doc 3 deleted, n = 2: the rewrite keeps docs 3 and 2, the search returns [2], and the count says 2. Select the still to open it at full size. Generated with AI, checked against the code.- Index docs 0-3, all matching
f:a, each with a rescore valuevequal to its doc id. Delete doc 3, the best one: it stays in the segment until a merge removes it. - Search
new RescoreTopNQuery(new TermQuery(f:a), DoubleValuesSource.fromLongField("v"), 2): re-score every match byvand keep the best 2. - You expect the two best live documents,
[2, 1], andIndexSearcher#count= 2. - Instead you get 1 hit,
[2], and a count of 2. The query picks its top 2 straight from the inner scorer, which still returns deleted docs, so doc 3 takes a slot; the search then drops it, but the count includes it. - No error. With n=10 over 3 matches and one deleted, the hits are right but the count says 3. Wrapping a kNN query is safe: its own search keeps only live docs.
Applications that wrap a term, boolean, range or other non-kNN query in RescoreTopNQuery, also through createLateInteractionQuery, which is documented for hybrid search, and search an index with deletions not yet merged away, which is normal with default settings. Wrapping a kNN query is not affected. No use was found in the main code of Elasticsearch, OpenSearch or Solr.
Mechanism, terms and numbers630 words
RescoreTopNQuery re-scores every match of an inner query with a DoubleValuesSource and keeps the best n. It collects those matches in rewrite straight from the inner Scorer, which by contract does not apply live docs, and neither of its two collection loops checks them. A deleted document that is still in its segment therefore competes for the n slots and can take one: the search returns fewer than n hits, or the wrong ones, and IndexSearcher#count counts the deleted document through the rewritten query's count shortcut. In the repro, 4 matches with the best one deleted and n=2 give [2] instead of [2, 1] and a count of 2, with no error. A kNN inner query is not affected; inner queries whose scorers return deleted documents are, including through the documented hybrid-search factory createLateInteractionQuery (TermQuery and MatchAllDocsQuery inner queries were run; boolean, range and other ordinary queries take the same two loops, read but not run). It has been in every release since the class was added in 10.3.0.
- Deleted but not merged away:
deleteDocumentsorupdateDocumentonly clears the document's bit in its segment's live docs. Postings and scorers still return the document until a merge rewrites the segment; searches skip it becauseIndexSearcherhands the live docs to the bulk scorer (IndexSearcher.java:851-852). RescoreTopNQuery(query, valuesSource, n)(@lucene.experimental): runsquery, re-scores each match withvaluesSource, keeps the top n, and rewrites itself into aDocAndScoreQuerythat matches exactly those n documents with their new scores.
rewrite(RescoreTopNQuery.java:68-88) creates the innerWeight(:72) and aHitQueueof size n (:73), then collects candidates inrescoreInline(:90-107, value sources that need scores) orrescoreWithPrefetch(:123-157, all others).- Both loops iterate
weight.scorer(leaf).iterator()(:100-104,:139-149) and insert every document into the queue (rescoreInto,:221-228). Neither readsleaf.reader().getLiveDocs(), althoughWeight#scorersays: "The returned Scorer does not have LeafReader#getLiveDocs() applied, they need to be checked on top." (Weight.java:121-122). - A deleted document with a competitive value takes a queue slot and pushes out a live match.
- The queue becomes a
DocAndScoreQuery(:87). Searching it applies live docs again (IndexSearcher.java:851-852), so the deleted entry is dropped and fewer than n hits come back. IndexSearcher#countusesWeight#countwhen it is not -1 (TotalHitCountCollector.java:47-52), andDocAndScoreQueryreturns the number of stored entries (DocAndScoreQuery.java:192-195), deleted ones included, against "Counts the number of live documents that match" (Weight.java:182).
That count shortcut is correct for kNN, whose rewrite builds its candidates from live docs only (AbstractKnnVectorQuery.java:250-253).
One segment; docs 0 to m-1 match f:a (m = 4 unless the row says otherwise) with v = doc id; one more doc does not match. Main ea57360912ed:
| case | deleted | expected hits / count | got |
|---|---|---|---|
fromLongField("v"), n=2 | doc 3 (best) | [2, 1] / 2 | [2] / 2 |
DoubleValuesSource.SCORES (all tie), n=2 | doc 0 | [1, 2] / 2 | [1] / 2 |
| 3 matches, n=10 | doc 2 | [1, 0] / 2 | [1, 0] / 3 |
| 1 match, n=2 | doc 0 | [] / 0 | [] / 1 |
| control, n=2 | none | [3, 2] / 2 | [3, 2] / 2 |
A stage-by-stage probe of the first case: the inner iterator returns [0, 1, 2, 3(deleted)], the rewrite keeps docs 3 and 2, search returns [2], count returns 2. With a KnnFloatVectorQuery as the inner query the same index gives [2, 1] and 2. Independent verifiers also reproduced it through createLateInteractionQuery and createFullPrecisionRescorerQuery over a TermQuery, with the default IndexWriterConfig and an NRT reader after updateDocument, and in a randomized multi-segment check against a brute-force oracle (4 of 6 iterations mismatched).
Exposure and likelihood289 words
- Who is exposed. Callers of
RescoreTopNQuerywith a non-kNN inner query, directly or throughcreateLateInteractionQueryorcreateFullPrecisionRescorerQuery.createLateInteractionQueryis documented for exactly this: it "computes late interaction field similarity for the entire match-set of wrapped query ... This is typically useful in combining a query's results with other queries for hybrid search" (RescoreTopNQuery.java:310-313). - Not exposed. A kNN inner query, the use the class was written for: its rewrite keeps only live docs. Run for
KnnFloatVectorQuery; the other kNN queries share that rewrite (AbstractKnnVectorQuery.java:250-268, read but not run). - Default settings. Ordinary deletes and updates, the default
IndexWriterConfig, an NRT or a committed reader. Deletions are normal state: the defaultTieredMergePolicyworks toward a target of at most 20% deleted documents as part of its merge selection (deletesPctAllowed = 20.0), so deletions routinely stay in segments until a merge rewrites them.RescoreTopNQueryitself is opt-in and@lucene.experimental. - When. Whenever a deleted match would rank inside the top n by the rescore value; no scale is needed. How often that happens depends on the workload and was not measured. A merge that expunges the deletion makes it go away, so results can change after a background merge.
- What you see. No error. Fewer than n hits or a wrong top n, and an
IndexSearcher#counthigher than the number of hits by the number of deleted documents kept (at most n), even when the hits are right. - Downstream. A code search found no use of
RescoreTopNQueryin the main code of Elasticsearch, OpenSearch or Solr; the users found (an open Solr PR, neo4j, nrtsearch, zuliasearch, luceneutil) all wrap a kNN query. That fits the absence of reports, and says nothing about private code.
The test shows that RescoreTopNQuery over a non-kNN query keeps a deleted document in its top n: 4 docs with v = 0..3, best doc 3 deleted, n = 2.
/*
* 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 java.util.Arrays;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
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.NoMergePolicy;
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;
public class TestRescoreTopNDeletedDocs extends LuceneTestCase {
public void testDeletedDocTakesTopNSlot() throws Exception {
IndexWriterConfig iwc = new IndexWriterConfig().setMergePolicy(NoMergePolicy.INSTANCE);
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
for (int i = 0; i < 4; i++) {
Document doc = new Document();
doc.add(new StringField("id", Integer.toString(i), Field.Store.NO));
doc.add(new NumericDocValuesField("v", i));
w.addDocument(doc);
}
w.commit();
w.deleteDocuments(new Term("id", "3"));
try (DirectoryReader reader = DirectoryReader.open(w)) {
IndexSearcher searcher = new IndexSearcher(reader);
Query q =
new RescoreTopNQuery(MatchAllDocsQuery.INSTANCE, DoubleValuesSource.fromLongField("v"), 2);
int[] hits = Arrays.stream(searcher.search(q, 10).scoreDocs).mapToInt(d -> d.doc).toArray();
// expected the 2 best live docs [2, 1] and a count of 2; actual [2] while count says 2
assertEquals("[2, 1] count=2", Arrays.toString(hits) + " count=" + searcher.count(q));
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-087/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.search.TestRescoreTopNDeletedDocs
- Expected vs actual
- Expected hits
[2, 1]andIndexSearcher#count2; actual[2]while count still says 2 (expected:<[2[, 1]] count=2> but was:<[2[]] count=2>), with-eaand-da.
More files, environment, full output35 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> org.junit.ComparisonFailure: expected:<[2[, 1]] count=2> but was:<[2[]] count=2>
org.junit.Assert.assertEquals(Assert.java:117)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.search.TestRescoreTopNDeletedDocs.testDeletedDocTakesTopNSlot(TestRescoreTopNDeletedDocs.java:53)- The javadoc says it "can work with any type of Query";
createLateInteractionQueryis documented for rescoring a whole match set in hybrid search. Weight#scorerleaves the live-docs check to the caller, andWeight#countmust count live documents only.- The kNN query had the same omission, fixed in 2021 (LUCENE-10040). Here a deleted document displaces live matches.
The full argument206 words
- The contracts. Class javadoc (
RescoreTopNQuery.java:26-35): "re-scores another Query ... and cut-off the results at top N ... it can work with any type of Query".createLateInteractionQuery(:310-313): rescoring "the entire match-set of wrapped query ... for hybrid search". 10.3.0 CHANGES (GITHUB#14009,CHANGES.txt:1562-1563): "a new Query that can rescore other Query based on a generic DoubleValueSource and trim the results down to top N".Weight#scorer(Weight.java:121-122): live docs "need to be checked on top".Weight#count(Weight.java:182): "Counts the number of live documents that match". - Lucene's own count shortcuts respect deletions.
TermQuery's count returnsdocFreqonly when the segment has no deletions and otherwise falls back (TermQuery.java:255-267). - Made generic on purpose. In the #14009 review, vigyasharma asked whether it could be generalized "to rerank any query"; dungba88 replied "I changed the query to be generic" and later "API-wise there would be no such limitation". No comment in #14009, #14860, #14729, #16628 or #16705 mentions deletions or live docs.
- Untested case. The four upstream test classes never delete a document, and every functional test wraps a kNN query.
- Precedent. LUCENE-10040 (#11078), "Handle deletions in nearest vector search", fixed the same omission in the kNN rewrite in 2021.
The devil's-advocate check229 words
The strongest case for "intended" (built by a devil's-advocate verifier and by an OpenAI GPT-5.6 reviewer arguing "works as designed"): the class was designed to rerank kNN shortlists (#14009 is titled "Add Query for reranking KnnFloatVectorQuery with full-precision vectors", and a review comment says it "only applies to vector search queries right now, but in spirit is generic to all approx search queries"), and kNN rewrites already keep only live docs; the class is @lucene.experimental; n is documented as "the number of documents to find", so fewer than n hits could be read as allowed; and DocAndScoreQuery#count is correct for the input it is given.
Why it fails: the merged javadoc, the CHANGES entry and the PR author all say any query, and the late-interaction factory documents a non-kNN use over the whole match set; Weight#scorer puts the live-docs check on the caller, and RescoreTopNQuery is that caller (that note alone is not decisive, since some consumers such as QueryBitSetProducer deliberately see deleted documents; the query's documented meaning and the Weight#count contract are); more than n live matches exist and a deleted one displaces them, which the kNN rewrite avoids by collecting live docs only; IndexSearcher#count breaks the Weight#count contract outright (count 1 with 0 hits); and @lucene.experimental marks an unstable API, not permission to return wrong results. Every reviewer rated the intended argument weak. Verdict: a defect.
Recommended A: skip non-live documents in both loops, and return MatchNoDocsQuery when nothing is left
Skip non-live documents in both rescoring loops of rewrite, and return MatchNoDocsQuery when no candidate is left (+12 lines, one file); without that guard the skip makes "every match deleted" throw. The test passes 5 of 5 with assertions on and off; 109 tests in 11 nearby suites and the core static checks pass.
Bits liveDocs = leaf.reader().getLiveDocs();
while (iterator.nextDoc() != DocIdSetIterator.NO_MORE_DOCS) {
int docId = iterator.docID();
if (liveDocs != null && liveDocs.get(docId) == false) {
continue; // deleted (or hidden) documents never become candidates
}
... // unchanged: prefetch / rescoreInto
}
// in rewrite, after collection
if (queue.size() == 0) {
return new MatchNoDocsQuery();
}All options and trade-offs4 options ยท 777 words
The two loops must apply the live docs that Weight#scorer leaves to its caller, and rewrite must cope with an empty result. One option was built and validated in a scratch worktree at ea57360912ed (removed afterwards); the others are untested.
A: skip non-live documents in both loops, and return MatchNoDocsQuery when nothing is left (validated by the checks) (recommended)
In rescoreInline and rescoreWithPrefetch, read leaf.reader().getLiveDocs() once per segment and skip documents whose bit is clear, before the prefetch and the value read. After collection, return new MatchNoDocsQuery() when the queue is empty, as AbstractKnnVectorQuery does (AbstractKnnVectorQuery.java:153-155). +12 lines, RescoreTopNQuery.java only.
Bits liveDocs = leaf.reader().getLiveDocs();
while (iterator.nextDoc() != DocIdSetIterator.NO_MORE_DOCS) {
int docId = iterator.docID();
if (liveDocs != null && liveDocs.get(docId) == false) {
continue; // deleted (or hidden) documents never become candidates
}
... // unchanged: prefetch / rescoreInto
}
// in rewrite, after collection
if (queue.size() == 0) {
return new MatchNoDocsQuery();
}Trade-offs
- The guard is required.
DocAndScoreQuery.createDocAndScoreQueryneeds a non-emptytopK(assert len > 0, thentopK.scoreDocs[0],DocAndScoreQuery.java:123-125). With the live-docs skip alone, "every match is deleted" becomes anAssertionError(assertions on) or anArrayIndexOutOfBoundsException(off) instead of a wrong count. - Side effect, welcome but separate. The guard also stops the stock throw when the inner query matches nothing at all; a comment on apache/solr#4919 already reports that throw.
- Cost. At most one
Bits.getper inner match, and only on segments with a live-docs mask (getLiveDocs() != null); elsewhere a null check. Deleted documents no longer cost a prefetch or a value read. No benchmark was run. - Deprecated constructor. The guard uses the no-argument
new MatchNoDocsQuery(), which is@Deprecated(MatchNoDocsQuery.java:39-40);:lucene:core:check -x testpassed with it. For upstream, pass a reason asAbstractKnnVectorQuerydoes, for examplenew MatchNoDocsQuery("no live document matched the inner query"); that one-line change was not re-run. - Informational count. The "visited" count handed to
DocAndScoreQuery(originalCount) now counts live matches only. - Rebase. Open draft #16847 (long doc ids) touches both files.
What the checks validated
- Stock: the test fails 4 of 5 through gradle with assertions on and with
-Ptests.asserts=false. - Patched, assertions on:
TestRescoreTopN*(with the new test and the empty-inner probe),TestFilterReadersForwardVectorPrefetch,TestKnnFloatVectorQuery,TestKnnByteVectorQueryand*LateInteraction*: 11 classes, 109 tests, 0 failures, 2 skipped (@Nightly). - Patched, assertions off: the same minus the two kNN query suites: 9 classes, 26 tests, 0 failures, 2 skipped (
@Nightly). Patched with-Ptests.nightly=true:TestRescoreTopNQuery6 of 6 pass. :lucene:core:check -x testpasses (google-java-format, ecjLint, forbiddenApis);tidydid not change the fix.- Standalone harness with the patched class: 5 of 5 pass with assertions on and off. With both parts, the getLiveDocs-masking probe passes 4 of 4, and a fact check re-ran the finder's, the verifiers' and the cross-model reviewers' tests with assertions on and off: all pass (finder 3/3, the independent reproduction 8/8, the intended-behaviour check 3/3 and its all-deleted probe 1/1, the reviewers 3/3 each). The earlier hunt runs of those tests used the finder's live-docs skip without the guard; the empty-result probes of the independent reproduction and the intended-behaviour check failed with it, which is why the guard was added.
Replace the Scorer#iterator loops by weight.bulkScorer(leaf).score(collector, liveDocs, 0, DocIdSetIterator.NO_MORE_DOCS) with a small LeafCollector that feeds the prefetch ring or the queue, the way IndexSearcher applies live docs.
Trade-offs
Keeps the accept-docs rule in one place and lets the inner query use its bulk scorer, but it is a larger change: the inline path needs the Scorable from setScorer for score-dependent value sources, and the prefetch ring must be driven from collect. Still needs the empty-queue guard and a null check on weight.bulkScorer(leaf), and it does not touch the separate SCORES observation (the inner weight is still created with COMPLETE_NO_SCORES).
Make the count honour live docs, or return -1 for segments with deletions, as TermQuery does.
Trade-offs
Fixes IndexSearcher#count only: the deleted document still takes a top-n slot, so the search still returns fewer than n hits or the wrong ones. DocAndScoreQuery.createDocAndScoreQuery is public, so any caller passing non-live documents gets the same count; the only in-tree callers are AbstractKnnVectorQuery (live documents only) and RescoreTopNQuery, so the issue is best scoped to RescoreTopNQuery.
State that the inner query must already exclude deleted documents.
Trade-offs
Contradicts the class javadoc ("any type of Query"), the CHANGES entry and the documented hybrid use of createLateInteractionQuery, and leaves the Weight#count contract broken.
Some access-control layers hide documents by masking getLiveDocs(). Through such a reader, the count and the missing top-n slots reveal how many hidden documents rank in a query's top n (a probe: count 1 with 0 hits). Nothing hidden is returned and no content leaks. Out of scope of SECURITY.md, which leaves authorization to the application.
Code reading. An AI tool auditing every Weight#count shortcut noticed that DocAndScoreQuery's count trusts candidates that RescoreTopNQuery collects without a live-docs check, then confirmed it with a JUnit test on the stock jar. The hunt's formal and exhaustive checks (Z3, bounded-exhaustive and differential runs) did not cover this query.
The method in full173 words
Method: code reading (the hunt's non-formal tier).
- A hunt unit (2026-10-25) set out to show that every
Weight#countreturns -1 or the exact number of live matches, and thatTotalHitsnever claimsEQUAL_TOfor a wrong count. Its plan listedWeight,TermQuery,PointRangeQuery,BooleanWeight,FieldExistsQuery,TotalHits, the total-hits collectors,IndexSearcherandIndexSortSortedNumericDocValuesRangeQuery; it did not nameRescoreTopNQueryorDocAndScoreQuery. - The finder, an AI tool, checked the
BooleanWeight#countalgebra with Z3 in bounded scopes (8 mutants killed) and bounded-exhaustively on the stock jar, and ran 240,000 differential count checks over random indexes with deletions; none of these covered this query. - Separately it audited every
Weight#countoverride andTotalHitsproducer by reading code.DocAndScoreQuery#count, fed byRescoreTopNQuery's rewrite, stood out; a JUnit on the stock jar then showed both the wrong top n and the wrong count. - An independent reviewer from another model family (OpenAI GPT-5.6) then tried to refute it, verifiers and an adjudication confirmed it, and the history, prior-report and security checks followed.
No prior report found: 273 queries over GitHub issues and PRs, commits, downstream code, mailing lists, the JIRA archive and the web (2026-10-25), five related PR threads read in full, and the file lists of all 412 open PRs. The closest precedent is the 2021 kNN fix of the same omission (LUCENE-10040).
Searches6 searches
The searches found no upstream issue, PR, commit, mailing-list thread or JIRA entry that reports this bug, and no open PR that fixes it. GitHub search reads only the first page per query, so "none found" is not proof that none exists.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene and other repositories) | 2026-10-25 | 100 queries from the scripted prior-report search in 4 runs (class and method names such as RescoreTopNQuery, DocAndScoreQuery, rescoreInline, rescoreWithPrefetch, createDocAndScoreQuery, getLiveDocs; rescoring framing; symptoms such as "fewer than k", "fewer results", count + rescore), plus 33 manual read-only searches | no report; only the feature PRs #14009, #14860, #14729, #16628, #16705 and unrelated items |
| Threads read in full | 2026-10-25 | #14009 (introduced the class), #14860 (backport), #14729 (late interaction), #16628 and #16705 (prefetch) | none mentions deletions or live docs |
| Open PRs | 2026-10-25 | the file lists of all 412 open apache/lucene PRs | only draft #16847 (long doc ids) touches either file; it adds no live-docs check |
| Commits and downstream code | 2026-10-25 | 12 commit queries and 5 REST commit/PR queries on both files and every 10.x branch; 8 downstream-repo queries and 5 code searches | no fix; downstream users (an open Solr PR, neo4j, nrtsearch, zuliasearch, luceneutil) all wrap a kNN query |
| Mailing lists and JIRA archive | 2026-10-25 | 80 queries over dev@, java-user@, issues@ and commits@, 3 direct archive queries, and 16 JIRA-archive queries | no report; a 2024 dev@ thread on Weight#count and deletes is consistent with the contract |
| Web | 2026-10-25 | 11 of 13 queries (2 throttled) | only generic rescoring and deleted-document pages |
Introduced with the class by #14009 (merged 2025-06-28; backport #14860), so it is not a regression; first released in 10.3.0. All seven releases 10.3.0 to 10.5.2 and main fail; 10.2.2 and earlier have no such class. Not fixed upstream.
Versions tested8 rows
Introduced by #14009, "Add Query for reranking KnnFloatVectorQuery with full-precision vectors" (dungba88, approved by vigyasharma), merged 2025-06-28 as 3404496b3d77 on main and backported by #14860 as 4fcb6d6d50de (merged 2025-06-29). The loop it added never reads live docs. DocAndScoreQuery's count shortcut is older (#12152, 2023) and is correct for kNN; #14009 started feeding it from a loop that does not filter. #14729, #16628 and #16705 (the split into two loops, main only) kept the omission, and git log -G 'LiveDocs|liveDocs' on the file returns no commit. branch_10_6 (10.6.0, released 2026-10-29) has a file byte-identical to 10.5.2's (read, not run).
| Version or commit | Verdict | Result |
|---|---|---|
| 9.12.3, 10.2.2 | class absent | the repro does not compile: RescoreTopNQuery does not exist |
| 10.3.0 | first affected | finder's test 2 of 3 fail (also with -da); this doc's test 4 of 5 fail |
| 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1 | affected | finder's test 2 of 3 fail |
| 10.5.2 | affected | finder's test 2 of 3 fail (also with -da); this doc's test 4 of 5 fail |
main ea57360912ed | affected | this doc's test 4 of 5 fail through gradle and the harness, assertions on and off |
3404496b3d77 (#14009, main) and 4fcb6d6d50de (#14860, 10.x) | commit level | that commit's RescoreTopNQuery.java shadowed over the main or 10.3.0 jars: 2 of 3 fail (single-class runs, not full builds) |
| 10.3.0 + only a live-docs skip | necessity check | 3 of 3 pass over the 10.3.0 jars |
| main + the fix | not affected | 5 of 5 pass; 109 tests in 11 nearby suites pass |
An adversarial review by another model family (OpenAI GPT-5.6), independent verifiers and an adjudication all say real, with no dissent; each ran a test of its own on main, and the adjudication re-ran one with a fresh seed. This doc's test was re-run on main, 10.5.2 and 10.3.0, and with the fix.
Checks and verdicts360 words
Every check ran on main ea57360912ed with assertions on and off unless noted. The verifiers' and reviewers' fix checks used the finder's live-docs skip without the empty-queue guard; the fact check re-ran all their tests against the complete fix.
| Check | Verdict | What it found |
|---|---|---|
| Refute review (OpenAI GPT-5.6) | real, high confidence | Its own MatchAllDocsQuery test fails with assertions on and off and in 3 of 3 iterations; the no-deletion and non-competitive-deletion controls pass; the finder's live-docs skip passes its test and 5 existing RescoreTopNQuery tests. |
| Independent reproducer | real | Own test written from the claim only: 7 of 8 fail on main, 10.5.2 and 10.3.0, covering both loops, a count-only case, createLateInteractionQuery over a TermQuery, the default config with an NRT reader, and a randomized brute-force oracle (4 of 6 iterations mismatch); 8 of 8 pass with the finder's live-docs skip. It also found that the fix needs the empty-queue guard. |
| "works as designed" reviewer (OpenAI GPT-5.6) | real, high; intended argument weak | Its own test fails 2 of 3 on main, 10.5.2 and 10.3.0 and passes with the finder's live-docs skip, which also passes 10 existing component tests. All its runs were with assertions on; the fact check ran its test on main with assertions off: 3 run, 2 failed. |
| Devil's advocate | real, high | No intended or out-of-contract argument survives. The kNN inner-query control passes; createLateInteractionQuery and createFullPrecisionRescorerQuery over a TermQuery fail; the only match deleted gives 0 hits and count 1. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's test with a fresh seed (7 of 8 fail with assertions on and off, 0 of 8 with the finder's live-docs skip) and the finder's test (2 of 3 fail). |
| This doc's re-runs | reproduces; fix validated | The new test fails 4 of 5 on main (gradle and harness, assertions on and off), 10.5.2 and 10.3.0, and passes 5 of 5 with the fix; the closest suites and the core static checks pass. A fact check re-ran it (gradle and harness, assertions on and off, a second seed) and ran every verifier's and reviewer's test against the fix: all pass. |
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 475 KB
Finding record, Lucene and upstream sources, repro files and diagram notes965 words
- Registry title
- RescoreTopNQuery lets deleted documents take top-n slots, and IndexSearcher#count counts them
- Area
- search / rescoring
- Priority
- Medium, rank 52: Experimental RescoreTopNQuery around any non-kNN query lets deleted documents take top-n slots and inflates the count on any index with unmerged deletes, silently and usually, but almost nobody uses it that way (no Elasticsearch, OpenSearch or Solr use).
- Impact class
- silent wrong results
- Impact note
- Applications that wrap a non-kNN query (term, boolean, range, match-all, or the documented hybrid-search use through createLateInteractionQuery) in the experimental RescoreTopNQuery and search a reader whose segments still hold deletions (any delete or update not yet merged away; the default IndexWriterConfig and an NRT reader suffice), whenever a deleted match would rank in the top n; ordinary scale; kNN inner queries are not affected; released 10.3.0-10.6.0 (10.6.0 by reading).
- Summary
- You wrap an ordinary query, for example a TermQuery, in RescoreTopNQuery to re-score its matches with a DoubleValuesSource and keep the best n, on an index where some matching documents are deleted but not yet merged away. You expect the n best live matches, and IndexSearcher#count to count only live documents. Instead a deleted document whose rescore value ranks in the top n takes one of the n slots, so the search returns fewer than n hits (n=2: [2] instead of [2, 1]) while IndexSearcher#count still reports 2; the deleted document itself is never returned.
- Introduced
- 3404496b3d77 (#14009 'Add Query for reranking KnnFloatVectorQuery with full-precision vectors', main), released through its branch_10x backport 4fcb6d6d50de (#14860) [read: git show, git log --all --diff-filter=A; exec: RescoreTopNQuery.java at each commit, shadowed over the main and 10.3.0 jars, fails 2 of 3]; not a regression: the missing live-docs check is in the class's first version (2025-06-28 (#14009 merged; backport #14860 merged 2025-06-29))
- First affected version
- 10.3.0 [exec]
- Affected versions
- 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main ea57360912ed [exec; 10.3.0, 10.5.2 and main also with -da]; not applicable to 10.2.2 and earlier, including 9.12.3 (RescoreTopNQuery absent, the repro does not compile) [exec]; branch_10_6 4ae946e5a9b (10.6.0 RC1) and branch_10x 657014b3c57 have a RescoreTopNQuery.java byte-identical to 10.5.2 [read 2026-10-29]; main 645d4fe14a20, after #16926 [exec 2026-10-29, -ea and -da]; branch_11x f85446cb1af [read: RescoreTopNQuery.java identical to main]; not fixed upstream as of main 645d4fe14a20
- 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
- non-formal: code reading
- Upstream and prior reports
- none found (2026-10-25: a scripted prior-report search, 4 runs with 100 GitHub issue/PR, 12 commit, 8 downstream-repo, 80 mailing-list (dev@, java-user@, issues@, commits@) and 16 JIRA-archive queries; 33 manual read-only gh issue/PR searches, 5 gh code searches, 5 REST commit/PR queries, 3 direct mailing-list archive queries, 11 of 13 web queries; full reads of the #14009, #14860, #14729, #16628 and #16705 threads; file lists of all 412 open PRs); related prior items: #11078 (LUCENE-10040, precedent: the kNN rewrite was fixed in 2021 to honour deletions), #14009 (introduced the code, no mention of deletions), #16705 (split the loop, kept the omission), open draft #16847 (touches both files, adds no live-docs check); #16926 (Greg Miller, merged 2026-10-28) changed RescoreTopNQuery.rewrite (empty-queue guard) but adds no live-docs check: not a fix of this bug
- Lucene commit tested
- main
ea57360912ed(2026-10-25/26), assertions on and off, seed DEADBEEF, through gradle and through a standalone JUnit harness; this doc's test also on the Maven Central jars of 10.5.2 and 10.3.0 (assertions on); the finder's test on all seven releases 10.3.0-10.5.2 (on 10.3.0 and 10.5.2 also with assertions off) (10.2.2 and 9.12.3 do not have the class); the introducing commits as single-class shadow runs; the fix in a scratch worktree atea57360912ed(the test, the closest suites with assertions on and off, the@Nightlytests,:lucene:core:check -x test), removed afterwards; then, in the fact check, every verifier's and reviewer's own test against main plus the fix, assertions on and off.
- Finding record
a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/RescoreTopNQuery.java:26-35 (class javadoc: 'it can work with any type of Query', @lucene.experimental), :68-88 (rewrite: HitQueue of size n, createDocAndScoreQuery at :87), :90-107 (rescoreInline), :123-157 (rescoreWithPrefetch), :305-316 (createLateInteractionQuery javadoc: 'the entire match-set of wrapped query ... hybrid search')a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/Weight.java:121-122 ('The returned Scorer does not have LeafReader#getLiveDocs() applied, they need to be checked on top'), :182 ('Counts the number of live documents that match')a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/DocAndScoreQuery.java:121-125 (createDocAndScoreQuery: assert len > 0, then topK.scoreDocs[0]), :192-195 (Weight#count returns the number of stored entries)a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/AbstractKnnVectorQuery.java:153-154 (empty result -> MatchNoDocsQuery), :250-253 (kNN rewrite builds its accept set from live docs)
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/RescoreTopNQuery.java:68-88, 90-107, 123-157, 221-228Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/Weight.java:121-122, 182; DocAndScoreQuery.java:192-195; TotalHitCountCollector.java:47-52; IndexSearcher.java:851-852; AbstractKnnVectorQuery.java:250-253
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/RescoreTopNQuery.java:26-35, 306-321Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/TieredMergePolicy.java:82
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/RescoreTopNQuery.java:26-35, 55, 310-313; Weight.java:121-122, 182; TermQuery.java:255-267; lucene/CHANGES.txt:1562-1563Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/search/TestRescoreTopNQuery.java, TestRescoreTopNQueryPrefetch.java, TestRescoreTopNQueryFloat16Prefetch.java; lucene/core/src/test/org/apache/lucene/index/TestFilterReadersForwardVectorPrefetch.java (no deletes; kNN inner queries)
- Suggested fix
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/DocAndScoreQuery.java:123-125; AbstractKnnVectorQuery.java:153-155; BulkScorer.java:76; TermQuery.java:255-267Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/MatchNoDocsQuery.java:39-40; DocAndScoreQuery.java:99, 121
- TestRescoreTopNDeletedDocs.java (JUnit test):
repro/LFV-087/src/org/apache/lucene/search/TestRescoreTopNDeletedDocs.java(57 lines)
The summary card (bug.png) shows the query, the four stages from rewrite to count, expected against actual, and the javadoc contracts. The alt text follows the scene script's final card, and every number shown comes from the probe run on ea57360912ed.
assets/LFV-087/bug.png(475 KB)
![Summary card titled "RescoreTopNQuery lets a deleted document take a top-n slot, and count(q) counts it" (Apache Lucene; one segment, docs 0-3 all match f:a, rescore value v = doc id; doc 3 is deleted, not merged away; n = 2). Four numbered steps: 1, q = new RescoreTopNQuery(new TermQuery(new Term("f", "a")), DoubleValuesSource.fromLongField("v"), 2): rescore the matches of f:a by v and keep the best 2, expected the live docs 2 and 1. 2, searcher.search(q, 10) first calls RescoreTopNQuery.rewrite: it reads weight.scorer(leaf).iterator(), which still returns deleted doc 3; no live-docs check, so the queue keeps docs 3 and 2. 3, collection of the rewritten DocAndScoreQuery skips deleted doc 3: one hit, [2]. 4, searcher.count(q): Weight#count returns the 2 stored entries, deleted doc 3 included. Expected: search(q, 10) gives docs 2 and 1, count(q) = 2. Actual: doc 2 and an empty slot, 1 hit; count(q) = 2, doc 2 plus deleted doc 3. Notes: the other loop (value sources that need scores) has the same gap, DoubleValuesSource.SCORES with doc 0 deleted gives hits [1] and count 2; when n covers every match, deleted ones too, the hits are right: n = 10, docs 0-2, doc 2 deleted gives hits [1, 0] but count(q) = 3, not 2; a kNN inner query is not affected. Affected: Lucene 10.3.0 to 10.5.2, and main. Bottom: the RescoreTopNQuery, Weight#count and Weight#scorer javadoc sentences.](../assets/LFV-087/bug.png)