从关键词到语义:为什么 RAG 一定要做混合检索
问「邮费是多少」,知识库里明明有「运费怎么算」,系统却答「未找到」。这不是模型笨,是检索方式选错了。
一个真实失败案例
先看一个我实际遇到过的场景。知识库里有这么一条:
「运费怎么算?」
—— 单笔订单满 99 元免运费;不满 99 元的订单,全国统一收取 8 元运费。
knowledge_docs/运费说明.md
用户问的是:「邮费是多少?」
如果检索用的是关键词匹配(SQL 里最常见的那种 WHERE question LIKE '%关键词%'),
系统会拿「邮费」去比对,而文档里写的是「运费」—— 两个字对不上,结果是查不到,
模型只能回答「抱歉,我没有找到相关信息」。
这就是 RAG 落地时最常见的第一个坑:能答的题,却因为检索没找着而答不上。
关键词检索:死在同义表达
关键词检索的原理非常直接:把问题拆成词,去文档里找有没有这些词。
常见的实现是 BM25 —— 它比简单的 LIKE 匹配聪明一些,会考虑词频和文档长度,但本质仍然是「字面匹配」。
它的优势在哪
- 精确匹配极准:查型号
W40、订单号20260901001,字面匹配一次就中,不会被打岔。 - 无需训练、无需模型:建索引快,成本低。
- 可解释:为什么召回这一条,能明确说出是哪个词命中的。
它什么时候会失灵
一旦用户的说法和文档的写法不一致,它就完全无能为力。这类情况在中英文里都极其常见:
- 同义词:邮费 / 运费,退钱 / 退款,保修 / 质保
- 口语化:这东西坏了想换 / 申请换货流程
- 语序不同:怎么退 / 退款怎么申请
- 跨语言:用户写英文,文档是中文
注意
这类失败最危险的地方在于:它不报错。系统正常返回「没有找到」,你从日志上看不出任何异常, 只有用户知道答案明明就在库里。
向量检索:死在精确细节
向量检索换了个思路:不比对字,而是比对意思。 先把每段文本送进嵌入模型,变成一个高维向量;检索时把问题也变成向量,然后找余弦相似度最高的几段。
这样一来,「邮费」和「运费」在向量空间里几乎挨在一起,上面那个失败案例就解决了。 但向量检索有自己的死穴 —— 它对精确的、没有语义的数字和代号非常不敏感。
假设商品规格里有四个型号:饮水机 W20 / W40 / W60,猫砂盆 LP50。 用户问「W40 的滤芯多久换一次」。 向量检索很可能把 W20、W60 的段落也一起召回 —— 因为它们语义上几乎一样, 都是「饮水机滤芯更换周期」。
换成关键词检索,W40 这个字符串会精确命中,一次就到位。
所以两种方式的问题正好相反:
- 关键词:抓得准,但认不出同义说法
- 向量:认得同义说法,但分不清近似型号
两种方式的实测对比
光讲道理不够直观。下面这个组件可以切换检索方式,看同一个问题在不同方式下的召回结果 —— 数据来自我在项目里实测的真实分数。
BM25 拿「邮费」去比对,但文档里写的是「运费」。两个字对不上 —— 正确答案「运费说明」压根没有被召回。注意系统不会报错,只会说「没找到」。
可以看到:关键词方式下「运费说明」压根不在结果里;向量方式能召回它,但顺带把两条不相关的也拉进来了; 混合检索加上重排之后,正确答案稳定排在第一位。
RRF:怎么把两路结果合起来
既然两种方式各有长短,自然的想法就是把它们同时跑,再把两份结果合成一份。问题是:怎么合?
最朴素的思路是「按分数加权」。但这里有个麻烦:BM25 的分数范围可能是 0 到 20 多, 向量相似度是 0 到 1,两个刻度完全不可比。硬加权的话,一方的分数会直接淹没另一方。
所以更常用的是 RRF(Reciprocal Rank Fusion,倒数排名融合)。 它干脆不看分数,只看排名:
# RRF 的核心:只用排名,不用原始分数
def rrf_score(ranks, k=60):
"""ranks: 该文档在各路检索中的排名(从 1 开始)"""
return sum(1.0 / (k + r) for r in ranks)
# 例:某文档在 BM25 排第 1、在向量检索排第 5
rrf_score([1, 5]) # = 1/61 + 1/65 ≈ 0.0318
# 另一文档在两边都排第 3
rrf_score([3, 3]) # = 1/63 + 1/63 ≈ 0.0317
注意上面这两行的对比:一个第 1 名 + 一个第 5 名,得分几乎等于两个第 3 名。 这正是 RRF 想要的效果 —— 它奖励「在多个榜单里都靠前」的文档,而不是「在某一个榜单里特别突出」的。
提示
常数
k一般取 60(来自原论文的经验值)。它的作用是削弱第一名和后面名次的差距, 让融合结果更平滑。k越小,头部排名的影响越大。
别急着上重排
说到这里要泼一盆冷水:混合检索不是所有场景都需要,重排更是。 Anthropic 在他们的工程博客里反复强调一个原则:
we recommend finding the simplest solution possible, and only increasing complexity when needed.
(建议先找最简单的方案,只在确实需要时才增加复杂度。)
Anthropic Engineering · Building Effective Agents
判断要不要加,最直接的方法是先做评估集。我自己的做法是:
- 手工标注 20-30 条问题,标出「正确答案应该来自哪一段」
- 分别用纯关键词、纯向量、混合检索跑一遍,记录正确答案进入 Top-K 的比例
- 如果纯向量的准确率已经够了,就停在那里 —— 每多一层,就多一份延迟和成本
我是这样做的:先收录一批「换说法提问」的样本,跑纯向量检索,发现同义问题基本能召回, 但型号类问题的精确度明显不够 —— 这时候才决定引入 BM25 做双路。
注意
重排模型通常要额外加载几百 MB 到几 GB 的权重,明显增加首字延迟。 如果检索本身的准确率还不到 80%,先优化切块策略,而不是加重排。
顺带一提,切块策略本身也会影响召回质量:把章节标题一起带进 chunk, 比只切正文段落效果好 —— 标题本身就是这一段内容的「语义摘要」。
小结
- 关键词和向量不是「谁替代谁」,而是各自解决不同的问题
- 同义表达靠向量,精确型号靠关键词,两个都要就双路召回再用 RRF 融合
- RRF 的价值在于只看排名不看分数,天然规避了不同量纲无法比较的问题
- 每加一层都要有评估数据支撑,不要凭感觉堆复杂度