混合检索:向量检索 + 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.yml
version: "3.8"
services:
milvus:
image: milvusdb/milvus:v2.4-latest
ports:
- "19530:19530"
- "9091:9091"
environment:
ETCD_USE_EMBED: "true"
COMMON_STORAGETYPE: local
volumes:
- milvus_data:/var/lib/milvus
elasticsearch:
image: elasticsearch:8.13.0
ports:
- "9200:9200"
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
volumes:
- es_data:/usr/share/elasticsearch/data
volumes:
milvus_data:
es_data:
docker compose up -d
3.3 安装 Python 依赖
pip install pymilvus 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)
from pymilvus import MilvusClient, DataType
from sentence_transformers import SentenceTransformer
# 初始化
model = SentenceTransformer("BAAI/bge-m3")
client = MilvusClient(uri="http://localhost:19530")
# 创建 Collection
if client.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)
# 插入数据
for doc in documents:
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)
from elasticsearch import Elasticsearch
es = Elasticsearch("http://localhost:9200")
# 创建索引(使用 ik 分词器,中文效果更好;英文可用 standard)
index_name = "hybrid_demo"
if es.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)
# 插入数据
for doc in documents:
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 向量检索
def vector_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 = []
for hit in results[0]:
ranked.append({
"id": hit["id"],
"score": hit["distance"],
"title": hit["entity"]["title"],
"content": hit["entity"]["content"],
"source": "vector"
})
return ranked
5.2 BM25 检索
def bm25_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 = []
for hit in resp["hits"]["hits"]:
ranked.append({
"id": hit["_id"],
"score": hit["_score"],
"title": hit["_source"]["title"],
"content": hit["_source"]["content"],
"source": "bm25"
})
return ranked
5.3 RRF 融合排序
def reciprocal_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 = {}
# 向量检索排名贡献
for rank, item in enumerate(vector_results, start=1):
doc_id = item["id"]
if doc_id not in rrf_scores:
rrf_scores[doc_id] = {"item": item, "score": 0}
rrf_scores[doc_id]["score"] += 1.0 / (k + rank)
# BM25 检索排名贡献
for rank, item in enumerate(bm25_results, start=1):
doc_id = item["id"]
if doc_id not in rrf_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=lambda x: x["score"], reverse=True
)
return [
{**r["item"], "rrf_score": r["score"]}
for r in sorted_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 运行完整测试
def hybrid_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)
return reciprocal_rank_fusion(vec_results, bm25_results)
# 批量测试
for q in test_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 快速实现
from langchain_community.retrievers import BM25Retriever
from langchain_milvus import Milvus
from langchain.schema import Document
from langchain.retrievers import EnsembleRetriever
# 构建 LangChain 文档
lc_docs = [
Document(page_content=d["content"], metadata={"title": d["title"], "id": d["id"]})
for d in documents
]
# 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
)
# 混合 Retriever
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, vector_retriever],
weights=[0.4, 0.6], # BM25 40%,向量 60%
c=60 # RRF 平滑参数
)
# 使用
results = ensemble_retriever.invoke("Ollama 怎么安装?")
for doc in results[: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
from pymilvus import AnnSearchRequest, 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 帮你用数据说话。
更多推荐


所有评论(0)