Skip to content

SOLR-18550: Fix for Wrong numFound and numFoundExact for Combined Query results - #5172

Open
punAhuja wants to merge 3 commits into
apache:mainfrom
SearchScale:puneet/combined-numfound
Open

punAhuja wants to merge 3 commits into
apache:mainfrom
SearchScale:puneet/combined-numfound

Conversation

@punAhuja

@punAhuja punAhuja commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

https://issues.apache.org/jira/browse/SOLR-18550

Description

For a combined query, the coordinator reported numFound as the largest sub-query's match count, not the distinct document count of the fused result, so the response could return more documents than the header claimed. The exactness check in mergeIds was also inverted, making an exact shard count report numFoundExact: false.

Reported on dev@solr.apache.org by Justin Michael Coyne, verified on 10.1.0-rc2 with 3 docs matching subject_s:cat and 2 matching subject_s:dog (no overlap): 5 documents returned, numFound: 3, numFoundExact: false.

Solution

  • Report numFound as the distinct number of fused documents.
  • Set numFoundExact false when a sub-query list was truncated by the fusion pool (shards.rows, start + rows, or knn topK), and fix the inverted conjunction to match QueryComponent.mergeIds.
  • Remove the leftover numFound-- in the queue-overflow branch.

Tests

  • CombinedQueryComponentTest: the reported reproduction (3-cat/2-dog, no overlap) as a regression test asserting numFound 5 and numFoundExact true, plus updated assertions for the new semantics elsewhere in the suite.
  • DistributedCombinedQueryComponentTest: two queries matching 6 docs with 2 overlapping assert a fused count of 10, exact; the same queries truncated by the fusion pool report the pool size and numFoundExact false.

Note: independent of SOLR-18542 / #5119 (weighted RRF) - no shared production code. Both add blocks to DistributedCombinedQueryComponentTest, so whichever merges second trivially rebases.

AI disclosure: parts of this change were developed with the help of an AI
coding assistant. I reviewed and tested everything and take full
responsibility for it.

Checklist

Please review the following and check all that apply:

  • I have reviewed the guidelines for How to Contribute and my code conforms to the standards described there to the best of my ability.
  • I have created a Jira issue and added the issue ID to my pull request title.
  • I have given Solr maintainers access to contribute to my PR branch. (optional but recommended, not available for branches on forks living under an organisation)
  • I have developed this patch against the main branch.
  • I have run ./gradlew check.
  • I have added tests for my changes.
  • I have added documentation for the Reference Guide
  • I have added a changelog entry for my change

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests cat:search labels Oct 16, 2026

@ercsonusharma ercsonusharma left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the fix!
The numFoundExact fix is right, and removing the numFound-- on a failed push is a good cleanup. My concern is the new numFound value.

In Solr, numFound is the number of matching documents, independent of rows/start/shards.rows, never the size of the page or candidate pool. Even when approximate (minExactCount, timeAllowed), it estimates the match count, as do grouping's per-group counts.
responseDocs.setNumFound(combinedShardDocs.size()) makes numFound depend on the size of the fusion pool. Each sub-query's queue holds at most perQueryQueueSize = max(start + rows, shards.rows), so the count follows how many docs were requested rather than how many matched.

  • rows=0 returns numFound: 0. The queue size is 0 and the shards return no docs, so the fused list is empty. This breaks count-only and facet-only requests, which previously got a reasonable lower bound.
  • It can drop below a known match count. With two largely overlapping sub-queries of 6 hits each and rows=2, the fused list has 2–4 docs, so the response reports 2–4 even though we know at least 6 docs match.
  • It changes while paging. As start + rows grows the pool, numFound grows too, so page counts built on numFound shift from page to page.

numFoundExact: false doesn't fully cover this. Callers read an inexact count as "at least N", and in these cases N is too low.

Suggestion: use the larger of the two counts we have:

maxQueryNumFound = max(maxQueryNumFound, queryNumFound); // per query, summed across shards
....
responseDocs.setNumFound(max(maxQueryNumFound, combinedShardDocs.size()));
responseDocs.setNumFoundExact(hitCountIsExact && !someQueryListTruncated); 

Comment thread solr/solr-ref-guide/modules/query-guide/pages/json-combined-query-dsl.adoc Outdated
+ ":[\"lexical1\",\"lexical2\"],\"debug\":[\"results\"]}}"));
assertEquals(10, rsp.getResults().getNumFound());
// the test combiner fuses to an empty list, so numFound reflects the fused result
assertEquals(0, rsp.getResults().getNumFound());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

10 docs match here, so numFound: 0 documents the pool-size behaviour rather than a count we want.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed, the empty fused pool now falls back to the largest sub-query's match count.

"params": {"combiner": true, "combiner.query": ["lexical1", "lexical2"]}
}"""));
assertEquals(4, rsp.getResults().size());
assertEquals(8, rsp.getResults().getNumFound());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The true union is 10 and the largest sub-query has 6. Asserting 8 locks the test to the pool size, which changes with limit. With the suggested change this would assert the max, at least 6, and stay numFoundExact == false.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed; now asserts the max: limit 2 -> numFound=6, numFoundExact stays false. Also added a limit-0 case for the rows=0 concern.

@punAhuja

Copy link
Copy Markdown
Contributor Author

Thanks for the fix! The numFoundExact fix is right, and removing the numFound-- on a failed push is a good cleanup. My concern is the new numFound value.

In Solr, numFound is the number of matching documents, independent of rows/start/shards.rows, never the size of the page or candidate pool. Even when approximate (minExactCount, timeAllowed), it estimates the match count, as do grouping's per-group counts. responseDocs.setNumFound(combinedShardDocs.size()) makes numFound depend on the size of the fusion pool. Each sub-query's queue holds at most perQueryQueueSize = max(start + rows, shards.rows), so the count follows how many docs were requested rather than how many matched.

  • rows=0 returns numFound: 0. The queue size is 0 and the shards return no docs, so the fused list is empty. This breaks count-only and facet-only requests, which previously got a reasonable lower bound.
  • It can drop below a known match count. With two largely overlapping sub-queries of 6 hits each and rows=2, the fused list has 2–4 docs, so the response reports 2–4 even though we know at least 6 docs match.
  • It changes while paging. As start + rows grows the pool, numFound grows too, so page counts built on numFound shift from page to page.

numFoundExact: false doesn't fully cover this. Callers read an inexact count as "at least N", and in these cases N is too low.

Suggestion: use the larger of the two counts we have:

maxQueryNumFound = max(maxQueryNumFound, queryNumFound); // per query, summed across shards
....
responseDocs.setNumFound(max(maxQueryNumFound, combinedShardDocs.size()));
responseDocs.setNumFoundExact(hitCountIsExact && !someQueryListTruncated); 

Thanks @ercsonusharma for the careful review. I have addressed your comments in my latest commit.

@ercsonusharma ercsonusharma left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please drop "knn topK" from the ref guide and changelog. topK defines how many docs a KNN sub-query matches (its numFound), so it never truncates the list. A KNN list is truncated only by the fusion pool (shards.rows / start + rows), the same as any other sub-query.

@ercsonusharma

Copy link
Copy Markdown
Contributor

cc: @dsmiley for help taking it forward!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cat:search documentation Improvements or additions to documentation tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants