fix(retriever): return top_k documents from the chunk retriever - #1706
fix(retriever): return top_k documents from the chunk retriever#1706Yigtwxx wants to merge 1 commit into
Conversation
hybrid_search documents top_k as a document count, but the fusion query was limited to top_k chunk rows. Grouping those rows by document means a single chunk-dense document can fill the window on its own, so callers asking for ten documents could get one, and the doc_order[:top_k] slice that was meant to apply the cap could never truncate anything. Limit the fused query to n_results, the candidate-pool size the function already computes, and apply the document cap after grouping -- the same shape the newer shared/retrieval module uses. Widening the window makes the per-document chunk cap load-bearing: matched chunks were exempt from _MAX_FETCH_CHUNKS_PER_DOC and only stayed within it because the window was small. Bound each document's contribution, keeping its citable chunks first, and report matched_chunk_ids from what survives.
|
@Yigtwxx is attempting to deploy a commit to the Rohan Verma's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Reading the red checks, since none of them come from this diff.
Green: Unit Tests, Integration Tests, Test Gate, Backend Quality, File Quality, Security Scan, CodeRabbit. Update — both reds now have fixes rather than just explanations.
Neither is a dependency of this PR; merging them first is just what turns this board green. |
ChucksHybridSearchRetriever.hybrid_searchdocumentstop_kas "Number of documents to return", but the reciprocal-rank-fusion query is limited totop_kchunk rows. Those rows are then grouped by document, so a single chunk-dense document can consume the whole window and every other match is silently dropped before the caller sees it.Description
Two changes in
app/retriever/chunks_hybrid_search.py, which have to land together.n_results(top_k * 5, the candidate-pool size the function already computes) instead oftop_k. The document cap is applied after grouping, where it always was._MAX_FETCH_CHUNKS_PER_DOCpreviously bounded only the surrounding context chunks, never the matched ones.Symptom
Ask for ten documents, get one.
ConnectorService._combined_rrf_searchcalls this retriever withretriever_top_k = top_k * 2for every connector surface, so the loss scales with the request.Root cause
Both CTE legs collect
top_k * 5candidates, and then the fusion query throws that pool away by takingtop_krows. Those rows are chunks. Grouping N chunks yields at most N documents, sodoc_order[:top_k]on the last line can never truncate anything: it runs, but the slice is always a no-op. The two comments describe an intent the code does not implement.Three things in the tree say this is a mistake rather than a design choice:
app/retriever/documents_hybrid_search.py:323ends with the same.limit(top_k), but there the rows are documents, so it is correct. The chunk retriever inherited a limit whose unit changed underneath it.app/agents/chat/multi_agent_chat/shared/retrieval/hybrid_search.py:194limits the fused query tocandidate_pool(top_k * _CANDIDATE_MULTIPLIER, also× 5) and truncates totop_kdocuments afterwards, at:233.test_top_k_caps_the_number_of_documents. The legacy retriever has no equivalent —test_optimized_chunk_retriever.pyonly assertedlen(results) >= 1.This code is live:
ConnectorServicebuilds the retriever atapp/services/connector_service.py:28and:210,_combined_rrf_searchis the shared path behind roughly fifteen connector searches, andConnectorServiceis constructed on the streaming chat path (app/tasks/chat/streaming/flows/shared/pre_stream_setup.py:17).Why the second change is required, not scope creep
With only the limit fixed, the existing test
test_per_doc_chunk_limit_respectedfails:The per-document fetch filter is
rn <= _MAX_FETCH_CHUNKS_PER_DOC OR Chunk.id.in_(matched_chunk_ids), so matched chunks were always exempt from the cap. That was invisible while the fusion window held at mosttop_kchunks in total — the matched set was small enough to fit underrn <= 20on its own. Widen the window and a 35-chunk document returns all 35, which is a context-size regression rather than a fix._cap_chunks_per_documentbounds each document's contribution at_MAX_FETCH_CHUNKS_PER_DOC, keeping matched (citable) chunks ahead of surrounding context and preserving reading order.matched_chunk_idsis recomputed from what survives, so it never advertises a chunk that is not in the payload. This mirrors_reading_orderin the newer module, which keeps_MAX_PASSAGES_PER_DOCchunks per document for the same reason.Motivation and Context
No linked issue — found while comparing the legacy retriever against the newer
shared/retrievalpath.Screenshots
Not applicable — no UI change.
API Changes
Change Type
Testing Performed
Two tests were added to
tests/integration/retriever/test_optimized_chunk_retriever.py, using the existingseed_large_docfixture (one document with 35 matching chunks, one with a single matching chunk):test_top_k_counts_documents_not_chunks— withtop_k=10, both documents must come back.test_top_k_still_caps_the_number_of_documents— withtop_k=1, exactly one comes back, so the cap itself is still exercised.Against
dev, the first one fails:The retriever's own perf log makes the change visible:
docs=1before,docs=2after, from the same query and the same seed data.Results on this branch:
tests/integration/retriever+tests/unit/retriever:12 passed. No existing test was modified.11 failed, 2994 passed, 1 skipped, identical to a cleandevcheckout on this machine (git-tree and knowledge-store tests plustest_pat_fail_closed_static), so none of them come from this diff.ruff checkandruff format --checkclean on both changed files.What does not change
n_resultscandidate pools and thescore DESCordering are untouched.chunk_id,content,chunks,document,score,matched_chunk_ids,source.document_type, the date range, workspace scoping, and the exclusion of documents in thedeletingstate.documents_hybrid_search.py, which is correct as it stands._MAX_FETCH_CHUNKS_PER_DOCkeeps its value of 20.Remaining risk
The fused query now returns up to
top_k * 5rows instead oftop_k, so it does more work — that is the cost of actually filling the window, and it is the same trade-off the newershared/retrievalmodule already makes with an identical multiplier. Per-document payload size is bounded either way by the cap in change 2.One behaviour genuinely changes: a document that matches more than 20 chunks now returns its 20 best-ranked matches instead of all of them. Before this PR that case could not arise in practice, because the fusion window was never wide enough to produce it.
Checklist
High-level PR Summary
This PR fixes a critical bug in
ChucksHybridSearchRetrieverwhere requesting N documents would often return fewer documents than expected. The issue occurred because the reciprocal-rank-fusion query was limited totop_kchunk rows instead of documents, allowing a single chunk-dense document to consume the entire result window and silently drop other matches. The fix widens the fusion window totop_k * 5(the existing candidate pool size) and applies thetop_klimit after grouping chunks by document. Additionally, a per-document chunk cap (_MAX_FETCH_CHUNKS_PER_DOC) is enforced to prevent context-size regression when matched chunks exceed 20 per document.⏱️ Estimated Review Time: 15-30 minutes
💡 Review Order Suggestion
surfsense_backend/tests/integration/retriever/test_optimized_chunk_retriever.pysurfsense_backend/app/retriever/chunks_hybrid_search.py