跳到主要内容

多路检索实战:三通道架构、RRF 融合的数学与一个「均值骗人」的评估教训

· 阅读需 17 分钟

「单路检索不够用,要上多路」——这话现在几乎是共识。但多路到底怎么融合、路怎么选、效果怎么证明,多数文章停在「向量 + 关键词,效果更好」。

这篇想把它做实。我做了一个可复现的三通道检索实验(向量 / 关键词 / 图谱,对应语义、精确、关系三条通道),把 RRF 的公式、k 常数、加权、以及查询路由都跑了一遍,然后用准确率/召回率逐查询拆开看——结果推翻了我自己一开始的预期:

融合的收益被均值严重高估了。 在这个语料上,三路等权 RRF 的平均 nDCG 只比最好的单通道高 1.8%;而在某一类查询上,等权 RRF 反而比单独用图谱通道差了将近一半

命名说明:你说的「RFF 算法」,业界标准名是 RRF(Reciprocal Rank Fusion,互惠排名融合),本文统一用 RRF。

一、实验设置(先看这个,否则数字没意义)

语料24 篇虚构公司「云雀科技」的内部技术文档 + 一个服务依赖/归属图谱
查询13 条人工标注查询,标注相关文档(qrels)
通道① 精确(SQLite FTS5 + bm25())② 语义(TF-IDF + 截断 SVD ≈ LSA)③ 关系(图谱 2 跳 BFS)
依赖零第三方依赖——FTS5 来自 Node 内置 node:sqlite,LSA 是纯 JS 实现
指标P@5、R@5、MRR、nDCG@5(取回深度 10,评估截断 5)

脚本随仓库留档:source/multipath-retrieval/corpus.mjs / retrieval.mjs / run.mjsnode run.mjs 即可复现)。

必须先声明的四条局限(后面还会再提一次):

  1. 语料只有 24 篇、13 条查询,数字不可外推,只看趋势和机制;
  2. 相关标注是我自己标的,单标注者、没有一致性校验;
  3. 语义通道用的是 LSA,不是稠密 embedding 模型——这一点很重要,实测里它基本没提供独立信号(见 §三);
  4. 环境是本机装不了稠密模型onnxruntime-node 只有 arm64 构建、本机 Node 是 x64)才退到 LSA 的,不是我认为 LSA 够用。

二、为什么单路不够:三种失败方式

三种查询,各自死在不同的地方:

① 语义型 「缓存被打穿导致数据库连接耗尽怎么办」
→ 用户不会复述文档用词。纯关键词召回靠运气。

② 精确型 「ERR_TLS_4021」
→ 分词器会把代码/参数名切碎、或当成普通词降权。错一个字符就全错。

③ 关系型 「哪些服务同时依赖 Redis 和 Kafka」
→ 这是对「关系」提问,而不是对「文本」提问。
任何把文档当词袋的方法(向量、BM25)都答不了:答案需要跨文档的图遍历。

这三种失败不是程度差别,是种类差别。 前两种是「召回得不够好」,第三种是**「这个问题在你的表示里根本不存在」**——Redis 和 Kafka 的「同时依赖」关系不在任何单篇文档的向量里。这是多路架构真正的理由。

三、三通道架构

3.1 统一契约:每个通道只输出一份有序列表

整个架构的关键设计是通道之间完全解耦——每条通道只做一件事:

type Hit = { id: string; score: number };
interface Channel {
name: string;
search(query: string, topK: number): Hit[]; // 有序、越大越相关
}

有了这个契约,新增通道不需要改融合层,融合层也不需要知道通道内部是 BM25 还是余弦。RRF 之所以能跨通道工作,前提就是这个契约。

3.2 精确通道:FTS5 + BM25

用 Node 内置 node:sqlite 建 FTS5 虚拟表,靠 bm25() 排序。

一个必须提的中文坑:SQLite 的 unicode61 分词器不切分中文——一整段中文会被当成一个 token,检索直接失效。解决办法是入库和查询时都先把中文切成「单字 + 二字组」

// 中文切单字与二字组,英文/数字/下划线整体保留
export function tokenize(text) {
const out = [];
for (const m of text.match(/[-鿿]+|[A-Za-z0-9_.\-]+/g) ?? []) {
if (/^[-鿿]+$/.test(m)) {
for (let i = 0; i < m.length; i++) {
out.push(m[i]);
if (i + 1 < m.length) out.push(m.slice(i, i + 2));
}
} else out.push(m.toLowerCase());
}
return out;
}

注意 bm25() 返回的是负数、越小越相关,务必要翻正(一个很容易搞反的地方)。

3.3 语义通道:向量 + 余弦

