You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ds...@apache.org on 2026/10/23 04:49:51 UTC
(solr) branch main updated: SOLR-18550: Fix numFound and numFoundExact for CombinedQuery (#5172)
This is an automated email from the ASF dual-hosted git repository.
dsmiley pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/main by this push:
new 79b23df58da SOLR-18550: Fix numFound and numFoundExact for CombinedQuery (#5172)
79b23df58da is described below
commit 79b23df58daac14b92cc35f23b8b81f72098b4ba
Author: Puneet Ahuja <16...@users.noreply.github.com>
AuthorDate: Fri Oct 23 10:19:44 2026 +0530
SOLR-18550: Fix numFound and numFoundExact for CombinedQuery (#5172)
For combined queries (e.g. RRF), numFound is the distinct fused document count when numFoundExact
is true, and otherwise a lower bound of at least the largest sub-query's match count,
marked inexact when a sub-query list was truncated by the fusion pool (shards.rows
or start + rows).
---
.../SOLR-18550-combined-query-numfound.yml | 13 +++++
.../handler/component/CombinedQueryComponent.java | 35 +++++++-----
.../component/CombinedQueryComponentTest.java | 36 +++++++++++--
.../DistributedCombinedQueryComponentTest.java | 63 ++++++++++++++++++++++
.../query-guide/pages/json-combined-query-dsl.adoc | 3 ++
5 files changed, 134 insertions(+), 16 deletions(-)
diff --git a/changelog/unreleased/SOLR-18550-combined-query-numfound.yml b/changelog/unreleased/SOLR-18550-combined-query-numfound.yml
new file mode 100644
index 00000000000..702ef9458db
--- /dev/null
+++ b/changelog/unreleased/SOLR-18550-combined-query-numfound.yml
@@ -0,0 +1,13 @@
+title: >
+ For combined queries, numFound is the distinct fused document count when numFoundExact
+ is true, and otherwise a lower bound of at least the largest sub-query's match count,
+ marked inexact when a sub-query list was truncated by the fusion pool (shards.rows
+ or start + rows).
+type: fixed
+authors:
+ - name: Puneet Ahuja
+ nick: punAhuja
+ - name: Justin Michael Coyne
+links:
+ - name: SOLR-18550
+ url: https://issues.apache.org/jira/browse/SOLR-18550
\ No newline at end of file
diff --git a/solr/core/src/java/org/apache/solr/handler/component/CombinedQueryComponent.java b/solr/core/src/java/org/apache/solr/handler/component/CombinedQueryComponent.java
index 5447b57a79c..b4b956d7745 100644
--- a/solr/core/src/java/org/apache/solr/handler/component/CombinedQueryComponent.java
+++ b/solr/core/src/java/org/apache/solr/handler/component/CombinedQueryComponent.java
@@ -311,8 +311,9 @@ public class CombinedQueryComponent extends QueryComponent implements SolrCoreAw
rb.rsp.getValues().add(ShardParams.SHARDS_INFO, shardInfo);
}
- long numFound = 0;
boolean hitCountIsExact = true;
+ boolean someQueryListTruncated = false;
+ long maxQueryNumFound = 0;
boolean thereArePartialResults = false;
Boolean segmentTerminatedEarly = null;
boolean maxHitsTerminatedEarly = false;
@@ -340,7 +341,9 @@ public class CombinedQueryComponent extends QueryComponent implements SolrCoreAw
&& docList.get(queryIndex) instanceof SolrDocumentList solrDocumentList) {
docs = Objects.requireNonNull(solrDocumentList);
queryNumFound += docs.getNumFound();
- hitCountIsExact = hitCountIsExact && Boolean.FALSE.equals(docs.getNumFoundExact());
+ if (hitCountIsExact && Boolean.FALSE.equals(docs.getNumFoundExact())) {
+ hitCountIsExact = false;
+ }
}
failedShardCount +=
addShardInfo(
@@ -430,27 +433,26 @@ public class CombinedQueryComponent extends QueryComponent implements SolrCoreAw
shardDoc.scoreDependentFields = doc.getSubsetOfFields(scoreDependentFields);
}
shardDoc.sortFieldValues = unmarshalledSortFieldValues;
- if (!queuePerQuery.push(shardDoc)) {
- numFound--;
- }
+ queuePerQuery.push(shardDoc);
} // end for-each-doc-in-response
} // end for-each-response
List<ShardDoc> shardDocsPerQuery = new ArrayList<>(queuePerQuery.resultIds(0).values());
shardDocsPerQuery.sort(Comparator.comparingInt(a -> a.positionInResponse));
shardDocMap.put(queriesToCombineKeys[queryIndex], shardDocsPerQuery);
- numFound = max(numFound, queryNumFound);
+ // a sub-query list is complete iff every match reached the fusion pool
+ someQueryListTruncated |= queryNumFound != shardDocsPerQuery.size();
+ maxQueryNumFound = max(maxQueryNumFound, queryNumFound);
approximateTotalHits = max(approximateTotalHits, queryApproximateTotalHits);
} // for each query to combine
- rb.rsp.addToLog("hits", numFound);
-
SolrDocumentList responseDocs = new SolrDocumentList();
- responseDocs.setNumFound(numFound);
- responseDocs.setNumFoundExact(hitCountIsExact);
+ responseDocs.setNumFoundExact(hitCountIsExact && !someQueryListTruncated);
responseDocs.setStart(ss.getOffset());
- rb.resultIds = computeResultIdsWithCombiner(rb, shardDocMap, responseDocs, sortFields);
+ rb.resultIds =
+ computeResultIdsWithCombiner(rb, shardDocMap, responseDocs, sortFields, maxQueryNumFound);
rb.setResponseDocs(responseDocs);
+ rb.rsp.addToLog("hits", responseDocs.getNumFound());
populateNextCursorMarkFromMergedShards(rb);
@@ -570,8 +572,10 @@ public class CombinedQueryComponent extends QueryComponent implements SolrCoreAw
* @param rb The ResponseBuilder containing the request and context, such as sort specifications.
* @param shardDocMap A map from shard addresses to the list of documents returned by each shard.
* @param responseDocs The final response document list, which will be populated with null
- * placeholders and have its max score set.
+ * placeholders and have its numFound and max score set.
* @param sortFields An array of field for sorting to be applied.
+ * @param maxQueryNumFound The largest sub-query's match count, summed across shards; a floor for
+ * the reported numFound.
* @return A map from document IDs to the corresponding ShardDoc objects for the documents in the
* final sorted page of results.
*/
@@ -579,13 +583,18 @@ public class CombinedQueryComponent extends QueryComponent implements SolrCoreAw
ResponseBuilder rb,
Map<String, List<ShardDoc>> shardDocMap,
SolrDocumentList responseDocs,
- SortField[] sortFields) {
+ SortField[] sortFields,
+ long maxQueryNumFound) {
String algorithm =
rb.req.getParams().get(CombinerParams.COMBINER_ALGORITHM, CombinerParams.DEFAULT_COMBINER);
QueryAndResponseCombiner combinerStrategy =
QueryAndResponseCombiner.getImplementation(algorithm, combiners);
List<ShardDoc> combinedShardDocs = combinerStrategy.combine(shardDocMap, rb.req.getParams());
+ // the fused list is deduped by doc id, so its size is the distinct doc count; numFound is
+ // that count, but never below the largest sub-query's match count
+ responseDocs.setNumFound(max(maxQueryNumFound, combinedShardDocs.size()));
+
// adding explanation for the ordered shard docs as debug info
if (rb.isDebugResults()) {
String[] queryKeys = rb.req.getParams().getParams(CombinerParams.COMBINER_QUERY);
diff --git a/solr/core/src/test/org/apache/solr/handler/component/CombinedQueryComponentTest.java b/solr/core/src/test/org/apache/solr/handler/component/CombinedQueryComponentTest.java
index 4bf7b18fcf9..61bcccb2829 100644
--- a/solr/core/src/test/org/apache/solr/handler/component/CombinedQueryComponentTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/component/CombinedQueryComponentTest.java
@@ -127,16 +127,44 @@ public class CombinedQueryComponentTest extends BaseDistributedSearchTestCase {
params(
CommonParams.JSON,
"{\"queries\":"
- + "{\"lexical1\":{\"lucene\":{\"query\":\"title:title test for doc 1\"}},"
- + "\"lexical2\":{\"lucene\":{\"query\":\"text:test text for doc 2\"}}},"
+ + "{\"lexical1\":{\"lucene\":{\"query\":\"id:(2^=4 OR 3^=2 OR 6^=3 OR 5^=1)\"}},"
+ + "\"lexical2\":{\"lucene\":{\"query\":\"id:(4^=1 OR 5^=3 OR 7^=4 OR 10^=2)\"}}},"
+ "\"limit\":5,"
+ "\"fields\":[\"id\",\"score\",\"title\"],"
+ "\"params\":{\"combiner\":true,\"debug\":[\"results\"],\"combiner.query\":[\"lexical1\",\"lexical2\"],"
+ "\"rid\": \"test-1\"}}"));
- assertEquals(10, rsp.getResults().getNumFound());
+ // both queries match 4 docs, overlapping on doc 5, so the fused result has 7 distinct docs
+ assertEquals(7, rsp.getResults().getNumFound());
assertTrue(rsp.getDebugMap().containsKey("combinerExplanations"));
}
+ /**
+ * Tests that numFound is the distinct count of fused documents, not the larger sub-query's match
+ * count. Two queries matching three and two distinct docs, no overlap.
+ */
+ @Test
+ public void testNumFoundIsFusedCount() throws Exception {
+ prepareIndexDocs();
+ QueryResponse rsp =
+ query(
+ "/search",
+ params(
+ CommonParams.JSON,
+ """
+ {
+ "queries": {
+ "cats": {"lucene": {"query": "id:(1^=3 OR 2^=2 OR 3^=1)"}},
+ "dogs": {"lucene": {"query": "id:(4^=2 OR 5^=1)"}}
+ },
+ "limit": 10,
+ "fields": ["id"],
+ "params": {"combiner": true, "combiner.query": ["cats", "dogs"]}
+ }"""));
+ assertEquals(5, rsp.getResults().size());
+ assertEquals(5, rsp.getResults().getNumFound());
+ assertEquals(Boolean.TRUE, rsp.getResults().getNumFoundExact());
+ }
+
/** Test no results in combined queries. */
@Test
public void testNoResults() throws Exception {
@@ -196,6 +224,8 @@ public class CombinedQueryComponentTest extends BaseDistributedSearchTestCase {
+ "\"fields\":[\"id\",\"score\",\"title\"],"
+ "\"params\":{\"combiner\":true,\"combiner.algorithm\":test,\"combiner.query\""
+ ":[\"lexical1\",\"lexical2\"],\"debug\":[\"results\"]}}"));
+ // the test combiner fuses to an empty list, so numFound falls back to the largest
+ // sub-query's match count rather than the (empty) fused pool
assertEquals(10, rsp.getResults().getNumFound());
assertEquals(
"org.apache.lucene.search.Explanation:30 = this is test combiner\n",
diff --git a/solr/core/src/test/org/apache/solr/handler/component/DistributedCombinedQueryComponentTest.java b/solr/core/src/test/org/apache/solr/handler/component/DistributedCombinedQueryComponentTest.java
index 3c8044f4619..1c901d648b0 100644
--- a/solr/core/src/test/org/apache/solr/handler/component/DistributedCombinedQueryComponentTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/component/DistributedCombinedQueryComponentTest.java
@@ -162,6 +162,69 @@ public class DistributedCombinedQueryComponentTest extends BaseDistributedSearch
assertEquals(5, rsp.getResults().size());
assertFieldValues(rsp.getResults(), id, "5", "7", "2", "6", "3");
+ // both queries match 6 docs, overlapping on docs 5 and 6, so the fused result has 10 distinct
+ // docs; numFound is that distinct count, exact since no list was truncated
+ rsp =
+ query(
+ "/search",
+ params(
+ CommonParams.JSON,
+ """
+ {
+ "queries": {
+ "lexical1": {"lucene": {"query": "id:(1^=6 OR 2^=5 OR 3^=4 OR 4^=3 OR 5^=2 OR 6^=1)"}},
+ "lexical2": {"lucene": {"query": "id:(5^=6 OR 6^=5 OR 7^=4 OR 8^=3 OR 9^=2 OR 10^=1)"}}
+ },
+ "limit": 10,
+ "fields": ["id", "score"],
+ "params": {"combiner": true, "combiner.query": ["lexical1", "lexical2"]}
+ }"""));
+ assertEquals(10, rsp.getResults().getNumFound());
+ assertEquals(Boolean.TRUE, rsp.getResults().getNumFoundExact());
+
+ // same queries, but the fusion pool (start + rows = 2) truncates each 6-match list to its
+ // top 2; numFound never drops below the largest sub-query's match count (6) and is
+ // reported inexact because the fused pool holds only a subset of the matches
+ rsp =
+ query(
+ "/search",
+ params(
+ CommonParams.JSON,
+ """
+ {
+ "queries": {
+ "lexical1": {"lucene": {"query": "id:(1^=6 OR 2^=5 OR 3^=4 OR 4^=3 OR 5^=2 OR 6^=1)"}},
+ "lexical2": {"lucene": {"query": "id:(5^=6 OR 6^=5 OR 7^=4 OR 8^=3 OR 9^=2 OR 10^=1)"}}
+ },
+ "limit": 2,
+ "fields": ["id", "score"],
+ "params": {"combiner": true, "combiner.query": ["lexical1", "lexical2"]}
+ }"""));
+ assertEquals(2, rsp.getResults().size());
+ assertEquals(6, rsp.getResults().getNumFound());
+ assertEquals(Boolean.FALSE, rsp.getResults().getNumFoundExact());
+
+ // count-only: rows=0 leaves the fusion pool empty, but numFound still reports the
+ // largest sub-query's match count rather than the pool size of 0
+ rsp =
+ query(
+ "/search",
+ params(
+ CommonParams.JSON,
+ """
+ {
+ "queries": {
+ "lexical1": {"lucene": {"query": "id:(1^=6 OR 2^=5 OR 3^=4 OR 4^=3 OR 5^=2 OR 6^=1)"}},
+ "lexical2": {"lucene": {"query": "id:(5^=6 OR 6^=5 OR 7^=4 OR 8^=3 OR 9^=2 OR 10^=1)"}}
+ },
+ "limit": 0,
+ "fields": ["id", "score"],
+ "params": {"combiner": true, "combiner.query": ["lexical1", "lexical2"]}
+ }"""));
+ assertEquals(0, rsp.getResults().size());
+ assertEquals(6, rsp.getResults().getNumFound());
+ assertEquals(Boolean.FALSE, rsp.getResults().getNumFoundExact());
+
// Multiple queries with sort
rsp =
query(
diff --git a/solr/solr-ref-guide/modules/query-guide/pages/json-combined-query-dsl.adoc b/solr/solr-ref-guide/modules/query-guide/pages/json-combined-query-dsl.adoc
index ee56007dfea..99d9b03b635 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/json-combined-query-dsl.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/json-combined-query-dsl.adoc
@@ -83,6 +83,9 @@ The query structure is similar to JSON Query DSL except for how multiple queries
[NOTE]
====
Consider setting `shards.rows` to at least your deepest expected `start + rows` to enlarge the per-shard candidate pool used for fusion, giving more consistent ordering across pages.
+
+`numFound` in the response is the number of distinct documents in the fused result when `numFoundExact` is true.
+Otherwise it is a lower bound — at least the largest sub-query's match count — because a sub-query matched more documents than were retained for fusion (`shards.rows` or `start + rows`).
====
=== Example