第26回 高级 RAG(中)——混合搜索与重排序

一把钥匙开一门,两把钥匙更稳当。
词要对上才好找,意要相近才通达。

上回我们修“问法”:查询变换与 HyDE。
这一回修“找法”:混合搜索与重排序。

看官若只记一件事:
检索不是只有一种信号。

你用关键词搜“贝尔曼方程”,BM25 一下就命中;
可你搜“长期收益怎么写成方程”,关键词未必出现,但语义相近。

所以工程里常用“混合检索”:

  • 稀疏检索(sparse):擅长关键词、专名、数字
  • 稠密检索(dense):擅长语义相似、同义表达

再加一层“重排(rerank)”:

  • 先广撒网召回候选
  • 再精挑细选排顺序

这就像办案:
先把所有可能线索都收集(召回),再逐条核验优先级(重排)。


一、BM25 与向量检索:一个像查目录,一个像找意思

BM25(稀疏)

直觉:关键词出现得越多越像;太常见的词要降权。

优点:

  • 专名与关键词很强
  • 可解释(为什么命中)

缺点:

  • 同义改写容易漏
  • 语义相关但词不同会失手

Dense Embedding(稠密)

直觉:把语义压进向量空间,用相似度找邻居。

优点:

  • 同义表达更稳
  • 语义相关更容易召回

缺点:

  • 专名、编号、细粒度匹配容易翻车
  • 可解释性更弱

所以混合检索本质是:
把两种“盲点互补”的信号合在一起。


二、重排序:先召回,再精排

混合检索会带来一个新问题:
候选更多了,怎么排?

常见三招:

  1. 线性融合:把 BM25 分与 dense 分按权重加起来
  2. 投票融合:两个检索器各给 top-k,按出现次数或名次融合
  3. 重排模型(Cross-Encoder / Reranker):对“query-文段”对进行更细的相关性判断

学术与工业里最常见的是“召回+重排”两阶段结构:
因为重排更贵,不能对全库算,只能对候选算。

RAG 的 2024 综述中也常把“post-retrieval”阶段单独拎出来讨论:
包括重排、过滤、压缩与去噪。12


三、极简可跑代码:手写一个 BM25 + 向量的混合检索

我们用同一套小语料,做三种检索:

  • BM25
  • 词袋余弦(当作一个玩具 dense)
  • 混合:两者分数归一化后相加
import math
import re


DOCS = [
    "贝尔曼方程把长期收益写成递推关系。",
    "余弦相似度衡量向量夹角,夹角越小越相似。",
    "混合检索结合 BM25 与向量检索,提高召回与稳健性。",
    "重排序在候选集合上精排,常用交叉编码器或打分模型。",
    "查询改写与 HyDE 属于预检索阶段,用来提升召回。",
]


def tok(s):
    return [w for w in re.split(r"[^0-9A-Za-z\u4e00-\u9fff]+", s) if w]


def bow(tokens):
    v = {}
    for t in tokens:
        v[t] = v.get(t, 0) + 1
    return v


def cosine(a, b):
    da = sum(x * x for x in a.values())
    db = sum(x * x for x in b.values())
    if da == 0 or db == 0:
        return 0.0
    dot = sum(a.get(k, 0) * b.get(k, 0) for k in a.keys())
    return dot / math.sqrt(da * db)


def bm25_build(docs, k1=1.2, b=0.75):
    doc_toks = [tok(d) for d in docs]
    N = len(docs)
    df = {}
    lens = [len(t) for t in doc_toks]
    avgdl = sum(lens) / max(1, N)
    for ts in doc_toks:
        seen = set(ts)
        for w in seen:
            df[w] = df.get(w, 0) + 1
    idf = {}
    for w, c in df.items():
        idf[w] = math.log(1 + (N - c + 0.5) / (c + 0.5))
    return {"doc_toks": doc_toks, "idf": idf, "lens": lens, "avgdl": avgdl, "k1": k1, "b": b}


