第26回 高级 RAG(中)——混合搜索与重排序
一把钥匙开一门,两把钥匙更稳当。
词要对上才好找,意要相近才通达。
上回我们修“问法”:查询变换与 HyDE。
这一回修“找法”:混合搜索与重排序。
看官若只记一件事:
检索不是只有一种信号。
你用关键词搜“贝尔曼方程”,BM25 一下就命中;
可你搜“长期收益怎么写成方程”,关键词未必出现,但语义相近。
所以工程里常用“混合检索”:
- 稀疏检索(sparse):擅长关键词、专名、数字
- 稠密检索(dense):擅长语义相似、同义表达
再加一层“重排(rerank)”:
- 先广撒网召回候选
- 再精挑细选排顺序
这就像办案:
先把所有可能线索都收集(召回),再逐条核验优先级(重排)。
一、BM25 与向量检索:一个像查目录,一个像找意思
BM25(稀疏)
直觉:关键词出现得越多越像;太常见的词要降权。
优点:
- 专名与关键词很强
- 可解释(为什么命中)
缺点:
- 同义改写容易漏
- 语义相关但词不同会失手
Dense Embedding(稠密)
直觉:把语义压进向量空间,用相似度找邻居。
优点:
- 同义表达更稳
- 语义相关更容易召回
缺点:
- 专名、编号、细粒度匹配容易翻车
- 可解释性更弱
所以混合检索本质是:
把两种“盲点互补”的信号合在一起。
二、重排序:先召回,再精排
混合检索会带来一个新问题:
候选更多了,怎么排?
常见三招:
- 线性融合:把 BM25 分与 dense 分按权重加起来
- 投票融合:两个检索器各给 top-k,按出现次数或名次融合
- 重排模型(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
-
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
-
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