接口上它和精确通道一模一样,替换的是内部实现:embed(query) 然后算余弦。生产环境这里放稠密 embedding 模型;本文为了零依赖,用 TF-IDF + 截断 SVD(LSA):对 TF-IDF 矩阵做幂迭代求前 12 个奇异向量,文档向量取 V·S,查询用 fold-in 投影到同一空间。

实测它就是本文最大的局限——见 §六的逐查询表:在多条查询上,它的 nDCG 和 BM25 一模一样(0.613 / 0.613、1.000 / 1.000)。原因是这个语料里「语义型」查询仍然和文档共享词汇,LSA 那点泛化能力没派上用场。加上零词面重叠的探针查询后,三通道一起挂零

「大促之前怎么把系统整体承压水平准备好」→ 精确 0.000 | 语义 0.000 | 关系 0.000 | RRF 0.000

要老实说的结论:语义通道的价值完全取决于向量质量。用 LSA 这种词袋变体,它只是 BM25 的影子;换稠密模型才有独立信号。任何「加了向量通道就一定更好」的说法,都必须先证明向量本身够好。

3.4 关系通道:图谱多跳

图谱用有向三元组建(张伟 owns 支付网关订单服务 depends_on Redis故障-2026-03 affects 订单服务),检索分两步:

  1. 实体链接:找出查询里出现的、且存在于图中的实体名当种子;
  2. 有界传播:从种子做 BFS(最多 2 跳),节点得分取 1/hop(多路径取最大),再把节点分数累加到「提及了该实体的文档」上。
种子: 故障-2026-03
1 跳 -> 订单服务(1.0), Kafka(1.0)
2 跳 -> Redis(0.5), MySQL(0.5), 搜索服务(0.5), ...
文档分数 = Σ 该文档提及节点的分数

它回答的正是「关系型提问」哪些服务同时依赖 Redis 和 Kafka 这类问题,在文档向量里无解,在图上是两次邻居查询。

四、RRF:为什么用排名而不是分数

4.1 公式

RRF(d)=cC1k+rankc(d)\text{RRF}(d) = \sum_{c \in C} \frac{1}{k + \text{rank}_c(d)}

其中 C 是通道集合,rank_c(d) 是文档 d 在通道 c 里的排名(从 1 开始),k 是平滑常数(标准取 60)。文档在某个通道里没出现,就不计这一项。

加权版本:

RRFw(d)=cCwck+rankc(d)\text{RRF}_w(d) = \sum_{c \in C} \frac{w_c}{k + \text{rank}_c(d)}

4.2 核心洞察:分数不可比,排名可比

三条通道的分数根本不在一个尺度上,实测:

关键词通道 bm25() 原始值 负数,量级随语料变化(实测靠翻正后使用)
语义通道 余弦相似度 [-1, 1]
关系通道 1/hop (0, 1],且大量文档为 0

想直接相加,你得先归一化——而归一化本身就是个会坑人的活儿:min-max 归一化对每路查询的极值敏感,某路只返回一条垃圾结果就会把这条垃圾抬到 1.0。RRF 绕开了整件事:它只用排名,天然跨通道可比。这是 RRF 最大的工程价值,不是精度。

4.3 常数 k 的作用与实测敏感性

k 决定「排名差异被放大到什么程度」:

  • k 很小 → 1/(k+rank) 衰减快 → 强烈奖励「被多路同时排前面」的文档
  • k 很大 → 衰减慢 → 退化成「各路排名的平均」,融合更温和。

实测(13 条查询):

kP@5R@5MRRnDCG@5
10.2770.8080.9340.809
50.2620.7560.9340.785
100.2620.7560.9360.786
200.2620.7560.9360.786
60(标准)0.2620.7560.9360.786
1000.2620.7560.9360.786

结论k=1 略好,k 从 5 到 100 完全持平。原因是这个实验的候选集很小(每路最多 10 条),k 一大,1/(k+rank) 的分母被 k 主导,排名差异被抹平,融合退化成「出现过就得分」。

实践含义k=60 是文献惯例(来自 RRF 原始论文),但它不是需要精调的超参。候选集短时它几乎无影响;真正影响结果的候选集长度和通道数。别把时间花在调 k 上,把它花在通道质量和路由上。

4.4 加权 RRF:实测有用

权重(精确 : 语义 : 关系)P@5R@5nDCG@5
1:1:10.2620.7560.786
2:1:10.2620.7560.786
1:2:10.2620.7560.789
1:1:20.2770.7820.804
0.5:1:1.50.2770.7820.804
2:1:20.2770.7820.800

给关系通道加倍权重,nDCG 从 0.786 升到 0.804——因为它在这个语料的关系型查询上明显更强(§六)。加权 RRF 的用处就在这:当你知道某条通道在某类查询上更强时,用权重把它拉起来。

