第 7 章 混合检索:RAG 召回率的关键一跃 第 6 章的四种检索各有盲区:语义检索漏掉精确关键词,关键词检索漏掉同义表达。生产级 RAG 的标配,是把它们融合起来——这就是混合检索(Hybrid Search)。本章解决一个核心问题:怎么用 Zvec 的多向量查询 + 重排序,把召回率推到上限。 7.1 为什么单一检索不够:两段失败案例 先看两个真实的 RAG 翻车现场,理解为什么必须融合: 案例 A(纯语义检索翻车):知识库里有"报错码 的处理方法"。用户问"ERR4093 怎么解决"。稠密向量把 当成一串普通字符,和别的错误码"拉得不够开",结果 top-5 里没召回这条——专有标识是语义检索的盲区。 案例 B(纯关键词检索翻车):用户问"怎么退货"。知识库里写的是"商品退款流程"。
第 6 章的四种检索各有盲区:语义检索漏掉精确关键词,关键词检索漏掉同义表达。生产级 RAG 的标配,是把它们融合起来——这就是混合检索(Hybrid Search)。本章解决一个核心问题:怎么用 Zvec 的多向量查询 + 重排序,把召回率推到上限。
先看两个真实的 RAG 翻车现场,理解为什么必须融合:
案例 A(纯语义检索翻车):知识库里有"报错码 ERR_4093 的处理方法"。用户问"ERR_4093 怎么解决"。稠密向量把 ERR_4093 当成一串普通字符,和别的错误码"拉得不够开",结果 top-5 里没召回这条——专有标识是语义检索的盲区。
案例 B(纯关键词检索翻车):用户问"怎么退货"。知识库里写的是"商品退款流程"。关键词检索搜"退货"找不到"退款"——同义表达是关键词检索的盲区。
纯语义检索 纯关键词检索 ┌──────────┐ ┌──────────┐ 专有名词 │ ❌ 漏 │ │ ✅ 准 │ (ERR_4093) │ │ │ │ ├──────────┤ ├──────────┤ 同义表达 │ ✅ 懂 │ │ ❌ 漏 │ (退款/退货) │ │ │ │ └──────────┘ └──────────┘ │ │ └─────► 融合 ◄──────────┘ (混合检索) ✅ 两类都抓住
💡 混合检索的哲学:让每种检索做它擅长的事,再用重排序把多路结果合并。业界经验:语义 + 关键词混合检索,通常比单一方式召回率高 10%~30%,是 RAG 效果提升性价比最高的一招。
Zvec 的混合检索,本质是单次 query() 里传入多条 Query 路线,每条路线检索一个向量空间,再用 reranker(重排序器) 融合。这正是第 6 章那条"单路线内 vector 与 fts 互斥"铁律的解法——要结合,就用多路线。
collection.query(queries=[路线1, 路线2, ...], reranker=融合策略) │ ┌───────────────┼───────────────┐ ▼ ▼ ▼ 路线1: 稠密向量 路线2: 稀疏向量 (路线3: FTS,可选) Query(dense,vec) Query(sparse,vec) │ │ ▼ ▼ 各自召回 topk 候选(每路线独立检索) │ │ └───────┬───────┘ ▼ reranker 融合重排 │ ▼ 返回最终 topn 结果
⚠️
topk和topn是两个不同的数,这是混合检索最容易混淆的点:
topk(在query()里):每条路线各自召回多少候选(喂给 reranker 的原料)。topn(在 reranker 里):融合重排后最终返回多少条。通常
topk > topn——每路线多召回些,让 reranker 有更多原料精选。
reranker 决定怎么把多路线的分数合并。Zvec 提供两种策略,理解它们的区别是选对策略的关键:
| 策略 | 融合依据 | 适用场景 | 参数 |
|---|---|---|---|
| WeightedReRanker | 归一化后的分数加权求和 | 各路分数大致可比、且你知道哪路更重要 | weights(各向量权重)、metric |
| RrfReRanker | 仅看排名位置,不看分数 | 各路度量/尺度不同、想无脑稳健 | rank_constant k |
把每路线的相似度分数归一化后,按你给的权重加权求和。适合你清楚"语义更重要还是关键词更重要"的场景:
result = collection.query( topk=5, # 每路线各召回 5 个候选 queries=[ zvec.Query(field_name="dense", vector=dense_vec), # 语义路线 zvec.Query(field_name="sparse", vector=sparse_vec), # 关键词路线 ], reranker=zvec.WeightedReRanker( topn=3, # 融合后最终返回 3 个 metric=zvec.MetricType.IP, # 用于分数归一化的度量 weights={ "dense": 1.2, # 给语义更高权重(多数 RAG 这样配) "sparse": 1.0, }, ), )
RRF(Reciprocal Rank Fusion)完全不看分数,只看每条结果在各路线里的排名。排名越靠前,贡献越大;公式 RRF(r) = 1 / (k + r + 1),其中 r 是排名、k 是衰减常数。适合各路度量不可比、或你不想调权重的场景:
result = collection.query( topk=5, queries=[ zvec.Query(field_name="dense", vector=dense_vec), zvec.Query(field_name="sparse", vector=sparse_vec), ], reranker=zvec.RrfReRanker( topn=3, rank_constant=60, # k:越大,排名靠前者优势越弱(更"民主") ), )
💡 怎么选? 经验法则:
- 知道哪路更重要 → Weighted(如 RAG 通常语义为主,
dense:1.2, sparse:1.0)。- 各路度量不可比 / 懒得调参 → RRF(稳健、无需权重,是"无脑可用"的默认选择)。
- 很多团队最终选 RRF,正是因为它不依赖分数标定、对异常值不敏感。
理论讲完,来个能跑的真实案例。BGE-M3 是混合检索的理想模型——它一次推理同时输出稠密向量(语义)和稀疏向量(关键词),免去维护两个模型。下面是完整链路:
import zvec from FlagEmbedding import BGEM3FlagModel # ① 加载 bge-m3:一次推理,稠密+稀疏双输出 model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True) def embed_hybrid(texts: list[str]): out = model.encode(texts, return_dense=True, return_sparse=True, return_colbert_vecs=False) dense = out["dense_vecs"].tolist() sparse = [{int(k): float(v) for k, v in d.items()} for d in out["lexical_weights"]] return dense, sparse # ② 建库:稠密(COSINE) + 稀疏(IP) 双向量,覆盖语义+关键词 schema = zvec.CollectionSchema( name="rag_hybrid", fields=[zvec.FieldSchema(name="text", data_type=zvec.DataType.STRING, index_param=zvec.FtsIndexParam(tokenizer_name="jieba")), zvec.FieldSchema(name="source", data_type=zvec.DataType.STRING, index_param=zvec.InvertIndexParam())], vectors=[ zvec.VectorSchema(name="dense", data_type=zvec.DataType.VECTOR_FP32, dimension=1024, index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.COSINE)), zvec.VectorSchema(name="sparse", data_type=zvec.DataType.SPARSE_VECTOR_FP32, index_param=zvec.HnswIndexParam(metric_type=zvec.MetricType.IP)), ], ) kb = zvec.create_and_open(path="./rag_hybrid", schema=schema) # ③ 入库:一次 embed 拿到两套向量,分别写入两个字段 chunks = ["报错码 ERR_4093 表示连接超时,请检查网络。", "商品退款流程:登录后在订单页申请。"] dense_vecs, sparse_vecs = embed_hybrid(chunks) kb.insert([ zvec.Doc(id=f"c{i}", vectors={"dense": dense_vecs[i], "sparse": sparse_vecs[i]}, fields={"text": chunks[i], "source": "faq.md"}) for i in range(len(chunks)) ]) kb.optimize() # ④ 混合检索:稠密+稀疏双路线,RRF 融合 q_dense, q_sparse = embed_hybrid(["ERR_4093 怎么解决?"]) # 注意:query 也用同一模型 hits = kb.query( topk=10, # 每路线各召回 10 queries=[ zvec.Query(field_name="dense", vector=q_dense[0]), zvec.Query(field_name="sparse", vector=q_sparse[0]), ], reranker=zvec.RrfReRanker(topn=5, rank_constant=60), # 融合后取 5 ) for h in hits: print(h.id, round(h.score, 4), h.fields["text"][:40])
💡 bge-m3 的价值:稠密负责抓住"解决/处理/排查"这类语义关联,稀疏负责精确锁定"ERR_4093"这个标识。两者经 RRF 融合后,那条报错处理文档会稳定出现在 top-5——而单用任何一路都可能漏掉它。这就是混合检索的威力。
除了稠密+稀疏双向量路线,你也可以把全文检索(FTS)作为一条独立路线纳入融合。思路完全一样——多路线 + reranker:
from zvec.model.param.query import Fts, Query hits = kb.query( topk=10, queries=[ zvec.Query(field_name="dense", vector=q_dense[0]), # 语义路线 zvec.Query(field_name="content", fts=Fts(match_string="ERR_4093")), # 关键词路线(FTS) ], reranker=zvec.RrfReRanker(topn=5, rank_constant=60), )
⚠️ 注意区分稀疏向量路线和 FTS 路线:稀疏向量需要一个稀疏 Embedding 模型(如 bge-m3 稀疏头)生成查询向量;FTS 路线直接传文本字符串(
Fts(match_string=...)),零模型成本。轻量场景用 FTS 路线更省事;追求召回上限用稀疏向量路线(能学到更智能的词项权重)。
| 调优点 | 怎么做 | 影响 |
|---|---|---|
| 每路线 topk | 调大 query() 的 topk |
给 reranker 更多原料,可能提质量但增加成本 |
| 最终 topn | 调 reranker 的 topn |
控制最终喂给 LLM 的段数(RAG 通常 3~8) |
| Weighted 权重 | 调 weights 字典 |
知道哪路更重要时微调(如语义 1.2 / 关键词 1.0) |
| RRF 的 k | 调 rank_constant |
k 大则排名靠前者优势弱(更民主),k 小则头部优势强 |
| 每路线的索引参数 | 各 Query 的 param |
如语义路线调 ef、稀疏路线也可调 |
💡 RAG 混合检索推荐起步配置:
topk=10~20(每路线)、topn=5、reranker 用 RRF(rank_constant=60)。跑通后再根据召回/延迟细调。先用 RRF 兜底,效果不够再上 Weighted 精调。
WeightedReRanker(dense:1.2/sparse:1.0)和 RrfReRanker,对比最终 top-5 的差异,体会两种策略的风格。topk 从 5 调到 20,观察最终 topn=5 的结果稳定性变化——体会"原料充足度"对融合质量的影响。Query 路线 + reranker 融合;topk 是每路线候选数,topn 是最终返回数,二者不同。topk=10~20 / topn=5 / RRF rank_constant=60。检索讲完了,接下来是怎么把这些能力工程化、稳定上线。详见第 8 章。