跳转至

第3章 搜索策略

学习目标

  • 区分稠密检索(Dense)与稀疏检索(Sparse)的原理与适用场景
  • 理解 BM25 算法及其在关键词检索中的角色
  • 掌握混合检索(Hybrid Search)的实现与融合方法(RRF)
  • 理解重排序(Reranker)的作用与 Cross-Encoder 原理
  • 能够用 Python 实现多路召回 + Reranker 的两阶段检索

问题导入

某医疗知识库 RAG 上线后遇到一个怪现象:用户问"阿司匹林的禁忌症",稠密向量检索返回的是"布洛芬的副作用"——语义相近但答非所问;而医生搜索"ICD-10 编码 I21.9"这种精确编码时,向量检索完全失效,因为编码本身没有语义。

这是单路检索的典型短板:稠密检索擅长语义匹配但弱于精确关键词;稀疏检索(BM25)擅长关键词匹配但无法理解语义。生产级 RAG 必须用混合检索(Hybrid Search)+ 重排序(Reranker)的两阶段架构,才能兼顾召回率与精度。本章系统讲解搜索策略。

核心概念

1. 稠密检索 vs 稀疏检索

维度 稠密检索(Dense) 稀疏检索(Sparse/BM25)
表示 1536 维稠密向量 高维稀疏词袋(词→权重)
匹配方式 语义相似度 关键词精确匹配
优势 理解同义词、近义、跨语言 精确词、专有名词、编码
劣势 弱于精确关键词 无法理解语义
模型依赖 需 Embedding 模型 无需模型(基于统计)
典型实现 OpenAI Embedding + Qdrant Elasticsearch / BM25

2. BM25 算法

BM25(Best Matching 25)是经典的关键词检索算法,基于词频(TF)与逆文档频率(IDF):

  • 词频饱和:词频贡献随出现次数递减(避免堆词刷分)
  • 文档长度归一:长文档词频天然偏高,需归一惩罚
  • IDF 权重:罕见词权重高,常见词("的"、"是")权重低

公式简化:score(D, Q) = Σ IDF(qi) · (f(qi,D) · (k1+1)) / (f(qi,D) + k1·(1 - b + b·|D|/avgdl))

典型参数 k1=1.2, b=0.75。BM25 在 Elasticsearch、Lucene 中是默认打分算法。

同时执行稠密与稀疏检索,再融合结果。融合方法:

加权融合:final_score = α · dense_score + (1-α) · bm25_score,需先归一化两路分数。简单但对分数尺度敏感。

RRF(Reciprocal Rank Fusion):score(d) = Σ 1 / (k + rank_i(d)),只看排名不看分数,鲁棒性强,是生产环境首选。典型 k=60。

4. 重排序(Reranker)

两阶段检索架构:

  • 第一阶段(召回):用高效的向量检索/BM25 召回 Top-20-100
  • 第二阶段(精排):用 Cross-Encoder 对 query 与每个候选拼接后联合编码打分,精选 Top-3-5

Bi-Encoder vs Cross-Encoder:

维度 Bi-Encoder(双塔) Cross-Encoder(交叉)
编码方式 query 与 doc 独立编码 query+doc 拼接联合编码
速度 快(doc 可预编码) 慢(每对都要前向)
精度 中(交互浅) 高(深层交互)
角色 召回阶段 精排阶段

常见 Reranker:Cohere Rerank(API)、bge-reranker-large(开源)、jina-reranker。

5. 多路召回

除稠密+稀疏外,可再加更多召回通道:

  • 元数据过滤召回:先按属性筛再向量检索
  • 查询改写召回:用 LLM 生成多个子查询并行检索
  • HyDE 召回:让 LLM 先生成假设答案,用答案向量去检索

多路结果统一用 RRF 融合。

原理图

flowchart TD
    Query[用户查询] --> Dense[稠密检索<br/>Embedding + HNSW]
    Query --> Sparse[稀疏检索<br/>BM25]
    Query --> Rewritten[查询改写<br/>多子查询]

    Dense --> TopA[Top-50 候选]
    Sparse --> TopB[Top-50 候选]
    Rewritten --> TopC[Top-50 候选]

    TopA --> RRF[RRF 融合<br/>score=Σ 1/(k+rank)]
    TopB --> RRF
    TopC --> RRF

    RRF --> Merged[Top-20 合并候选]
    Merged --> Rerank[Cross-Encoder Reranker<br/>query+doc 联合打分]
    Rerank --> TopN[Top-5 精选]
    TopN --> Prompt[拼装 Prompt]
