公司动态
从 BM25 迁到混合检索:影子双跑、RRF 与降级
从 BM25 迁到混合检索影子双跑、RRF 与降级线上搜索服务若长期运行在传统全文检索BM25/Elasticsearch架构上在引入向量检索Dense Vector Search时直接全量切换存在较高的生产风险。网络搜索与知识库检索的场景复杂直接替换可能导致精准匹配能力下降。语义向量检索虽然擅长理解模糊意图但遇到精确商品型号、错误代码或者人名拼音时召回率惨不忍睹。把传统的全文检索直接扔掉等于用一个短板去换另一个短板。更加稳妥的路线是平滑渐进将系统升级为结合 BM25 与 Dense Vector 的 Hybrid 混合检索架构并通过三阶段切换策略确保生产稳定。flowchart TD UserQuery[用户 Query 输入] -- Router{平滑迁移灰度闸门} subgraph Phase1[第一阶段: 影子双跑 Shadow Run] Router --|100% 流量主路| ES_Only[Elasticsearch 传统 BM25 检索] Router --|后台异步 Shadow| Async_Vector[Milvus/Qdrant 向量检索] Async_Vector -- Metric_Diff[计算召回重合率与 Top-K 差异指标] end subgraph Phase2[第二阶段: RRF 混合检索 动态切流] Router --|5% ~ 50% 灰度| RRF_Engine[RRF 混合重排序引擎] RRF_Engine -- BM25_Branch[BM25 检索分支] RRF_Engine -- Vector_Branch[Dense 向量检索分支] BM25_Branch -- Combine[Reciprocal Rank Fusion 融合] Vector_Branch -- Combine end subgraph Phase3[第三阶段: 可观测熔断防线] Combine -- Fallback_Check{向量库 P99 延迟 200ms / 报错} Fallback_Check --|是| Degrade[自动降级为纯 BM25 兜底] Fallback_Check --|否| ReturnResult[返回 Top-K 上下文给 LLM] end ES_Only -- ReturnResult1. 为什么全量一刀切必定遭遇生产翻车很多团队在 Demo 阶段觉得向量检索神乎其神只要输入“适合夏天穿的凉爽衣服”就能召回一堆短袖衬衫。但在真实的生产搜索框里用户输入的 Query 千奇百怪。用户输入“ERR_CONNECTION_REFUSED”向量模型很可能把它映射到“网络连接超时”的泛化概念区召回一堆相关的文档偏偏把包含该精确错误码的排障指南挤出了 Top-3。全文检索在精准词匹配、专有名词、特殊符号上具有不可替代的确定性。向量检索在泛语义匹配、跨语言同义词上表现优异。Hybrid 混合检索的目标就是把两者的得分归一化后重新排序Re-ranking。直接改动核心检索链路风险极高。我们必须通过“影子双跑Shadow Run”➔“RRF 动态融合切流”➔“自动降级熔断”三步走策略完成平滑迁移。2. 第一阶段Shadow Run 影子双跑与数据指纹比对在第一阶段主路流量依然 100% 走原有的 BM25 检索响应直接返回给上层应用不影响任何线上 SLA。与此同时我们在 API 闸门处通过异步 Goroutine 或 Task 队列复制一份 Query 发送给新搭建的向量检索服务。后台服务比对两者的 Top-K 召回集合计算 Jaccard 相似度与 NDCG 指标并将差异写入 Log 监控。这个阶段的核心是积累真实 Query 的评价指标暴露向量数据库的索引构建延迟与 GC 内存抖动。import asyncio import time import structlog from typing import List, Dict, Any logger structlog.get_logger() class BM25SearchEngine: async def search(self, query: str, top_k: int 10) - List[str]: # 模拟 Elasticsearch 精确检索 await asyncio.sleep(0.02) # 20ms return [fdoc_bm25_{i} for i in range(top_k)] class VectorSearchEngine: async def search(self, query: str, top_k: int 10) - List[str]: # 模拟 Milvus/Qdrant 向量检索 await asyncio.sleep(0.04) # 40ms return [fdoc_vector_{i} for i in range(top_k)] class ShadowMigrationRunner: def __init__(self, bm25: BM25SearchEngine, vector: VectorSearchEngine): self.bm25 bm25 self.vector vector async def execute_query_with_shadow(self, query: str) - List[str]: # 主路执行成熟的 BM25 检索必须保证低延迟返回 main_start time.perf_counter() primary_results await self.bm25.search(query, top_k10) main_cost (time.perf_counter() - main_start) * 1000 # 旁路异步触发 Shadow 向量检索不阻塞主路返回 asyncio.create_task(self._run_shadow_analysis(query, primary_results)) logger.info(main_path_success, queryquery, cost_msmain_cost) return primary_results async def _run_shadow_analysis(self, query: str, primary_results: List[str]): try: shadow_start time.perf_counter() shadow_results await self.vector.search(query, top_k10) shadow_cost (time.perf_counter() - shadow_start) * 1000 # 计算两者的召回 Jaccard 重合率 set_p set(primary_results) set_s set(shadow_results) intersection set_p.intersection(set_s) jaccard_score len(intersection) / len(set_p.union(set_s)) if set_p.union(set_s) else 0.0 logger.info( shadow_run_metrics, queryquery, shadow_cost_msshadow_cost, jaccard_scorejaccard_score, common_docslist(intersection) ) except Exception as e: # 旁路异常绝不能影响主路 logger.error(shadow_run_failed, queryquery, errorstr(e))影子双跑跑满一周后如果日志显示向量检索的 P99 延迟稳定在 50ms 以内且在泛化 Query 上的重合率符合预期我们就可以安全进入第二阶段。3. 第二阶段RRF 算法融合与百分比流量灰度进入第二阶段我们需要将两路检索的得分进行融合。因为 BM25 算出的分数如 12.5和向量检索算的余弦相似度如 0.82不在同个数量级直接加权求和效果极差。工业界最常用且最鲁棒的归一化算法是 Reciprocal Rank Fusion (RRF倒数排名融合算法)。RRF 忽略具体的分数值只关注文档在各自列表中的相对排名位置。计算公式非常简单$RRF_Score(d) \sum_{m \in Models} \frac{1}{k r_m(d)}$其中 $k$ 通常取 60$r_m(d)$ 是文档 $d$ 在系统 $m$ 中的排名位置。from typing import List, Dict from collections import defaultdict class HybridRRFReRanker: RRF 混合重排序引擎 def __init__(self, rrf_k: int 60): self.rrf_k rrf_k def combine_results( self, bm25_list: List[str], vector_list: List[str], top_n: int 10 ) - List[Dict[str, Any]]: scores defaultdict(float) doc_sources defaultdict(set) # 累加 BM25 排名得分 for rank, doc_id in enumerate(bm25_list, start1): scores[doc_id] 1.0 / (self.rrf_k rank) doc_sources[doc_id].add(BM25) # 累加 Vector 排名得分 for rank, doc_id in enumerate(vector_list, start1): scores[doc_id] 1.0 / (self.rrf_k rank) doc_sources[doc_id].add(Vector) # 按 RRF 得分降序排列 sorted_docs sorted(scores.items(), keylambda x: x[1], reverseTrue) hybrid_results [] for doc_id, rrf_score in sorted_docs[:top_n]: hybrid_results.append({ doc_id: doc_id, rrf_score: rrf_score, sources: list(doc_sources[doc_id]) }) return hybrid_resultsRRF 引擎部署后可按用户或租户的稳定哈希进行灰度。放量比例和观察窗口由请求量、错误预算与业务风险决定除了反馈和点踩还要比较召回指标、延迟和降级次数。4. 第三阶段高并发下的降级熔断与兜底策略线上的向量数据库如 Milvus、Qdrant在进行全量索引重建或者突发大流量冲击时延迟往往会出现陡峭飙升。如果 RRF 节点死等向量库响应整个 RAG 系统的接口延迟就会同步被拖垮。第三阶段要补上自动降级。向量检索超过该场景的延迟预算或返回连接错误时放弃这一分支并使用 BM25 结果。预算应从现有 SLA 和延迟基线推导不能把示例毫秒数当作统一阈值。降级可能改变召回结果因此要记录触发原因和结果质量并让上层知道当前使用了兜底路径。影子双跑、灰度融合与自动降级分别控制质量、放量和依赖故障三者都需要可观测与可回退。