只靠向量检索的 RAG 有一个结构性弱点:语义相近不等于字面匹配。用户搜“怎么退订会员”,向量检索能抓到“取消订阅”的语义文档,却常常漏掉只在正文里出现“退订 扣费 别再续”这种字面表述的条款。反过来,关键词检索(BM25)擅长精确字面命中,却对“用户说的”与“文档写的”之间的语义鸿沟无能为力。混合检索(Hybrid Search) 把两者的优势叠加:稀疏检索保字面精确,稠密检索保语义泛化,再用融合算法把它们合成一个有序列表。本指南系统覆盖稀疏与稠密检索的原理、RRF 融合算法、重排器、查询改写与混合检索质量评估,给出可落地的完整实践。
一、稀疏检索与稠密检索的本质
1.1 两种检索的互补性
| 维度 | 稀疏检索(BM25) | 稠密检索(向量) |
|---|---|---|
| 原理 | 词项频率 + 逆文档频率 | embedding 空间余弦相似 |
| 匹配 | 字面精确匹配 | 语义近似匹配 |
| 依赖 | 无模型,纯统计 | 需要 embedding 模型 |
| 优势 | 精确词命中、零训练、可解释 | 同义改写、跨语言、语义泛化 |
| 弱点 | 词汇鸿沟、对拼写敏感 | 字面词被忽略、术语缺失时弱 |
| 延迟 | 快(倒排索引) | 中(ANN 检索) |
| 成本 | 低 | 中(embedding 计算) |
ℹ️ 核心洞察:稀疏与稠密不是竞争关系,而是互补关系——它们检索到的“好结果集”重合度低(通常在 20-40%),融合后的召回显著大于任一种单独召回。
1.2 BM25 的核心公式
BM25 的分数由词频、文档长度归一化与逆文档频率三部分组成:
score(D, Q) = Σ IDF(qi) × (tf(qi, D) × (k1 + 1))
─────────────────────────────
tf(qi, D) + k1 × (1 - b + b × |D| / avgdl)
IDF(qi) = ln( (N - n(qi) + 0.5) / (n(qi) + 0.5) + 1 )
| 参数 | 作用 | 默认值 |
|---|---|---|
| k1 | 词频饱和:高频词边际收益递减 | 1.2-2.0 |
| b | 文档长度惩罚强度 | 0.75 |
| avgdl | 平均文档长度 | 统计得到 |
# bm25_score.py — BM25 分数计算(示意)
import math
def bm25_score(tf, dl, avgdl, n_docs, df, k1=1.5, b=0.75) -> float:
"""单词项 BM25 分数:词频饱和 + 长度归一化。"""
idf = math.log((n_docs - df + 0.5) / (df + 0.5) + 1)
tf_norm = tf * (k1 + 1) / (tf + k1 * (1 - b + b * dl / avgdl))
return idf * tf_norm
1.3 向量检索的两种形态
稠密检索有两种实现:纯 embedding 相似度与 向量 + 元数据过滤:
| 形态 | 实现 | 适用 |
|---|---|---|
| 纯相似度 | 全库 ANN 检索 Top-K | 小库、无过滤需求 |
| 预过滤 | 先按元数据过滤再向量检索 | 按分类/权限/时间筛选 |
| 后过滤 | 先向量检索再按元数据过滤 | 高召回、可容忍少量无效 |
# vector_search.py — 向量检索
def vector_search(query_emb, index, top_k=10, filters=None) -> list[dict]:
"""ANN 检索 + 可选元数据过滤。"""
hits = index.search(query_emb, top_k=top_k * 3) # 多召回再过滤
if filters:
hits = [h for h in hits if match_filters(h["metadata"], filters)]
return hits[:top_k]
二、混合检索的架构
2.1 并行混合的两种形态
| 形态 | 流程 | 优点 | 缺点 |
|---|---|---|---|
| 串行混合 | 先用向量粗召回,再对结果做字面重排 | 少一套 BM25 索引 | 漏掉纯字面独有文档 |
| 并行混合 | BM25 与向量各召回,融合 | 召回最大化 | 需融合算法 |
生产上推荐并行混合:两条召回通道互不干扰,召回集的并集更大。
query
├──▶ BM25 检索(倒排索引) ──▶ 稀疏结果列表
│ │
└──▶ 向量检索(ANN 索引) ──▶ 稠密结果列表
│
▼
融合(RRF / 加权)
│
▼
重排器(Cross-Encoder)
│
▼
Top-K 送入 LLM 上下文
2.2 混合检索的代码骨架
# hybrid_search.py — 并行混合检索骨架
class HybridRetriever:
def __init__(self, bm25_index, vector_index, embed_fn):
self.bm25 = bm25_index
self.vector = vector_index
self.embed = embed_fn
def retrieve(self, query: str, top_k: int = 10) -> list[dict]:
# 1. 两条通道并行召回
sparse_hits = self.bm25.search(query, top_k=top_k * 2)
dense_hits = self.vector.search(self.embed(query), top_k=top_k * 2)
# 2. 融合成一个列表(RRF / 加权)
fused = fuse(sparse_hits, dense_hits, top_k=top_k)
# 3. 可选:重排器精排
if self.reranker:
fused = self.reranker.rerank(query, fused, top_k=top_k)
return fused
2.3 两个索引的构建
混合检索需要同时维护两套索引,构建流程要并行:
def build_hybrid_index(documents: list[dict]):
"""同一批文档构建 BM25 倒排与向量索引。"""
# BM25 索引(ES / OpenSearch / 自建)
for doc in documents:
bm25_index.add_document(doc["id"], doc["text"], doc["metadata"])
# 向量索引(FAISS / Qdrant / pgvector)
embeddings = embed_batch([d["text"] for d in documents])
vector_index.add_vectors(
ids=[d["id"] for d in documents],
vectors=embeddings,
metadatas=[d["metadata"] for d in documents],
)
三、RRF 融合:简单而有效的排序合成
3.1 RRF 的公式
倒数排名融合(Reciprocal Rank Fusion) 用各列表的排名倒数求和,不依赖分数可比较性——这正是它适合混合检索的原因:BM25 分数与余弦相似度量纲完全不同,无法直接相加。
RRF_score(D) = Σ 1 / (k + rank_i(D))
k 为平滑常数(经验值 60)
rank_i(D) 为文档 D 在第 i 个列表中的排名
# rrf.py — 倒数排名融合
def rrf_fuse(lists: list[list[dict]], k=60, top_k=20) -> list[dict]:
"""对多个召回列表做 RRF 融合。"""
scores = {}
for ranked in lists:
for rank, doc in enumerate(ranked, start=1):
doc_id = doc["id"]
scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank)
# 保留分数与元数据引用
meta[doc_id] = doc
# 按融合分降序
ordered = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [meta[doc_id] | {"rrf_score": score}
for doc_id, score in ordered[:top_k]]
ℹ️ 核心洞察:RRF 的核心价值在于只用排名、不用分数——两个检索器的分数是否可比、是否校准都不重要,只有“谁排在前”起作用。这极大简化了混合检索的工程。
3.2 加权融合的变体
RRF 对两条通道等权。若要强调某通道,可引入加权 RRF:
def weighted_rrf(lists, weights, k=60, top_k=20) -> list[dict]:
"""加权 RRF:按通道重要性给权重。"""
scores = {}
for weight, ranked in zip(weights, lists):
for rank, doc in enumerate(ranked, start=1):
doc_id = doc["id"]
scores[doc_id] = scores.get(doc_id, 0) + weight / (k + rank)
ordered = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [doc_id for doc_id, _ in ordered[:top_k]]
| 融合方法 | 需要分数可比 | 可调权重 | 实现复杂度 |
|---|---|---|---|
| RRF | 否 | 无(等权) | 低 |
| 加权 RRF | 否 | 有 | 低 |
| 分数归一化加权 | 是 | 有 | 中 |
| 学习排序(LTR) | 是 | 自动学 | 高 |
融合不是终点——要验证融合确实优于单通道:在同一批标注查询上分别测混合、稀疏、稠密的 MAP,若混合未显著优于两者,说明通道配置或权重需要调整。
四、重排器:把 Top-K 变成真正相关的
4.1 为什么需要重排
双通道召回出的 Top-K 是“各自视角的 Top-K”,融合后仍可能有噪声。重排器(Reranker) 用更强的模型对候选重新打分,是质量提升最陡的一步:
| 技术 | 原理 | 质量 | 延迟 |
|---|---|---|---|
| BM25 自身 | 词频统计 | 基线 | 快 |
| Bi-Encoder 相似度 | 各自编码再点积 | 中 | 快 |
| Cross-Encoder | 拼接 query+doc 联合编码 | 高 | 慢(需逐条过模型) |
| LLM 排序 | 让 LLM 逐对判断 | 最高 | 很慢 |
# rerank.py — Cross-Encoder 重排
def rerank(query: str, candidates: list[dict], top_k=5) -> list[dict]:
"""用 Cross-Encoder 对候选精排,只对少量候选执行。"""
scores = cross_encoder.score(
[(query, c["text"]) for c in candidates])
ranked = sorted(zip(candidates, scores),
key=lambda x: x[1], reverse=True)
return [c for c, s in ranked[:top_k]]
4.2 重排的成本控制
Cross-Encoder 很贵,必须控制候选规模。经验法则是:先粗召回 50-100 条,重排器只精排这部分,输出 Top 5-10:
def two_stage_retrieve(query, retriever, reranker,
recall_k=50, final_k=5) -> list[dict]:
"""两阶段:粗召回 50 → 重排精排 5。"""
recall = retriever.retrieve(query, top_k=recall_k)
return reranker.rerank(query, recall, top_k=final_k)
4.3 重排器与融合的配合
重排可以放在融合之后(对融合结果精排),也可以替换融合(每条候选都重排,直接按重排分数排序)。后者质量最高但更贵:
| 方案 | 流程 | 成本 | 质量 |
|---|---|---|---|
| 融合即最终排序 | RRF 直接出序 | 最低 | 中 |
| 融合 + 重排 | RRF → Cross-Encoder | 中 | 高 |
| 全量重排 | 双通道并集全重排 | 高 | 最高 |
一句话:工程上最常用“融合 + 重排”——RRF 快速出候选,重排器精排,质量与成本的平衡点最好。
五、查询改写:跨越词汇鸿沟
5.1 查询改写解决的问题
用户口语化查询与文档术语之间的鸿沟,重排器能缓解但治标。查询改写(Query Rewrite) 在检索前把查询翻译成“文档更可能写成的样子”:
| 改写类型 | 例子 | 作用 |
|---|---|---|
| 同义扩展 | 退订 → 取消订阅 | 桥接词汇鸿沟 |
| 指代消解 | 它 → 会员到期 | 补全上下文 |
| 反问句转陈述 | 能退款吗 → 退款政策 | 适配文档表达 |
| 多路改写 | 生成 N 个查询变体 | 覆盖不同表述 |
5.2 多路查询改写 + 多路召回
最常见的工程化方案:用 LLM 生成多个改写,分别召回再融合:
# query_rewrite.py — 多路查询改写
def rewrite_queries(query: str, n_variants=3, llm=None) -> list[str]:
"""LLM 生成多个改写版本。"""
prompt = (
f"把用户问题改写为 {n_variants} 个更利于检索的表述,"
f"保留原意,覆盖不同用词。原问题:{query}"
)
return llm(prompt) # ["退订会员如何操作", "取消自动续费入口", "会员扣费停止办法"]
def multiway_retrieve(query: str, hybrid, variants_fn, top_k=10):
"""多路改写 + 每路混合检索 + 汇总融合。"""
queries = [query] + variants_fn(query)
all_lists = [hybrid.retrieve(q, top_k=top_k * 2) for q in queries]
return rrf_fuse(all_lists, top_k=top_k)
改写可能引入噪声,上线前要评估改写是否值得:对同一批标注查询对比“改写前”与“改写后”的检索 MAP,改写后的 MAP 提升达到阈值(如 5%)才启用改写。
六、混合检索的质量评估
6.1 检索评估指标
混合检索组件各自的质量用检索指标评估,区别于最终 RAG 的生成指标:
| 指标 | 含义 | 场景 |
|---|---|---|
| Recall@K | K 个结果里命中相关文档的比例 | 召回完整性 |
| Precision@K | K 个结果里相关文档的比例 | 精度 |
| MAP | 平均精度均值 | 综合排序质量 |
| NDCG | 折损累计增益 | 关注排序位置 |
| MRR | 首个相关结果的倒数排名 | 单答案场景 |
# retrieval_eval.py — 检索指标计算
def recall_at_k(retrieved_ids, relevant_ids, k) -> float:
"""Recall@K:Top-K 中相关文档占比。"""
hit = set(retrieved_ids[:k]) & set(relevant_ids)
return len(hit) / len(relevant_ids)
def mrr(retrieved_ids, relevant_ids) -> float:
"""MRR:第一个相关结果的排名倒数。"""
for rank, doc_id in enumerate(retrieved_ids, start=1):
if doc_id in relevant_ids:
return 1.0 / rank
return 0.0
6.2 组件级 vs 端到端评估
混合检索要分两层评估:检索层(召回准不准)与生成层(最终回答好不好):
| 评估层 | 指标 | 定位问题 |
|---|---|---|
| 检索层 | Recall@K、NDCG | 召回不足 / 排序差 |
| 生成层 | Faithfulness、Relevancy | 上下文噪音 / 幻觉 |
def rag_end_to_end_eval(queries, pipeline, judge_fn, golden):
"""端到端:检索层指标 + 生成层指标一起看。"""
return {
"retrieval": evaluate_retrieval(queries, pipeline.retriever, golden),
"generation": {
"faithfulness": judge_fn.avg_faithfulness(queries, pipeline),
"answer_relevancy": judge_fn.avg_relevancy(queries, pipeline),
},
}
6.3 检索诊断:召回不足还是排序差
Retrieval@K 低与 NDCG 低指向不同问题:
| 现象 | 根因 | 对策 |
|---|---|---|
| Recall@10 低 | 召回通道漏 | 检查分块大小、加查询改写、补通道 |
| Recall 正常 NDCG 低 | 排序差 | 加重排器、调融合权重 |
| 单通道独有命中多 | 通道互补不足 | 检查分块口径一致性 |
诊断时还应看两通道的重合与独有贡献:若某相关文档只被稀疏通道命中(sparse_only_hit),说明向量通道在该场景失效,需针对性补强。
七、工程落地要点
两套索引必须源自同一份数据,文档新增/更新时同步写两套索引(BM25 倒排 + 向量),保证任一时刻两通道看到的数据一致。
7.2 分块策略对混合检索的影响
分块大小对两通道的影响不同:BM25 喜欢小块(词频集中),向量喜欢语义完整。需要权衡:
| 分块大小 | BM25 | 向量 | 建议 |
|---|---|---|---|
| 小(200-300 词) | 好(词频集中) | 中(上下文不足) | 短事实类 |
| 中(500-800 词) | 中 | 好 | 生产常用 |
| 大(1000+ 词) | 差(稀释) | 中(噪声) | 长段落保留 |
7.3 延迟与成本预算
混合检索比单通道多了 BM25 查询、embedding 与融合,把延迟预算拆到各组件即可定位热点:
| 组件 | 延迟占比 | 成本占比 | 优化手段 |
|---|---|---|---|
| BM25 | 低 | 低 | 倒排索引 |
| 向量检索 | 中 | 中 | ANN、量化 |
| 融合 | 极低 | 无 | 无 |
| 重排器 | 高 | 高 | 减少候选数、量化 |
八、实战:一个知识库问答的混合检索改造
8.1 改造前后对比
改造前:纯向量检索,命中率低、术语文档常漏
症状:用户问"续费失败",检索不到写"扣款异常"的文档
改造后:BM25 + 向量 + RRF 融合 + 重排
效果:字面与语义两条路都覆盖,召回显著提升
8.2 完整实现
# kb_hybrid.py — 知识库混合检索完整实现
class KBHybridSearch:
def __init__(self, bm25, vector, reranker, embed_fn, rewrite_fn=None):
self.bm25 = bm25
self.vector = vector
self.reranker = reranker
self.embed = embed_fn
self.rewrite = rewrite_fn or (lambda q: [q])
def search(self, query: str, top_k: int = 5) -> list[dict]:
queries = self.rewrite(query) # 多路改写
recalls = []
for q in queries:
sparse = self.bm25.search(q, 50)
dense = self.vector.search(self.embed(q), 50)
recalls.append(rrf_fuse([sparse, dense], top_k=30))
fused = rrf_fuse(recalls, top_k=20) # 汇总各路
return self.reranker.rerank(query, fused, top_k=top_k)
8.3 上线评估清单
上线前必须回答:
- Recall@10 是否优于任一单通道?
- 重排器是否带来 NDCG 提升(且能覆盖成本)?
- 查询改写是否值得(Delta MAP > 5%)?
- 端到端 Faithfulness 是否未下降?
- 延迟 P95 是否在预算内?
总结:混合检索的四个组件
| 组件 | 职责 | 关键手段 |
|---|---|---|
| 稀疏检索 | 字面精确命中 | BM25、倒排索引 |
| 稠密检索 | 语义泛化召回 | embedding、ANN |
| 融合 | 合并两通道 | RRF(用排名不用分数) |
| 重排 | 精排 Top-K | Cross-Encoder、两阶段 |
混合检索的本质,是把“一种检索打天下”的假设替换为“多通道互补召回 + 融合排序”的系统设计。稀疏通道守字面,稠密通道守语义,RRF 用排名而非分数把它们合到一起,重排器再为最终 LLM 上下文精挑细选。这套体系不是让某一通道更聪明,而是让整个检索系统在同一份文档里,同时看见用户说的和文档写的内容——这正是高质量 RAG 的检索基石。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。