fix(kb): protect case-insensitive sparse exact matches in rank fusion - #9888
Open
xiaoyuyu6420 wants to merge 2 commits into
Open
fix(kb): protect case-insensitive sparse exact matches in rank fusion#9888xiaoyuyu6420 wants to merge 2 commits into
xiaoyuyu6420 wants to merge 2 commits into
Conversation
Dense embedding providers are case-sensitive: querying "oni" against a document that contains "Oni" can completely fail dense recall, so the target chunk is absent from the dense result list. In the fusion stage, such a chunk only earns its sparse contribution (weighted at 1 - dense_weight = 0.1), which is easily beaten by many pure-dense candidates and pushed out of top_k entirely - even though the chunk is a verbatim (case-insensitive) match of the query. Add an optional query parameter to RankFusion.fuse. When provided, a candidate that dense did not recall but sparse hit and whose content contains the query (case-insensitive) is floor-boosted to dense_weight + (1 - dense_weight) / 2, which exceeds the maximum score a pure-dense candidate can reach, guaranteeing the exact match stays in the fused top_k. Chunks already recalled by dense are untouched, so normal queries keep their previous ranking behavior. Fixes AstrBotDevs#9868
Contributor
There was a problem hiding this comment.
Hey - I've found 3 issues
Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments
### Comment 1
<location path="astrbot/core/knowledge_base/retrieval/rank_fusion.py" line_range="172-175" />
<code_context>
+ continue
+ sparse_result = chunk_id_to_sparse.get(identifier)
+ if sparse_result and lowered_query in sparse_result.content.lower():
+ fusion_scores[identifier] = max(
+ fusion_scores[identifier],
+ self.dense_weight + (1 - self.dense_weight) / 2,
+ )
+
# 5. 排序
</code_context>
<issue_to_address>
**issue (bug_risk):** The floor score of 0.95 is lower than the possible score of a candidate recalled by both Dense and Sparse, which can reach 1.0. Five or more such overlap candidates can therefore still rank ahead of the protected sparse-only exact match and push it out of `top_k`, so the claimed guarantee does not hold.
**Triggers:** When several candidates appear in both retrieval lists with high normalized scores.
**Suggested fix:** Reserve the protected candidate's position explicitly during truncation, or use a ranking rule that prioritizes the protected exact matches over all non-protected candidates rather than relying on a 0.95 score floor.
</issue_to_address>
### Comment 2
<location path="astrbot/core/knowledge_base/retrieval/rank_fusion.py" line_range="174-175" />
<code_context>
+ if sparse_result and lowered_query in sparse_result.content.lower():
+ fusion_scores[identifier] = max(
+ fusion_scores[identifier],
+ self.dense_weight + (1 - self.dense_weight) / 2,
+ )
+
# 5. 排序
</code_context>
<issue_to_address>
**issue (bug_risk):** When `dense_weight` is 1.0, the boost floor evaluates to 1.0, which only ties the maximum pure-Dense score instead of exceeding it. A pure-Dense candidate with a better RRF/rank tiebreak can remain ahead of the sparse exact match, allowing the exact match to be excluded from `top_k`.
**Triggers:** When a caller configures the valid boundary value `dense_weight=1.0` and the exact sparse match has a lower sparse rank than competing Dense results.
**Suggested fix:** Handle `dense_weight=1.0` with an explicit protected-match ordering, or reject that configuration for this protection mechanism.
</issue_to_address>
### Comment 3
<location path="astrbot/core/knowledge_base/retrieval/rank_fusion.py" line_range="171" />
<code_context>
+ # 目标 chunk 可能完全不被 Dense 召回。此时若 Sparse 命中了该 chunk
+ # 且其内容大小写不敏感地包含查询词,则给予保底提升,使其不会被
+ # 大量纯 Dense 候选挤出 top_k。
+ if query is not None and query.strip():
+ lowered_query = query.strip().lower()
+ for identifier in all_chunk_ids:
+ if identifier in vec_doc_id_to_dense:
+ continue
+ sparse_result = chunk_id_to_sparse.get(identifier)
+ if sparse_result and lowered_query in sparse_result.content.lower():
+ fusion_scores[identifier] = max(
+ fusion_scores[identifier],
</code_context>
<issue_to_address>
**issue (broader_impact):** The protection is applied to every Dense-missed Sparse result whose content contains the query case-insensitively, not only to case-variant matches. Ordinary exact Sparse hits that Dense missed for unrelated relevance reasons are also raised to 0.95, changing ranking behavior for queries that previously used the normal fusion score.
**Triggers:** When Dense misses a normal exact query match for reasons other than case sensitivity.
**Suggested fix:** Restrict the boost to a true case-only mismatch, such as requiring the case-sensitive query not to occur while the case-insensitive form does, if preserving normal-query ranking is required.
```suggestion
if (
sparse_result
and query.strip() not in sparse_result.content
and lowered_query in sparse_result.content.lower()
):
```
</issue_to_address>Sourcery assessment
Approval pending. 3 findings to address first.
Blocking findings: astrbot/core/knowledge_base/retrieval/rank_fusion.py:175, astrbot/core/knowledge_base/retrieval/rank_fusion.py:175, astrbot/core/knowledge_base/retrieval/rank_fusion.py:171
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
Address Sourcery review feedback on the initial fix: - Replace the score-floor boost (dense_weight + (1-dense_weight)/2) with explicit position reservation: protected case-variant exact matches are sorted ahead of all ordinary candidates. A score floor could still lose to several Dense+Sparse overlap candidates scoring 1.0, and at dense_weight=1.0 the floor only tied the pure-dense maximum. - Narrow the protection to true case-variant matches: the raw query must NOT appear in the chunk content while the lowercased query does. This avoids re-ranking ordinary exact sparse hits that Dense missed for unrelated relevance reasons.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #9868
Problem
Dense embedding providers are case-sensitive. Querying
oniagainst a knowledge base whose chunk containsOnican completely fail dense recall — the target chunk is absent from the dense result list, even though it is a verbatim (case-insensitive) match of the query.In
RankFusion.fuse, such a chunk only earns its sparse contribution, weighted at1 - dense_weight = 0.1. With many pure-dense candidates scoring up todense_weight = 0.9, the exact-match chunk is pushed out oftop_k_fusionentirely and never reaches the final top-5, even when Sparse/FTS5 ranked it first.Fix
Add an optional
queryparameter toRankFusion.fuse(threaded throughRetrievalManager.retrieve). When provided:dense_weight + (1 - dense_weight) / 2(0.95 with the default weight).dense_weight), so the exact match is guaranteed to stay in the fusedtop_k.Why this is safe
fusecallers/tests are unaffected — the parameter is optional).query, the exact-match chunk is pushed out of top_k; (2) withquery, it lands first; (3) dense-recalled chunks are not re-boosted.Testing
uv run pytest tests/unit/test_rank_fusion.py— 12 passeduv run pytest tests/unit/test_rank_fusion.py tests/unit/test_sparse_retriever.py tests/unit/test_knowledge_base_service_contract.py— 30 passedruff format/ruff checkcleanSummary by Sourcery
Ensure case-insensitive sparse exact matches remain in fused retrieval results when dense search misses them.
Bug Fixes:
Enhancements:
Tests: