Skip to content

向量数据库实战(Milvus/Chroma)

从 Embedding 的第一个维度到 RAG 系统的最后一公里——手把手带你玩转向量数据库。


7. 混合检索——当语义搜索遇上关键词搜索

前两章我们分别掌握了 Chroma 和 Milvus 的核心操作。但在实际 RAG 项目中,你很快会遇到一个问题:纯语义搜索有盲区

这一章,我们引入混合检索(Hybrid Search)Reranker 重排序,把检索精度再提升一个台阶。


7.1 语义搜索的盲区

语义搜索很强大,但它不是万能的。有几类场景它会"翻车":

语义搜索翻车案例:

  案例 1:精确术语 / ID 号码
  ─────────────────────────────────────
  用户问:"ERROR_CODE_4032 是什么意思?"
  语义搜索结果:
    → "常见错误代码汇总"(相似度 0.72)
    → "HTTP 状态码 403 详解"(相似度 0.68)   ← 错了!
    → "应用程序错误处理指南"(相似度 0.65)

  问题:
    → 用户要的是精确的 ERROR_CODE_4032
    → 语义搜索把它理解成"错误代码"这个概念
    → 找到了语义相关但不精确的结果

  案例 2:专有名词 / 缩写
  ─────────────────────────────────────
  用户问:"BGE-M3 模型的参数量是多少?"
  语义搜索结果:
    → "大语言模型参数量对比"(相似度 0.75)
    → "BERT 模型架构详解"(相似度 0.70)      ← 不对
    → "如何选择 Embedding 模型"(相似度 0.68)

  问题:
    → BGE-M3 是一个精确的模型名称
    → 语义搜索把它理解成"模型"这个主题
    → 找到了关于"模型"的泛结果,而非 BGE-M3 专属文档

  案例 3:带数字的精确查询
  ─────────────────────────────────────
  用户问:"Python 3.12 新增了什么特性?"
  语义搜索结果:
    → "Python 新特性速览"(相似度 0.82)
    → "Python 3.11 性能优化"(相似度 0.79)   ← 版本不对
    → "Python 3.10 的结构化模式匹配"(0.76)  ← 版本不对

  问题:
    → 语义搜索不擅长区分 3.10 / 3.11 / 3.12
    → 它认为这些都是"Python 新特性",语义高度相似

根本原因:语义搜索(Dense Retrieval)擅长理解"大致意思",但不擅长精确匹配特定的词汇、编号、版本号。而传统的关键词搜索(BM25)恰恰擅长这些。

7.2 Dense + Sparse 混合检索原理

解决方案很直觉——两种搜索方式的优势互补

混合检索 = Dense(语义) + Sparse(关键词)

  Dense Retrieval(密集检索)
  ─────────────────────────────────────
  • 方式:Embedding 向量 + 余弦相似度
  • 擅长:理解语义、同义词匹配、概念关联
  • 不擅长:精确术语、ID、数字

  Sparse Retrieval(稀疏检索)
  ─────────────────────────────────────
  • 方式:BM25 / TF-IDF / 稀疏向量
  • 擅长:精确关键词匹配、术语、ID
  • 不擅长:同义词、概念理解

  混合检索:取两者之长
  ─────────────────────────────────────
  • 用 Dense 找"语义相关"的文档
  • 用 Sparse 找"关键词精确匹配"的文档
  • 合并两组结果,取并集
  • 用加权分数排序

什么是 Sparse Embedding

Dense vs Sparse 向量对比:

  Dense 向量(语义向量):
    "Python 性能优化" → [0.82, 0.15, 0.93, -0.41, ..., 0.23]
                       ↑ 每个维度都有值
                       ↑ 1024 维,全是非零浮点数
                       ↑ 维度没有可解释的含义

  Sparse 向量(稀疏向量):
    "Python 性能优化" → {
        "Python": 2.31,      ← 词汇 ID → 权重
        "性能": 1.87,
        "优化": 1.92,
        ...其他 30000 个维度都是 0
    }
                       ↑ 只有出现的词有值
                       ↑ 30000+ 维,但 99% 是零
                       ↑ 每个维度对应一个词汇

  关键区别:
    Dense:编码语义信息 → "跑得更快" ≈ "性能优化"
    Sparse:编码词汇信息 → "Python 3.12" 只匹配含 "3.12" 的文档