图:多路召回 + RRF 融合 + Reranker 两阶段检索

示例代码

实现稠密 + BM25 混合检索与 Reranker 精排:

import os
import math
from collections import defaultdict
from qdrant_client import QdrantClient
from openai import OpenAI

os.environ.setdefault("OPENAI_API_KEY", "sk-xxx")
oai = OpenAI()
qdrant = QdrantClient(":memory:")

COLLECTION = "kb"


def embed(text: str) -> list[float]:
    return oai.embeddings.create(
        model="text-embedding-3-small", input=text
    ).data[0].embedding


def dense_search(query: str, top_k: int = 20) -> list[dict]:
    """稠密向量检索。"""
    results = qdrant.search(
        collection_name=COLLECTION,
        query_vector=embed(query),
        limit=top_k,
        with_payload=True,
    )
    return [{"text": r.payload["text"], "score": r.score} for r in results]


def bm25_search(corpus: list[str], query: str, top_k: int = 20) -> list[dict]:
    """简易 BM25 实现(生产环境用 Elasticsearch/rank_bm25 库)。"""
    k1, b = 1.2, 0.75
    tokenized = [doc.split() for doc in corpus]
    avgdl = sum(len(d) for d in tokenized) / len(tokenized)
    df = defaultdict(int)
    for doc in tokenized:
        for w in set(doc):
            df[w] += 1
    idf = {w: math.log((len(corpus) - df[w] + 0.5) / (df[w] + 0.5) + 1) for w in df}
    q_tokens = query.split()
    scored = []
    for i, doc in enumerate(tokenized):
        score = 0.0
        for q in q_tokens:
            f = doc.count(q)
            if f == 0:
                continue
            score += idf.get(q, 0) * (f * (k1 + 1)) / (f + k1 * (1 - b + b * len(doc) / avgdl))
        scored.append({"text": corpus[i], "score": score})
    scored.sort(key=lambda x: x["score"], reverse=True)
    return scored[:top_k]


def rrf_fuse(rank_lists: list[list[dict]], k: int = 60, top_n: int = 20) -> list[dict]:
    """Reciprocal Rank Fusion 融合多路结果。"""
    scores = defaultdict(float)
    text_map = {}
    for ranks in rank_lists:
        for rank, item in enumerate(ranks):
            scores[item["text"]] += 1.0 / (k + rank + 1)
            text_map[item["text"]] = item
    fused = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_n]
    return [{"text": t, "score": s} for t, s in fused]


def rerank(query: str, candidates: list[dict], top_n: int = 5) -> list[dict]:
    """用 Cohere Rerank API 精排(示例用,需 cohere 包)。"""
    # from cohere import Client
    # co = Client(os.environ["COHERE_API_KEY"])
    # docs = [c["text"] for c in candidates]
    # resp = co.rerank(model="rerank-multilingual-v3.0", query=query, documents=docs, top_n=top_n)
    # return [{"text": docs[r.index], "score": r.relevance_score} for r in resp.results]

    # 无 API 时用 Cross-Encoder 本地模型(需 sentence-transformers)
    from sentence_transformers import CrossEncoder
    model = CrossEncoder("BAAI/bge-reranker-base")
    pairs = [(query, c["text"]) for c in candidates]
    scores = model.predict(pairs)
    ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
    return [{"text": c["text"], "score": float(s)} for c, s in ranked[:top_n]]


def hybrid_search(query: str, corpus: list[str]) -> list[dict]:
    """两阶段混合检索:稠密 + BM25 → RRF → Reranker。"""
    dense = dense_search(query, top_k=20)
    sparse = bm25_search(corpus, query, top_k=20)
    fused = rrf_fuse([dense, sparse], top_n=20)
    return rerank(query, fused, top_n=5)


if __name__ == "__main__":
    corpus = [
        "阿司匹林用于预防心血管疾病。",
        "布洛芬是非甾体抗炎药,用于止痛。",
        "阿司匹林禁用于活动性溃疡患者。",
        "ICD-10 编码 I21.9 表示急性心肌梗死。",
    ]
    print(hybrid_search("阿司匹林禁忌症", corpus))