4.5 RRF 最重要的缺陷:等权会被弱通道拖累

这条是本文最该记住的发现。看关系型查询的逐查询 nDCG@5:

查询关键词语义图谱等权 RRF
张伟还负责哪些服务0.4690.4690.8530.469
哪些服务同时依赖 Redis 和 Kafka0.6130.6130.9200.613
故障-2026-03 影响了什么0.6130.6130.9200.613

在这三条上,等权 RRF 的结果和「关键词单通道」完全一致,而且远差于图谱单通道(0.853 / 0.920 vs 0.469 / 0.613)。

原因是数学上的:RRF 只加项、不减项。当两条通道(关键词、语义)在这些查询上返回了相似但排得不那么好的列表,它们给同一个文档各加了一份分,把图谱通道正确排第一的文档挤了下去。RRF 假设「多条通道都排前面 = 更可能相关」,但当两条通道高度相关(本文的 LSA 就是 BM25 的影子)时,这个假设直接失效——你实际是给同一路信号投了两票

两条可迁移的推论

  1. 融合前先看通道间的相关性。 通道两两高度相关(如本文的 BM25 与 LSA)时,等权融合≈重复计票,收益趋零甚至为负。
  2. 融合不是终点,路由才是。 既然等权 RRF 会拖累强通道,就不要在所有查询上都三路融合——按查询类型只选该用的路(§五)。

五、查询路由策略

5.1 为什么必须路由

路由解决两个独立的问题:

  • 质量:避免 §4.5 的「弱通道拖累」;
  • 成本:少查一路就少一份延迟与资源(实测各通道耗时见 §六末)。

5.2 三种路由手段

手段做法优点代价
规则正则/关键词特征判断查询类型零延迟、可解释、可控要手写、覆盖面有限、易过拟合
分类器用标注数据训一个小模型预测「该走哪路」可学习、能扩展需要标注数据、有训练与漂移成本
LLM 路由让模型判断查询类型甚至改写灵活、能处理开放问法延迟与成本最高、结果不稳定

推荐顺序:先用规则把明显的类型分流,再把规则覆盖不到的交给分类器。 LLM 路由放在最外层,且不要放在关键路径上(它一次调用的延迟可能比三条通道检索加起来还高两个数量级)。

5.3 实测:路由打败全融合,而且更便宜

规则很简单(三个特征):

const hasExact = (q) => /[A-Z]{2,}[_A-Z0-9]*\d|[A-Z_]{4,}|[a-z]+\.[a-z]+|[a-z]+_[a-z]+|\bap-[a-z]+-\d\b/.test(q);
const hasRelational = (q) => /哪些|哪个||负责人|负责|依赖|影响|同时|还有|下游/.test(q);

function route(q) {
const linked = graph.link(q).length > 0; // 查询里能链出图谱实体吗
if (hasExact(q) && !hasRelational(q)) return [0]; // 只走精确通道
if (hasRelational(q) && linked) return [2, 0]; // 关系 + 精确
return [0, 1]; // 语义 + 精确
}

实测对比:

策略nDCG@5R@5平均调用通道数平均检索耗时
全融合(三路都查)0.7860.7563.001.10 ms
规则路由0.8410.8331.690.94 ms

路由同时赢了质量和成本:nDCG 0.786 → 0.841,R@5 0.756 → 0.833,而通道调用数从 3 降到 1.69、耗时反而略降。

⚠️ 必须声明的偏差:这些规则是照着这个查询集的特征写的,等于拿着答案设计路由,所以增益偏乐观。真实场景里查询分布你不知道,路由规则必须在一份没参与设计的留出集上评估,否则就是自欺。

六、用准确率/召回率评估:别被均值骗了

6.1 四个指标分别在说什么

设某查询的相关文档集合为 Rel,系统返回前 k 个为 Top k

P@k=RelTopkkR@k=RelTopkRelP@k = \frac{|Rel \cap Top_k|}{k} \qquad R@k = \frac{|Rel \cap Top_k|}{|Rel|}

MRR=1第一个相关文档的排名nDCG@k=DCG@kIDCG@k,DCG@k=i=1krelilog2(i+1)\text{MRR} = \frac{1}{\text{第一个相关文档的排名}} \qquad nDCG@k = \frac{DCG@k}{IDCG@k},\quad DCG@k = \sum_{i=1}^{k} \frac{rel_i}{\log_2(i+1)}

  • P@k:前 k 里有多少是相关的——关心「别塞垃圾」
  • R@k:相关文档被找回了多少——关心「别漏」
  • MRR:第一个正确答案排多前——关心「首条命中」(问答类最重要);
  • nDCG@k:按位置打折的相关性——关心「排序质量」,本文用它做主轴。

