4.7 KiB
4.7 KiB
id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
| id | title | type | domain | stack | tags | level | prerequisites | est_hours | related | week | order | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ai.rag.hybrid-rerank | 检索优化:向量 + 关键词混合检索、RRF 融合、重排、查询改写 | lesson | ai |
|
|
intermediate |
|
3 |
|
|
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):第一阶段召回 2050 个候选,用交叉编码器(cross-encoder)重排模型对“问题 + 候选块”逐对打分,取前 35 个给大模型。重排更准但更慢,只对少量候选使用。重排模型可以用云服务或私有部署的开源模型,以对应官方文档为准。
查询改写
- 多轮对话中,“那病假呢?”要结合历史改写成独立问题“病假怎么规定的?”再检索;
- 把一个复杂问题拆成多个子问题分别检索;
- 生成假设答案再用它去检索(HyDE)。 改写会多一次模型调用,增加延迟和成本,按需使用。
元数据过滤:按权限、文档类型、时间等过滤,在检索阶段完成(向量库的 filter 或预过滤),见权限场景。
案例
IT 知识库:用户问“报错 0x80070005 怎么办”,纯向量检索返回的都是“权限问题”的泛泛文章,没有命中包含这个错误码的那篇;加入 BM25 后,精确匹配错误码的文档排第一。反过来,“在家怎么连公司网络”在 BM25 下命中很差(文档里写的是“VPN 远程办公”),向量检索能找到。混合之后两类问题都能覆盖。
代码示例
RRF 融合(摘自 examples/mini-rag,示例中还包含向量检索和 BM25 检索的实现):
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):
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 可能是 020,量纲不同。用 RRF 或先归一化。 - 重排所有候选:重排模型慢,只对召回的前几十个使用。
- top-k 设得很大以为更保险:无关内容增多,模型更容易被干扰,成本也更高。
- 多轮对话直接用最后一句检索:“那病假呢”检索不到东西,需要改写。
面试常问
见 RAG 面试题:为什么要混合检索、RRF 原理、重排的作用、多轮对话怎么检索。