混合检索 RAG:BM25 与向量融合

只靠向量检索的 RAG 有一个结构性弱点:语义相近不等于字面匹配。用户搜“怎么退订会员”,向量检索能抓到“取消订阅”的语义文档,却常常漏掉只在正文里出现“退订 扣费 别再续”这种字面表述的条款。反过来,关键词检索(BM25)擅长精确字面命中,却对“用户说的”与“文档写的”之间的语义鸿沟无能为力。混合检索(Hybrid …

只靠向量检索的 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@KK 个结果里命中相关文档的比例召回完整性
Precision@KK 个结果里相关文档的比例精度
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-KCross-Encoder、两阶段

混合检索的本质,是把“一种检索打天下”的假设替换为“多通道互补召回 + 融合排序”的系统设计。稀疏通道守字面,稠密通道守语义,RRF 用排名而非分数把它们合到一起,重排器再为最终 LLM 上下文精挑细选。这套体系不是让某一通道更聪明,而是让整个检索系统在同一份文档里,同时看见用户说的和文档写的内容——这正是高质量 RAG 的检索基石。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「LLM」更多文章

  1. 推理增强技术工程化:CoT/ToT/ReAct
  2. 模型路由与选型:大小模型分层调度
  3. LLM 输出护栏与内容安全