A rescoring clause whose values are negative makes a top-k OR query skip documents that match its other clauses.
RescoreTopNQuery(query, valuesSource, n)runsquery, gives each match the value ofvaluesSource(a doc-values field, an expression, a vector similarity) and keeps the n best. The result acts like a query that matches exactly those n documents, each with its value as the score.- A
BooleanQuerywith SHOULD clauses matches a document if any clause matches it; its score is the sum of the matching clauses' scores. Combining a rescored result with other queries this way is the hybrid use named by the #14009 author and in the javadoc ofRescoreTopNQuery.createLateInteractionQuery. - A top-k search skips work: each clause reports the highest score it can give, and documents whose best possible score cannot reach the minimum competitive score are skipped unscored. That threshold starts at 0, because Lucene scores are never negative.
- Index one document with
NumericDocValuesField("v", -2). Query:RescoreTopNQuery(MatchAllDocsQuery, DoubleValuesSource.fromLongField("v"), 1)as one SHOULD clause of aBooleanQuery,MatchAllDocsQueryas the other. - You expect
searcher.search(q, 10)to return the document: it matches theMatchAllDocsQueryclause, andcount(q)is 1. - You get 0 hits and totalHits 0, with no error, assertions on or off. With 10 documents (v = -2 to -11) it is 0 of 10.
RescoreTopNQuerycopies the value -2 into the score, so its clause reports a best possible score of -2. Lucene requires scores to be at least 0;FunctionScoreQuerywould have turned -2 into 0.- The top-k scorer adds up the clauses' best scores, -2 + 1 = -1, and compares the sum with its starting threshold 0. Below it, no document can compete, so it skips the window; the best scores are the same in every window, so it skips them all.
Applications that use the experimental RescoreTopNQuery with a values source that can be negative (a signed numeric field, an expression such as -age or ln(x) for x < 1, a negative constant) next to other scoring clauses, on 10.3.0 to 10.6.0 and main. One document is enough. Vector-similarity sources are never negative and are safe.
Mechanism, terms and numbers593 words
RescoreTopNQuery (experimental, new in 10.3.0) rescores another query's matches with a DoubleValuesSource and keeps the top n. It stores each value as the score as it is, so a negative value becomes a negative score and a negative max score, although Lucene requires non-negative scores (LUCENE-7996) and FunctionScoreQuery maps negative values to 0. When the query is a SHOULD clause of a top-k BooleanQuery, MaxScoreBulkScorer starts with a minimum competitive score of 0; if the clauses' max scores add up to less than 0, it treats every clause as non-essential and skips the window, and here every window of the segment. One document with v = -2, OR MatchAllDocsQuery: search(q, 10) returns 0 hits while count(q) is 1, with no error, default configuration, assertions on or off, in every release from 10.3.0 to 10.6.0 and on main.
RescoreTopNQuery.rewritefills aHitQueueof size n throughrescoreIntoand returnsDocAndScoreQuery.createDocAndScoreQuery(reader, topDocs, 0)(RescoreTopNQuery.java:68-91).rescoreIntoinsertsnew ScoreDoc(docBase + docId, (float) values.doubleValue())with no clamp; only a missing value becomes 0 (:224-231). The value -2 becomes the score -2.DocAndScoreQuerytakesmaxScore = topK.scoreDocs[0].score(DocAndScoreQuery.java:125) and reports it fromgetMaxScore(:239-240): -2.search(q, 10)collects withScoreMode.TOP_SCORES, and for two SHOULD clausesBooleanScorerSupplierbuilds aMaxScoreBulkScorer(BooleanScorerSupplier.java:315-328), whose minimum competitive score starts at 0 (SimpleScorable.java:24).partitionScorersmarks clauses non-essential while their running max-score sum stays below that threshold (MaxScoreBulkScorer.java:494-511): -2 < 0, then -2 + 1 = -1 < 0. Every clause is non-essential, it returnsfalse(:515-516), and the window is skipped as "No matches in this window" (:133-137). The max score is the same for every window, so the whole segment is skipped.
- One document, v = -2:
count(q)= 1,search(q, 10)= 0 hits, totalHits 0. Collected exhaustively (TopScoreDocCollectorManager(10, Integer.MAX_VALUE)), the same query returns the document with score -1.0. - 10 documents, v = -2 to -11: 0 of 10 hits, while exhaustive collection returns all 10 (nine at +1.0).
- A hybrid query,
TermQueryORRescoreTopNQuery(same TermQuery, fromLongField("v"), 1), 3 matching documents with v = -2, -3, -4: 0 of 3 hits. - Expressions as the source:
-ageandln(age / 100000)give 0 of 10 hits. - Two segments, {-2, -3} and {-5, -6}, n = 1: 2 of 4 hits. Only the segment that holds the kept document loses its hits; a segment without one has no scorer for the rescored clause (
DocAndScoreQuery.java:198-200). - n > 1 with mixed signs, OR
MatchAllDocsQuery: values {5, -2} with n = 2 give 0 of 2 hits, {5, 3, -2} with n = 3 give 0 of 3; with n = 2 the -2 is not kept and all 3 are returned. The clause's max score is the first entry of the kept set in heap order, its lowest value.
- If the max-score sum is still at or above 0 (for example a constant -1 OR
MatchAllDocsQuery), the search reachesScorerUtil.minRequiredScoreat a threshold of 0 and, with assertions off, never returns; with assertions on it throwsAssertionError"All clauses are essential if minCompetitiveScore is equal to zero". That loop is a separate bug with its own fix site (LFV-1016). - With
minimumNumberShouldMatch2 over three SHOULD clauses,WANDScorerthrowsIllegalArgumentException"Scores must be positive or null" (WANDScorer.java:69-71). - The query alone, or as a MUST or FILTER clause, returns the right hits, with negative scores.
Exposure and likelihood300 words
- Who is hit. Callers of
RescoreTopNQuerywith a non-vector values source that can be negative, used as a SHOULD clause of a top-kBooleanQuery. With n = 1 the kept value has to be negative; with n > 1 one negative value among the n kept documents is enough, because the clause's max score isscoreDocs[0].score(DocAndScoreQuery.java:125) andrewritefillsscoreDocsin heap order (RescoreTopNQuery.java:83-87), so it is the lowest kept value: values {5, -2} with n = 2 give 0 of 2 hits. That is the use the class was built for: the #14009 author described "a hybrid search where KnnFloatVectorQuery and TermQuery (amongst others) are combined into a single BooleanQuery", and the late-interaction factory's javadoc says it is "typically useful in combining a query's results with other queries for hybrid search" (RescoreTopNQuery.java:313-315). - Who is not. The built-in vector-similarity sources (full precision, float16, late interaction) are non-negative by construction. The query alone, or as a MUST or FILTER clause, returns the right hits.
- Default vs opt-in. Default
IndexWriterConfig, codec andIndexSearcher; the trigger is the opt-in,@lucene.experimentalquery (RescoreTopNQuery.java:34) with a non-vector source. - Scale. In a single-segment index every matching document disappears; in the tested two-segment index the segment that holds the kept document loses both its hits, and totalHits is wrong too. In general a window is skipped when its clauses' max scores add up to less than the minimum competitive score; at 0 or more, the separate LFV-1016 loop applies instead. No exception or warning, assertions on or off.
- How likely. Rare: every public user of the query that was found uses vector similarities. The failure does not depend on chance: the same index and query give 0 hits every time (10 of 10 derived seeds).
One JUnit class for lucene/core/src/test/org/apache/lucene/search/, one method, public API: one document, default IndexWriterConfig, plain IndexSearcher. It fails with assertions on and off; it is also the suggested 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.NumericDocValuesField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestRescoreTopNQueryNegativeValues extends LuceneTestCase {
public void testDisjunctionReturnsAllMatches() throws Exception {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
Document doc = new Document();
doc.add(new NumericDocValuesField("v", -2));
w.addDocument(doc);
}
try (DirectoryReader reader = DirectoryReader.open(dir)) {
Query q =
new BooleanQuery.Builder()
.add(
new RescoreTopNQuery(
MatchAllDocsQuery.INSTANCE, DoubleValuesSource.fromLongField("v"), 1),
BooleanClause.Occur.SHOULD)
.add(MatchAllDocsQuery.INSTANCE, BooleanClause.Occur.SHOULD)
.build();
// expected: the doc matches the MatchAllDocsQuery clause, so 1 hit; actual: 0 hits
assertEquals(1, new IndexSearcher(reader).search(q, 10).scoreDocs.length);
}
}
}
}
cp $REPRO/TestRescoreTopNQueryNegativeValues.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestRescoreTopNQueryNegativeValues -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same failure)
- Expected
- 1 hit: the document matches the
MatchAllDocsQueryclause, andcount(q)is 1. - Observed
- 0 hits and totalHits 0, no error (
expected:<1> but was:<0>), with assertions on and off.
More files, environment, full output179 words
The test indexes one document with NumericDocValuesField("v", -2) in a ByteBuffersDirectory and runs search(q, 10) with q = SHOULD RescoreTopNQuery(MatchAllDocsQuery.INSTANCE, DoubleValuesSource.fromLongField("v"), 1) + SHOULD MatchAllDocsQuery.INSTANCE. Deterministic, single thread, no random data (it failed on 10 of 10 derived seeds). It uses a plain IndexSearcher on purpose, to keep it deterministic: through newSearcher the test still fails, but the randomized searcher sometimes takes another scorer path (in 200 seeds with -ea, 198 returned 0 hits and 2 threw IllegalArgumentException "Scores must be positive or null" from WANDScorer; no AssertionError, because no score is computed). On 10.3.x replace MatchAllDocsQuery.INSTANCE with new MatchAllDocsQuery() (the constant first appears in 10.4.0); that port fails the same way.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); every release from 10.3.0 to 10.6.0 also fails; 10.2.2 and older: class absent |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
TestRescoreTopNQueryNegativeValues > testDisjunctionReturnsAllMatches FAILED
java.lang.AssertionError: expected:<1> but was:<0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:45D837F95F0A64BE]:0)
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:647)
at org.junit.Assert.assertEquals(Assert.java:633)
at org.apache.lucene.search.TestRescoreTopNQueryNegativeValues.testDisjunctionReturnsAllMatches(TestRescoreTopNQueryNegativeValues.java:47)
1 test completed, 1 failed
# the same with -Ptests.asserts=false; with the recommended fix the test passes- Lucene requires non-negative scores since LUCENE-7996: "Queries are now required to produce positive scores."
FunctionScoreQuery, which the class javadoc compares this query to, maps negative values to 0, and a test checks it on a similar input (one document, v = -2).- Nothing documents or checks a value range, and the same query collected exhaustively returns the hit.
The full argument274 words
- The rule.
lucene/CHANGES.txt:6636: "LUCENE-7996: Queries are now required to produce positive scores."lucene/MIGRATE.md:1368-1375: "Scorers are no longer allowed to produce negative scores" and "FunctionScoreQuerymaps negative values to 0." The test framework asserts it (AssertingScorer.java:131): withAssertingIndexSearcherand-ea, the rescored clause alone fails withAssertionError: -2.0. - Lucene clamps caller-supplied values itself.
FunctionScoreQuerymaps a negative value or NaN to 0 (FunctionScoreQuery.java:243-251), andTestFunctionScoreQuery.testTruncateNegativeScores(:236-253) checks it on one document withNumericDocValuesField("foo", -2)andfromLongField. In the LUCENE-7996 thread (#9044) romseygeek asked "could we just set the score to 0?", and jpountz answered with a patch: "New patch that maps negative scores to 0. The explanation says when this happens so that this change is not too trappy." In the same disjunction,FunctionScoreQueryover the same source returns the hit. - The class invites this use. Its javadoc says it re-scores "with a DoubleValuesSource function", "can work with any type of Query" and compares it with
FunctionScoreQuery(RescoreTopNQuery.java:26-32); the constructor checks onlyn >= 1(:58-61); CHANGES calls the source "generic" (CHANGES.txt:1595-1596); hybrid use is advertised (:313-315). - Two collections disagree. Exhaustive collection returns the document, top-k returns nothing;
CheckHits.doCheckTopScoresrequires them to be equal (CheckHits.java:700-709). - History. Earlier built-in negative-score paths were fixed as bugs: #11184, #12342 / #12727, #13356, #16139. The upstream
RescoreTopNQuerytests useconstant(0),SCORESover a kNN query's scores and vector similarities, so no test value is negative, and they never put the query in aBooleanQuery; no comment on #14009 or #16926 discusses value ranges.
The devil's-advocate check166 words
The strongest case for "works as designed": the class is experimental and was built to rescore vector shortlists, whose similarities are non-negative; MIGRATE.md tells authors of custom queries to keep "their score formula" non-negative, so a negative values source is the caller's mistake; and negative values carry ranking information (for example -distance), so passing them through is a choice.
Why it fails: RescoreTopNQuery is Lucene's own query, and the caller only passes a values source through a public constructor; for that exact shape LUCENE-7996 chose to clamp inside Lucene; the query is advertised for generic sources and hybrid use, where sorting by the function instead is not an option; nothing documents or checks the precondition; and the failure is silent and far from its cause. Even if negative values are declared illegal, the silent 0-hit result needs a check or a clamp. The ranking point is real, which is why the recommended fix keeps the selection on the raw values. Verdict: not intended, a defect.
Recommended B: keep the top n by raw value, clamp only the emitted scores (validated)
Keep picking the top n by the raw value, then report negative values (and NaN) as score 0, as FunctionScoreQuery does: 5 lines in RescoreTopNQuery.rewrite. With it the repro and the 56 tests of five upstream RescoreTopNQuery, prefetch and kNN test classes pass (0 failures; the nightly tests too).
for (ScoreDoc topDoc : queue) {
// The top n are chosen by raw value, but scores must not be negative (LUCENE-7996): like
// FunctionScoreQuery, report negative values and NaN as 0
if ((topDoc.score > 0) == false) {
topDoc.score = 0;
}
scoreDocs[i++] = topDoc;
}All options and trade-offs4 options · 674 words
Options A and B were applied in a scratch worktree of 645d4fe14a20 and run with gradle (the worktree was removed after the runs). Options C and D were not built. Whichever is chosen removes this query as a trigger of a separate ScorerUtil.minRequiredScore loop (LFV-1016) but does not fix that loop.
+5 lines in RescoreTopNQuery.rewrite, in the loop that copies the queue into scoreDocs (RescoreTopNQuery.java:83-87): a kept document whose value is not above 0 gets score 0.
for (ScoreDoc topDoc : queue) {
// The top n are chosen by raw value, but scores must not be negative (LUCENE-7996): like
// FunctionScoreQuery, report negative values and NaN as 0
if ((topDoc.score > 0) == false) {
topDoc.score = 0;
}
scoreDocs[i++] = topDoc;
}Trade-offs
- Keeps the documented meaning (the top n of the values source) and meets the non-negative rule; the max score handed to
DocAndScoreQuerycan no longer be negative.> 0also maps -0.0 and NaN to +0. - Kept documents with negative values all score 0, so their relative order no longer shows in the combined score (as with
FunctionScoreQuery). NaN values, whichFloat.compareorders above every number in theHitQueue, are still kept first, then reported as 0. - Cost: one branch per kept document, outside the per-candidate loop. No benchmark was run.
What the checks validated
- Gradle, stock
645d4fe14a20: the repro fails with assertions on and off. - Gradle with the fix (seed DEADBEEF): the repro, the selection probe below and
TestRescoreTopNQuery,TestRescoreTopNQueryPrefetch,TestRescoreTopNQueryFloat16Prefetch,TestFilterReadersForwardVectorPrefetchandTestKnnFloatVectorQuery: 7 classes, 58 tests (56 of them upstream), 0 failures, 2@Nightlyskipped. With assertions off, the repro and theTestRescoreTopNQuery*suites: 10 tests, 0 failures.TestRescoreTopNQuerywith-Ptests.nightly=true: 7 of 7 pass.checkGoogleJavaFormatpasses. - Shadowed into the stock main jars: the two-segment probe returns 4 of 4 hits, and a 7-case control probe (including the hybrid
TermQuerycase) passes 7 of 7. - A selection probe (v = -4, -3, -2, n = 1) keeps doc 2, the best value, with score 0.0.
- n > 1 with mixed signs (values {5, -2}, n = 2; {5, 3, -2}, n = 3), shadowed: every document is returned.
In rescoreInto (RescoreTopNQuery.java:224-231): value >= 0 ? (float) value : 0f.
if (values.advanceExact(docId)) {
double value = values.doubleValue();
// Scores must not be negative (LUCENE-7996): like FunctionScoreQuery, map negative values and
// NaN to 0
queue.insertWithOverflow(new ScoreDoc(docBase + docId, value >= 0 ? (float) value : 0f));
} else {
queue.insertWithOverflow(new ScoreDoc(docBase + docId, 0f));
}Trade-offs
- Mirrors
FunctionScoreQueryline for line, and also fixes the repro. - All negative values tie at 0 before the top n are chosen, so the
HitQueuekeeps the lowest doc ids instead of the best values: with v = -4, -3, -2 and n = 1 it keeps doc 0 (the worst value) where stock and option B keep doc 2. A-distancerescore would keep arbitrary documents.-0.0passes through.
What the checks validated
Gradle: the repro, the selection probe and the TestRescoreTopNQuery* suites, 11 tests, 0 failures, 2 @Nightly skipped. The hunt's earlier checks validated it too (with the upstream TestRescoreTopNQuery* tests, 7 run, 0 failed).
Throw during rewrite when a value is below 0 (or NaN), and document the range on the constructor.
Trade-offs
Loud and explicit, but breaks callers whose sources are legitimately negative (log-probabilities, -distance) and who only need the selection. A maintainer may still prefer it for an experimental API.
The factory has been public on main and branch_11x since #16686 (DocAndScoreQuery.java:121-125; package-private on branch_10_6); a check there covers every producer at once.
Trade-offs
Changes a public factory's contract and does not apply to 10.x as it is; the kNN producers already emit non-negative scores.
Results only lose hits, never gain any; if an application rescores by values its users can set (a signed field they fill, a boost expression), every search that rescores by those values can come back empty.
After an upstream fix for empty inner results in RescoreTopNQuery (#16926), AI tools tried its other unchecked inputs; negative values gave 0 hits in an executed public-API probe. A differential harness (1,560 query and value combinations) bounded the effect, and the case was reduced to a one-document JUnit.
The method in full173 words
Method: non-formal (sibling analysis of an upstream fix plus executed probes; the finder tagged it semi-formal; this page classes it by the first signal).
- Hunt planners read upstream fix #16926 (empty inner results, 2026-10-28) and listed the other inputs
RescoreTopNQuerynever checks. For negative values an executed public-API probe on main645d4fe14a20showed both 0 hits and a hang. - An AI finder reduced it to a JUnit and ran a differential harness on the stock jars: 39 value producers x 10
BooleanQueryshapes x 2 indexes x k in {1, 10}, 1,560 cases, with termination, hit count and the exhaustive top k as the oracle. Result: 94 hangs, 128 zero-hit results, 56 IllegalArgumentExceptions and 392 cases with negative scores only; all 520 non-negative controls pass, and with the clamp all 1,440RescoreTopNQuerycases pass. An SMT check (Z3 and cvc5) covered the siblingminRequiredScoreloop. - The 0-hit disjunction was then reduced to the one-document test. So this is a find from targeted probing, confirmed by execution, not a blind discovery by a tool.
No prior report found in two searches (2026-10-29 and 2026-10-30) of GitHub issues and PRs incl. the JIRA archive, four mailing lists and the web, including every public item that names RescoreTopNQuery and the file lists of all 410 open PRs. Not fixed upstream.
Searches5 searches
No report of this bug was found, and upstream main still inserts the raw value. The non-negative rule (LUCENE-7996) and the clamps in other producers state the expected behaviour; they do not report this defect, and RescoreTopNQuery (2025) is newer than most of them. Open #16847 is the only PR that touches RescoreTopNQuery.java and DocAndScoreQuery.java; it changes doc-id types and keeps the raw insert.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs, commits (apache/lucene incl. the migrated JIRA archive; downstream repositories) | 2026-10-29 and 2026-10-30 | six automated passes (identifiers, symptoms, variants) and 34 + 43 manual search-API queries, including every public GitHub item naming RescoreTopNQuery (11 in apache/lucene, 16 in all) and items created or updated since 2026-10-27 | no report; closest #14009 (added the query; hybrid use is its motivation), #16926 (last change) |
| Related items (read in full) | 2026-10-30 | full threads of 32 items, grepped for negative and clamp | precedents only: #9044 / LUCENE-7996 and #9054 / LUCENE-8006 (the rule), #12342, #12727, #13356, #11184, #16139, #15411 (clamps in other producers), #16410 (open, signed values kept internal), #3347 / LUCENE-2271 (2010, different code) |
| Open PRs | 2026-10-30 | changed-file lists of all 410 open apache/lucene PRs | only #16847 touches the two files (doc-id types), keeping the raw insert |
| Mailing lists, JIRA, web | 2026-10-29 and 2026-10-30 | dev@, java-user@, issues@, commits@ (tool queries plus every October subject since 2026-10-28); 12 JIRA queries; 19 web queries | nothing about this bug; the class postdates the 2022 JIRA migration |
| Re-check for this page | 2026-10-30 | last commit on RescoreTopNQuery.java on main; issues and PRs created since 2026-10-29 | unchanged (645d4fe14a20 is still the last commit on the file); nothing new |
Introduced with the class by #14009 (merged 2025-06-28) and its backport #14860, first released in 10.3.0. Every release from 10.3.0 to 10.6.0 and main fails; the rescore loop never clamped. 10.2.2 and older have no RescoreTopNQuery.
Versions tested7 rows
#14009 "Add Query for reranking KnnFloatVectorQuery with full-precision vectors" (dungba88, commit 3404496b3d77, merged 2025-06-28) added RescoreTopNQuery with the unclamped insert; the branch_10x backport #14860 (4fcb6d6d50de, 2025-06-29) shipped it in 10.3.0 (tagged 2025-09-13). The original insert, in rewrite(), reads double v = rescores.doubleValue(); and (float) v; #16705 (82d73908cb1, 2026-10-01, on main and branch_11x, not on 10.x) moved it into rescoreInto as (float) values.doubleValue(), with the same unclamped behaviour; branch_10_6 still has the (float) v form; later changes (#16628, #16926, #16271, #16686) never clamp or reject negative values. Not a regression: the parents of both commits have no RescoreTopNQuery.java.
The release rows ran the repro (on 10.3.x its port with new MatchAllDocsQuery(), as MatchAllDocsQuery.INSTANCE first appears in 10.4.0); the commits built from source ran a public-API main() twin of it with a v = 2 control that passes; all on OpenJDK 25.0.3, Linux aarch64; release jars were SHA-1 checked.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 (unreleased 11.0) | affected | fails, -ea and -da, gradle and standalone: 0 hits instead of 1 |
| 10.6.0 | affected | fails, -ea and -da (release jars) |
| 10.5.0 to 10.5.2 | affected | fail (10.5.2 with -ea and -da) |
| 10.4.0 | affected | fails |
| 10.3.0 to 10.3.2 | affected | fail with the 10.3.x port (10.3.0 with -ea and -da) |
commits 3404496b3d77 (#14009) and 4fcb6d6d50de (#14860), built from source | affected | fail, -ea and -da (main() twin of the repro; a v = 2 control passes) |
10.2.2, 9.12.3, parent b6fb10d5f931 | n/a | do not compile: no RescoreTopNQuery (the repro on the releases, the main() twin on the parent) |
The finder, an independent reproducer, an intended-behaviour devil's advocate, an adjudication, two later re-checks and this page's gradle runs all reproduce it. Cross-model reviews (OpenAI GPT-5.6) could run nothing: some said real, the others "not real" for procedure alone; separate re-runs of the repro failed as claimed. A read-only cross-model fact check found this page substantially sound.
Checks and verdicts520 words
Every executing check ran on the stock jars and voted real. The only dissent is procedural: two cross-model refute reviews that could run nothing voted not real on procedure only at low confidence (one could not even read the code and called the mechanism technically plausible from the report), and separate re-runs of the repro failed as claimed.
| Check | Verdict | What it found |
|---|---|---|
| Finder | candidate | Its JUnit fails 3 of 3 with -ea and -da; the 1,560-case differential harness bounds the effect; with the clamp all 1,440 RescoreTopNQuery cases pass. |
| Independent reproducer | real | Own JUnit, written from the claim: 4 of 4 fail on main and 10.5.2; exhaustive collection returns 10 hits, top-k 0; positive and zero constants pass; passes with the clamp. |
| Cross-model review before the candidate, refute lens (OpenAI GPT-5.6) | not real, low (procedural) | Read-only: it could run nothing, so it voted not real on procedure only; root cause agreed, inputs in contract, intended-behaviour argument weak. A separate run re-ran the repro: 3 of 3 fail with -da. |
| Cross-model review, intended lens (OpenAI GPT-5.6) | real, medium; intended argument weak | Read-only review; root cause agreed; a separate run re-ran the repro: 3 of 3 fail with -da. |
| Intended-behaviour devil's advocate | real | Built and rejected the "experimental, caller's problem" case; narrowed the claim to the 0-hit disjunction; re-runs fail on main and 10.5.2. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's JUnit: 4 of 4 fail with -ea and -da. |
| Cross-model review of this bug, refute lens (OpenAI GPT-5.6) | not real, low (procedural) | It could run no command, so it could neither run nor read the code; from the report alone it called the mechanism technically plausible and the intended-behaviour case weak. A separate re-run fails 1 of 1. |
| Current-main reproducer | real | The minimal test fails on main and 10.5.2 with -ea and -da and passes with the clamp. |
| Second adjudication | real, high | 10 of 10 seeds, 10.6.0 and 10.5.2 fail; a 7-case control probe isolates the cause (a positive value, exhaustive collection and FunctionScoreQuery all return the hit). |
| Credibility audit | keep | Re-ran the claimed test on main (fails with -ea and -da, passes with the clamp); re-checked the code chain, contract, dedup and prior art. |
| This page's own runs | reproduced; fix B validated | Gradle in a scratch worktree: the repro fails stock with assertions on and off; fix B passes the repro, the selection probe and 56 upstream tests (58 in all), 10 with assertions off and 7 nightly; a selection probe shows option A's trade-off; a two-segment probe shows which segments lose hits. |
| Fact check of this page | reproduced; wording fixes | Re-ran the doc's gradle command (fails with -ea and -da), fixes A and B and the standalone and tie-break probes; found the n > 1 trigger and how newSearcher behaves; its wording, quote and count fixes are applied. |
| Cross-model fact check of this page (OpenAI GPT-5.6, read-only) | substantially sound | Read the code and the saved command output; no-noise call claim-low; its wording errors, overclaims and missing caveats are applied. |
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 470 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,283 words
- Registry title
- RescoreTopNQuery passes negative DoubleValuesSource values through as negative scores, so a top-k BooleanQuery that ORs it with another clause silently returns no hits
- Area
- search / rescoring (RescoreTopNQuery scores, consumed by MaxScoreBulkScorer dynamic pruning)
- Priority
- Medium, rank 55: In released 10.3+ the experimental RescoreTopNQuery with a negative-valued non-vector source silently returns 0 hits when ORed with another clause, which is severe but a rarer pairing than its siblings LFV-087 and LFV-315, so it closes that RescoreTopNQuery group ahead of LFV-424 and the related negative-score row LFV-190.
- Impact class
- silent wrong results
- Impact note
- Applications that use the experimental RescoreTopNQuery with a values source whose best values are negative (a signed numeric field, an expression such as -age or ln(x) for x < 1, a negative constant; the built-in vector-similarity sources are non-negative and safe) as a SHOULD clause of a top-k BooleanQuery, the hybrid use its javadoc names, get 0 hits and totalHits 0 from the default search(q, n) whenever the clauses' max scores sum below 0, while count() matches every document; default IndexWriterConfig, codec and searcher, 10 documents suffice, -ea and -da, released 10.3.0 to 10.6.0; with smaller negative values the same search instead never returns (-da) or throws an AssertionError (-ea), a loop owned by the sibling ScorerUtil candidate.
- Summary
- You index 10 documents with a numeric doc-values field v = -2..-11 and run BooleanQuery(SHOULD RescoreTopNQuery(MatchAllDocsQuery, DoubleValuesSource.fromLongField("v"), 1), SHOULD MatchAllDocsQuery) with searcher.search(q, 10), expecting all 10 documents, since each one matches the MatchAllDocsQuery clause and count(q) is 10. Instead it returns 0 hits and totalHits 0. RescoreTopNQuery copies the raw value -2 into the score, although Lucene requires non-negative scores (LUCENE-7996) and FunctionScoreQuery maps negative values to 0, so the clause reports a max score of -2, the two clauses' max scores sum to -1, below the initial minimum competitive score of 0, and MaxScoreBulkScorer skips the whole window as if no document could compete.
- Introduced
- 3404496b3d77 (#14009 'Add Query for reranking KnnFloatVectorQuery with full-precision vectors', by GitHub dungba88, main), released through its branch_10x backport 4fcb6d6d50de (#14860 'Backport RescoreTopNQuery to 10.3') [read: git log -G/-S on the insert line finds only #14009 and 82d73908cb1 (#16705, main only, 2026-10-01), which moved the unclamped line into rescoreInto; exec: the repro fails on 10.3.0, the first release with the class, and RescoreTopNQuery is absent in 10.2.2]. Not a regression: the rescore loop never clamped, while FunctionScoreQuery has clamped since LUCENE-7996 (8.0.0) (2025-06-28 (#14009 author date; backport #14860 merged 2025-06-29))
- First affected version
- 10.3.0 [exec]; 10.2.2 and 9.12.3 are not applicable (no RescoreTopNQuery, the repro does not compile) [exec]
- Affected versions
- 10.3.0 (-ea and -da), 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1 (-da), 10.5.2 (-ea and -da), branch_10x 657014b3c57 (-da), the 10.6.0 RC1 core jar (branch_10_6 4ae946e5a9b, main() twin, -ea and -da) and main 645d4fe14a20 (-ea and -da) [exec: the finder's JUnit fails 3 of 3 on each, and the claimed test gives 0 hits instead of 10 on every one, seed DEADBEEF; the independent reproduction's own JUnit also fails on 10.5.2 and 10.3.0]; not applicable to 10.2.2 and 9.12.3 [exec: compile error, class absent]; not fixed upstream: main HEAD is still 645d4fe14a20 and rescoreInto inserts the raw value on main, branch_11x and branch_10_6 [read, 2026-10-29]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal; also reached by Z3/cvc5
- Upstream and prior reports
- none found (2026-10-29, read-only, the prior-report record verdict new: three scripted prior-report searches with 170 queries over GitHub issues and PRs incl. the migrated JIRA archive, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, JIRA and the web; 34 manual GitHub searches, including every public GitHub item that names RescoreTopNQuery (16 in 6 repositories, all read); full reads of #14009, #14729, #14860, #16628, #16686, #16705, #16926 and the LUCENE-7996 / LUCENE-8006 threads; and the changed-file lists of 415 open apache/lucene PRs, none of which touches RescoreTopNQuery.java or DocAndScoreQuery.java). Related, not reports: #14009 (introduced the query; hybrid BooleanQuery use is its stated motivation), #9044 / LUCENE-7996 (the non-negative score rule; FunctionScoreQuery clamps), #12342, #12727, #13356, #11184 and #16139 (earlier negative-score clamps), #3347 / LUCENE-2271 (2010: -inf/NaN function scores in TopScoreDocCollector, different code), #11464 / LUCENE-10428 (an earlier ulp loop, precedent for the sibling loop candidate only), #16926 (empty inner results, LFV-297). Follow-up re-check 2026-10-29: the last commit on RescoreTopNQuery.java is still 645d4fe14a20 (#16926, author date 2026-10-28); 'RescoreTopNQuery negative' finds only #14009; since 2026-10-27 no issue or PR matches 'RescoreTopNQuery' (except #16926), 'DocAndScoreQuery', 'negative score(s)' or 'clamp negative', and none of the 11 open PRs created since then is related. Not fixed upstream
- Lucene commit tested
- main
645d4fe14a20(commit of 2026-10-28; upstream main716d6f216f52has the sameRescoreTopNQuery.javaon 2026-10-30): the repro with gradle in a scratch worktree, stock with assertions on and off, and with fixes A and B together with theRescoreTopNQuery, prefetch and kNN suites (the worktree was removed after the runs); a standalone runner on main with-eaand-da(10 of 10 derived seeds fail), on the published jars of every release from 10.3.0 to 10.6.0 (10.3.x with a port that usesnew MatchAllDocsQuery()), and on the introducing commits built from source; the independent tests of the finder, verifiers, an adjudication, a current-main reproducer and a tie-break. JDK OpenJDK 25.0.3, Linux aarch64.
- Finding record
a Lucene main checkout (645d4fe14a20): lucene/core/src/java/org/apache/lucene/search/RescoreTopNQuery.java:25-57 (class and constructor javadoc, @lucene.experimental at :34, only n >= 1 checked), :80-90 (rewrite hands the top n to DocAndScoreQuery.createDocAndScoreQuery), :224-231 (rescoreInto stores (float) values.doubleValue(); only a missing value becomes 0f), :313-315 ('useful in combining a query's results with other queries for hybrid search'); DocAndScoreQuery.java:125 (maxScore = topK.scoreDocs[0].score), :239-240 (getMaxScore); MaxScoreBulkScorer.java:474-517 (partitionScorers: a clause is non-essential while the max-score sum is below minCompetitiveScore; all non-essential returns false and the window is skipped), :447-458 (assert 'All clauses are essential if minCompetitiveScore is equal to zero', filterCompetitiveHits); ScorerUtil.java:131-142 (minRequiredScore); WANDScorer.java:69-72 ('Scores must be positive or null')a Lucene main checkout: lucene/MIGRATE.md:1368-1375 ('Scorer must produce positive scores (LUCENE-7996)'); lucene/CHANGES.txt:6636 ('Queries are now required to produce positive scores') and :6732 (FunctionQuery and FunctionScoreQuery return 0 for negative values); lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:243-251 (clamp); lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorable.java:36 and AssertingScorer.java:131 (score >= 0 asserts)gh api read-only re-check 2026-10-29: repos/apache/lucene/commits?path=...RescoreTopNQuery.java; search/issues 'RescoreTopNQuery created:>=2026-10-27', '"negative score" created:>=2026-10-27', '"negative scores" created:>=2026-10-27', 'RescoreTopNQuery negative', 'is:pr is:open created:>=2026-10-27', 'DocAndScoreQuery created:>=2026-10-27', 'clamp negative created:>=2026-10-26'
- The finding in 30 seconds
Lucene main 645d4fe14a20: lucene/core/src/java/org/apache/lucene/search/RescoreTopNQuery.java:68-91, :224-231; DocAndScoreQuery.java:125, :198-200, :239-240; BooleanScorerSupplier.java:315-328; SimpleScorable.java:24; MaxScoreBulkScorer.java:46, :133-137, :494-517; IndexSearcher.java:100, :599; TopScoreDocCollector.java:58-59; WANDScorer.java:69-71
- Who is affected
Lucene main 645d4fe14a20: RescoreTopNQuery.java:26-34, :313-315
- Why it looks unintended
Lucene main 645d4fe14a20: lucene/CHANGES.txt:6636, :6732-6733, :1595-1596; lucene/MIGRATE.md:1368-1375; lucene/queries/src/java/org/apache/lucene/queries/function/FunctionScoreQuery.java:243-251; lucene/queries/src/test/org/apache/lucene/queries/function/TestFunctionScoreQuery.java:236-253; lucene/test-framework/src/java/org/apache/lucene/tests/search/AssertingScorer.java:131, CheckHits.java:700-709; RescoreTopNQuery.java:26-32, :58-61, :313-315Lucene main 645d4fe14a20: lucene/core/src/test/org/apache/lucene/search/TestRescoreTopNQuery.java:77, :101
- Suggested fix
Lucene main 645d4fe14a20: DocAndScoreQuery.java:121-125; RescoreTopNQuery.java:83-87, :224-231; HitQueue.java:76-82
- History
a Lucene clone: git show origin/branch_10_6 and origin/branch_11x RescoreTopNQuery.java (insert lines :76-77 and :227), git merge-base --is-ancestor 82d73908cb1 (branch_11x yes, branch_10_6 no), DocAndScoreQuery.java factory visibility per branch
- TestRescoreTopNQueryNegativeValues.java (JUnit for Lucene's build):
repro/LFV-1015/src/org/apache/lucene/search/TestRescoreTopNQueryNegativeValues.java(51 lines)
Made in parallel with this page: a summary card (PNG), built from facts checked against the code, the minimal repro and executed probe output. The alt text follows the repro and the mechanism.
assets/LFV-1015/bug.png(470 KB)
