Skip to content

fix(kb): protect case-insensitive sparse exact matches in rank fusion - #9888

Open
xiaoyuyu6420 wants to merge 2 commits into
AstrBotDevs:masterfrom
xiaoyuyu6420:fix/9868-kb-case-insensitive-match
Open

fix(kb): protect case-insensitive sparse exact matches in rank fusion#9888
xiaoyuyu6420 wants to merge 2 commits into
AstrBotDevs:masterfrom
xiaoyuyu6420:fix/9868-kb-case-insensitive-match

Conversation

@xiaoyuyu6420

@xiaoyuyu6420 xiaoyuyu6420 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Fixes #9868

Problem

Dense embedding providers are case-sensitive. Querying oni against a knowledge base whose chunk contains Oni can 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 at 1 - dense_weight = 0.1. With many pure-dense candidates scoring up to dense_weight = 0.9, the exact-match chunk is pushed out of top_k_fusion entirely and never reaches the final top-5, even when Sparse/FTS5 ranked it first.

Fix

Add an optional query parameter to RankFusion.fuse (threaded through RetrievalManager.retrieve). When provided:

  • For each candidate that dense did not recall but sparse hit and whose content contains the query case-insensitively, floor-boost its fusion score to dense_weight + (1 - dense_weight) / 2 (0.95 with the default weight).
  • This exceeds the maximum score any pure-dense candidate can reach (≤ dense_weight), so the exact match is guaranteed to stay in the fused top_k.
  • Chunks already recalled by dense are left untouched, so normal queries keep their previous ranking behavior exactly.

Why this is safe

  • Only activates when the query string is provided (all existing fuse callers/tests are unaffected — the parameter is optional).
  • Only affects candidates that dense completely missed, i.e. the specific case-sensitivity failure mode.
  • Adds regression tests proving: (1) without query, the exact-match chunk is pushed out of top_k; (2) with query, it lands first; (3) dense-recalled chunks are not re-boosted.

Testing

  • uv run pytest tests/unit/test_rank_fusion.py — 12 passed
  • uv run pytest tests/unit/test_rank_fusion.py tests/unit/test_sparse_retriever.py tests/unit/test_knowledge_base_service_contract.py — 30 passed
  • ruff format / ruff check clean

Summary by Sourcery

Ensure case-insensitive sparse exact matches remain in fused retrieval results when dense search misses them.

Bug Fixes:

  • Protect case-insensitive sparse exact matches that dense retrieval missed from being excluded during rank fusion.

Enhancements:

  • Pass the original query through retrieval and rank fusion while preserving existing ranking behavior for dense-recalled and ordinary exact matches.

Tests:

  • Add regression coverage for query-based protection, dense-recalled candidates, the dense-weight boundary, and unchanged handling of exact-case matches.

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

@sourcery-ai sourcery-ai Bot 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.

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


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread astrbot/core/knowledge_base/retrieval/rank_fusion.py Outdated
Comment thread astrbot/core/knowledge_base/retrieval/rank_fusion.py Outdated
Comment thread astrbot/core/knowledge_base/retrieval/rank_fusion.py Outdated
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.

@sourcery-ai sourcery-ai Bot 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.

Sourcery assessment

Approved.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] 知识库混合检索在 Dense 召回失败时可能压掉 Sparse 的精确匹配,导致短英文实体检索失真

2 participants