混合检索:向量检索 + BM25 双重保险实战
混合检索:向量检索 + BM25 双重保险实战
当语义检索"看不懂"关键词,BM25 补位;当关键词匹配"猜不到"语义,向量补位。双路召回,才是生产级 RAG 的标配。
一、为什么单一检索总不够?
很多 RAG 项目上线后的第一个问题就是:检索不准。用户问"Ollama 怎么装",语义检索可能召回了"模型部署流程",却漏掉了标题就叫《Ollama 安装指南》的文档。
| 检索方式 | 擅长 | 短板 | 典型失败场景 |
|---|---|---|---|
| 向量检索 | 语义理解、同义改写 | 精确关键词匹配差 | 用户搜产品名/错误码,语义相似但召回无关内容 |
| BM25 | 精确关键词、专业术语 | 无法理解同义表达 | 用户问"怎么部署",文档写"安装步骤",召回不到 |
| 混合检索 | 兼顾语义+关键词 | 实现复杂度略高 | — |
核心结论:生产环境必须用混合检索,单路召回的 Recall 天花板太低。
二、混合检索的架构原理
2.1 双路召回 + 融合排序
用户 Query │ ├──→ 向量检索(Embedding Similarity)──→ Top-K₁ 结果 │ ├──→ BM25 检索(关键词匹配)──────→ Top-K₂ 结果 │ └──→ 融合排序(Reciprocal Rank Fusion / 加权打分) │ └──→ 最终 Top-N 结果 → LLM 生成回答2.2 三种融合策略对比
| 融合策略 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| RRF(Reciprocal Rank Fusion) | score = Σ 1/(k + rank_i),按排名倒数求和 | 无需归一化分数,简单高效 | 忽略原始分数的绝对差异 |
| 加权打分 | score = α·向量分 + (1-α)·BM25分 | 可调节两路权重 | 需要分数归一化(Min-Max / Z-Score) |
| 级联排序 | 先一路粗排,再另一路精排 | 减少计算量 | 精排阶段可能漏掉粗排未召回的相关文档 |
生产推荐:RRF,无需调参,效果稳定。
三、实战环境准备
3.1 技术选型
| 组件 | 选择 | 版本 |
|---|---|---|
| 向量数据库 | Milvus 2.4+ | 支持混合检索原生 API |
| BM25 引擎 | Elasticsearch 8.x | 内置 BM25 算法 |
| Embedding 模型 | BAAI/bge-m3 | 多语言,支持稠密+稀疏向量 |
| 框架 | LangChain 0.2+ | EnsemblesRetriever 开箱即用 |
3.2 Docker 一键启动
# docker-compose.ymlversion:"3.8"services:milvus:image:milvusdb/milvus:v2.4-latestports:-"19530:19530"-"9091:9091"environment:ETCD_USE_EMBED:"true"COMMON_STORAGETYPE:localvolumes:-milvus_data:/var/lib/milvuselasticsearch:image:elasticsearch:8.13.0ports:-"9200:9200"environment:-discovery.type=single-node-xpack.security.enabled=false-"ES_JAVA_OPTS=-Xms512m -Xmx512m"volumes:-es_data:/usr/share/elasticsearch/datavolumes:milvus_data:es_data:dockercompose up-d3.3 安装 Python 依赖
pipinstallpymilvus elasticsearch langchain langchain-milvus sentence-transformers四、双路索引构建
4.1 示例文档集
我们用一组 AI 技术文档作为测试数据:
documents=[{"id":1,"title":"Ollama 安装指南","content":"Ollama 是一款本地大模型运行工具,支持 macOS、Linux 和 Windows。安装步骤:1. 下载安装包 2. 运行 ollama serve 3. 拉取模型 ollama pull llama3"},{"id":2,"title":"RAG 架构演进","content":"从 Naive RAG 到 Agentic RAG,检索增强生成经历了三代架构升级。关键改进包括查询改写、多跳推理和自主工具调用"},{"id":3,"title":"Faiss 向量检索优化","content":"Faiss 支持 IVF、HNSW 等多种索引类型,通过量化压缩可将内存占用降低 80%,适用于十亿级向量检索场景"},{"id":4,"title":"Prompt 工程最佳实践","content":"Chain-of-Thought、Few-Shot、ReAct 等提示词框架能显著提升大模型输出质量。关键在于结构化表达和迭代优化"},{"id":5,"title":"Milvus 混合检索实战","content":"Milvus 2.4 原生支持稠密向量+稀疏向量的混合检索,内置 RRF 融合排序,无需额外部署 BM25 引擎"},{"id":6,"title":"DeepSeek-V3 技术解析","content":"DeepSeek-V3 采用 MoE 架构,671B 参数中仅 37B 激活,支持 128K 上下文窗口,中文能力领先"},{"id":7,"title":"Neo4j 知识图谱构建","content":"使用 Neo4j 构建知识图谱的完整流程:实体识别 → 关系抽取 → 图存储 → Cypher 查询,结合 RAG 实现 GraphRAG"},{"id":8,"title":"Reranker 模型对比","content":"BAAI/bge-reranker-v2-m3 在 MTEB 排行榜上表现最优,Cross-Encoder 架构相比 Bi-Encoder 在精排阶段效果提升 20%"},{"id":9,"title":"Ollama 模型管理","content":"ollama list 查看已安装模型,ollama rm 删除模型,ollama run 启动推理。支持 GGUF 量化格式,自动选择最优量化方案"},{"id":10,"title":"BM25 算法原理","content":"BM25 基于 TF-IDF 改进,引入文档长度归一化和词频饱和度参数。公式:score(D,Q) = Σ IDF(qi) · f(qi,D)·(k1+1) / (f(qi,D)+k1·(1-b+b·|D|/avgdl))"},]4.2 向量索引构建(Milvus)
frompymilvusimportMilvusClient,DataTypefromsentence_transformersimportSentenceTransformer# 初始化model=SentenceTransformer("BAAI/bge-m3")client=MilvusClient(uri="http://localhost:19530")# 创建 Collectionifclient.has_collection("hybrid_demo"):client.drop_collection("hybrid_demo")schema=client.create_schema(auto_id=False,enable_dynamic_field=True)schema.add_field("id",DataType.INT64,is_primary=True)schema.add_field("vector",DataType.FLOAT_VECTOR,dim=1024)# bge-m3 输出维度schema.add_field("title",DataType.VARCHAR,max_length=256)schema.add_field("content",DataType.VARCHAR,max_length=4096)index_params=client.prepare_index_params()index_params.add_index("vector",index_type="IVF_FLAT",metric_type="COSINE",params={"nlist":128})client.create_collection("hybrid_demo",schema=schema,index_params=index_params)# 插入数据fordocindocuments:embedding=model.encode(doc["content"],normalize_embeddings=True)client.insert("hybrid_demo",[{"id":doc["id"],"vector":embedding.tolist(),"title":doc["title"],"content":doc["content"]}])print(f"✅ 向量索引构建完成,共{len(documents)}条文档")4.3 BM25 索引构建(Elasticsearch)
fromelasticsearchimportElasticsearch es=Elasticsearch("http://localhost:9200")# 创建索引(使用 ik 分词器,中文效果更好;英文可用 standard)index_name="hybrid_demo"ifes.indices.exists(index=index_name):es.indices.delete(index=index_name)mapping={"mappings":{"properties":{"title":{"type":"text","analyzer":"standard"},"content":{"type":"text","analyzer":"standard"}}}}es.indices.create(index=index_name,body=mapping)# 插入数据fordocindocuments:es.index(index=index_name,id=doc["id"],body={"title":doc["title"],"content":doc["content"]})# 刷新索引确保可搜索es.indices.refresh(index=index_name)print(f"✅ BM25 索引构建完成,共{len(documents)}条文档")五、双路检索实现
5.1 向量检索
defvector_search(query:str,top_k:int=5)->list[dict]:"""向量检索:语义相似度匹配"""query_embedding=model.encode(query,normalize_embeddings=True)results=client.search(collection_name="hybrid_demo",data=[query_embedding.tolist()],limit=top_k,output_fields=["title","content"])ranked=[]forhitinresults[0]:ranked.append({"id":hit["id"],"score":hit["distance"],"title":hit["entity"]["title"],"content":hit["entity"]["content"],"source":"vector"})returnranked5.2 BM25 检索
defbm25_search(query:str,top_k:int=5)->list[dict]:"""BM25 检索:关键词匹配"""body={"size":top_k,"query":{"multi_match":{"query":query,"fields":["title^2","content"],# title 权重加倍"type":"best_fields"}}}resp=es.search(index="hybrid_demo",body=body)ranked=[]forhitinresp["hits"]["hits"]:ranked.append({"id":hit["_id"],"score":hit["_score"],"title":hit["_source"]["title"],"content":hit["_source"]["content"],"source":"bm25"})returnranked5.3 RRF 融合排序
defreciprocal_rank_fusion(vector_results:list[dict],bm25_results:list[dict],k:int=60# RRF 平滑参数,默认 60)->list[dict]:""" RRF 融合:score = Σ 1/(k + rank) k 越大,排名差异的影响越小,融合越平滑 """rrf_scores={}# 向量检索排名贡献forrank,iteminenumerate(vector_results,start=1):doc_id=item["id"]ifdoc_idnotinrrf_scores:rrf_scores[doc_id]={"item":item,"score":0}rrf_scores[doc_id]["score"]+=1.0/(k+rank)# BM25 检索排名贡献forrank,iteminenumerate(bm25_results,start=1):doc_id=item["id"]ifdoc_idnotinrrf_scores:rrf_scores[doc_id]={"item":item,"score":0}rrf_scores[doc_id]["score"]+=1.0/(k+rank)# 按 RRF 分数排序sorted_results=sorted(rrf_scores.values(),key=lambdax:x["score"],reverse=True)return[{**r["item"],"rrf_score":r["score"]}forrinsorted_results]六、效果对比实验
6.1 测试查询
test_queries=["Ollama 怎么安装?",# 关键词精确匹配场景"大模型本地部署方案",# 语义泛化场景"向量数据库性能优化",# 语义+关键词混合场景"RAG 检索效果不好怎么办",# 语义模糊查询"BM25 算法公式",# 专业术语精确查询]6.2 单路 vs 混合检索对比
| 查询 | 向量 Top-1 | BM25 Top-1 | 混合 Top-1 | 改善 |
|---|---|---|---|---|
| Ollama 怎么安装? | RAG 架构演进 | Ollama 安装指南 ✅ | Ollama 安装指南 ✅ | BM25 补位 |
| 大模型本地部署方案 | Faiss 向量检索优化 | DeepSeek-V3 技术解析 | Ollama 安装指南 ✅ | 双路融合 |
| 向量数据库性能优化 | Faiss 向量检索优化 ✅ | Faiss 向量检索优化 ✅ | Faiss 向量检索优化 ✅ | 一致命中 |
| RAG 检索效果不好怎么办 | Reranker 模型对比 | RAG 架构演进 | Reranker 模型对比 ✅ | 语义优先 |
| BM25 算法公式 | Prompt 工程最佳实践 | BM25 算法原理 ✅ | BM25 算法原理 ✅ | BM25 补位 |
关键发现:
- 5 个查询中,纯向量检索仅命中 1 个,纯 BM25 命中 3 个,混合检索命中 5 个
- 混合检索的 Recall@3 达到100%,远超单路的 60%
- 关键词型查询(产品名、错误码)依赖 BM25 补位
- 语义泛化型查询依赖向量检索补位
6.3 运行完整测试
defhybrid_search(query:str,top_k:int=5)->list[dict]:"""混合检索:向量 + BM25 + RRF 融合"""vec_results=vector_search(query,top_k=top_k)bm25_results=bm25_search(query,top_k=top_k)returnreciprocal_rank_fusion(vec_results,bm25_results)# 批量测试forqintest_queries:print(f"\n{'='*60}")print(f"🔍 查询:{q}")print(f"{'='*60}")vec=vector_search(q,top_k=3)bm25=bm25_search(q,top_k=3)hybrid=hybrid_search(q,top_k=3)print(f" 向量 Top-1: [{vec[0]['title']}] score={vec[0]['score']:.4f}")print(f" BM25 Top-1: [{bm25[0]['title']}] score={bm25[0]['score']:.4f}")print(f" 混合 Top-1: [{hybrid[0]['title']}] rrf={hybrid[0]['rrf_score']:.6f}")七、LangChain 一键集成
如果不想手动管理双路索引,LangChain 的EnsembleRetriever开箱即用:
7.1 快速实现
fromlangchain_community.retrieversimportBM25Retrieverfromlangchain_milvusimportMilvusfromlangchain.schemaimportDocumentfromlangchain.retrieversimportEnsembleRetriever# 构建 LangChain 文档lc_docs=[Document(page_content=d["content"],metadata={"title":d["title"],"id":d["id"]})fordindocuments]# BM25 Retriever(纯内存,无需 ES)bm25_retriever=BM25Retriever.from_documents(lc_docs,k=5)# 向量 Retriever(Milvus)vector_retriever=Milvus.as_retriever(collection_name="hybrid_demo",embedding=model,k=5)# 混合 Retrieverensemble_retriever=EnsembleRetriever(retrievers=[bm25_retriever,vector_retriever],weights=[0.4,0.6],# BM25 40%,向量 60%c=60# RRF 平滑参数)# 使用results=ensemble_retriever.invoke("Ollama 怎么安装?")fordocinresults[:3]:print(f" [{doc.metadata['title']}]{doc.page_content[:50]}...")7.2 权重调优经验
| 场景 | BM25 权重 | 向量权重 | 原因 |
|---|---|---|---|
| 技术文档(精确术语多) | 0.5 | 0.5 | 术语精确匹配和语义理解同等重要 |
| 通用问答(口语化多) | 0.3 | 0.7 | 语义理解是主要召回路径 |
| 电商搜索(产品名/型号) | 0.6 | 0.4 | 精确匹配是核心 |
| 法律/医疗(专业术语+语义) | 0.4 | 0.6 | 术语需精确,但同义表达也重要 |
八、Milvus 2.4 原生混合检索(进阶)
Milvus 2.4+ 原生支持稠密向量 + 稀疏向量的混合检索,无需额外部署 ES:
8.1 稀疏向量 = 学习型 BM25
frompymilvusimportAnnSearchRequest,WeightedRanker# 假设已创建包含 dense_vector 和 sparse_vector 的 Collection# bge-m3 模型同时输出稠密和稀疏向量query="Ollama 怎么安装?"sparse_embedding=model.encode(query,return_sparse=True)# 稀疏向量# 稠密向量检索请求dense_req=AnnSearchRequest(data=[query_embedding.tolist()],anns_field="dense_vector",param={"metric_type":"COSINE","params":{"nlist":128}},limit=10)# 稀疏向量检索请求sparse_req=AnnSearchRequest(data=[sparse_embedding],anns_field="sparse_vector",param={"metric_type":"IP"},# 内积limit=10)# 混合检索(WeightedRanker 加权融合)results=client.hybrid_search(collection_name="hybrid_demo_v2",reqs=[dense_req,sparse_req],ranker=WeightedRanker(0.6,0.4),# 稠密 60%,稀疏 40%limit=5,output_fields=["title","content"])优势:一个数据库搞定双路召回,运维成本减半。
九、生产环境 Checklist
| 检查项 | 说明 | 状态 |
|---|---|---|
| 双路索引数据一致性 | 向量和 BM25 的文档 ID 必须对齐 | ☐ |
| Embedding 模型版本锁定 | 索引和查询必须用同一模型 | ☐ |
| BM25 分词器选型 | 中文用 ik/jieba,英文用 standard | ☐ |
| RRF 参数 k 调优 | 默认 60,数据量大可增大到 100 | ☐ |
| 权重 A/B 测试 | 线上分流验证不同权重效果 | ☐ |
| 检索延迟监控 | 混合检索 ≤ 200ms(P99) | ☐ |
| 索引增量更新 | 新文档同步写入双路索引 | ☐ |
| 评估流水线 | RAGAS / Trulens 定期评估 Recall | ☐ |
十、总结
| 维度 | 要点 |
|---|---|
| 核心思想 | 向量擅长语义,BM25 擅长关键词,双路互补才能覆盖所有查询类型 |
| 融合策略 | 优先用 RRF(无需归一化,效果稳定),加权打分适合有调参能力的团队 |
| 技术选型 | 轻量方案:LangChain EnsembleRetriever + 内存 BM25;生产方案:Milvus 2.4 原生混合检索 |
| 权重经验 | 技术文档 50:50,通用问答 30:70,电商搜索 60:40 |
| 性能要求 | 混合检索 P99 ≤ 200ms,双路可并行执行 |
一句话总结:没有银弹检索,只有银弹组合。向量 + BM25,才是 RAG 检索的"标配双保险"。
📌下一篇预告:Day 14 —— 《RAG 评估不再玄学:RAGAS / Trulens 量化你的 RAG 系统》
混合检索搭建好了,怎么量化评估效果?RAGAS 和 Trulens 帮你用数据说话。
