做检索的时候,一个很自然的想法是:关键词检索算一个分,向量检索算一个分,两个分数加权平均一下,排个序。
这个想法是错的。错的原因很具体:这两个分数根本不是一套记分体系。这篇讲清楚为什么,以及正确的做法 RRF 是什么。
一、它解决什么问题
假设你要找「毛玻璃风格」。两种检索方式各有各的毛病:
| 方式 | 擅长 | 不擅长 |
|---|---|---|
| 关键词检索(BM25) | 精确匹配。「glassmorphism」这个词出现就一定中 | 不懂同义。「毛玻璃」和「玻璃拟态」在它眼里是两个无关的词 |
| 向量检索(embedding) | 懂语义。「安静高级的透明质感」能召回 glassmorphism | 可能漏精确匹配。你搜一个具体的模型名,它反而给你一堆「相关」的 |
两种毛病刚好互补,所以工业界的做法是两路都跑,然后融合结果。问题出在融合这一步。
二、不用它会怎样
先讲个生活化的类比。
两个老师给同一批学生打分。A 老师用百分制,最高分 98,最低分 12。B 老师用等级制,只给 1 到 5。
现在你要算总分,直接写 总分 = A分 + B分,会发生什么?
A 老师的分数完全主导了结果——A 的分数量级是几十,B 只有个位数。B 老师就算把最喜欢的学生打成 5 分,也影响不了排名。你实际上只用了 A 老师的意见。
「归一化一下再相加」听起来能解决,但归一化很脆:它依赖当次结果的分数分布。这次最高分 98,下次 40,同一个分数在两次查询里含义完全不同。
真实后果是:你的融合逻辑变成了一个需要反复调的参数,而且换个数据集就失效。
三、代码是怎么做的
两路检索的真实形态
在 StyleKit 的检索设计里(docs/RAG_SEMANTIC_RETRIEVAL.md):
- 关键词这一路用 SQLite 的 FTS5,返回的是 BM25 分数
- 向量这一路用
sqlite-vec,1024 维,返回的是余弦相似度
它们的取值范围:
| 检索器 | 分数范围 | 有没有上界 |
|---|---|---|
| BM25 | 0 到正无穷 | 无界 |
| 余弦相似度 | -1 到 1 | 有界 |
这就是不能相加的根因。 BM25 的分数是「这个词在本篇里的重要性」,文档越长、词频越高,分数就越大,理论上没有上限。余弦相似度是两个向量夹角的余弦,永远在 -1 到 1 之间。
一个是无界实数,一个是 [-1, 1]。它们之间没有任何换算关系。你把它们加起来,等于把「米」和「摄氏度」加起来。
RRF:只用排名,不用分数
RRF 全称 Reciprocal Rank Fusion(倒数排名融合)。做法是:彻底丢掉分数,只看名次。
score(d) = Σ 1 / (k + rank_r(d))
r∈检索器rank_r(d)是文档 d 在第 r 路结果里的名次(从 1 开始)k是平滑常数,工业界默认取 60- 把所有检索器给出的贡献加起来,就是最终分数
举个例子,k=60。一个文档在关键词路排第 1、向量路排第 3:
1/(60+1) + 1/(60+3) = 0.01639 + 0.01587 = 0.03226另一个文档在两路都排第 2:
1/(60+2) + 1/(60+2) = 0.01613 + 0.01613 = 0.03226分数几乎一样。这是想要的效果:两路都认可的结果,比只有一路强烈认可的结果更可信。
为什么 k 要取 60
k 的作用是压平头部。看第一名的贡献:
| k 取值 | 第 1 名得分 | 第 10 名得分 | 差距 |
|---|---|---|---|
| 1 | 0.5 | 0.0909 | 5.5 倍 |
| 60 | 0.0164 | 0.0143 | 1.15 倍 |
k 小的时候,第一名会碾压后面所有人,融合退化成「谁排第一听谁的」。k 到 60 之后前几名差距被抹平,多路的一致性才起作用。60 是学术界在多个数据集上试出来的经验值,好处是换数据集基本不用改。
为什么这比加权求和好
| 对比项 | 加权求和 | RRF |
|---|---|---|
| 需要调参吗 | 需要,权重是超参 | 不需要,k 固定 60 |
| 分数分布变化敏感吗 | 敏感,依赖当次分布 | 不敏感,只用名次 |
| 换数据集 | 权重要重调 | 直接用 |
| 两路分数必须可比吗 | 必须 | 不需要 |
这就是 RRF 成为工业界混合检索默认选择的原因。
加权重这一步我做错了
写完上面那节之后,我在设计里又加了一套 chunk 类型权重,乘在 RRF 融合之后。理由看起来站得住:philosophy 是长文本、语义密度高,应该比 identity 更重要,给它 1.2;组件配方 0.8。我在设计文档里管这个叫「轻推」。
评估结果:Recall@1 掉了 18 个百分点。
原因是 RRF 在 k=60 时分数区间很窄:
rank 1 → 1/61 = 0.016393
rank 13 → 1/73 = 0.013699 相差 16%1.2 倍的权重在这个尺度上相当于十几名的差距。算一下:
1.2/(60+r) ≥ 1/61 ⟺ r ≤ 13.2一条排在第 13 名的 philosophy chunk,得分高过全语料排第 1 的 identity chunk。再叠上 locale 加成,第 20 名能赢第 1 名。
60 条 query 里,28 条的 rank-1 因此改变,54 条的 top-5 改变。它不是轻推。
问题出在上面那张表里其实已经写了:RRF 不敏感于分数分布,只用名次。既然它不读分数,就不该在它后面乘分数——那等于把 RRF 当初要避开的东西重新引进来。
权重已经删掉,Recall@1 回到 80.0%,不带任何加权的关键词路是 81.7%。
顺带说一个工程决定
向量库选了 sqlite-vec 而不是 pgvector:语料一共约 600 个向量,暴力遍历就够快,连 HNSW 索引都不需要;FTS5 和 sqlite-vec 在同一个库里,混合检索一条 SQL 做完;自有服务器内存受限,为 600 个向量起一个 Postgres 不值得。
降级链也必须实现:向量服务超时就只用 FTS5,标记 degraded: true;FTS5 也没结果就返回空候选,让 LLM 自由发挥。空结果不能报错,要退化成和升级前一样的行为,否则就是引入回归。
四、面试官会追问什么
问:为什么 BM25 和余弦相似度不能直接加权相加?
答:它们的分数不可比。BM25 是无界的实数,文档越长词频越高分数越大;余弦相似度被限制在 [-1, 1]。两个不同量纲的数相加没有意义。归一化能缓解,但归一化依赖当次结果的分数分布,同一个分数在不同查询里含义不同,会让结果变得不稳定。
问:RRF 里 k=60 是怎么来的?丢掉分数不可惜吗?
答:k 是经验值,不是推导的。它的作用是压平头部——k 太小(比如 1),第 1 名得分是第 10 名的 5 倍,融合会退化成只看单路排名;k 到 60 时前几名差距在 1.15 倍以内,多路一致性才起作用。
至于丢掉分数:分数里其实有两部分信息,排序(谁比谁好)和置信度(好多少)。后者在不同检索器之间不可比,前者才可靠。RRF 保留可靠的、丢掉不可靠的,这是取舍不是损失。
问:为什么这个项目不用 pgvector?
答:数据量太小了。600 个向量暴力检索就是微秒级,用不上近似索引。而引入 Postgres 意味着多一个要运维的服务,服务器内存还受限。选 sqlite-vec 还让向量和 FTS5 在同一个库里,混合检索一条 SQL 搞定。
问:向量服务挂了怎么办?
答:降级到只用 FTS5,并标记
degraded: true,让调用方知道结果质量降级了。如果 FTS5 也没结果,返回空候选让 LLM 自由生成——这和升级前的行为一致,不引入回归。
五、一句话版本
BM25 无界、余弦有界,两个分数不可比所以不能相加;RRF 只用排名不用分数,
score = Σ 1/(k+rank),k=60,免调参且鲁棒。
六、自测
- 用自己的话说清 BM25 和余弦相似度的取值范围差异,以及为什么这导致它们不能加权相加。
- k=60 的时候,第 1 名和第 10 名的 RRF 贡献差多少倍?如果 k=1 呢?
- 一个文档在 A 路排第 1、B 路排第 5,另一个文档在 A、B 两路都排第 3。哪个 RRF 分数高?这说明了 RRF 的什么性质?
- 降级链里为什么强调「FTS5 也无结果时返回空候选,而不是报错」?