RAG 双路召回与 RRF 融合:为什么是 1/(60+rank)
RAG 项目里最难调的不是模型,是召回。模型再好,检索回来的段落不相关,回答一定是编的。
单路召回各缺一半
先用的是纯向量检索(pgvector):语义匹配确实强,问「怎么防止重复扣款」能召回讲幂等的段落,即使那段落里没有「扣款」两个字。
但它有个明显的洞:精确串匹配。用户问「ERR_DUPLICATE_REQUEST 这个错误码是什么意思」,向量检索会把语义上「类似错误处理」的段落排前面,而不一定是最准确提到这个码的那段。代码标识符、配置项、版本号,这些词向量表达得很差。
反过来,BM25 精确但要命地不懂同义词:搜「幂等」不会召回只写「重复提交要防住」的段落。
两路并行,然后只比名次
所以召回改成 ES(BM25)+ pgvector 双路并行,每路取 20 条。问题来了:怎么合?
两路的分数完全不在一个量纲上。余弦相似度是 0~1 的小数,BM25 是没有上界的正数,随着文档长度和词频浮动。要加权求和,就得先归一化,而归一化的参数还得跟着查询调——这基本等于拍脑袋。
RRF(Reciprocal Rank Fusion)绕开了这件事:不看分数,只看名次。
score(d) = Σ_i 1 / (k + rank_i(d))
k 取 60,是论文里给的平滑常数。它的作用是把「第一名」和「第二名」的差距压平,让名次靠后的文档也有机会靠另一路的支撑翻上来——一路排第 1、另一路排第 20 的文档,不会被单路排第 2 但另一路没召回的文档轻易压死。
只用名次的好处很直接:「谁排第几」是两路都同意的语义,而「谁得了多少分」不是。
融合之后还要重排
融合到 40 条,再交给 Rerank 模型重排,取前 10 喂给模型。整条漏斗是「每路 20 → 融合 40 → 重排 10」。
重排这一步是必要的:RRF 只解决了「怎么公平地合并两份名单」,没解决「相关性到底谁高」。Cross-encoder 把查询和段落一起编码,判断质量比各自独立编码的向量检索高一截,代价是慢,所以只能用在 40 条这个量级上。
回答要能点回去
最后回答带文档版本引用,SSE 流式吐字。引用里必须有文档版本号——知识库更新后,旧回答指向的段落可能已经不存在了,没有版本号就无法复现当时的上下文。
评估用的是一个小评估集,看两个指标:证据覆盖率(回答里的每个论断能不能在召回段落里找到依据)和拒答正确率(知识库里没有的东西,模型有没有老实说不知道)。
第二个指标比第一个重要。一个会说「不知道」的 RAG,比一个什么都能答的 RAG 可信得多。