95 lines
4.7 KiB
Markdown
95 lines
4.7 KiB
Markdown
---
|
||
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 原理、重排的作用、多轮对话怎么检索。
|