EasySearch 两阶段检索实战:BM25 + KNN + RRF + Rerank 全链路
结论先放前面:在 EasySearch 2.3 上,本项目验证了一条候选链路:“BM25 Top20 + KNN Top20 → RRF 融合 Top10 → CrossEncoder 精排 Top5”。它不是所有场景的固定最优架构,是否采用要看真实评测。
文章目录
- EasySearch 两阶段检索实战:BM25 + KNN + RRF + Rerank 全链路
- 一、链路总览
- 二、实验环境
- 三、链路代码拆解
- 3.1 第一步:两路召回 + RRF 融合
- 3.2 第二步:CrossEncoder 精排
- 3.3 保留每一阶段的 rank
- 四、真实排名变化案例
- 五、dev 真实评测结果
- 六、怎么读这张表(诚实版)
- 6.1 KNN 单路在这个小数据集上最强
- 6.2 Rerank 没有超过 KNN
- 6.3 Rerank 的延迟成本是实打实的
- 七、如果继续做工程验证,怎么参考这个结论
- 八、写在最后
一、链路总览
上一篇讲了为什么需要 Rerank,这篇直接上手:在 EasySearch 2.3 上把完整链路跑起来。
整条链路分四步:
用户 Query │ ├─→ BM25 Top20(EasySearch match 查询) │ ├─→ KNN Top20(EasySearch knn_nearest_neighbors) │ ▼ RRF 融合 → Top10 │ ▼ CrossEncoder 精排 → 最终 Top5每一步对应一个参数:
| 参数 | 含义 | 我的取值 |
|---|---|---|
retrieve_k | BM25 和 KNN 各自召回的深度 | 20 |
rrf_top_k | RRF 融合后保留多少条进入精排 | 10 |
rerank_top_k | 精排后最终返回多少条 | 5 |
candidates | KNN 近似搜索的候选数 | 80 |
为什么要这样分层?因为每一步的成本不同:
- BM25/KNN:EasySearch 服务端算,毫秒级,可以召回深一点。
- RRF:本地 Python 做排名融合;本实验没有把融合部分单独计时,不能宣称“零成本”。
- CrossEncoder:本地 CPU 对候选文本对打分。本次 dev 中,候选从 5 增至 10,P50 增加约 693ms;从 10 增至 20,又增加约 1290ms。延迟不是固定“每 10 条增加 700ms”,所以需要在目标环境实测。
二、实验环境
- 搜索引擎:EasySearch 2.3.0
- 索引:
week3_ops_eval,80 条运维故障文档 - Embedding 模型:
BAAI/bge-small-zh-v1.5(KNN 召回用) - Reranker 模型:
BAAI/bge-reranker-base(精排用) - 评测 query:25 条 dev query,带 0/1/2 分级相关性标签
文档索引 mapping 和 Week2 一致:text字段做 BM25,embedding字段(knn_dense_float_vector,lsh + cosine)做 KNN。
三、链路代码拆解
3.1 第一步:两路召回 + RRF 融合
defretrieve_and_fuse(baseline,rrf_module,es,embedding_model,index_name,query,retrieve_k,candidates,rrf_top_k,rrf_k,bm25_weight,knn_weight):started_at=perf_counter()# BM25 召回 Top20bm25_hits=baseline.normalize_hits(baseline.bm25_search(es,index_name,query,retrieve_k))# KNN 召回 Top20knn_hits=baseline.normalize_hits(baseline.knn_search(es,embedding_model,index_name,query,retrieve_k,candidates))# RRF 融合,保留 Top10rrf_hits=rrf_module.rrf_fuse(bm25_hits,knn_hits,rrf_k=rrf_k,bm25_weight=bm25_weight,knn_weight=knn_weight,)[:rrf_top_k]forrrf_rank,hitinenumerate(rrf_hits,start=1):hit["rrf_rank"]=rrf_rank retrieval_ms=(perf_counter()-started_at)*1000return{"bm25":bm25_hits,"knn":knn_hits,"rrf":rrf_hits},retrieval_ms这里复用了 Week2 的三个成果:
bm25_search:EasySearchmatch查询。knn_search:EasySearchknn_nearest_neighbors查询。rrf_fuse:手写 RRF 排名融合。
注意:报告中的 RRF 阶段 P50 约 168ms,包含顺序执行的 BM25、KNN 和本地融合;本实验没有单独测量 RRF Python 融合耗时,因此不能写成“几乎零成本”。
3.2 第二步:CrossEncoder 精排
defrerank_candidates(reranker,query,rrf_hits,rerank_top_k,batch_size):ifnotrrf_hits:return[],0.0# 构造 (query, 文档) 配对pairs=[(query,hit["text"])forhitinrrf_hits]# 逐对打分started_at=perf_counter()raw_scores=reranker.predict(pairs,batch_size=batch_size,show_progress_bar=False,convert_to_numpy=True,)rerank_ms=(perf_counter()-started_at)*1000scores=normalize_scores(raw_scores,len(rrf_hits))# 复制候选并写入分数,避免直接覆盖上游对象ranked=[]forhit,scoreinzip(rrf_hits,scores,strict=True):item=dict(hit)item["rerank_score"]=score ranked.append(item)# 按精排分数降序,分数相同时保留较好的 RRF 顺序ranked.sort(key=lambdaitem:(-item["rerank_score"],item["rrf_rank"]))ranked=ranked[:rerank_top_k]forrerank_rank,iteminenumerate(ranked,start=1):item["rerank_rank"]=rerank_rankreturnranked,rerank_ms这里是本次链路耗时最高的一步:10 条候选组成 10 个文本对,模型可按 batch 处理,但每个文本对都要参与推理。它能联合读取 query 和候选正文,却不保证在当前数据上一定胜过 KNN。
3.3 保留每一阶段的 rank
调试 Rerank 效果时,最重要的是能回答:"这篇文档在每个阶段排第几?"所以每个文档对象里,我都保留了:
{"id":"ops-cpu-002","bm25_rank":3,# BM25 里排第几"knn_rank":1,# KNN 里排第几"rrf_rank":2,# RRF 融合后排第几"rerank_rank":1,# 精排后排第几"rerank_score":0.95# 返回结构示意,不是本文保存的实测分数}有了这四个 rank,可以确认文档在哪个阶段发生了位置变化;至于模型为什么这样判断,仍需要结合正文、标签和额外实验分析。
四、真实排名变化案例
看一个真实 query:“Java 进程 CPU 突然飙升”。
| 阶段 | Top3 文档 | 已标注相关文档位置 |
|---|---|---|
| RRF | ops-cpu-002 → ops-cpu-001 → ops-cpu-003 | ops-cpu-002 第 1,ops-cpu-001 第 2 |
| Rerank | ops-cpu-002 → ops-cpu-005 → ops-disk-003 | ops-cpu-002 第 1,ops-cpu-001 掉到第 9 |
这个 case 很有意思:RRF 本来排得很好(一篇 relevance=2、一篇 relevance=1 的文档都在前 2),但 Rerank 反而把 ops-cpu-001 从第 2 拉到了第 9。这就是后面要重点讲的“精排退化”。
再看一个 Rerank 发挥正面作用的真实 dev 场景:q-dev-020 是“Elasticsearch 节点磁盘水位过高怎么办”。RRF Top3 为ops-disk-006 → ops-es-004 → ops-es-003,Rerank Top3 为ops-es-004 → ops-disk-006 → ops-es-006;标注的直接相关文档是ops-es-004和ops-es-005。这次精排把更直接的 ES 水位文档从第 2 提到第 1,但不能由这一个案例推断所有 query 都会受益。
五、dev 真实评测结果
在 80 文档、25 条 dev query 上,各策略指标如下(真实跑出,非示意):
| 策略 | Top1 | MRR | Hit@3 | Recall@3 | nDCG@10 |
|---|---|---|---|---|---|
| BM25 | 0.800 | 0.884 | 0.920 | 0.653 | 0.825 |
| KNN | 0.960 | 0.960 | 0.960 | 0.767 | 0.891 |
| RRF | 0.840 | 0.925 | 0.960 | 0.740 | 0.871 |
| RRF+Rerank(5) | 0.920 | 0.940 | 0.960 | 0.660 | 0.858 |
| RRF+Rerank(10) | 0.920 | 0.946 | 0.960 | 0.647 | 0.881 |
| RRF+Rerank(20) | 0.920 | 0.933 | 0.960 | 0.660 | 0.861 |
稳态延迟:
| 策略 | P50 (ms) | P95 (ms) |
|—|—|—|—|
| BM25 | 77.50 | 297.45 |
| KNN | 91.32 | 320.65 |
| RRF | 167.82 | 577.74 |
| RRF+Rerank(10) | 1445.16 | 1967.96 |
指标口径:Top1 要求第一名是 relevance=2 的直接相关文档;MRR/Hit@3 把 relevance>0 视为相关;nDCG@10 用 0/1/2 分级标签。延迟为本地 CPU 串行请求。
可比性限制:评测脚本中的
Rerank(5)最多只返回 5 条,却仍按 nDCG@10 计算,因此它在列表深度上天然少于其他策略,nDCG 不能与返回 Top10 的策略做完全公平的横向比较。Rerank(10)和Rerank(20)都最终评估 Top10,二者可以直接比较;本次 10 的 nDCG 高于 20。
六、怎么读这张表(诚实版)
这组数据有三个值得说的地方,我如实呈现:
6.1 KNN 单路在这个小数据集上最强
KNN 的 Top1(0.960)、MRR(0.960)、nDCG@10(0.891)全是最高。可能原因包括数据规模较小、query 与文档语义较直接、标签分布对 KNN 有利等;本实验没有做消融,不能把结果确定归因于某一个原因或模型的领域能力。
6.2 Rerank 没有超过 KNN
RRF+Rerank(10) 的 nDCG@10 是 0.881,低于单独 KNN 的 0.891,但高于 RRF 的 0.871。准确说法是:它相对 RRF 有小幅提升,却没有超过本次 dev 上最强的 KNN,因此相对最强基线没有净收益。
我没有为了好看而挑数据。这正是做评测的意义:用固定评估集和指标说话,而不是挑几条 Rerank 表现好的 query 讲故事。
6.3 Rerank 的延迟成本是实打实的
RRF 的 P50 是 168ms,加了 Rerank(10) 后 P50 增至 1445ms,P95 接近 2 秒。Rerank(10) 相对 RRF 的 nDCG 有小幅提升,但是否值得这部分延迟,需要结合业务目标判断;相对 KNN,它没有质量优势。
工程结论:在这个 80 文档的小规模实验里,KNN 单路是最优性价比;Rerank 是否值得,要在更大规模数据、更细粒度标签上重新验证。
七、如果继续做工程验证,怎么参考这个结论
如果你的场景和这个实验类似(小规模中文运维知识库),可以参考:
- 先把 EasySearch 的 KNN 调优:换更好的 Embedding 模型、调
candidates、补充文档,收益可能比上 Rerank 更直接。 - Rerank 不是必选项:它是候选集内排序质量不足时的补丁,不是链路标配。本次实验中 P95 明显增加,具体增幅必须在目标硬件、并发和部署方式下重新测量。
- 上 Rerank 前必须有评测:dev/test 拆分 + 冻结参数 + 质量/延迟双指标,缺一不可。不然很可能花了延迟,只换来几条好看的个案。
八、写在最后
这条链路的价值不在于"Rerank 一定提升效果",而在于它把每个阶段的贡献都拆开了:
- BM25 的角色是词项匹配召回。
- KNN 的角色是向量语义召回。
- RRF 的角色是按排名融合两路结果;本次 Recall@3 高于 BM25、但低于 KNN。
- Rerank 的角色是候选内重新打分;本次相对 RRF 有小幅提升、但没有超过 KNN。
哪一步有效、哪一步是成本,用固定评估集测出来,而不是拍脑袋。
下一篇我会写:怎么用 nDCG、P95 延迟和 dev/test 拆分,把"Rerank 有没有用"这个问题回答得更可信。
环境:EasySearch 2.3.0 + Python 3.14.4 + sentence-transformers 5.6.0 + BAAI/bge-small-zh-v1.5 + BAAI/bge-reranker-base(CPU)
你在生产里上过 Rerank 吗?质量提升和延迟成本,你是怎么权衡的?评论区聊聊。