第四篇:召回优化
前一篇聊了 Embedding 和向量检索,很多人到这一步就觉得"检索搞定了"。但实际跑测试集会发现:向量检索的召回率往往只有 60-70%,漏掉的那 30% 才是用户投诉的来源。
先说结论
- 纯向量检索有先天缺陷:擅长语义匹配,但弱于精确匹配(专有名词、数字、代码、型号)。
- Hybrid Search = 向量检索 + BM25 关键词检索,两者互补,召回率通常能提升 15-25%。
- Reranker 是召回后的"精排" :用更强的交叉编码器对召回结果重新打分,Top-1 准确率可提升 20-40%。
- 生产环境标配:BM25 + 向量 → 合并去重 → Reranker → Top-K 给 LLM。
- Reranker 有延迟成本:只在召回后对少量候选(50-100 条)重排,不要全库跑。
一个实际案例
某技术文档问答系统,用户问 "Kafka 3.5.1 版本怎么配置 SASL_SSL"。
- 纯向量检索:召回一堆"Kafka 配置教程""SSL 配置指南",但版本号和 SASL_SSL 这两个关键词没被精准命中,正确答案排在第 18 位,Top-10 里根本没出现。
- BM25 关键词检索 :命中了包含
"Kafka 3.5.1"和"SASL_SSL"的文档,但召回了很多不相关的页面(比如只是提到了版本号但没有配置说明)。 - Hybrid Search + Reranker:向量召回语义相关文档,BM25 召回关键词匹配文档,合并后用 Reranker 精排,正确答案排到第 1 位。
召回率从 64% 提升到 91%。
一、为什么纯向量检索不够用?
向量检索的本质是语义相似度匹配,但以下场景它会失效:
| 场景 | 向量检索的问题 | 例子 |
|---|---|---|
| 专有名词 | 模型可能不知道"张三"和"张伟"不是同一个人 | 问"张三的工位",召回"张伟的工位" |
| 数字/版本号 | 向量空间里 3.5.1 和 3.5.2 几乎没区别 |
问"3.5.1 版本",召回一堆其他版本的文档 |
| 代码/命令 | kubectl get pods 和 kubectl delete pods 语义很接近但含义相反 |
运维场景极易出错 |
| 精确匹配 | 用户就是想找包含某个特定短语的文档 | 问"报销上限是 5000 还是 8000" |
| 新词/术语 | 训练数据里没有的词,embedding 无法准确表达 | 公司内部缩写"XZ-2024 项目" |
BM25 恰好能补这些短板:它基于词频和逆文档频率,对精确关键词匹配非常敏感。
二、BM25 是什么?
BM25 是传统搜索引擎的核心排序算法,核心思想:
- 一个词在文档中出现越多,相关性越高(TF)
- 一个词在所有文档中出现越少(越稀有),区分度越高(IDF)
ini
# Elasticsearch 中 BM25 是默认评分算法,无需额外配置
# 如果用 Python 原生实现:
from rank_bm25 import BM25Okapi
corpus = [
"Kafka 3.5.1 配置 SASL_SSL 认证",
"Kafka 3.4.0 配置 SSL 加密",
"RabbitMQ 配置 SASL 认证",
]
tokenized_corpus = [doc.split() for doc in corpus]
bm25 = BM25Okapi(tokenized_corpus)
query = "Kafka 3.5.1 SASL_SSL"
scores = bm25.get_scores(query.split())
print(scores) # [0.82, 0.15, 0.08] ------ 第一条得分最高
BM25 的优势: 对精确关键词匹配敏感,实现简单,无需训练。 BM25 的劣势: 不懂语义,"手机" 和 "移动电话" 匹配不上。
三、Hybrid Search:1 + 1 > 2
3.1 基本流程
css
用户 Query
├──→ 向量检索 → Top-50 候选
└──→ BM25 检索 → Top-50 候选
↓
合并 + 去重(可能 80 条)
↓
Reranker 精排
↓
取 Top-10 给 LLM
3.2 结果合并策略
两路召回的结果怎么合并?常见三种方案:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| Reciprocal Rank Fusion(RRF) | 按排名倒数加权融合,无需归一化分数 | 最常用,推荐 |
| 分数归一化 + 加权求和 | 把向量相似度和 BM25 分数都归一化到 0,1,加权合并 | 需要调权重 |
| 简单去重 + Reranker 兜底 | 合并后直接交给 Reranker 排序 | Reranker 够强时可以偷懒 |
RRF 公式: RRF(d)=∑r∈Rk+rankr(d)1
其中 k 通常取 60, R 是各路召回结果, rankr(d) 是文档 d 在第 r 路中的排名。
python
def rrf_merge(results_list, k=60, top_n=10):
"""
results_list: 多路召回结果,每路是 [(doc_id, score), ...] 按排名排序
"""
rrf_scores = {}
for results in results_list:
for rank, (doc_id, _) in enumerate(results):
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1 / (k + rank)
# 按 RRF 分数降序
sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
return sorted_docs[:top_n]
3.3 代码示例(Elasticsearch 混合检索)
Elasticsearch 8.x 原生支持 Hybrid Search:
makefile
from elasticsearch import Elasticsearch
client = Elasticsearch("http://localhost:9200")
response = client.search(
index="knowledge_base",
query={
"bool": {
"should": [
# 向量检索
{
"script_score": {
"query": {"match_all": {}},
"script": {
"source": "cosineSimilarity(params.query_vector, 'embedding') + 1.0",
"params": {"query_vector": query_vector}
}
}
},
# BM25 关键词检索
{
"multi_match": {
"query": user_query,
"fields": ["text^2", "title^3", "metadata.section"]
}
}
]
}
},
size=50
)
3.4 代码示例(Milvus + 外部 BM25)
Milvus 本身不支持 BM25,需要外部组合:
ini
from rank_bm25 import BM25Okapi
# 1. 向量检索
vector_results = collection.search(
data=[query_vector],
anns_field="embedding",
param={"metric_type": "COSINE", "params": {"nprobe": 10}},
limit=50,
output_fields=["id", "text"]
)
# 2. BM25 检索(需提前建好 BM25 索引)
bm25_scores = bm25.get_scores(query_tokens)
top_bm25_indices = np.argsort(bm25_scores)[-50:][::-1]
# 3. 合并 + RRF
vector_ranked = [(r.id, r.score) for r in vector_results[0]]
bm25_ranked = [(doc_ids[i], bm25_scores[i]) for i in top_bm25_indices]
merged = rrf_merge([vector_ranked, bm25_ranked])
# 4. Reranker 精排(见下一节)
四、Reranker:召回后的"精排"
召回阶段追求高召回率 (宁可多召回,不能漏),但给 LLM 的上下文需要高精准度(噪声越少越好)。Reranker 就是干这个的。
4.1 为什么需要 Reranker?
- 向量检索和 BM25 都是双编码器(query 和 doc 分别编码再算相似度),速度快但精度有限
- Reranker 是交叉编码器(query 和 doc 一起输入模型),计算量大但精度高
- 典型用法:召回 100 条 → Reranker 重排 → 取 Top-10 给 LLM
4.2 主流 Reranker 模型
| 模型 | 语言 | 特点 |
|---|---|---|
| bge-reranker-large | 中文 | 中文重排 SOTA,开源免费 |
| bge-reranker-v2-m3 | 多语言 | 支持多语言,效果更强 |
| Cohere Rerank | 多语言 | API 服务,效果好但要花钱 |
| Jina Reranker | 多语言 | 开源 + API 两种选择 |
4.3 代码示例
ini
from FlagEmbedding import FlagReranker
reranker = FlagReranker('BAAI/bge-reranker-large', use_fp16=True)
# candidates: 召回的候选文档列表
pairs = [(user_query, doc_text) for doc_text in candidates]
scores = reranker.compute_score(pairs, normalize=True)
# 按分数排序,取 Top-10
ranked = sorted(zip(candidates, scores), key=lambda x: x[1], reverse=True)
top_k = ranked[:10]
4.4 性能与成本
| 阶段 | 候选数量 | 单次耗时 | 说明 |
|---|---|---|---|
| 向量检索 | 全库(百万级) | 10-50ms | 近似最近邻,快 |
| BM25 | 全库(百万级) | 5-20ms | 倒排索引,快 |
| Reranker | 50-100 条 | 200-500ms | 交叉编码,慢但准 |
关键优化: Reranker 只在召回后的少量候选上跑,不要全库跑。50 条候选用 fp16 推理,通常 200-300ms 能完成。
五、效果能提升多少?
根据公开基准和社区实践数据:
| 配置 | MRR@10(典型值) | 说明 |
|---|---|---|
| 纯向量检索 | 0.55-0.65 | 基线 |
| 向量 + BM25(RRF 合并) | 0.65-0.75 | +10-15% |
| 向量 + BM25 + Reranker | 0.75-0.85 | 再 +10-15% |
注意: 具体提升幅度取决于数据集和问题类型。专有名词多、数字多的场景,BM25 贡献更大;语义理解要求高的场景,向量和 Reranker 贡献更大。
六、避坑清单
- 只调向量检索,不上 BM25 → 专有名词/数字场景必翻车
- BM25 和向量结果直接拼接,不去重 → 同一条文档出现两次,浪费 LLM 上下文窗口
- RRF 的 k 值乱设 → 默认 60 即可,不需要调
- Reranker 跑全库 → 延迟爆炸,只在召回后的 50-100 条候选上跑
- Reranker 不用 fp16 → 推理速度慢一倍,效果几乎没区别
- 合并后不截断就直接给 LLM → 召回 50 条全塞进 Prompt,噪声太多反而降低回答质量
- 忽略字段权重 → BM25 检索时 title 字段应该比正文权重高(
title^3)
小结
Hybrid Search + Reranker 是当前 RAG 召回优化的标准答案。向量检索解决"语义相近",BM25 解决"精确匹配",Reranker 解决"精准排序"------三者各司其职。
一个经验法则: 如果你的 RAG 系统检索不准,先检查有没有上 BM25;如果上了 BM25 还是不准,再加 Reranker。这两步加完,召回率通常能从 60% 级别拉到 80%+。