9.2 混合检索:关键词 + 向量 + 重排
9.1 的向量检索有个尴尬:哈希嵌入无语义,向量那一路其实和关键词一样只看字面;而真实嵌入虽然懂语义,却对"人名、ID、专有名词"这类精确串不敏感——用户查 minagent 或某个错误码时,向量可能把它当成一堆普通字。工业界的做法不是二选一,而是两路都跑,再把结果融合。
本节把 minagent 推进到
minagent/retrieval.py:在向量检索旁并上一路 BM25 关键词召回,用 RRF 倒数排名融合两路结果,再用"命中标题加权"做一次简单重排,让召回既稳又准。
9.2.1 两路召回为什么互补
关键词和向量各有一块自己擅长、对方不擅长的地盘:
| 查询类型 | 关键词(BM25) | 向量(嵌入) |
|---|---|---|
| 精确串:错误码、变量名、ID | 强,字面命中 | 弱,被"语义化"抹平 |
| 换种说法:同义词、近义句 | 弱,字面不重合就丢 | 强(真实模型) |
| 常见词堆砌的查询 | 稳,IDF 压住高频词 | 易被高频词带偏 |
| 罕见词主导的查询 | 可能被罕见词过度放大 | 相对平滑 |
既然两者的失效模式几乎不重叠,让它们各自排一份名次、再融合,就能覆盖单路的盲区。这就是混合检索(hybrid retrieval)。注意"混合"混的是排名而不是分数,原因马上在 9.2.3 讲。
用一个更具体的例子体会这种互补。假设记忆里写着"pip 装包必须用中科大镜像源",用户问"装库要去哪下"。关键词那一路会因为"装"“库"“下"跟正文对不齐而几乎全丢;可一个懂语义的嵌入会认出"装库”≈“装包”、“下”≈“下载”,把它召回。反过来,如果用户直接问"中科大镜像源”,关键词一眼命中,而嵌入反而可能因为把专有名词揉进向量而变糊。两条路谁都不是万能的,合起来才稳。
9.2.2 BM25:关键词这一路
关键词打分里最经典的是 BM25。它比"数重合词"聪明在两点:一是 IDF 加权,越罕见的词权重越高,于是"token"“minagent"这种有区分度的词比"的"“是"更管用;二是 长度归一,长文档天然更容易撞上查询词,BM25 用文档长度把它压回去。
单个词 w 对文档 d 的贡献是:
idf(w) * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * dl / avgdl))
其中 tf 是词在文档里的出现次数,dl 是文档长度,avgdl 是平均长度,k1、b 是两个可调参数。这套公式的直觉是"饱和”:tf 涨到一定程度,分数就不再线性涨了——出现 10 次不比出现 5 次强多少。
复用 9.1 的 tokenize,BM25 可以写成一个小类。它一次建好每个词的 IDF 和每篇文档的词频,之后 score() 只做加法:
import math
from dataclasses import dataclass
@dataclass
class BM25:
docs: list[str]
k1: float = 1.5
b: float = 0.75
def __post_init__(self) -> None:
self.tokens = [tokenize(d) for d in self.docs]
self.N = len(self.tokens)
self.avgdl = sum(len(t) for t in self.tokens) / max(1, self.N)
df: dict[str, int] = {}
for t in self.tokens:
for w in set(t):
df[w] = df.get(w, 0) + 1
self.idf = {w: math.log(1 + (self.N - n + 0.5) / (n + 0.5)) for w, n in df.items()}
self.tf = []
for t in self.tokens:
c: dict[str, int] = {}
for w in t:
c[w] = c.get(w, 0) + 1
self.tf.append(c)
def score(self, query: str) -> list[float]:
q = tokenize(query)
out = []
for i, tf in enumerate(self.tf):
dl = len(self.tokens[i])
s = 0.0
for w in q:
if w not in tf:
continue
f = tf[w]
s += self.idf.get(w, 0.0) * (f * (self.k1 + 1)) / (
f + self.k1 * (1 - self.b + self.b * dl / self.avgdl))
out.append(s)
return out
idf 用的是带平滑的写法 log(1 + (N - n + 0.5) / (n + 0.5)),好处是当某个词在所有文档都出现(n == N)时,它不会变成负数,只是趋近于零——这种"到处都是的词"本来就该被忽略。
两个参数 k1、b 控制着这条曲线的形状:k1(默认 1.5)决定词频的饱和速度,越大越"奖励"词频高,越小越快饱和;b(默认 0.75)决定长度归一化的强度,b=0 完全不归一化,b=1 完全按长度折减。绝大多数语料上 k1=1.2~2.0、b=0.75 是安全默认,本卷沿用这个区间,不做调参。
9.2.3 RRF:融合两路排名
两路各自给出一串分数,可这两串分数不可比:BM25 是 0 到十几的无界实数,向量相似度是 [-1, 1] 里的小数。直接相加会被量纲带偏。稳妥的做法是只看名次、不看分数,这就是倒数排名融合(Reciprocal Rank Fusion,RRF):
score(d) = Σ 1 / (k + rank_i(d))
对每一条文档,把它在各路的排名取倒数相加,k 是平滑常数(常用 60)。第 1 名贡献 1/61,第 2 名 1/62……名次越靠前贡献越大,且两路都靠前的文档会拿到叠加分,自然浮到顶部。RRF 完全不需要调量纲、不需要归一化,鲁棒得几乎"傻”,正因如此被大量检索系统当默认融合器。
k 的作用是削平头部。若取 k=0,第 1 名会拿 1/1、第 2 名 1/2,头名的权重压倒性大,融合几乎退化成"以某一路为准";取一个较大的 k(如 60)后,前几名的差距被拉近,两路意见不合时更倾向于取折中。这也是为什么 RRF 对"某一路把噪声顶到第 1"这种情况有天然的抵抗力——单独一个第 1 名,只值 1/61,压不过"两路都进前 3"的文档。
def rrf(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
scores: dict[str, float] = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
return sorted(scores.items(), key=lambda kv: -kv[1])
把两路排名喂进去就得到融合结果。为了让重排有东西可看,这一节把记忆拆成标题 + 正文两段,关键词和向量只索引正文:
def keyword_rank(q: str) -> list[str]:
s = bm.score(q)
return [ids[i] for i in np.argsort(-np.asarray(s), kind="stable")]
def vector_rank(q: str) -> list[str]:
return [i for i, _ in store.search(emb.embed(q), len(ids))]
def hybrid_search(q: str, k: int = 3) -> list[tuple[str, float]]:
return rrf([keyword_rank(q), vector_rank(q)])[:k]
9.2.4 重排:命中标题加权
融合只是"把两路排名的名次拼起来",它不认识"标题"这个字段。可标题恰恰是记忆里信息密度最高的地方——“工具错误处理"这六个字,比正文里任何一句都更能说明这条记忆讲什么。于是我们在融合之后加一步重排:凡是标题里命中查询词元的候选,给它加一点分。
def rerank(q: str, fused: list[tuple[str, float]], bonus: float = 0.004) -> list[tuple[str, float]]:
qt = {t for t in tokenize(q) if len(t) >= 2}
scored = [(d, s + bonus * len(qt & set(tokenize(titles[d])))) for d, s in fused]
return sorted(scored, key=lambda kv: -kv[1])
规则里有三个刻意的选择:只用长度 ≥ 2 的词元(len(t) >= 2),避免单个汉字"的"“下"这种噪声命中标题;用交集大小做加成(命中越多加得越多),而不是一刀切的布尔值;bonus 取 0.004,跟 RRF 相邻名次之间的差距(约 0.0002~0.0005)在同一量级,足以翻盘又不会碾压原有排名。这是本章的简单规则重排,下一节之后到第 10 章我们会看到真实系统用的是学习出来的交叉编码器(cross-encoder)。
一句话概括重排的定位:它不改"召回”,只改"顺序”——候选集在融合那一步就定死了,重排只是把已经捞上来的东西重新排个座次。
为什么重排要放在融合之后,而不是当成第三路排名去融合?因为重排的信号很弱(就一个标题命中),单独排一份名次会太噪;而作为融合后的"微调",它只在原有排名的基础上做小幅修正,风险可控。顺序上永远是:先多路召回、再融合、最后重排——召回求全,融合求稳,重排求准。
9.2.5 真跑:三路对比与重排前后
复用 9.1 的 8 条记忆,但这次把它们拆成"标题 + 正文"两段——titles 存标题,bodies 存正文,关键词和向量只对 bodies 建索引。挑一个向量会翻车的查询 怎么省 token:
q = "怎么省 token"
print("kw :", keyword_rank(q)[:3])
print("vec:", vector_rank(q)[:3])
print("hyb:", [d for d, _ in hybrid_search(q, 3)])
实测输出(Python 3.14.6,numpy 2.5.3):
kw : ['m6', 'm1', 'm2']
vec: ['m7', 'm6', 'm1']
hyb: ['m6', 'm1', 'm2']
m6 是"上下文压缩",是这条查询的正确答案。纯关键词排对了:token 是罕见词,IDF 高,稳稳命中 m6。纯向量却把 m7(开会日程)顶到了第 1——哈希碰撞给了它一个虚高的相似度,这正是 9.1 说的"碰撞噪声"。而混合把两路一融合,m6 在两路里都靠前(关键词第 1、向量第 2),叠加后稳居第 1,把向量的假阳性压了下去。
再看一个三路一致的查询,说明融合不会在简单情形上添乱:
q = "minagent 出错返回什么"
print("kw :", keyword_rank(q)[:3])
print("vec:", vector_rank(q)[:3])
print("hyb:", [d for d, _ in hybrid_search(q, 3)])
实测输出:
kw : ['m5', 'm4', 'm3']
vec: ['m5', 'm4', 'm2']
hyb: ['m5', 'm4', 'm2']
三路都把 m5(工具错误处理)放在第 1,融合只是把两路的第 2、3 名合并了一下。可见 RRF 的价值不在"改变正确结果",而在"当某一路抽风时不被它带偏"。
最后看重排。下面这个查询里,正确答案 m5 的正文(“调用失败时不要抛异常,返回 error JSON”)跟查询的字面重合很少,于是融合把它压到了第 3:
q = "工具报错怎么处理"
fused = hybrid_search(q, 4)
print("fused :", [(d, round(s, 4)) for d, s in fused])
print("rerank:", [(d, round(s, 4)) for d, s in rerank(q, fused)])
实测输出:
fused : [('m1', 0.032), ('m2', 0.0315), ('m5', 0.0315), ('m7', 0.0313)]
rerank: [('m5', 0.0395), ('m1', 0.032), ('m2', 0.0315), ('m7', 0.0313)]
标题"工具错误处理"里,“工具"和"处理"两个词元都命中了查询,m5 拿到 2 × 0.004 = 0.008 的加成,从 0.0315 涨到 0.0395,一举反超 m1 和 m2。再看一个例子,这次命中在另一个标题上:
q = "用什么语言回答"
fused = hybrid_search(q, 4)
print("fused :", [(d, round(s, 4)) for d, s in fused])
print("rerank:", [(d, round(s, 4)) for d, s in rerank(q, fused)])
实测输出:
fused : [('m5', 0.0325), ('m3', 0.0325), ('m8', 0.0315), ('m2', 0.0315)]
rerank: [('m3', 0.0365), ('m5', 0.0325), ('m8', 0.0315), ('m2', 0.0315)]
m3(语言偏好)的标题命中了"语言”,于是和 m5 打成平手后被加成抬到第 1。这两个例子说明:当正文的字面信号弱、而标题的主题信号强时,标题加权能把正确记忆捞回来。
把这一节跑过的四组结果并排看,三路的行为一目了然:
| 查询 | 纯关键词 top-1 | 纯向量 top-1 | 混合 top-1 | 现象 |
|---|---|---|---|---|
怎么省 token | m6(对) | m7(错) | m6(对) | 向量碰撞翻车,融合救回 |
minagent 出错返回什么 | m5(对) | m5(对) | m5(对) | 三路一致,融合不添乱 |
工具报错怎么处理 | 第 3 | 第 3 | 重排后 m5 第 1 | 标题加权纠正 |
用什么语言回答 | 并列 | 并列 | 重排后 m3 第 1 | 标题加权纠正 |
结论很清楚:单路都可能翻车,融合负责兜底,重排负责收口。这三步串起来,就是记忆检索从"能跑"到"能信"的最小完整链路。
还要提醒一句:这张表是在 8 条记忆、4 个查询上调出来的,样本小得可怜。真实系统里每个参数都要用几百上千条标注数据去测召回率、MRR 之类的指标,本节的数值只用来演示机制,不能当结论。
但重排不是免费的午餐。标题加权同样可能被"词在标题里、但主题不对"的假命中骗到——比如查询里带了 minagent,m4 的标题"项目简介"并不含它,可若某条记忆的标题恰好含 minagent 却讲的是别的事,加成就会帮倒忙。这就是为什么生产系统宁可多花算力上交叉编码器:它把"查询和文档拼在一起"送进模型打分,能判断命中是否真的相关,而不是数标题里撞了几个词。
真实模型下会怎样:真实系统里 BM25 通常由现成的检索引擎(Elasticsearch、rank_bm25 库)提供,向量那一路接真实 embedding,两路各自返回几十条候选,再用 RRF 融合、用 cross-encoder 重排到最终几条。差异有三点:一是规模,真实候选集是几百上千条,k=60 的 RRF 才显出作用;二是重排质量,cross-encoder 直接建模查询-文档相关性,比"数标题词"强得多,但要为每条候选跑一次前向,成本也高;三是参数,k1、b、RRF 的 k、加权的 bonus 都要用真实数据标定,本节的数值只在小样本上成立。
阅读导航:上一节:9.1 嵌入与向量存储:本地可离线方案 · 下一节:9.3 记忆即工具 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。