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