混合检索的融合策略

两组结果怎么合并?——RRF(Reciprocal Rank Fusion)

  Dense 搜索结果(按语义排序):
    排名 1: 文档 A(语义最相关)
    排名 2: 文档 C
    排名 3: 文档 B

  Sparse 搜索结果(按关键词匹配排序):
    排名 1: 文档 B(关键词最匹配)
    排名 2: 文档 A
    排名 3: 文档 D

  RRF 融合公式:
    score(doc) = Σ 1 / (k + rank_i)
    k = 60(常用值)

    文档 A:1/(60+1) + 1/(60+2) = 0.0164 + 0.0161 = 0.0325
    文档 B:1/(60+3) + 1/(60+1) = 0.0159 + 0.0164 = 0.0323
    文档 C:1/(60+2) + 0        = 0.0161
    文档 D:0        + 1/(60+3) = 0.0159

  最终排序:A > B > C > D
  → 同时在两个列表中排名靠前的文档(A)得分最高
  → 只在一个列表中出现的文档(C、D)得分低

核心思想:如果一个文档既"语义相关"又"关键词匹配",它几乎一定是最好的答案。RRF 正是基于这个直觉,让两种搜索互相验证。

7.3 Milvus 混合检索实战

Milvus 从 2.4 版本开始原生支持混合检索。我们来完整实现一个 Dense + Sparse 的混合搜索。

Schema 设计:双向量字段

python
from pymilvus import (
    connections, Collection, CollectionSchema,
    FieldSchema, DataType, AnnSearchRequest,
    RRFRanker, WeightedRanker
)

connections.connect(host="localhost", port="19530")

# 关键:同时包含 Dense 和 Sparse 两个向量字段
fields = [
    FieldSchema(name="id", dtype=DataType.INT64,
                is_primary=True, auto_id=True),
    FieldSchema(name="title", dtype=DataType.VARCHAR, max_length=512),
    FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=2000),

    # Dense 向量:语义搜索用
    FieldSchema(name="dense_embedding", dtype=DataType.FLOAT_VECTOR, dim=1024),

    # Sparse 向量:关键词搜索用
    FieldSchema(name="sparse_embedding", dtype=DataType.SPARSE_FLOAT_VECTOR),
]

schema = CollectionSchema(fields, description="混合检索知识库")
collection = Collection("hybrid_docs", schema)

生成 Dense 和 Sparse 向量

python
from pymilvus.model.hybrid import BGEM3EmbeddingFunction

# BGE-M3 模型可以同时生成 Dense 和 Sparse 向量!
bge_m3 = BGEM3EmbeddingFunction(
    model_name="BAAI/bge-m3",
    use_fp16=False,
    device="cpu"
)

# 准备文档
docs = [
    "Python 3.12 的 per-interpreter GIL 特性详解",
    "Python 异步编程 asyncio 完全指南",
    "ERROR_CODE_4032:数据库连接超时的排查方法",
    "BGE-M3 模型的架构设计与训练方法",
    "如何优化 Python 程序的运行性能",
    "FastAPI 框架中的异步请求处理",
]

# 一次调用同时生成两种向量
embeddings = bge_m3.encode_documents(docs)

print(f"Dense 向量维度:{len(embeddings['dense'][0])}")   # 1024
print(f"Sparse 向量非零元素:{len(embeddings['sparse'][0])}")  # ~50-200

插入数据与建索引

python
# 插入数据
collection.insert([
    [d[:100] for d in docs],       # title(截取前100字)
    docs,                           # content
    embeddings["dense"],            # dense_embedding
    embeddings["sparse"],           # sparse_embedding
])
collection.flush()

# 分别为两个向量字段建索引
# Dense 索引
collection.create_index(
    "dense_embedding",
    {"metric_type": "COSINE", "index_type": "HNSW",
     "params": {"M": 16, "efConstruction": 200}}
)

