混合检索 + RRF + Rerank 实战评测:三条路线在 47 个真实问题上的取舍
本文所有数字来自我自研的 RAG 平台 DocMind 的真实评测,评测脚本开源可复现(
scripts/bench_report.py),不存在"编一个提升 30%"的修辞。TL;DR:纯向量在"规范提问"上已经满分,真正的分水岭在口语化/中英混杂的"困难问题"上;RRF 把困难集 Recall 从 94.1% 拉到 100%,Rerank 再把排序质量 MRR 从 0.873 提到 0.941------但代价是 Recall 掉了一条边。这个"掉"不是缺陷,而是阈值语义的必然结果,下文细说。
一、为什么要做路线对比:RAG 检索最怕"感觉还行"
大量 RAG 项目的检索模块停留在"调通了"的状态:接一个 embedding API,Chroma 里一查,返回 4 条,看起来像那么回事。问题在于没有任何度量------你不知道换一条路线会好多少,更不知道哪些 query 在被漏掉。
我做 DocMind 时的原则是:检索任何改动必须有评测集数字背书。于是先固定了一个 47 样本的评测集,分两组:
- 基础集(30 条):规范问法,比如"什么是 RAG?""Temperature 参数有什么作用?"
- 困难集(17 条):口语化、中英混杂、缩写、俚语,比如"知识库检索不准怎么办?"(期望命中概念文档,但问法与文档措辞差得很远)
评测口径两个:Recall@4 (前 4 条里有没有正确文档------决定"能不能答")和 MRR(正确文档排多靠前------决定"喂给模型的上下文质量")。
47 条样本量不大,但覆盖了我业务场景下的主要 query 模式;结论适用于同类 RAG 场景,不建议直接外推到通用搜索领域
二、三条路线
| 路线 | 检索流程 |
|---|---|
| 纯向量 | query → embedding → 余弦相似度 top-k |
| 混合 RRF | BM25 关键词路 + 向量语义路并行召回 → RRF 按排名融合 |
| 混合 + Rerank | 混合 RRF 出候选(≤10 条)→ gte-rerank 精排 → 阈值过滤 |
注:混合+Rerank 的 Recall 下降并非 Rerank 本身导致,而是后续阈值过滤砍掉了边缘相关候选(详见第 3 节)。
三路都走同一条评测脚本,全链路真实调用(embedding API + BM25 + rerank API),结果如下:
| 检索路线 | 基础集 Recall@4 | 基础集 MRR | 困难集 Recall@4 | 困难集 MRR |
|---|---|---|---|---|
| 纯向量 | 100.0% | 0.978 | 94.1% | 0.843 |
| 混合 RRF | 100.0% | 1.000 | 100.0% | 0.873 |
| 混合 + Rerank | 100.0% | 1.000 | 94.1% | 0.941 |
三、三个值得展开的发现
1. 基础集三条全满分------区分度全在困难集
规范问法下,现代 embedding 模型基本不会失手。这意味着用"正常问题"做的评测没有区分度,你永远看不出改动的收益。真正的分水岭在困难集:用户不会按你文档的措辞提问。
2. RRF 把困难集 Recall 从 94.1% 拉到 100%
困难集里大量是"钓鱼链接是什么意思"这类带黑话/缩写的问题。纯向量对这类词的语义泛化是弱项(embedding 空间里"钓鱼"和"phishing 链接"不一定近),而 BM25 的字面匹配正好补位。
关键在融合方式。BM25 分数无上界(跟文档长度、词频相关),余弦相似度是 0~1,直接加权求和等于让 BM25 的量纲统治一切。RRF(Reciprocal Rank Fusion)按排名融合,每条路的贡献只取决于"排第几"而不是"分多高":
python
def rrf_merge(vec_hits, bm25_hits, k=60):
"""每路的贡献 = 1/(k + rank),天然免疫两路分数量纲差异"""
rrf = {}
for rank, hit in enumerate(vec_hits, 1):
rrf[hit.id] = rrf.get(hit.id, 0.0) + 1.0 / (k + rank)
for rank, (idx, _) in enumerate(bm25_hits, 1):
rrf[idx] = rrf.get(idx, 0.0) + 1.0 / (k + rank)
return sorted(rrf.items(), key=lambda kv: kv[1], reverse=True)
k=60 是 Cormack 等人验证过的稳健选择;在我的业务场景下未观察到需要调整的迹象,但不同召回深度的场景可能需要实验。。(上面是节选示意------仓库实现还含切片索引对齐与 ACL 过滤。)RRF 的代价是丢失分数的绝对语义(融合分只是排名的调和,说不出"这条到底多相关")------这个坑下面 Rerank 一节正好接住。
3. Rerank:MRR 0.873 → 0.941,但 Recall 掉了一条
Rerank 模型(cross-encoder)把 query 和文档拼在一起过一遍 transformer,相关性判断远比双塔的独立编码准。它把正确答案排得更靠前(MRR 0.873 → 0.941),这是预期收益。
但注意困难集 Recall 从 100% 掉回了 94.1%------一条边缘相关的候选被阈值过滤砍掉了。我的过滤规则是"绝对下限 + 相对头部":
python
def filter_reranked(ranked, abs_floor=0.05, rel_ratio=0.15, min_top=0.08):
"""头部候选过低 → 整体无关(触发拒答链);
其余候选跟头部比,而不是跟固定线比"""
if not ranked or ranked[0].score < min_top:
return []
floor = max(abs_floor, ranked[0].score * rel_ratio)
return [h for h in ranked if h.score >= floor]
为什么不用固定阈值?因为 relevance_score 的真实分布在 0.1~0.9 全区间都有,固定线会随语料漂移失准。(签名是示意------仓库实现走 config 常量。)为什么明知道会砍掉边缘命中还要砍?因为下游是证据拒答:如果检索放进来一条弱相关片段,模型会基于它编一个似是而非的答案------这比"承认库里没有"危害大得多。评测上掉的那条,和线上"宁可拒答不可胡说"是同一套哲学在两个层面的投影。
这个取舍是可以调的:把 rel_ratio 降到 0.10,那条就回来了,但弱相关片段也会混进来。我的选择是用评测集固定住这组参数,任何改动跑回归。
四、两个工程细节,比数字更值钱
多库统一精排 。多知识库场景下,如果每个库各自 rerank 再合并,N 个库就是 N 次 rerank API 调用。做法是:各库只做双路召回 → 合并去重 → 全局只 rerank 一次。省 API 钱,而且跨库分数在同一把尺子上可比。
降级链 。rerank 是外部 API,会挂。失败时自动降级返回 RRF 结果(保证可用性),连续失败 3 次进熔断冷却 60 秒,避免每个请求都吃 8 秒超时。增强类组件的故障永远不允许阻断主链路------这是我在设计 RAG 系统时的底线之一。
五、性能账
三条路线全链路(embedding + BM25 + RRF + rerank)单机压测,QPS 在 2.2 封顶,瓶颈不在服务端而在上游 LLM API(每请求 1 次 embedding + 1 次 rerank 远程调用)。优化路径按 ROI 排序是:语义缓存 → 查询向量缓存 → rerank 结果缓存。后两者我后来做了,同一套代码优化前后对比:热点查询吞吐从 QPS 3.1 提到 801(重复问题直接命中确定性缓存),冷查询零退化------这是另一篇的内容,这里只说明:检索路线的"效果"和"成本"要放在一起谈,rerank 每条 query 都多一次 API 调用,值不值要看评测收益(困难集 MRR +7.8%)和你的预算。
六、复现
bash
python scripts/bench_report.py # 47 样本三路线评测,输出全部表格数字
评测集、加噪逻辑、阈值参数全部在仓库里。最后说一句:这套对比做下来最大的收获不是"混合检索更好"这个结论------这个结论文献里早有------而是每个设计决策都有了可复现的数字锚点。后来每一次改切片参数、换 embedding 模型、动阈值,都是改完跑一遍,数字说话。
仓库与评测脚本 :github.com/Ice-Breakin... (scripts/bench_report.py 可复现本文全部表格)
系列文章 :二、LoRA 查询改写器 A/B · 三、确定性分层缓存 · 四、模型路由的诚实成本账
评测环境:macOS (Apple M4),text-embedding-v3 + gte-rerank-v2(百炼),BM25 用 rank_bm25,中文分词 jieba。评测集 47 样本(基础 30 + 困难 17)。