纯向量检索在两类请求上会稳定失手:查"XH-2200B 的额定电流"和查"这段代码为什么抛 IllegalStateException"。前者是型号,后者是标识符——它们的共同点是字符本身就是答案的一部分,而 embedding 只保留语义,把字符信息在池化时抹平了。
混合检索要解决的不是"再加一路召回",而是两路分数的量纲不同,怎么合成一个排序。这篇按"为什么失手 → BM25 的直觉 → 三条融合路 → 重排与评估"拆。
1. 纯向量为什么在专名、代码、型号上失手
| 查询类型 | 向量检索 | BM25 | 原因 |
|---|---|---|---|
产品型号 XH-2200B |
常召回同系列近似文档 | 精确命中 | 型号被 tokenizer 切碎后语义接近"某个型号" |
报错信息 E0421: cannot find name |
召回泛泛的"报错处理" | 精确命中 | 稀有 token 的 IDF 极高,向量里没有这层权重 |
代码片段 if (!ctx.done) |
召回同主题文档 | 精确命中 | 标点与结构在 embedding 中几乎无区分力 |
| 自然语言问句"怎么配置超时" | 明显更好 | 一般 | 语义泛化正是向量的强项 |
向量负责"问得含糊",BM25 负责"问得精确"。两条腿都要有,比例要能调。
2. BM25 的直觉:饱和、IDF、长度归一
BM25 的打分由三块构成:
score(D, Q) = Σ_t IDF(t) · [ f(t,D) · (k1+1) ] / [ f(t,D) + k1 · (1 - b + b · |D|/avgdl) ]
| 因子 | 直觉 | 参数 | 调大的效果 |
|---|---|---|---|
| TF 饱和 | 一个词出现 10 次不该比 1 次强 10 倍 | k1(常 1.2–2.0) |
更快饱和,长文档里重复词权重被压 |
| IDF | 出现在所有文档里的词没有区分度 | 无(由语料统计) | — |
| 长度归一 | 长文档天然更容易命中,要惩罚 | b(常 0.75) |
归一更强,短文档受益 |
写一个最小实现,把"饱和"这件事看清楚:
import math
from collections import Counter
def bm25_scores(query, docs, k1=1.5, b=0.75):
N = len(docs)
avgdl = sum(len(d) for d in docs) / N
df = Counter(t for d in docs for t in set(d))
out = {}
for i, d in enumerate(docs):
tf, score = Counter(d), 0.0
for t in query:
if not df.get(t):
continue
idf = math.log(1 + (N - df[t] + 0.5) / (df[t] + 0.5))
f = tf[t]
score += idf * f * (k1 + 1) / (f + k1 * (1 - b + b * len(d) / avgdl))
out[i] = score
return out
k1 越小饱和越快:k1=0.5 时第 3 次出现的词几乎不再加分。中文语料必须先分词再算 BM25,否则整句被当一个 token,IDF 全部失效——这是"BM25 效果不如预期"的第一大原因。
3. 三条融合路径
| 路径 | 做法 | 优点 | 代价 | 适用 |
|---|---|---|---|---|
| RRF(倒数排名融合) | 只用排名,Σ 1/(k+rank) |
不需要分数可比,稳 | 丢失分数差距信息 | 默认首选 |
| 加权分数归一 | 各自归一后 α·v + (1-α)·b |
保留强弱差异,可调 | 归一方式敏感,换库就重调 | 两路质量接近且稳定 |
| 级联召回 | 先 BM25 粗召回,再向量精排 | 延迟低、可控 | 粗召回漏了就没了 | 候选集大、延迟敏感 |
先上 RRF,再谈加权。 因为 RRF 的前提最少:它只要求"每一路都能给出一个还算合理的排序"。
4. RRF 公式与 k 取值
RRF(d) = Σ_{r ∈ 各路结果} 1 / (k + rank_r(d))
k 的常见取值为 60(来自原论文经验值,按具体文献核对)。它的作用是压平头部:
k |
头部权重 | 效果 |
|---|---|---|
| 1 | 极高 | 只信各路第一名,脆弱 |
| 60 | 平缓 | 默认,两路都有贡献 |
| 200+ | 很平 | 接近"按出现次数投票",适合多路召回 |
def rrf(rank_lists, k=60, weights=None, top_n=20):
weights = weights or [1.0] * len(rank_lists)
fused = {}
for w, ranks in zip(weights, rank_lists):
for rank, doc_id in enumerate(ranks, start=1):
fused[doc_id] = fused.get(doc_id, 0.0) + w / (k + rank)
return sorted(fused.items(), key=lambda x: -x[1])[:top_n]
weights 可给 BM25 略高(如 1.2 : 1.0)强化精确匹配,但权重作用于排名而非分数,幅度别用 10 : 1。
5. Elasticsearch 8.x 的两种混合写法
5.1 retriever 语法(推荐,语义清晰)
{
"retriever": {
"rrf": {
"retrievers": [
{ "standard": { "query": { "match": { "content": "XH-2200B 额定电流" } } } },
{ "knn": { "field": "embedding", "query_vector": [0.01, -0.22, 0.08],
"k": 50, "num_candidates": 200 } }
],
"rank_window_size": 50,
"rank_constant": 60
}
},
"size": 10
}
rank_constant 即公式里的 k,rank_window_size 是每一路送进融合的深度(按 ES 8.x 文档核对,小版本支持范围不同)。
5.2 bool 组合(兼容老版本,但要自己控分)
{
"query": {
"bool": {
"should": [
{ "match": { "content": { "query": "XH-2200B 额定电流", "boost": 2.0 } } },
{ "knn": { "field": "embedding", "query_vector": [0.01, -0.22, 0.08],
"k": 50, "num_candidates": 200, "boost": 1.0 } }
]
}
}
}
boost 乘在原始分数上,而两路量纲不同。
6. 分数尺度不可比,是加权失效的根因
| 分数来源 | 典型范围 | 是否跨查询稳定 | 能否直接相加 |
|---|---|---|---|
| BM25 | 0 到几十,随查询词数与 IDF 变 | 否 | 否 |
| 余弦相似度 | 通常 0–1,分布集中在上半段 | 相对稳定 | 否 |
| 内积 | 无上界,随向量模长 | 否 | 否 |
| RRF | 约 1/(k+1) 量级 |
是(只依赖排名) | 是 |
| rerank 分数 | 模型输出,常为 logit 或 0–1 | 同模型内稳定 | 只能同模型内比 |
两个致命细节:BM25 的绝对值随查询长度变化(三词查询 8 分,十字查询可能 25 分,固定权重等于让长查询天然压过向量分);余弦相似度分布很窄(top50 常集中在 0.7–0.85),min-max 归一后分数差距被放大成噪声。
所以加权路线必须按查询做归一,不按全局:
def minmax(xs):
lo, hi = min(xs), max(xs)
return [(x - lo) / (hi - lo) if hi > lo else 0.0 for x in xs]
def zscore(xs):
mu = sum(xs) / len(xs)
sd = (sum((x - mu) ** 2 for x in xs) / len(xs)) ** 0.5 or 1.0
return [(x - mu) / sd for x in xs]
# 每路先各自归一,再线性组合
merged = {}
for doc_id, v, b in zip(ids, zscore(vec_scores), zscore(bm25_scores)):
merged[doc_id] = 0.6 * v + 0.4 * b
z-score 比 min-max 稳:min-max 强行把最好那条拉到 1.0,丢了"这一路整体都很差"这个信息——而这恰是该不该信它的依据。
7. 重排必须放在融合之后
顺序错了白花钱:BM25 → 向量 → 融合 → rerank 正确;BM25 → rerank → 融合 错——重排模型只见过一路候选,分数与另一路不可融合。
def retrieve(query, k_recall=50, k_final=8):
lex = es.search(query=bm25_query(query), size=k_recall)["hits"]["hits"]
vec = es.search(knn=ann_query(query, k=k_recall))["hits"]["hits"]
fused = rrf([[h["_id"] for h in lex], [h["_id"] for h in vec]], k=60)[:30]
docs = [get_doc(i) for i, _ in fused]
ranked = reranker.rank(query, [d["text"] for d in docs]) # 候选裁剪到 30
return [docs[i] for i in ranked[:k_final]]
两个实践点:
| 决策 | 建议 | 原因 |
|---|---|---|
| rerank 候选数 | 20–50 | 再多收益递减,延迟线性涨 |
| rerank 输入长度 | 截到模型上限的一半以内 | 长输入里关键句被稀释 |
| 是否必须 rerank | 专名查询多则必须 | 交叉编码器能同时看字面与语义 |
| rerank 的位置 | 融合之后、截断之前 | 否则融合拿不到统一信号 |
8. 评估:融合参数怎么证明有效
| 指标 | 定义 | 回答的问题 |
|---|---|---|
| recall@k | 相关文档是否进了前 k | 召回够不够 |
| MRR | 第一个相关结果的排名倒数均值 | 第一条对不对 |
| nDCG@k | 按位置折损的增益和 | 排序整体质量 |
| 单路 vs 融合对比 | 同评测集上分别跑 | 融合真的加分了吗 |
def ndcg_at_k(ranked_ids, rel, k=10):
dcg = sum(rel.get(d, 0) / math.log2(i + 2) for i, d in enumerate(ranked_ids[:k]))
ideal = sorted(rel.values(), reverse=True)[:k]
idcg = sum(v / math.log2(i + 2) for i, v in enumerate(ideal)) or 1.0
return dcg / idcg
必须三列并排:纯 BM25、纯向量、融合。只看融合绝对分,难以判断它是否只是被其中一路带起来的。典型输出(示意):
config recall@10 MRR nDCG@10
bm25_only 0.71 0.58 0.62
vector_only 0.78 0.61 0.66
rrf(k=60) 0.86 0.69 0.74
rrf + rerank(top30) 0.89 0.78 0.81
9. 排错:五种"混了但没用"
| 现象 | 原因 | 处理 |
|---|---|---|
| 融合后不如纯向量 | 中文未分词,BM25 那一路是噪声 | 先分词,确认 BM25 单路 recall 正常 |
| 精确查询仍查不到 | BM25 字段用了同一套 analyzer,型号被切碎 | 给编码/型号加 keyword 子字段或自定义 tokenizer |
| 加权后长查询结果变差 | BM25 分数随查询长度膨胀 | 改 RRF,或按查询归一 |
| 延迟翻倍 | 两路都取 k=200 再融合 |
降到 50,靠 rerank 精排 |
| 评测集上融合更好,线上没变 | 评测集全是自然语言问句 | 按查询类型分层评估 |
第二条最隐蔽:XH-2200B 被切成 xh、2200、b,三个 token 的 IDF 都不高,BM25 优势直接消失。
10. 边界
| 场景 | 是否需要混合 | 理由 |
|---|---|---|
| 语料是纯散文、查询全是自然语言 | 可以不混 | 向量已覆盖,多一路只加延迟 |
| 存在型号、错误码、API 名 | 必须混 | 这些是字面信号 |
| 多语言语料 | 必须混 | 跨语言向量对齐不稳,BM25 兜底 |
| 只有几十篇文档 | 不需要 | 直接全量塞进上下文 |
| 查询极短(1–2 词) | 混,且加重 BM25 | 短查询语义信息不足 |
混合检索不是"更高级",是"更抗特定查询类型"。查询日志里精确匹配占比低于一成时,先调 chunk 与 embedding 收益更大。
11. 自测
- 为什么
XH-2200B这类型号在纯向量检索里容易失手? - BM25 的
k1调小,会让长文档里的重复词权重变大还是变小? - RRF 的
k=60起什么作用?调到 1 会怎样? - BM25 分数为什么不适合直接与余弦相似度相加?
- 重排为什么必须放在融合之后?
- 中文语料上 BM25 效果不及预期,第一个该查什么?