多路检索首先要看 R(召回),因为多条通道的初衷就是「单路会漏」;但只看 R 会被骗——把所有文档都返回,R 就是 1。所以 P、MRR、nDCG 必须一起看。

6.2 总表:融合只赢了 1.8%

13 条查询的均值:

方法P@5R@5MRRnDCG@5
关键词通道 (BM25)0.2460.7310.9330.772
向量通道 (LSA)0.2460.7310.8960.742
图谱通道 (2 跳)0.1540.3080.3330.284
RRF (k=60)0.2620.7560.9360.786
CombSUM(归一化求和)0.2620.7690.9330.796
凸组合(1:1:1)0.2620.7690.9330.796

注意三件事:

  1. 融合相对最好单通道只有 +1.8% nDCG(0.772 → 0.786)。「多路融合大幅提升」在这个语料上不成立
  2. 图谱通道均值最差(0.284),但它在关系型查询上是最强的(0.853~0.920)——均值完全掩盖了它的价值
  3. CombSUM 和凸组合略优于 RRF(0.796 vs 0.786)。所以 RRF 的优势不在精度,在工程:不需要归一化、不需要调参、对尺度不敏感。别把 RRF 当精度方案推销。

6.3 本文最重要的教训:均值骗人,要逐查询和分桶看

同一个图谱通道:均值 0.284(最差),却在 4 条关系型查询上是唯一能把答案排到前面的通道。均值把这件事彻底埋掉了。

所以多路检索的评估必须做三件事:

  1. 逐查询看,不要只看均值;
  2. 按查询类型分桶报(语义 / 精确 / 关系 / 混合),因为不同桶的最优通道不同;
  3. 做通道归因(ablation):逐条去掉一路,看哪个桶掉分。掉分最多的那路才是这次查询真正的贡献者。

6.4 用「通道归因」验证路由

把「去掉某路后各桶 nDCG 的变化」算出来,路由规则就有依据了——本文的关系型桶里图谱通道掉了 0.3~0.4 nDCG,这正是 §5.3 里把关系型查询路由到图谱的实证理由,而不是拍脑袋。

6.5 再回到四条局限

评估结论的可信度,上限由实验设置决定

  1. 24 篇语料、13 条查询 → 数字全是趋势,不是结论;
  2. qrels 我一个人标的,没有第二标注者、没有 Kappa 一致性——相关性的主观性没被度量;
  3. 语义通道是 LSA 不是稠密模型,且实测它没提供独立信号 → §3.3 那条「语义通道的价值取决于向量质量」是本文最该带走的判断;
  4. 零词面重叠查询三路全灭 → 暴露的是「词袋类表示」的共同天花板,换稠密模型有可能救回来,但本文没测。

七、落地清单

  1. 先统一定义通道契约(有序 (id, score) 列表),融合层才能与通道解耦;
  2. 先上精确 + 语义两路,把 RRF 打通;图谱通道只在真有关系型提问时加——它是成本最高、最需要额外维护的一路;
  3. RRF 的 k 不用调k 大时几乎无影响),把精力放在通道质量、权重与路由上;
  4. 加权 RRF 优于等权,但权重必须来自分桶评估,不能凭感觉;
  5. 上路由:规则先行,覆盖不到的再上分类器;LLM 路由别放关键路径
  6. 评估必须分桶 + 逐查询 + 做归因,只看总均值会得出「融合没用」或「图谱没用」的错误结论。

八、一句话收尾

多路检索的价值不在「融合后均值高了几个点」,而在「每一路各有一个别人覆盖不了的查询子集」。 所以真正的工作量不在写 RRF() 那五行,而在把查询分对桶、把通道选对路、并把评估拆到能看见子集——做不到第三点,前两点你永远无法证明。

关联

参考

  • 实验脚本(随仓库留档,零第三方依赖,node run.mjs 可复现):source/multipath-retrieval/corpus.mjs / retrieval.mjs / run.mjs);SVD 幂迭代用随机初值,但实测连续 5 次运行的指标完全一致(收敛后与初值无关),即上表数字可稳定复现(耗时数字除外,它随机器负载漂移)
  • RRF 原始论文:G. V. Cormack, C. L. A. Clarke, S. Büttcher, Reciprocal rank fusion outperforms condorcet and individual rank learning methods, SIGIR 2009, pp. 758–759, DOI 10.1145/1571941.1572114 —— 论文以 RRF 融合多个 TREC 实验结果,并构建了在 LETOR 3 上优于当时所有已报告方法的 meta-learner。本文未复现该论文的数值,只引用其结论。
  • SQLite FTS5 与 bm25()sqlite.org/fts5.html
  • 本文实测环境:macOS(Darwin 24.6.0)/ Node v24.14.1,node:sqlite(SQLite 3.51.2);所有指标为 run.mjs 的真实输出