实操 08 · 检索进阶-混合检索与重排
速览卡
| 项 | 内容 |
|---|---|
| 一句话定位 | 实操 02 把 RAG 跑通了,本篇解决"跑通之后为什么还是不准" |
| 核心内容 | ANN 索引选型与调参、融合策略的边界、查询侧增强(HyDE / 学到的稀疏)、元数据过滤、GraphRAG 判断框架、RAG 评测分层 |
| 前置 | 实操 02 · RAG 实战(本篇不重复它的基础链路) |
| 读完能回答 | 召回不准该换索引、换融合、还是换策略?我该不该上 GraphRAG?怎么证明检索变好了? |
| 相关板块 | 02 · 模型能力增强技术 | 12 · 上下文工程 | 实操 05 · 评测集构建与回归 |
一、先界定边界:本篇不重复什么
实操 02 已经讲完并在代码里实现了这些,本篇不再重复:
| 实操 02 已覆盖 | 本篇讲什么 |
|---|---|
| 解析 → 分块 → 向量化 → 入库 | 索引类型怎么选、参数怎么调 |
| BM25 + 向量双路召回 + RRF 融合 | RRF 什么时候不够用,替代方案是什么 |
| CrossEncoder 重排(一行接入) | 重排之外还能怎么提升召回(HyDE、学到的稀疏) |
| 引用溯源与拒答 | 生产必须的元数据过滤 |
| 第一版评测(手工 10 条) | 分层指标体系与可回归的评测集 |
一句话:实操 02 是"让它跑起来",本篇是"让它跑准"。
二、ANN 索引:选型的三个变量
数据量上来后,精确扫描不可行,必须用近似最近邻(ANN)。三个变量决定选型: 内存预算 / 召回率要求 / 延迟要求——三者只能取其二。
| 索引 | 原理 | 适用 | 代价 |
|---|---|---|---|
| Flat(精确) | 暴力扫描 | 小数据集;评测的 ground truth | O(N),数据量一大就不行 |
| HNSW | 多层近邻图 | 读多写少、中小规模,默认选择 | 内存吃紧;构图慢 [易变] |
| IVF | 聚类分桶,只搜最近的几个桶 | 大规模、内存受限 | 需要调 nprobe;桶边缘会漏 |
| IVF-PQ | IVF + 向量压缩 | 超大规模、内存优先 | 量化有损,精度下降 |
| DiskANN / 对象存储 + 懒加载 | 索引放磁盘/对象存储 | 冷数据多、成本敏感 | 冷启动延迟上升 [易变] |
若你的数据已经在 PostgreSQL 里,不必另起一套向量库——pgvector 原生支持 HNSW/IVFFlat,且与业务数据同事务。 索引怎么建、参数为何这么调、过滤为何会悄悄失效,见 DB11 · pgvector。
调参只有三个旋钮
# HNSW:两个参数
index = client.create_index(
ef_search=128, # 搜索时考察的候选数 ↑ = 召回↑ 延迟↑
m=16, # 图的连接度 ↑ = 召回↑ 内存↑ 构图↑
)
# IVF:一个参数
nprobe = 32 # 探查的桶数 ↑ = 召回↑ 延迟↑⚠️ 不要用感觉调参。正确做法:用 Flat 索引跑一遍同一批 query 当作 ground truth, 再拿 ANN 结果对比算 recall@k,找到"召回率拐点"对应的参数值。 这一步花一小时,能省掉后面无数次"检索怎么又不准了"。
三、融合策略:RRF 很好,但有边界
RRF(Reciprocal Rank Fusion)是默认选择,理由是只用排名、不用分数,因此不需要归一化,几乎无参数:
def rrf(rank_lists, k=60):
"""rank_lists: [[doc_id...], [doc_id...]],越靠前越相关"""
fused = {}
for ranks in rank_lists:
for rank, doc_id in enumerate(ranks, start=1):
fused[doc_id] = fused.get(doc_id, 0) + 1 / (k + rank)
return sorted(fused, key=fused.get, reverse=True)但 RRF 在两种情况下不是最优:
| 情况 | 为什么 RRF 不合适 | 替代 |
|---|---|---|
| 两路的分数本身有可比含义(如一路是校准过的相关概率) | RRF 丢掉分数只用排名 = 信息损失 | 加权求和,权重在留出集上调 |
| 三路及以上召回(向量 + 关键词 + 图谱) | 排名的分母主导,融合变噪 | 学习式加权,或对合并结果再上一次重排 |
实践经验:两路 RRF 覆盖了绝大多数生产场景。 不要因为"听起来更先进"就上三路融合——先有两路出问题的证据再说。
融合权重可以偏向某一路,取决于你的 query 形态:
| 领域特征 | 偏向 |
|---|---|
| 大量标识符、代码、法条引用 | 偏稀疏(关键词) |
| 大量改写、口语化提问 | 偏稠密(向量) |
四、查询侧增强:不只是"换个检索器"
4.1 HyDE:先让模型编一个"假答案"
思路:用户问的是短问题,库里存的是陈述句,两者形态不同导致相似度低。 那就先让 LLM 生成一个假想的答案文档,拿它去检索。
def hyde_search(query, llm, retriever, top_k=10):
# 1. 让模型凭空写一个"看起来像答案"的段落
hypothetical = llm.complete(
f"请就下面这个问题写一段 100 字左右的回答(可以编,只要像文档):\n{query}"
).text
# 2. 拿这段假文档去检索,而不是拿原问题
return retriever.search(hypothetical, top_k=top_k)| 优点 | 代价 |
|---|---|
| 零样本场景召回提升明显 | 多一次 LLM 调用(成本 + 延迟) |
| 实现简单 | 编得离谱时会把检索带偏 |
判断标准:如果你的 query 是"问题形态"而文档是"陈述形态",HyDE 值得一试; 如果两边形态本来就接近(如 FAQ 对 FAQ),收益有限。
4.2 学到的稀疏检索(SPLADE / ColBERT)
BM25 只认字面,不认识同义词;稠密向量懂语义,但丢掉精确词。 学到的稀疏检索想两头都要:产出稀疏向量(能被倒排索引服务),但权重是学出来的,能表达语义。
| BM25 | SPLADE 类 | 稠密向量 | |
|---|---|---|---|
| 精确词匹配 | ✅ | ✅ | ❌ 常丢 |
| 同义/改写 | ❌ | 部分 ✅ | ✅ |
| 索引形态 | 倒排 | 倒排 | ANN 索引 |
什么时候该看它:dense + BM25 已经调到位、但仍有明显召回缺口时。 不要在起步阶段就上——先把便宜的两路做好。
五、元数据过滤:生产躲不开的一关
Demo 里"检索全库"没问题,生产里必须按租户、权限、时间、文档类型过滤。 这是安全与正确性问题,不是优化项。
两种实现路径:
| 方式 | 做法 | 优点 | 风险 |
|---|---|---|---|
| Pre-filter | 先按条件筛掉,再在小集合上做 ANN | 结果严格正确 | 条件很严格时,候选集太小会破坏 HNSW 图结构,召回骤降 |
| Post-filter | 先 ANN 取回,再丢掉不符合的 | 快 | 可能"取回来的全被丢掉",最终不足 k 条 |
# 关键:过滤后要检查「够不够」,不够要有兜底
hits = retriever.search(q, top_k=50, filter={"tenant_id": tid, "acl": {"$in": roles}})
if len(hits) < 5:
hits = retriever.search(q, top_k=50) # 放宽重查
hits = [h for h in hits if h.acl_allowed] # 再过滤⚠️ 租户隔离绝不能只靠"检索时过滤"。 权限校验要在写入侧和数据层都做,检索过滤只是其中一道。详见 09 篇 · 安全治理与合规。
六、GraphRAG:什么时候值得,什么时候别碰
GraphRAG 把文档抽成实体-关系图,靠图遍历 + 社区检测回答"跨文档关系"类问题。
| 它擅长 | 它不擅长 |
|---|---|
| 多跳推理("A 收购了 B,B 的供应商是谁") | 简单的单文档查找 |
| 实体密集、关系复杂的语料(财报、医疗、法规) | 散文式、关系稀疏的知识库 |
| 全局性问题("这批文档的总体主题是什么") | 延迟敏感场景 |
代价很实在:
- 构建成本:抽取实体关系要跑大量 LLM 调用,语料大时是一笔真金白银
- 维护成本:文档更新要重建图,增量更新比向量库麻烦得多
- 调试成本:错了很难定位是抽取错、建图错还是遍历错
判断框架(本篇最重要的一条): 在做 GraphRAG 之前,先确认混合检索 + 重排 + 好的分块已经做到位。 这三件事能解决绝大多数检索失败,成本却低一个数量级。 不要因为 GraphRAG 时髦就上它——它是"前三步都做完了还不行"之后的选项。
同理,Agentic RAG(让 Agent 自主决定检索几次、查哪个源)成本高、延迟高, 只在 query 确实需要多步迭代时才划算。
七、RAG 评测:把"感觉不准"变成可回归的数字
7.1 分层测——检索与生成必须分开测
系统可以在"检索对了但生成胡说"和"检索错了但答案碰巧对"之间摇摆, 只看端到端指标会掩盖问题。
| 层 | 指标 | 发现什么 |
|---|---|---|
| 检索 | recall@K(正确片段进没进 Top-K) | 召回漏没漏 |
| 检索 | MRR / nDCG | 排序好不好 |
| 生成 | faithfulness(忠实度) | 有没有凭空编 |
| 生成 | answer relevancy | 答得是不是所问 |
| 上下文 | context relevancy | 取回来的片段有没有用 |
| 端到端 | 与基线对比的胜率 | 整体好不好 |
7.2 评测集怎么来
优先级从高到低:
- 真实线上失败案例(最有价值,且天然防回归)
- 人工构造的分类型问题(标识符类 / 语义类 / 多跳类 / 应拒答类)
- LLM 生成 + 人工抽检(生成的问题常不真实,必须筛)
EVAL_SET = [
{"query": "ERR_0x80 是什么原因", "type": "identifier", "gold_chunk_ids": ["c123"]},
{"query": "怎么重置密码", "type": "semantic", "gold_chunk_ids": ["c456", "c457"]},
{"query": "A 部门和 B 部门审批流程区别", "type": "multi-hop", "gold_chunk_ids": ["c1", "c9"]},
{"query": "公司明年股价预测", "type": "should_refuse", "gold_chunk_ids": []},
]
# ⚠️ 「应拒答」类必须单独建——它最容易被优化掉把评测接进 CI,像对待编译错误一样对待它。 检索侧每次改动(换 embedding、改分块、调融合)都必须跑一遍。 具体做法见 实操 05 · 评测集构建与回归。
八、诊断:混合检索的"静默降级"
这是本篇最隐蔽、也最值得记住的一个坑:
现象:稀疏索引挂了(或没建),每次都返回空列表, 融合静默退化成"只剩稠密一路"。整体 recall 看起来没变—— 因为你本来就是靠稠密在撑,稀疏那一路的贡献本来就只在部分 query 上体现。
唯一能发现它的指标:每路贡献率——融合后 Top-K 里,各有多少来自"仅稠密""仅稀疏""两路都有"。
def contribution_rate(dense_ids, sparse_ids, fused_top_k):
d, s = set(dense_ids), set(sparse_ids)
only_d = sum(1 for x in fused_top_k if x in d and x not in s)
only_s = sum(1 for x in fused_top_k if x in s and x not in d)
both = sum(1 for x in fused_top_k if x in d and x in s)
n = len(fused_top_k)
return {"only_dense": only_d/n, "only_sparse": only_s/n, "both": both/n}| 观察值 | 判断 |
|---|---|
| 大致 30 / 30 / 40 | 健康,两路都在出力 |
| 80 / 10 / 10 | 你在为两个检索器付钱,实际只用了一个 |
| only_sparse ≈ 0 | ⚠️ 检查稀疏索引是否根本没生效 |
另外两个该盯的指标:按 query 类型切片的 recall@K(标识符类 vs 语义类)、 p95 检索延迟(不应比纯稠密基线高出太多)。
九、生产化检查清单
- [ ] ANN 索引参数用 Flat ground truth 对比调过,不是拍脑袋
- [ ] recall@k 有基线数字,且按 query 类型切片看
- [ ] 融合用 RRF,且知道它在什么情况下该换掉
- [ ] 有每路贡献率监控,能发现静默降级
- [ ] 元数据过滤在生产路径上,且有"过滤后不足"的兜底
- [ ] 权限校验不止在检索层
- [ ] 评测集含应拒答类,且已接入 CI
- [ ] 检索指标与生成指标分开看
- [ ] 考虑 GraphRAG 之前,已确认混合+重排+分块做到位
十、动手任务
任务 1:给索引调参,画一张"召回-延迟"曲线
用 Flat 跑 50 条 query 得到 ground truth,然后固定其他条件, 只改 ef_search(如 32 / 64 / 128 / 256),记录每个值的 recall@10 与 p95 延迟。 找到拐点——那个"再往上加参数收益很小"的点,就是你的生产参数。
任务 2:算一次贡献率,看你的混合检索是不是真的在混合
用第八节的代码跑 20 条 query,统计 only_dense / only_sparse / both。 如果是 80/10/10,去查稀疏索引是不是根本没建起来。
任务 3:加一组"应拒答"用例,看系统会不会露馅
构造 5 条库里确实没有答案的问题,看系统是拒答还是硬编。 这一组用例的价值通常高于 20 条普通用例——它检验的是系统会不会撒谎。
延伸方向
- 基础链路(解析/分块/入库/溯源)→ 实操 02 · RAG 实战:可溯源问答系统
- 检索与提示词、微调的层次区分 → 02 · 模型能力增强技术
- 取回来的东西怎么装配进上下文 → 12 · 上下文工程
- 评测集怎么科学扩、LLM-as-Judge 怎么做 → 实操 05 · 评测集构建与回归
- 上线后的监控与回归 → 05 · 工程化与 LLMOps
- 向量库与检索组件选型 → 附录 · 版本与产品速查表
- 权限与租户隔离的设计 → 09 · 安全治理与合规