# Sparse 索引
collection.create_index(
    "sparse_embedding",
    {"metric_type": "IP", "index_type": "SPARSE_INVERTED_INDEX",
     "params": {"drop_ratio_build": 0.2}}  # 丢弃权重最低的 20% 词汇
)

collection.load()

执行混合搜索

python
# 准备查询
query = "BGE-M3 模型的参数量是多少?"
query_embeddings = bge_m3.encode_queries([query])

# 构建两个搜索请求
dense_req = AnnSearchRequest(
    data=query_embeddings["dense"],
    anns_field="dense_embedding",
    param={"metric_type": "COSINE", "params": {"ef": 128}},
    limit=10
)

sparse_req = AnnSearchRequest(
    data=query_embeddings["sparse"],
    anns_field="sparse_embedding",
    param={"metric_type": "IP", "params": {}},
    limit=10
)

# 混合搜索 + RRF 融合
results = collection.hybrid_search(
    reqs=[dense_req, sparse_req],
    ranker=RRFRanker(k=60),          # RRF 融合,k=60
    limit=5,
    output_fields=["title", "content"]
)

# 打印结果
for hit in results[0]:
    print(f"  标题: {hit.entity.get('title')}")
    print(f"  融合分数: {hit.distance:.4f}")
    print(f"  ---")
混合搜索 vs 纯语义搜索的效果对比:

  查询:"BGE-M3 模型的参数量是多少?"

  纯语义搜索 Top-3:
    1. "如何优化 Python 程序的运行性能"     ← ❌ 不相关
    2. "BGE-M3 模型的架构设计与训练方法"    ← ✅ 相关
    3. "Python 异步编程 asyncio 完全指南"   ← ❌ 不相关

  混合搜索 Top-3:
    1. "BGE-M3 模型的架构设计与训练方法"    ← ✅ 精确匹配
    2. "如何优化 Python 程序的运行性能"     ← 语义相关
    3. "ERROR_CODE_4032:数据库连接超时"    ← (被过滤)

  → 混合搜索通过 Sparse 的精确匹配
    把包含 "BGE-M3" 关键词的文档提到了第一位

加权融合:如果你觉得语义搜索更重要,可以用 WeightedRanker(0.7, 0.3) 替代 RRFRanker,给 Dense 搜索 70% 的权重、Sparse 搜索 30% 的权重。

7.4 Reranker 重排序:精度的最后一公里

混合检索已经比纯语义搜索好很多了。但还有一个杀手锏——Reranker(重排序模型)

为什么需要重排序

检索 vs 重排序——两阶段流水线:

  阶段 1:检索(Retrieval)—— 快速筛选
  ─────────────────────────────────────
  • 从百万文档中找出 Top-20 候选
  • 要求:速度快(毫秒级)
  • 方式:向量相似度(ANN 搜索)
  • 精度:还行,但有噪声

  阶段 2:重排序(Reranking)—— 精细排序
  ─────────────────────────────────────
  • 对 Top-20 候选做精确的相关性判断
  • 要求:精度高(可以稍慢)
  • 方式:交叉编码器(Cross-Encoder)
  • 精度:显著提升

  为什么不直接用 Reranker 搜索全量数据?
  → 因为太慢了!
  → Reranker 是逐对比较(query, doc),无法利用索引
  → 100 万条 × 逐一比较 = 几小时
  → 但对 20 条候选做 Rerank = 50ms

Bi-Encoder vs Cross-Encoder

两种模型的区别:

  Bi-Encoder(双编码器,用于检索)
  ─────────────────────────────────────
    Query:    "Python 性能优化"  ──→ [向量 Q]
    Document: "提速 Python 程序" ──→ [向量 D]

    → Query 和 Document 分别独立编码
    → 可以预先计算文档向量
    → 搜索时只需计算 Query 向量 + 向量距离
    → 速度:极快 ✅  精度:中等 ⚠️

  Cross-Encoder(交叉编码器,用于重排序)
  ─────────────────────────────────────
    输入: ["Python 性能优化", "提速 Python 程序"]

          Transformer 联合编码

          相关性分数:0.92

    → Query 和 Document 拼接在一起送入模型
    → 模型能看到两者的交互关系
    → 不能预计算,必须逐对比较
    → 速度:慢 ❌  精度:极高 ✅

