多路检索实战:三通道架构、RRF 融合的数学与一个「均值骗人」的评估教训
「单路检索不够用,要上多路」——这话现在几乎是共识。但多路到底怎么融合、路怎么选、效果怎么证明,多数文章停在「向量 + 关键词,效果更好」。
这篇想把它做实。我做了一个可复现的三通道检索实验(向量 / 关键词 / 图谱,对应语义、精确、关系三条通道),把 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.mjs,node run.mjs 即可复现)。
必须先声明的四条局限(后面还会再提一次):
- 语料只有 24 篇、13 条查询,数字不可外推,只看趋势和机制;
- 相关标注是我自己标的,单标注者、没有一致性校验;
- 语义通道用的是 LSA,不是稠密 embedding 模型——这一点很重要,实测里它基本没提供独立信号(见 §三);
- 环境是本机装不了稠密模型(
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 订单服务),检索分两步:
- 实体链接:找出查询里出现的、且存在于图中的实体名当种子;
- 有界传播:从种子做 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 公式
其中 C 是通道集合,rank_c(d) 是文档 d 在通道 c 里的排名(从 1 开始),k 是平滑常数(标准取 60)。文档在某个通道里没出现,就不计这一项。
加权版本:
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 条查询):
| k | P@5 | R@5 | MRR | nDCG@5 |
|---|---|---|---|---|
| 1 | 0.277 | 0.808 | 0.934 | 0.809 |
| 5 | 0.262 | 0.756 | 0.934 | 0.785 |
| 10 | 0.262 | 0.756 | 0.936 | 0.786 |
| 20 | 0.262 | 0.756 | 0.936 | 0.786 |
| 60(标准) | 0.262 | 0.756 | 0.936 | 0.786 |
| 100 | 0.262 | 0.756 | 0.936 | 0.786 |
结论:k=1 略好,k 从 5 到 100 完全持平。原因是这个实验的候选集很小(每路最多 10 条),k 一大,1/(k+rank) 的分母被 k 主导,排名差异被抹平,融合退化成「出现过就得分」。
实践含义:
k=60是文献惯例(来自 RRF 原始论文),但它不是需要精调的超参。候选集短时它几乎无影响;真正影响结果的候选集长度和通道数。别把时间花在调 k 上,把它花在通道质量和路由上。
4.4 加权 RRF:实测有用
| 权重(精确 : 语义 : 关系) | P@5 | R@5 | nDCG@5 |
|---|---|---|---|
| 1:1:1 | 0.262 | 0.756 | 0.786 |
| 2:1:1 | 0.262 | 0.756 | 0.786 |
| 1:2:1 | 0.262 | 0.756 | 0.789 |
| 1:1:2 | 0.277 | 0.782 | 0.804 |
| 0.5:1:1.5 | 0.277 | 0.782 | 0.804 |
| 2:1:2 | 0.277 | 0.782 | 0.800 |
给关系通道加倍权重,nDCG 从 0.786 升到 0.804——因为它在这个语料的关系型查询上明显更强(§六)。加权 RRF 的用处就在这:当你知道某条通道在某类查询上更强时,用权重把它拉起来。
4.5 RRF 最重要的缺陷:等权会被弱通道拖累
这条是本文最该记住的发现。看关系型查询的逐查询 nDCG@5:
| 查询 | 关键词 | 语义 | 图谱 | 等权 RRF |
|---|---|---|---|---|
| 张伟还负责哪些服务 | 0.469 | 0.469 | 0.853 | 0.469 |
| 哪些服务同时依赖 Redis 和 Kafka | 0.613 | 0.613 | 0.920 | 0.613 |
| 故障-2026-03 影响了什么 | 0.613 | 0.613 | 0.920 | 0.613 |
在这三条上,等权 RRF 的结果和「关键词单通道」完全一致,而且远差于图谱单通道(0.853 / 0.920 vs 0.469 / 0.613)。
原因是数学上的:RRF 只加项、不减项。当两条通道(关键词、语义)在这些查询上返回了相似但排得不那么好的列表,它们给同一个文档各加了一份分,把图谱通道正确排第一的文档挤了下去。RRF 假设「多条通道都排前面 = 更可能相关」,但当两条通道高度相关(本文的 LSA 就是 BM25 的影子)时,这个假设直接失效——你实际是给同一路信号投了两票。
两条可迁移的推论:
- 融合前先看通道间的相关性。 通道两两高度相关(如本文的 BM25 与 LSA)时,等权融合≈重复计票,收益趋零甚至为负。
- 融合不是终点,路由才是。 既然等权 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@5 | R@5 | 平均调用通道数 | 平均检索耗时 |
|---|---|---|---|---|
| 全融合(三路都查) | 0.786 | 0.756 | 3.00 | 1.10 ms |
| 规则路由 | 0.841 | 0.833 | 1.69 | 0.94 ms |
路由同时赢了质量和成本:nDCG 0.786 → 0.841,R@5 0.756 → 0.833,而通道调用数从 3 降到 1.69、耗时反而略降。
⚠️ 必须声明的偏差:这些规则是照着这个查询集的特征写的,等于拿着答案设计路由,所以增益偏乐观。真实场景里查询分布你不知道,路由规则必须在一份没参与设计的留出集上评估,否则就是自欺。
六、用准确率/召回率评估:别被均值骗了
6.1 四个指标分别在说什么
设某查询的相关文档集合为 Rel,系统返回前 k 个为 Top k:
- P@k:前 k 里有多少是相关的——关心「别塞垃圾」;
- R@k:相关文档被找回了多少——关心「别漏」;
- MRR:第一个正确答案排多前——关心「首条命中」(问答类最重要);
- nDCG@k:按位置打折的相关性——关心「排序质量」,本文用它做主轴。
多路检索首先要看 R(召回),因为多条通道的初衷就是「单路会漏」;但只看 R 会被骗——把所有文档都返回,R 就是 1。所以 P、MRR、nDCG 必须一起看。
6.2 总表:融合只赢了 1.8%
13 条查询的均值:
| 方法 | P@5 | R@5 | MRR | nDCG@5 |
|---|---|---|---|---|
| 关键词通道 (BM25) | 0.246 | 0.731 | 0.933 | 0.772 |
| 向量通道 (LSA) | 0.246 | 0.731 | 0.896 | 0.742 |
| 图谱通道 (2 跳) | 0.154 | 0.308 | 0.333 | 0.284 |
| RRF (k=60) | 0.262 | 0.756 | 0.936 | 0.786 |
| CombSUM(归一化求和) | 0.262 | 0.769 | 0.933 | 0.796 |
| 凸组合(1:1:1) | 0.262 | 0.769 | 0.933 | 0.796 |
注意三件事:
- 融合相对最好单通道只有 +1.8% nDCG(0.772 → 0.786)。「多路融合大幅提升」在这个语料上不成立;
- 图谱通道均值最差(0.284),但它在关系型查询上是最强的(0.853~0.920)——均值完全掩盖了它的价值;
- CombSUM 和凸组合略优于 RRF(0.796 vs 0.786)。所以 RRF 的优势不在精度,在工程:不需要归一化、不需要调参、对尺度不敏感。别把 RRF 当精度方案推销。
6.3 本文最重要的教训:均值骗人,要逐查询和分桶看
同一个图谱通道:均值 0.284(最差),却在 4 条关系型查询上是唯一能把答案排到前面的通道。均值把这件事彻底埋掉了。
所以多路检索的评估必须做三件事:
- 逐查询看,不要只看均值;
- 按查询类型分桶报(语义 / 精确 / 关系 / 混合),因为不同桶的最优通道不同;
- 做通道归因(ablation):逐条去掉一路,看哪个桶掉分。掉分最多的那路才是这次查询真正的贡献者。
6.4 用「通道归因」验证路由
把「去掉某路后各桶 nDCG 的变化」算出来,路由规则就有依据了——本文的关系型桶里图谱通道掉了 0.3~0.4 nDCG,这正是 §5.3 里把关系型查询路由到图谱的实证理由,而不是拍脑袋。
6.5 再回到四条局限
评估结论的可信度,上限由实验设置决定:
- 24 篇语料、13 条查询 → 数字全是趋势,不是结论;
- qrels 我一个人标的,没有第二标注者、没有 Kappa 一致性——相关性的主观性没被度量;
- 语义通道是 LSA 不是稠密模型,且实测它没提供独立信号 → §3.3 那条「语义通道的价值取决于向量质量」是本文最该带走的判断;
- 零词面重叠查询三路全灭 → 暴露的是「词袋类表示」的共同天花板,换稠密模型有可能救回来,但本文没测。
七、落地清单
- 先统一定义通道契约(有序
(id, score)列表),融合层才能与通道解耦; - 先上精确 + 语义两路,把 RRF 打通;图谱通道只在真有关系型提问时加——它是成本最高、最需要额外维护的一路;
- RRF 的
k不用调(k大时几乎无影响),把精力放在通道质量、权重与路由上; - 加权 RRF 优于等权,但权重必须来自分桶评估,不能凭感觉;
- 上路由:规则先行,覆盖不到的再上分类器;LLM 路由别放关键路径;
- 评估必须分桶 + 逐查询 + 做归因,只看总均值会得出「融合没用」或「图谱没用」的错误结论。
八、一句话收尾
多路检索的价值不在「融合后均值高了几个点」,而在「每一路各有一个别人覆盖不了的查询子集」。 所以真正的工作量不在写 RRF() 那五行,而在把查询分对桶、把通道选对路、并把评估拆到能看见子集——做不到第三点,前两点你永远无法证明。
关联
- 检索基础与解析损失:RAG 的现状、老问题,以及那条「像素方案」
- 检索做在 SQLite 上(FTS5 + BM25 的写法与调优):高性能 SQLite 理论分析入门、驱动写法横向对比
- 用图谱/状态承载「关系」的工程样本:InkOS 架构拆解(FTS5 检索 + 37 维审计 + 原子提交)
- 框架选型(多路检索要落在哪套运行时):LangChain 还是 pi-agent?
参考
- 实验脚本(随仓库留档,零第三方依赖,
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的真实输出
