记一次 RAG 检索系统的“bug”与优化之旅:从“黄油煎虾”霸榜到精准检索
背景
在开发我的 “尝尝咸淡” (Smart Cooks) 智能食谱助手时,我遇到在一个非常经典的 RAG (检索增强生成) 问题。后端使用 FastAPI,核心是一个混合检索系统(结合了 Vector Search 和 BM25)。
案发现场:诡异的“黄油煎虾”
用户提问:“我有胡萝卜,木耳,猪肉,我可以做什么菜?”
标准答案:“鱼香肉丝”。
系统回答:
- 黄油煎虾
- 黄油煎虾
- 黄油煎虾 ….
第一回合:此路不通 - 阈值调整 (The Threshold Trap)
思考过程
现象观察:
看到“黄油煎虾”这种完全风马牛不相及的结果,我的第一反应是:系统一定是在“凑数”。检索器可能根本没有找到相关文档,但是因为没有设置下限,把不相关的结果也硬塞给了大模型。
我的推测:
向量检索的相似度阈值 (score_threshold) 太低了(默认 0.4)。许多只有一点点相关的文档(包含个别字)混了进来。
决策:
我决定提高门槛,宁缺毋滥。
我将 config.py 中的阈值从 0.4 提高到了 0.6。
代码尝试
代码修改 (config.py):
1 | # 检索配置 |
结果与反转
结果:
再次提问,系统回答:“抱歉,没有找到相关的食谱。”
不仅“黄油煎虾”没了,连正确的“鱼香肉丝”也没了!
深度分析:
我写了一个调试脚本查看原始分数,惊讶地发现由于用户的问题比较发散(“我可以做什么菜”),即使是正解“鱼香肉丝”,其向量相似度得分也只有 0.42!
设置为 0.6 的阈值,直接把正确答案给“误杀”了。
修正:
这证明单纯提高阈值不是解决办法,反而会降低召回率。我不得不灰溜溜地把阈值改回了 0.4,并开始寻找真正的病灶——为什么排在前面的全是错的?
第二回合:治标 - 结果去重 (Deduplication)
思考过程
深入观察:
阈值恢复后,“黄油煎虾”又回来了,而且还是霸榜前 5 名。
我仔细看了日志,发现这 5 个结果其实都来自同一个文档(同一个 parent_id),只是不同的切片(Chunk)。
我的推测:
因为某种原因(后来发现是 BM25 匹配失效),“黄油煎虾”被排在了前面。而混合检索算法 RRF 会累加排名。如果一个菜品的 5 个切片占据了 BM25 的前 5 名,它的总分会极高,直接把其他候选结果挤出去了。
决策:
对于同一个菜品,我只需要它最相关的那一个片段。必须在合并结果前进行去重。
代码实现
原始代码 (retrieval_optimization.py):
1 | def hybrid_search(self, query: str, top_k: int = 3) -> List[Document]: |
修改后代码:
增加 _deduplicate_by_parent 方法,并扩大初筛范围 k=10。
1 | def hybrid_search(self, query: str, top_k: int = 3) -> List[Document]: |
第三回合:治标 - 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 | def _rrf_rerank(self, vector_docs, bm25_docs, k=60, weights=None): |
第四回合:治本 - BM25 中文分词 (Jieba)
思考过程
深入追问:
为什么 BM25 会觉得“黄油煎虾”跟我问的“胡萝卜、木耳”相关?
我查看了调试日志:
- Query:
"我有胡萝卜..." - Doc:
"黄油煎虾..." - Match Score: 0.
如果分数是 0,为什么它会排第一?
原来,我的 aquatic 文件夹排第一,黄油煎虾 排第一。当所有文档都匹配失败(得分 0)时,系统按默认顺序返回了第一篇文档。这简直是瞎蒙!
问题的本质:
LangChain 的 BM25Retriever 默认按空格分词。
- 中文:
"我有胡萝卜"->["我有胡萝卜"](看作一个长单词) -> 文档里只有"胡萝卜"-> 不匹配。
决策:
必须引入 Jieba 分词。
代码实现
修改后代码:
引入 jieba,并注入到 preprocess_func 中。
1 | def setup_retrievers(self): |
效果验证:
Query "我有胡萝卜" -> Jieba 切分 -> ["我", "有", "胡萝卜"]
Doc "胡萝卜 100g" -> Jieba 切分 -> ["胡萝卜", "100", "g"]
匹配成功!“鱼香肉丝”的 BM25 分数瞬间飙升到第一,结合向量检索,稳稳地占据了推荐榜首。
总结
这就是我从盲目调参 (0.4 -> 0.6) 到发现本质 (分词缺失) 的完整心路历程。
- 阈值:不是万能药,调高了容易误杀。
- 去重:解决同质化刷屏。
- 加权:解决信号强弱不对等。
- 分词:解决中文理解的根本性缺陷。
