混合检索:向量检索 + 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 帮你用数据说话。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