def bm25_score(q_tokens, i, idx):
    ts = idx["doc_toks"][i]
    idf = idx["idf"]
    k1 = idx["k1"]
    b = idx["b"]
    dl = idx["lens"][i]
    avgdl = idx["avgdl"]
    tf = {}
    for w in ts:
        tf[w] = tf.get(w, 0) + 1
    s = 0.0
    for w in q_tokens:
        if w not in tf:
            continue
        f = tf[w]
        denom = f + k1 * (1 - b + b * dl / avgdl)
        s += idf.get(w, 0.0) * (f * (k1 + 1) / denom)
    return s


def minmax_norm(scores):
    if not scores:
        return scores
    mn = min(scores)
    mx = max(scores)
    if mx == mn:
        return [0.0 for _ in scores]
    return [(s - mn) / (mx - mn) for s in scores]


def hybrid_retrieve(q, topk=2, w_sparse=0.5):
    q_tokens = tok(q)
    idx = bm25_build(DOCS)
    bm = [bm25_score(q_tokens, i, idx) for i in range(len(DOCS))]
    dense = [cosine(bow(q_tokens), bow(tok(DOCS[i]))) for i in range(len(DOCS))]
    bm_n = minmax_norm(bm)
    de_n = minmax_norm(dense)
    mix = [w_sparse * bm_n[i] + (1 - w_sparse) * de_n[i] for i in range(len(DOCS))]
    ranked = sorted(list(enumerate(mix)), key=lambda x: x[1], reverse=True)[:topk]
    return [(DOCS[i], round(score, 3), round(bm[i], 3), round(dense[i], 3)) for i, score in ranked]


if __name__ == "__main__":
    for q in ["长期收益 方程", "怎么衡量向量相似", "HyDE 提升召回"]:
        print("Q:", q)
        for d, mix, bm, de in hybrid_retrieve(q, topk=2, w_sparse=0.5):
            print("  ", mix, "bm25=", bm, "dense=", de, "|", d)
        print()

它当然很粗糙,但你能清晰看到:

  • 关键词强时,BM25 拉得动
  • 同义表达时,dense 更稳
  • 混合后,召回更不容易偏科

这就是混合检索的现实意义:
宁可多抓点候选,再用重排与过滤把噪声压下去。


四、重排的“守门”作用:把噪声挡在生成前

RAG 的生成最怕“脏证据”:

  • 段落看似相关,其实答的是另一个问题
  • 多段证据互相冲突,模型挑错一句

重排与过滤做的事就是:
在证据进嘴前,先过一道筛。

你可以把它理解成“检索器是撒网,重排器是择鱼”。


五、小结:混合与重排,让证据更全、更准、更稳

这一回你要记住:

  • BM25 擅长词,dense 擅长意
  • 混合检索先追求召回,不要一开始就追求完美排序
  • 重排序把噪声挡在生成前,是 RAG 稳健性的重要来源

下一回(第27回)我们把这些技巧装进“模块化架构”:
让系统能路由、能训练、能在不同问题上选不同策略。
从此 RAG 不再是一个固定配方,而是一套可控机器。

欲知后事如何,且听下回分解。


幻觉核查

  • 混合检索与 post-retrieval(重排/过滤)作为 RAG 组件:可核对 RAG 综述对组件与分类的归纳。12
  • 本回代码中的 dense 以词袋余弦代替真实向量嵌入,用于说明“稀疏/稠密互补”的结构性直觉。

逻辑审计

  • 与第25回衔接:第25回提升“问”,本回提升“找”;两者共同提升检索栏指标。
  • 与第24回一致:先提升召回,再提升排序;评估要拆分,才能知道哪里变好。
  • 为后续铺路:第27回把混合检索、重排、过滤、压缩纳入模块化流水线,并讨论可训练的路由与策略。

引用与溯源

Footnotes

  1. Gao, Y., et al. Retrieval-Augmented Generation for Large Language Models: A Survey arXiv:2312.10997 (v5: 2024-03-27) https://arxiv.org/abs/2312.10997 2

  2. Huang, Y., et al. A Survey on Retrieval-Augmented Text Generation for Large Language Models arXiv:2404.10981 (v2: 2024-08-23) https://arxiv.org/abs/2404.10981 2