fix: keep the highest-scoring duplicate in merge and RRF joins - #12774
linhongyu510 wants to merge 1 commit into
Conversation
`_merge` and `_reciprocal_rank_fusion` both accumulate a fused score per document
id while writing `documents_map[doc.id] = doc` unconditionally, so the copy that
survives is whichever list came last. `_concatenate` and
`_distribution_based_rank_fusion` instead keep the highest-scoring copy, and
`_deduplicate_documents` -- three lines above the RRF helper in the same module --
already implements exactly that rule.
Duplicates share an id but not necessarily a payload: two retrievers can return
the same document with different content or meta. The fused score in both modes
is order-independent, so the mismatch means the score stays stable while the
returned content silently depends on connection order:
list_a = [Document(id="d1", content="full text", score=0.9)]
list_b = [Document(id="d1", content="stub", score=0.1)]
merge([list_a, list_b]) -> score 0.5, content "stub"
merge([list_b, list_a]) -> score 0.5, content "full text"
Keep the higher-scoring copy in both, matching the other two modes. Fixing the
shared `_reciprocal_rank_fusion` helper covers every caller, not just
`DocumentJoiner`. The score arithmetic is untouched. The `-inf` sort key that
`_concatenate` already used is lifted into a small `_score_or_neg_inf` helper so
both call sites in the joiner share one definition.
|
@linhongyu510 is attempting to deploy a commit to the deepset Team on Vercel. A member of the Team first needs to authorize it. |
|
Closing, we state in our docs that IDs are used to de-duplicate so if they have the same ID then the content is considered to be the same. That is why our default ID generation uses the full doc dataclass to generate a unique ID |
|
Thanks for the quick call — that settles it, and I verified the reasoning rather than just taking it on faith: So the divergence between the modes is cosmetic under the documented contract, and the extra comparison is not worth the cost. Closing as you did — sorry for the noise. One small thing that may still be worth a separate look, at your discretion: |
Problem
DocumentJoinerhas four join modes. Two of them state which copy of a duplicate they keep, and do so:_concatenate— "return only the Document with the highest score for duplicates"_distribution_based_rank_fusion— "If a Document is in more than one retriever, the one with the highest score is used"The other two accumulate a fused score per id while writing the document unconditionally:
so the copy that survives is whichever list came last.
Duplicates share an id but not necessarily a payload — two retrievers can return the same document with different
contentormeta(a summary from one, the full text from another). The fused score in both modes is order-independent, so the result is a score that stays stable while the returned content silently depends on connection order:Measured on
main(0defdcff), same input, all four modes:concatenatefull text(score 0.9)distribution_based_rank_fusionfull text(score 0.9)mergestubreciprocal_rank_fusionstubFix
Keep the higher-scoring copy in both, matching the other two modes.
utils/misc.pyalready contains_deduplicate_documents()— three lines above the RRF helper — implementing exactly this rule, so this aligns RRF with a convention the module had already settled on. Fixing the shared helper covers every caller, not justDocumentJoiner.The score arithmetic is untouched; only which copy is stored changes. The
-infsort key_concatenatealready used is lifted into a small_score_or_neg_infhelper so both joiner call sites share one definition.Tests
test_run_with_merge_join_mode_keeps_highest_scoring_duplicate— both list orders return the high-scoring payloadtest_run_with_rrf_join_mode_keeps_highest_scoring_duplicate— same for RRFtest_run_with_merge_join_mode_keeps_scored_duplicate_over_unscored— a scored copy beats ascore=Noneone, consistent with the-infconventionVerification
test_document_joiner.py(45 pre-existing + 3 new).test/components/joiners/andtest/utils/the failure set is identical before and after; passing count goes 648 → 651, exactly the 3 added tests. (4 pre-existing failures on this machine are environmental and unrelated;test/utils/test_jinja2_extensions.pyis excluded because it cannot be collected here for a missing optional dependency.)ruff checkandruff format --checkclean on all three changed files.expand_page_range) touch them but none changes duplicate handling.