运行结果说明:纯稠密检索可能把"布洛芬止痛"排前面(语义接近"药物"),但加入 BM25 后"阿司匹林禁用于活动性溃疡"因关键词命中加权提升,再经 Reranker 精排后稳居 Top-1,体现了混合检索的价值。

企业案例

案例 1:客服中心高召回搜索

某运营商客服 RAG 从纯向量检索升级为 Hybrid + Cohere Rerank: - 召回率(Recall@5)从 78% → 92% - Top-1 命中率从 61% → 84% - 端到端延迟从 80ms → 220ms(可接受) 客户满意度提升 18%,工单一次解决率提升 22%。

案例 2:法律条款精确检索

某法律平台对"民法典第1064条"这类精确编号检索,纯向量失效(编号无语义)。加入 BM25 通道后精确命中,再经 Reranker 把相关司法解释排前,律师检索效率提升 4 倍。

案例 3:多路召回调优

某电商搜索用"稠密 + BM25 + 查询改写"三路召回 + RRF 融合 + Reranker。查询改写让"夏天穿的薄外套"改写为"夏季 薄款 外套 透气",召回率比单路稠密提升 25%。

常见问题

Q1:Reranker 必须用吗? A:不是必须,但强烈推荐。Reranker 通常能提升 Top-3 命中率 10-20 个百分点,成本是增加 50-150ms 延迟。对答案精度敏感的场景(医疗、法律)必上。

Q2:RRF 的 k 值怎么选? A:k=60 是经验默认值,含义是排名第 1 与第 61 的权重比为 61:1。k 越大对低排名项越宽容,k 越小越强调头部。多数场景用 60 即可。

Q3:稠密和稀疏的权重怎么分配? A:用 RRF 时无需调权重(只看排名)。用加权融合时,关键词型查询(编码、型号)给 BM25 更高权重,语义型查询给稠密更高权重。可用 LLM 判断查询类型动态调权。

Q4:Reranker 太慢怎么办? A:减少 Reranker 输入候选数(从 50 降到 20);用轻量模型(bge-reranker-base 而非 large);批量并行调用;或用 Cohere API(GPU 加速)。

面试题

1. 为什么混合检索优于单一检索? 参考答案:稠密检索擅长语义匹配但弱于精确关键词(如产品型号、ICD 编码);BM25 擅长关键词但无法理解同义。两者互补,融合后召回率与精度同时提升,是生产 RAG 主流方案。

2. Reranker 与向量检索的区别? 答题要点:向量检索用 Bi-Encoder,query 与 doc 独立编码,doc 可预编码入库,速度快但交互浅;Reranker 用 Cross-Encoder,query+doc 拼接联合编码,能捕获深层交互,精度高但慢,用于精排少量候选。

3. RRF 相比加权融合的优势? 参考答案:RRF 只用排名不用分数,对不同检索器的分数尺度不敏感,无需归一化,对异常分鲁棒。加权融合需要两路分数尺度一致,调参成本高。生产环境首选 RRF。

4. 如何评估检索质量? 答题要点:用标注的查询-相关文档对评测集,计算 Recall@K(召回率)、MRR(平均倒数排名)、NDCG(归一化折损累计增益)。检索与生成分开评估,避免混淆。

练习题

  1. BM25 实现:用 rank_bm25 库替换本章手写 BM25,对比检索结果差异。
  2. 融合方法对比:对同一组多路召回结果,分别用 RRF(k=60)和加权融合(α=0.5)融合,对比 Top-5 排序差异。
  3. Reranker 实验:用 bge-reranker-base 与 bge-reranker-large 在同一评测集上对比 Top-3 命中率与延迟。

小结

搜索策略是 RAG 的核心环节。本章介绍了稠密与稀疏两类检索、BM25 算法、混合检索与 RRF 融合、Cross-Encoder Reranker 两阶段架构。生产环境推荐"稠密 + BM25 双路召回 + RRF 融合 + Reranker 精排"作为默认方案,兼顾召回率与精度。下一章将讲解元数据管理与过滤查询,让检索支持更精细的业务控制。