记一次 RAG 检索系统的“bug”与优化之旅:从“黄油煎虾”霸榜到精准检索

背景

在开发我的 “尝尝咸淡” (Smart Cooks) 智能食谱助手时,我遇到在一个非常经典的 RAG (检索增强生成) 问题。后端使用 FastAPI,核心是一个混合检索系统(结合了 Vector SearchBM25)。

案发现场:诡异的“黄油煎虾”

用户提问:“我有胡萝卜,木耳,猪肉,我可以做什么菜?”
标准答案:“鱼香肉丝”。
系统回答

  1. 黄油煎虾
  2. 黄油煎虾
  3. 黄油煎虾 ….

第一回合:此路不通 - 阈值调整 (The Threshold Trap)

思考过程

现象观察
看到“黄油煎虾”这种完全风马牛不相及的结果,我的第一反应是:系统一定是在“凑数”。检索器可能根本没有找到相关文档,但是因为没有设置下限,把不相关的结果也硬塞给了大模型。

我的推测
向量检索的相似度阈值 (score_threshold) 太低了(默认 0.4)。许多只有一点点相关的文档(包含个别字)混了进来。

决策
我决定提高门槛,宁缺毋滥。
我将 config.py 中的阈值从 0.4 提高到了 0.6

代码尝试

代码修改 (config.py)

1
2
3
4
    # 检索配置
top_k: int = 3
- score_threshold: float = 0.4
+ score_threshold: float = 0.6

结果与反转

结果
再次提问,系统回答:“抱歉,没有找到相关的食谱。”
不仅“黄油煎虾”没了,连正确的“鱼香肉丝”也没了!

深度分析
我写了一个调试脚本查看原始分数,惊讶地发现由于用户的问题比较发散(“我可以做什么菜”),即使是正解“鱼香肉丝”,其向量相似度得分也只有 0.42
设置为 0.6 的阈值,直接把正确答案给“误杀”了。

修正
这证明单纯提高阈值不是解决办法,反而会降低召回率。我不得不灰溜溜地把阈值改回了 0.4,并开始寻找真正的病灶——为什么排在前面的全是错的?


第二回合:治标 - 结果去重 (Deduplication)

思考过程

深入观察
阈值恢复后,“黄油煎虾”又回来了,而且还是霸榜前 5 名。
我仔细看了日志,发现这 5 个结果其实都来自同一个文档(同一个 parent_id),只是不同的切片(Chunk)。

我的推测
因为某种原因(后来发现是 BM25 匹配失效),“黄油煎虾”被排在了前面。而混合检索算法 RRF 会累加排名。如果一个菜品的 5 个切片占据了 BM25 的前 5 名,它的总分会极高,直接把其他候选结果挤出去了。

决策
对于同一个菜品,我只需要它最相关的那一个片段。必须在合并结果前进行去重

代码实现

原始代码 (retrieval_optimization.py)

1
2
3
4
5
6
def hybrid_search(self, query: str, top_k: int = 3) -> List[Document]:
vector_docs = self.vector_retriever.invoke(query)
bm25_docs = self.bm25_retriever.invoke(query)
# 直接合并,导致同质化刷屏
reranked_docs = self._rrf_rerank(vector_docs, bm25_docs)
return reranked_docs[:top_k]

修改后代码
增加 _deduplicate_by_parent 方法,并扩大初筛范围 k=10

1
2
3
4
5
6
7
8
9
10
11
def hybrid_search(self, query: str, top_k: int = 3) -> List[Document]:
# 1. 扩大初筛 (k=10),给去重留出余量
vector_docs = self.vector_retriever.invoke(query)
bm25_docs = self.bm25_retriever.invoke(query)

# 2. 核心优化:去重 - 相同 parent_id 只保留第一名
vector_docs = self._deduplicate_by_parent(vector_docs)
bm25_docs = self._deduplicate_by_parent(bm25_docs)

reranked_docs = self._rrf_rerank(vector_docs, bm25_docs)
return reranked_docs[:top_k]

第三回合:治标 - RRF 算法加权 (Weighting)

思考过程

现象观察
去重后,“黄油煎虾”依然排在第一(Rank 1),而正确答案“鱼香肉丝”排在后面(Rank 4)。
原始 RRF 算法公式:$Score = \frac{1}{k + rank_{vector}} + \frac{1}{k + rank_{bm25}}$。它认为 BM25 的第一名和 Vector 的第一名同样重要。

根本原因
在这个 Case 里,BM25 提供的完全是噪音(Rank 1 是错的)。但 RRF 把它当成了金科玉律。

决策
既然在中文环境下 BM25 不太靠谱,而 Vector Search 至少还能基于语义摸到点边(排第 4),我应该人为降低 BM25 的权重,提高 Vector 的权重

代码实现

原始代码 (_rrf_rerank)
完全平等的投票。

1
rrf_score = 1.0 / (k + rank + 1)

修改后代码
引入权重机制,向量检索一票顶六票。

1
2
3
4
5
6
7
8
def _rrf_rerank(self, vector_docs, bm25_docs, k=60, weights=None):
if weights is None:
# 向量权重 3.0,BM25 权重 0.5
weights = {"vector": 3.0, "bm25": 0.5}

rrf_score = weights["vector"] * (1.0 / (k + rank + 1))
# ...
rrf_score = weights["bm25"] * (1.0 / (k + rank + 1))

第四回合:治本 - BM25 中文分词 (Jieba)

思考过程

深入追问
为什么 BM25 会觉得“黄油煎虾”跟我问的“胡萝卜、木耳”相关?
我查看了调试日志:

  • Query: "我有胡萝卜..."
  • Doc: "黄油煎虾..."
  • Match Score: 0.

如果分数是 0,为什么它会排第一?
原来,我的 aquatic 文件夹排第一,黄油煎虾 排第一。当所有文档都匹配失败(得分 0)时,系统按默认顺序返回了第一篇文档。这简直是瞎蒙

问题的本质
LangChain 的 BM25Retriever 默认按空格分词。

  • 中文: "我有胡萝卜" -> ["我有胡萝卜"] (看作一个长单词) -> 文档里只有 "胡萝卜" -> 不匹配

决策
必须引入 Jieba 分词。

代码实现

修改后代码
引入 jieba,并注入到 preprocess_func 中。

1
2
3
4
5
6
7
8
9
10
11
12
def setup_retrievers(self):
# 定义分词函数
def jieba_tokenizer(text: str) -> List[str]:
import jieba
return list(jieba.cut_for_search(text))

# 注入分词器
self.bm25_retriever = BM25Retriever.from_documents(
self.chunks,
k=10,
preprocess_func=jieba_tokenizer # 关键!让 BM25 看懂中文
)

效果验证
Query "我有胡萝卜" -> Jieba 切分 -> ["我", "有", "胡萝卜"]
Doc "胡萝卜 100g" -> Jieba 切分 -> ["胡萝卜", "100", "g"]
匹配成功!“鱼香肉丝”的 BM25 分数瞬间飙升到第一,结合向量检索,稳稳地占据了推荐榜首。


总结

这就是我从盲目调参 (0.4 -> 0.6)发现本质 (分词缺失) 的完整心路历程。

  1. 阈值:不是万能药,调高了容易误杀。
  2. 去重:解决同质化刷屏。
  3. 加权:解决信号强弱不对等。
  4. 分词:解决中文理解的根本性缺陷。