From e04d88d0625576436ec177bb2af80f08b2e5e27f Mon Sep 17 00:00:00 2001 From: xiaoyuyu6420 <93528429+xiaoyuyu6420@users.noreply.github.com> Date: Tue, 1 Sep 2026 01:44:07 +0800 Subject: [PATCH 1/2] fix(kb): protect case-insensitive sparse exact matches in rank fusion 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 #9868 --- .../core/knowledge_base/retrieval/manager.py | 1 + .../knowledge_base/retrieval/rank_fusion.py | 21 ++++++ tests/unit/test_rank_fusion.py | 71 +++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/astrbot/core/knowledge_base/retrieval/manager.py b/astrbot/core/knowledge_base/retrieval/manager.py index 5b414b171e..1cce536856 100644 --- a/astrbot/core/knowledge_base/retrieval/manager.py +++ b/astrbot/core/knowledge_base/retrieval/manager.py @@ -139,6 +139,7 @@ async def retrieve( dense_results=dense_results, sparse_results=sparse_results, top_k=top_k_fusion, + query=query, ) time_end = time.time() logger.debug( diff --git a/astrbot/core/knowledge_base/retrieval/rank_fusion.py b/astrbot/core/knowledge_base/retrieval/rank_fusion.py index 0fb3ab8997..c4ee29838b 100644 --- a/astrbot/core/knowledge_base/retrieval/rank_fusion.py +++ b/astrbot/core/knowledge_base/retrieval/rank_fusion.py @@ -60,6 +60,7 @@ async def fuse( dense_results: list[Result], sparse_results: list[SparseResult], top_k: int = 20, + query: str | None = None, ) -> list[FusedResult]: """融合稠密和稀疏检索结果。 @@ -72,6 +73,9 @@ async def fuse( dense_results: 稠密检索结果 sparse_results: 稀疏检索结果 top_k: 返回结果数量 + query: 原始查询文本。提供时,对 Dense 完全未召回但 Sparse 命中 + 且内容包含查询词(大小写不敏感)的候选给予保底提升,避免 + Dense embedding 的大小写敏感导致精确词面匹配被挤出 top_k。 Returns: List[FusedResult]: 融合后的结果列表 @@ -153,6 +157,23 @@ async def fuse( rrf_scores[identifier] = rrf_score + # 保护 Dense 漏召回的精确词面匹配:Dense embedding 对大小写敏感, + # 当查询词以大小写变体出现时(如查询 "oni" 而文档中是 "Oni"), + # 目标 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], + self.dense_weight + (1 - self.dense_weight) / 2, + ) + # 5. 排序 sorted_ids = sorted( fusion_scores, diff --git a/tests/unit/test_rank_fusion.py b/tests/unit/test_rank_fusion.py index 94e5217ecd..c77ae2af4b 100644 --- a/tests/unit/test_rank_fusion.py +++ b/tests/unit/test_rank_fusion.py @@ -256,3 +256,74 @@ async def test_rank_fusion_does_not_promote_a_single_low_scoring_kb_result(): "weak", ] assert results[-1].score == pytest.approx(0.1) + + +@pytest.mark.asyncio +async def test_rank_fusion_protects_case_insensitive_sparse_exact_match(): + # Dense 因 embedding 对大小写敏感而完全没有召回目标 chunk(查询 "oni", + # 文档中是 "Oni")。Sparse (FTS5) 大小写不敏感,命中了目标 chunk。 + # 大量高分的纯 Dense 候选会把目标挤出 top_k,除非融合阶段对 + # 大小写不敏感的词面匹配给予保护。 + dense_results = [ + make_dense_result(f"dense-{rank}", 0.95 - rank / 100) for rank in range(1, 21) + ] + sparse_results = [ + make_sparse_result( + "target-oni", + "kb", + 30.0, + 1, + content="#### 恶鬼\n**恶鬼** **Oni** 是日式奇幻中的经典怪物。", + ), + *[ + make_sparse_result(f"sparse-{rank}", "kb", 20.0 - rank, rank) + for rank in range(2, 11) + ], + ] + + # 不传 query:目标 chunk 仅靠稀疏侧得分,被大量 dense 候选挤出。 + without_query = await RankFusion(kb_db=None).fuse( + dense_results=dense_results, + sparse_results=sparse_results, + top_k=5, + ) + assert "target-oni" not in [r.chunk_id for r in without_query] + + # 传入 query:目标 chunk 因大小写不敏感的词面匹配被保底提升,进入 top_k。 + with_query = await RankFusion(kb_db=None).fuse( + dense_results=dense_results, + sparse_results=sparse_results, + top_k=5, + query="oni", + ) + assert "target-oni" in [r.chunk_id for r in with_query] + assert with_query[0].chunk_id == "target-oni" + + +@pytest.mark.asyncio +async def test_rank_fusion_query_protection_ignores_dense_recalled_chunks(): + # 已同时被 Dense 召回的目标 chunk 不应因 query 保护而再次提升排序, + # 即保护只作用于 Dense 完全漏召回的候选。 + dense_results = [ + make_dense_result("target-oni", 0.99), + make_dense_result("other", 0.95), + ] + sparse_results = [ + make_sparse_result( + "target-oni", + "kb", + 30.0, + 1, + content="#### 恶鬼\n**恶鬼** **Oni**", + ), + ] + + with_query = await RankFusion(kb_db=None).fuse( + dense_results=dense_results, + sparse_results=sparse_results, + top_k=5, + query="oni", + ) + + assert [r.chunk_id for r in with_query] == ["target-oni", "other"] + assert with_query[0].score == pytest.approx(1.0) From 439c650dfa0478f2887d150f4e3a6a9b6cd8f2fd Mon Sep 17 00:00:00 2001 From: xiaoyuyu6420 <93528429+xiaoyuyu6420@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:46:56 +0800 Subject: [PATCH 2/2] refactor(kb): reserve rank position for case-variant exact matches 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. --- .../knowledge_base/retrieval/rank_fusion.py | 23 +++++--- tests/unit/test_rank_fusion.py | 56 +++++++++++++++++++ 2 files changed, 70 insertions(+), 9 deletions(-) diff --git a/astrbot/core/knowledge_base/retrieval/rank_fusion.py b/astrbot/core/knowledge_base/retrieval/rank_fusion.py index c4ee29838b..ff10979642 100644 --- a/astrbot/core/knowledge_base/retrieval/rank_fusion.py +++ b/astrbot/core/knowledge_base/retrieval/rank_fusion.py @@ -160,24 +160,29 @@ async def fuse( # 保护 Dense 漏召回的精确词面匹配:Dense embedding 对大小写敏感, # 当查询词以大小写变体出现时(如查询 "oni" 而文档中是 "Oni"), # 目标 chunk 可能完全不被 Dense 召回。此时若 Sparse 命中了该 chunk - # 且其内容大小写不敏感地包含查询词,则给予保底提升,使其不会被 - # 大量纯 Dense 候选挤出 top_k。 + # 且其内容仅以大小写变体形式包含查询词,则将其显式排到所有普通 + # 候选之前,确保它不会被大量纯 Dense 候选挤出 top_k。 + # 仅保护"大小写变体"匹配(原样查询词未出现、小写形式出现), + # 避免改变普通精确命中的既有排序。 + protected_ids: set[str] = set() if query is not None and query.strip(): - lowered_query = query.strip().lower() + raw_query = query.strip() + lowered_query = raw_query.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], - self.dense_weight + (1 - self.dense_weight) / 2, - ) + if not sparse_result: + continue + content = sparse_result.content or "" + if raw_query not in content and lowered_query in content.lower(): + protected_ids.add(identifier) - # 5. 排序 + # 5. 排序。受保护的大小写变体精确匹配优先于所有普通候选。 sorted_ids = sorted( fusion_scores, key=lambda cid: ( + cid not in protected_ids, -fusion_scores[cid], -rrf_scores[cid], dense_ranks.get(cid, float("inf")), diff --git a/tests/unit/test_rank_fusion.py b/tests/unit/test_rank_fusion.py index c77ae2af4b..f48a20ce8a 100644 --- a/tests/unit/test_rank_fusion.py +++ b/tests/unit/test_rank_fusion.py @@ -327,3 +327,59 @@ async def test_rank_fusion_query_protection_ignores_dense_recalled_chunks(): assert [r.chunk_id for r in with_query] == ["target-oni", "other"] assert with_query[0].score == pytest.approx(1.0) + + +@pytest.mark.asyncio +async def test_rank_fusion_query_protection_survives_dense_weight_boundary(): + # dense_weight=1.0 是合法边界值。保护不依赖分数保底,而是显式排序, + # 因此即使 dense 权重为 1.0,大小写变体精确匹配仍必须排在第一位。 + dense_results = [ + make_dense_result(f"dense-{rank}", 0.95 - rank / 100) for rank in range(1, 11) + ] + sparse_results = [ + make_sparse_result( + "target-oni", + "kb", + 30.0, + 1, + content="#### 恶鬼\n**恶鬼** **Oni** 是日式奇幻中的经典怪物。", + ), + ] + + results = await RankFusion(kb_db=None, dense_weight=1.0).fuse( + dense_results=dense_results, + sparse_results=sparse_results, + top_k=5, + query="oni", + ) + + assert results[0].chunk_id == "target-oni" + + +@pytest.mark.asyncio +async def test_rank_fusion_query_protection_skips_exact_case_matches(): + # 只有当查询词以大小写变体出现时才保护(原样词未出现、小写形式出现)。 + # 如果 chunk 内容本身就包含原样的查询词,说明不是大小写变体问题, + # 应保持既有融合排序,不触发保护。 + dense_results = [ + make_dense_result(f"dense-{rank}", 0.95 - rank / 100) for rank in range(1, 11) + ] + sparse_results = [ + make_sparse_result( + "target-oni", + "kb", + 30.0, + 1, + content="#### 恶鬼\n**恶鬼** **oni** 是日式奇幻中的经典怪物。", + ), + ] + + results = await RankFusion(kb_db=None).fuse( + dense_results=dense_results, + sparse_results=sparse_results, + top_k=5, + query="oni", + ) + + # 内容包含原样查询词 "oni",不是大小写变体,不触发保护。 + assert results[0].chunk_id != "target-oni"