篇四:召回优化——为什么纯向量检索不够用,Hybrid Search 怎么配

17 阅读7分钟

第四篇:召回优化

前一篇聊了 Embedding 和向量检索,很多人到这一步就觉得"检索搞定了"。但实际跑测试集会发现:向量检索的召回率往往只有 60-70%,漏掉的那 30% 才是用户投诉的来源。

先说结论

  1. 纯向量检索有先天缺陷:擅长语义匹配,但弱于精确匹配(专有名词、数字、代码、型号)。
  2. Hybrid Search = 向量检索 + BM25 关键词检索,两者互补,召回率通常能提升 15-25%。
  3. Reranker 是召回后的"精排" :用更强的交叉编码器对召回结果重新打分,Top-1 准确率可提升 20-40%。
  4. 生产环境标配:BM25 + 向量 → 合并去重 → Reranker → Top-K 给 LLM。
  5. 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)
# 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 基本流程

用户 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∈R1k+rankr(d)RRF(d) = \sum_{r \in R} \frac{1}{k + rank_r(d)}

其中 kk 通常取 60,RR 是各路召回结果,rankr(d)rank_r(d) 是文档 dd 在第 rr 路中的排名。

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:

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,需要外部组合:

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 代码示例

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倒排索引,快
Reranker50-100 条200-500ms交叉编码,慢但准

关键优化: Reranker 只在召回后的少量候选上跑,不要全库跑。50 条候选用 fp16 推理,通常 200-300ms 能完成。


五、效果能提升多少?

根据公开基准和社区实践数据:

配置MRR@10(典型值)说明
纯向量检索0.55-0.65基线
向量 + BM25(RRF 合并)0.65-0.75+10-15%
向量 + BM25 + Reranker0.75-0.85再 +10-15%

注意: 具体提升幅度取决于数据集和问题类型。专有名词多、数字多的场景,BM25 贡献更大;语义理解要求高的场景,向量和 Reranker 贡献更大。


六、避坑清单

  1. 只调向量检索,不上 BM25 → 专有名词/数字场景必翻车
  2. BM25 和向量结果直接拼接,不去重 → 同一条文档出现两次,浪费 LLM 上下文窗口
  3. RRF 的 k 值乱设 → 默认 60 即可,不需要调
  4. Reranker 跑全库 → 延迟爆炸,只在召回后的 50-100 条候选上跑
  5. Reranker 不用 fp16 → 推理速度慢一倍,效果几乎没区别
  6. 合并后不截断就直接给 LLM → 召回 50 条全塞进 Prompt,噪声太多反而降低回答质量
  7. 忽略字段权重 → BM25 检索时 title 字段应该比正文权重高(title^3)

小结

Hybrid Search + Reranker 是当前 RAG 召回优化的标准答案。向量检索解决"语义相近",BM25 解决"精确匹配",Reranker 解决"精准排序"——三者各司其职。

一个经验法则: 如果你的 RAG 系统检索不准,先检查有没有上 BM25;如果上了 BM25 还是不准,再加 Reranker。这两步加完,召回率通常能从 60% 级别拉到 80%+。