EasySearch 两阶段检索实战:BM25 + KNN + RRF + Rerank 全链路

📅 2026/7/22 2:26:31
EasySearch 两阶段检索实战:BM25 + KNN + RRF + Rerank 全链路
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 没有超过 KNN6.3 Rerank 的延迟成本是实打实的七、如果继续做工程验证怎么参考这个结论八、写在最后一、链路总览上一篇讲了为什么需要 Rerank这篇直接上手在 EasySearch 2.3 上把完整链路跑起来。整条链路分四步用户 Query │ ├─→ BM25 Top20EasySearch match 查询 │ ├─→ KNN Top20EasySearch knn_nearest_neighbors │ ▼ RRF 融合 → Top10 │ ▼ CrossEncoder 精排 → 最终 Top5每一步对应一个参数参数含义我的取值retrieve_kBM25 和 KNN 各自召回的深度20rrf_top_kRRF 融合后保留多少条进入精排10rerank_top_k精排后最终返回多少条5candidatesKNN 近似搜索的候选数80为什么要这样分层因为每一步的成本不同BM25/KNNEasySearch 服务端算毫秒级可以召回深一点。RRF本地 Python 做排名融合本实验没有把融合部分单独计时不能宣称“零成本”。CrossEncoder本地 CPU 对候选文本对打分。本次 dev 中候选从 5 增至 10P50 增加约 693ms从 10 增至 20又增加约 1290ms。延迟不是固定“每 10 条增加 700ms”所以需要在目标环境实测。二、实验环境搜索引擎EasySearch 2.3.0索引week3_ops_eval80 条运维故障文档Embedding 模型BAAI/bge-small-zh-v1.5KNN 召回用Reranker 模型BAAI/bge-reranker-base精排用评测 query25 条 dev query带 0/1/2 分级相关性标签文档索引 mapping 和 Week2 一致text字段做 BM25embedding字段knn_dense_float_vectorlsh 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_atperf_counter()# BM25 召回 Top20bm25_hitsbaseline.normalize_hits(baseline.bm25_search(es,index_name,query,retrieve_k))# KNN 召回 Top20knn_hitsbaseline.normalize_hits(baseline.knn_search(es,embedding_model,index_name,query,retrieve_k,candidates))# RRF 融合保留 Top10rrf_hitsrrf_module.rrf_fuse(bm25_hits,knn_hits,rrf_krrf_k,bm25_weightbm25_weight,knn_weightknn_weight,)[:rrf_top_k]forrrf_rank,hitinenumerate(rrf_hits,start1):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_searchEasySearchmatch查询。knn_searchEasySearchknn_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_atperf_counter()raw_scoresreranker.predict(pairs,batch_sizebatch_size,show_progress_barFalse,convert_to_numpyTrue,)rerank_ms(perf_counter()-started_at)*1000scoresnormalize_scores(raw_scores,len(rrf_hits))# 复制候选并写入分数避免直接覆盖上游对象ranked[]forhit,scoreinzip(rrf_hits,scores,strictTrue):itemdict(hit)item[rerank_score]score ranked.append(item)# 按精排分数降序分数相同时保留较好的 RRF 顺序ranked.sort(keylambdaitem:(-item[rerank_score],item[rrf_rank]))rankedranked[:rerank_top_k]forrerank_rank,iteminenumerate(ranked,start1):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 文档已标注相关文档位置RRFops-cpu-002 → ops-cpu-001 → ops-cpu-003ops-cpu-002 第 1ops-cpu-001 第 2Rerankops-cpu-002 → ops-cpu-005 → ops-disk-003ops-cpu-002 第 1ops-cpu-001 掉到第 9这个 case 很有意思RRF 本来排得很好一篇 relevance2、一篇 relevance1 的文档都在前 2但 Rerank 反而把 ops-cpu-001 从第 2 拉到了第 9。这就是后面要重点讲的“精排退化”。再看一个 Rerank 发挥正面作用的真实 dev 场景q-dev-020 是“Elasticsearch 节点磁盘水位过高怎么办”。RRF Top3 为ops-disk-006 → ops-es-004 → ops-es-003Rerank 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 上各策略指标如下真实跑出非示意策略Top1MRRHit3Recall3nDCG10BM250.8000.8840.9200.6530.825KNN0.9600.9600.9600.7670.891RRF0.8400.9250.9600.7400.871RRFRerank(5)0.9200.9400.9600.6600.858RRFRerank(10)0.9200.9460.9600.6470.881RRFRerank(20)0.9200.9330.9600.6600.861稳态延迟| 策略 | P50 (ms) | P95 (ms) ||—|—|—|—|| BM25 | 77.50 | 297.45 || KNN | 91.32 | 320.65 || RRF | 167.82 | 577.74 || RRFRerank(10) | 1445.16 | 1967.96 |指标口径Top1 要求第一名是 relevance2 的直接相关文档MRR/Hit3 把 relevance0 视为相关nDCG10 用 0/1/2 分级标签。延迟为本地 CPU 串行请求。可比性限制评测脚本中的Rerank(5)最多只返回 5 条却仍按 nDCG10 计算因此它在列表深度上天然少于其他策略nDCG 不能与返回 Top10 的策略做完全公平的横向比较。Rerank(10)和Rerank(20)都最终评估 Top10二者可以直接比较本次 10 的 nDCG 高于 20。六、怎么读这张表诚实版这组数据有三个值得说的地方我如实呈现6.1 KNN 单路在这个小数据集上最强KNN 的 Top10.960、MRR0.960、nDCG100.891全是最高。可能原因包括数据规模较小、query 与文档语义较直接、标签分布对 KNN 有利等本实验没有做消融不能把结果确定归因于某一个原因或模型的领域能力。6.2 Rerank 没有超过 KNNRRFRerank(10) 的 nDCG10 是 0.881低于单独 KNN 的 0.891但高于 RRF 的 0.871。准确说法是它相对 RRF 有小幅提升却没有超过本次 dev 上最强的 KNN因此相对最强基线没有净收益。我没有为了好看而挑数据。这正是做评测的意义用固定评估集和指标说话而不是挑几条 Rerank 表现好的 query 讲故事。6.3 Rerank 的延迟成本是实打实的RRF 的 P50 是 168ms加了 Rerank(10) 后 P50 增至 1445msP95 接近 2 秒。Rerank(10) 相对 RRF 的 nDCG 有小幅提升但是否值得这部分延迟需要结合业务目标判断相对 KNN它没有质量优势。工程结论在这个 80 文档的小规模实验里KNN 单路是最优性价比Rerank 是否值得要在更大规模数据、更细粒度标签上重新验证。七、如果继续做工程验证怎么参考这个结论如果你的场景和这个实验类似小规模中文运维知识库可以参考先把 EasySearch 的 KNN 调优换更好的 Embedding 模型、调candidates、补充文档收益可能比上 Rerank 更直接。Rerank 不是必选项它是候选集内排序质量不足时的补丁不是链路标配。本次实验中 P95 明显增加具体增幅必须在目标硬件、并发和部署方式下重新测量。上 Rerank 前必须有评测dev/test 拆分 冻结参数 质量/延迟双指标缺一不可。不然很可能花了延迟只换来几条好看的个案。八、写在最后这条链路的价值不在于Rerank 一定提升效果而在于它把每个阶段的贡献都拆开了BM25 的角色是词项匹配召回。KNN 的角色是向量语义召回。RRF 的角色是按排名融合两路结果本次 Recall3 高于 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-baseCPU你在生产里上过 Rerank 吗质量提升和延迟成本你是怎么权衡的评论区聊聊。