混合检索是什么?为什么要用混合检索而不是纯向量检索?
结论:混合检索(Hybrid Search)就是稠密向量召回和稀疏词项召回各跑一遍,再把两路结果融合。 纯向量检索擅长"意思相近",但对错误码、型号、人名、内部缩写这类精确串不敏感;BM25 恰好相反。 两路的漏召回集合并不重合,所以并起来能显著抬高召回上限——这是它唯一的、也是充分的理由。
1. 两种召回各自会在哪里翻车
| 用户查询 | 纯向量召回 | BM25 召回 |
|---|---|---|
| "ORA-01555 怎么处理" | 召回一堆"回滚段原理"泛文,具体错误码可能进不了前 10 | 精确命中含该错误码的片段 |
| "电脑一直转圈打不开网页" | 正确理解成 DNS/网络问题 | 原文没有"转圈""打不开"这些词,命中 0 |
| "P0 故障升级流程" | 尚可,但"P0"这种短代码容易被语义稀释 | 依赖分词,"P0" 可能被当停用词切掉 |
| "怎么开发票" vs 原文"发票申请入口" | 命中,语义对齐 | 词面不同,可能漏 |
向量模型在训练时没见过的内部术语,会被塞到一个"差不多"的位置;BM25 不懂同义改写,但只要字面出现就一定算分。
2. 融合方式:RRF 比加权求和更稳
最简单的融合是加权:score = α·cos + (1-α)·bm25。问题是两个分数分布完全不同——余弦在 0~1,BM25 是无上界的正数,
每次查询的分布还在变,归一化做不好就会一路碾压另一路。
倒数排名融合(Reciprocal Rank Fusion,RRF)只用名次,不看分数:
score(d) = Σ_i w_i / (k + rank_i(d)) k 常取 60
文档 向量排名 BM25 排名 RRF 得分(k=60, w=1)
D1 1 9 1/61 + 1/69 = 0.0309
D2 6 1 1/66 + 1/61 = 0.0315 ← 双路都在前列者胜出
D3 20 - 1/80 = 0.0125
k 的作用是压平"头几名"的差距:k 越大,第 1 名和第 5 名的得分越接近,融合越依赖两路的共识而不是单路的第一名。
3. 整体链路
query
├─▶ Embedding ─▶ ANN top-50 ─┐
│ ├─▶ RRF 融合 top-20 ─▶ Rerank top-5 ─▶ LLM
└─▶ 分词 ─▶ BM25 top-50 ─────┘
两路召回各取 30~50 条,融合后 20 条交给重排——混合检索解决"有没有",重排解决"准不准",别指望混完就完事。
4. 代码:本地 BM25 + 向量 + RRF
import jieba
import numpy as np
from rank_bm25 import BM25Okapi
def bm25_rank(query: str, corpus: list[str], top: int = 50) -> list[int]:
# 中文必须先分词,整句当 token 的 BM25 等于没做
tokens = [list(jieba.cut(doc)) for doc in corpus]
scores = BM25Okapi(tokens, k1=1.5, b=0.75).get_scores(list(jieba.cut(query)))
return list(np.argsort(scores)[::-1][:top])
def vector_rank(qvec: np.ndarray, mat: np.ndarray, top: int = 50) -> list[int]:
scores = mat @ qvec # 向量已归一化,点积即余弦
return list(np.argsort(scores)[::-1][:top])
def rrf(rankings: list[list[int]], k: int = 60, weights: list[float] | None = None) -> list[int]:
weights = weights or [1.0] * len(rankings)
fused: dict[int, float] = {}
for w, ranking in zip(weights, rankings):
for rank, doc_id in enumerate(ranking, start=1):
fused[doc_id] = fused.get(doc_id, 0.0) + w / (k + rank)
return sorted(fused, key=fused.get, reverse=True)
# 短查询里型号/错误码权重上调;长口语问句里向量权重上调
final_ids = rrf([vector_rank(qv, mat), bm25_rank(q, corpus)], k=60, weights=[0.7, 1.3])[:20]
生产环境通常把 BM25 那一路交给 Elasticsearch 或 OpenSearch(它们同时支持 knn 字段),省掉自己维护分词与倒排。
5. 什么时候可以不混
| 场景 | 判断 |
|---|---|
| 语料是自然语言问答,查询也全是口语 | 纯向量通常够 |
| 领域内有大量编号、型号、错误码 | 必须加 BM25 |
| 多语言混合语料 | 建议加,BM25 对小语种分词要求高,可用字符 n-gram |
| 延迟预算 < 50 ms | 慎用,两路召回加热融合会翻倍 |
6. 常见追问
Q:两路权重怎么定? 按查询类型动态调:含大写代码、数字、引号的查询提高 BM25 权重;长自然语言问句提高向量权重。固定 0.5/0.5 也能用,但不如按查询特征分层。
Q:混合检索能替代重排吗? 不能。融合只是把两路粗排结果并起来,真正判"这段能不能回答问题"要靠交叉编码器逐对打分。
Q:中文分词用什么? 通用场景 jieba,ES 里用 IK 或 smartcn;分词粒度不一致会让同一术语被切成不同 token,务必在索引与查询两侧用同一套分析器。
小结
混合检索的本质是用两种互补的偏差互相纠错。先看评估集里漏召回的是"语义类"还是"字面类",再决定要不要加第二路,别默认上。