Files
zhuangang/topics/ai/rag/02-hybrid-rerank.md
T

95 lines
4.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: ai.rag.hybrid-rerank
title: 检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写
type: lesson
domain: ai
stack: [rag]
tags: [混合检索, BM25, RRF, 重排, rerank, 查询改写]
level: intermediate
prerequisites: [ai.rag.pipeline-chunking]
est_hours: 3
related: [ai.rag.generation-eval, ai.vector-db.ann-metrics, ai.rag.interview]
week: [6]
order: 2
---
# 检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写
## 解释说明
**向量检索和关键词检索各有短板**
| | 向量检索 | 关键词检索(BM25) |
| --- | --- | --- |
| 擅长 | 语义相近、换种说法(“带薪假” ↔ “年假”) | 精确词:产品型号、错误码、人名、条款编号 |
| 不擅长 | 精确的编号、罕见专有名词 | 同义词、口语化的问题 |
所以生产环境常用**混合检索**:两路各取 top-N,再融合。
**融合:RRF(Reciprocal Rank Fusion)**:`score(d) = Σ 1 / (k + rank_i(d))`,`k` 常取 60。只用排名、不用原始分数,不需要处理两种分数的量纲差异,简单稳健。也可以按权重加权归一化后的分数。
**中文关键词检索要先分词**(jieba 等),否则 BM25 按单字或整句计算,效果很差。Elasticsearch / OpenSearch 需要安装中文分词插件;PostgreSQL 可以用 `pg_trgm` 或中文分词扩展。
**重排(rerank)**:第一阶段召回 20~50 个候选,用交叉编码器(cross-encoder)重排模型对“问题 + 候选块”逐对打分,取前 3~5 个给大模型。重排更准但更慢,只对少量候选使用。重排模型可以用云服务或私有部署的开源模型,以对应官方文档为准。
**查询改写**
- 多轮对话中,“那病假呢?”要结合历史改写成独立问题“病假怎么规定的?”再检索;
- 把一个复杂问题拆成多个子问题分别检索;
- 生成假设答案再用它去检索(HyDE)。
改写会多一次模型调用,增加延迟和成本,按需使用。
**元数据过滤**:按权限、文档类型、时间等过滤,**在检索阶段**完成(向量库的 filter 或预过滤),见[权限场景](scenarios/permission-aware-kb.md)。
## 案例
IT 知识库:用户问“报错 0x80070005 怎么办”,纯向量检索返回的都是“权限问题”的泛泛文章,没有命中包含这个错误码的那篇;加入 BM25 后,精确匹配错误码的文档排第一。反过来,“在家怎么连公司网络”在 BM25 下命中很差(文档里写的是“VPN 远程办公”),向量检索能找到。混合之后两类问题都能覆盖。
## 代码示例
RRF 融合(摘自 [examples/mini-rag](examples/mini-rag/),示例中还包含向量检索和 BM25 检索的实现):
```python
def rrf(result_lists: list[list[str]], k: int = 60, top: int = 5) -> list[tuple[str, float]]:
"""result_lists:每一路检索按相关度排好序的文档 ID 列表。"""
scores: dict[str, float] = {}
for results in result_lists:
for rank, doc_id in enumerate(results, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores.items(), key=lambda kv: -kv[1])[:top]
vector_hits = ["vpn-guide", "permission-faq", "printer"] # 语义检索结果
bm25_hits = ["error-0x80070005", "vpn-guide", "office-install"] # 关键词检索结果
for doc_id, score in rrf([vector_hits, bm25_hits]):
print(f"{doc_id:18s} {score:.4f}")
# vpn-guide 两路都排得靠前,融合后排第一;只出现在一路的文档按名次排在后面
```
中文 BM25(`rank_bm25` + `jieba`):
```python
import logging
import jieba
from rank_bm25 import BM25Okapi
jieba.setLogLevel(logging.WARNING)
docs = ["Windows 更新报错 0x80070005 的处理方法", "VPN 远程办公连接指南", "打印机共享设置"]
bm25 = BM25Okapi([jieba.lcut(d) for d in docs])
print(bm25.get_scores(jieba.lcut("报错 0x80070005 怎么办")).round(2)) # 第一篇得分最高
```
## 易错点
- **只做向量检索**:错误码、型号、人名这类精确查询效果差。
- **中文 BM25 不分词**:分数几乎没有意义。
- **直接把两路的原始分数相加**:余弦相似度在 0~1,BM25 可能是 0~20,量纲不同。用 RRF 或先归一化。
- **重排所有候选**:重排模型慢,只对召回的前几十个使用。
- **top-k 设得很大以为更保险**:无关内容增多,模型更容易被干扰,成本也更高。
- **多轮对话直接用最后一句检索**:“那病假呢”检索不到东西,需要改写。
## 面试常问
见 [RAG 面试题](interview.md):为什么要混合检索、RRF 原理、重排的作用、多轮对话怎么检索。