Reranker 实战代码

python
# pip install sentence-transformers

from sentence_transformers import CrossEncoder

# 加载重排序模型(中文推荐 bge-reranker)
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3", max_length=512)

# 假设混合检索返回了这些候选文档
query = "BGE-M3 模型的参数量是多少?"
candidates = [
    "BGE-M3 模型的架构设计与训练方法",
    "如何优化 Python 程序的运行性能",
    "Python 异步编程 asyncio 完全指南",
    "大语言模型参数量对比分析",
    "Embedding 模型选型指南",
]

# 构建 query-document 对
pairs = [[query, doc] for doc in candidates]

# 计算相关性分数
scores = reranker.predict(pairs)

# 按分数重排序
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)

for doc, score in ranked:
    print(f"  [{score:.4f}] {doc}")

# 输出:
#   [0.9847] BGE-M3 模型的架构设计与训练方法     ← 🎯 最相关
#   [0.7231] 大语言模型参数量对比分析             ← 也相关
#   [0.3012] Embedding 模型选型指南              ← 有关联
#   [0.0234] 如何优化 Python 程序的运行性能       ← 不相关
#   [0.0089] Python 异步编程 asyncio 完全指南    ← 不相关

完整的两阶段检索流程

python
def hybrid_search_with_rerank(
    query: str,
    collection,
    bge_m3,
    reranker,
    top_k_retrieval: int = 20,
    top_k_final: int = 5,
) -> list[dict]:
    """混合检索 + Reranker 两阶段流水线"""

    # === 阶段 1:混合检索,快速获取候选集 ===
    query_emb = bge_m3.encode_queries([query])

    dense_req = AnnSearchRequest(
        data=query_emb["dense"], anns_field="dense_embedding",
        param={"metric_type": "COSINE", "params": {"ef": 128}},
        limit=top_k_retrieval
    )
    sparse_req = AnnSearchRequest(
        data=query_emb["sparse"], anns_field="sparse_embedding",
        param={"metric_type": "IP", "params": {}},
        limit=top_k_retrieval
    )

    candidates = collection.hybrid_search(
        reqs=[dense_req, sparse_req],
        ranker=RRFRanker(k=60),
        limit=top_k_retrieval,
        output_fields=["title", "content"]
    )

    # === 阶段 2:Reranker 精排 ===
    docs = [hit.entity.get("content") for hit in candidates[0]]
    pairs = [[query, doc] for doc in docs]
    scores = reranker.predict(pairs)

    # 按 Reranker 分数排序,取 Top-K
    results = sorted(
        zip(candidates[0], scores),
        key=lambda x: x[1], reverse=True
    )[:top_k_final]

    return [
        {"title": hit.entity.get("title"),
         "score": float(score),
         "content": hit.entity.get("content")}
        for hit, score in results
    ]

性能参考:20 条候选的 Reranker 处理时间约 30-50ms(CPU)或 5-10ms(GPU)。这个延迟在大多数应用中完全可接受。


本章小结

知识点要点
语义搜索盲区不擅长精确术语、ID 号码、版本号等精确匹配
Dense 检索语义向量搜索,擅长同义词和概念理解
Sparse 检索稀疏向量/BM25,擅长精确关键词匹配
混合检索Dense + Sparse 互补,用 RRF 或加权融合结果
BGE-M3同时生成 Dense 和 Sparse 向量,一步到位
Reranker交叉编码器,对候选集精细重排序,精度最高
两阶段流水线检索(快+粗)→ 重排序(慢+精),工业界标准做法
推荐模型bge-reranker-v2-m3(中文重排序首选)

下一章预告:实战:构建完整的 RAG 知识库问答系统。我们会串联前 7 章的所有知识,从文档解析→分块→向量化→存储→混合检索→Rerank→LLM 生成,构建一个完整可运行的知识库问答应用。

坚持是一种品格