2733 字
约 9 分钟
21
S20-X04 · 混合检索:BM25 与向量怎么融合

纯向量检索在两类请求上会稳定失手:查"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. 自测

  1. 为什么 XH-2200B 这类型号在纯向量检索里容易失手?
  2. BM25 的 k1 调小,会让长文档里的重复词权重变大还是变小?
  3. RRF 的 k=60 起什么作用?调到 1 会怎样?
  4. BM25 分数为什么不适合直接与余弦相似度相加?
  5. 重排为什么必须放在融合之后?
  6. 中文语料上 BM25 效果不及预期,第一个该查什么?
S20-X04 · 混合检索:BM25 与向量怎么融合
https://www.clxhxhhr.top/posts/5679/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。