Skip to content

实操 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 truthO(N),数据量一大就不行
HNSW多层近邻图读多写少、中小规模,默认选择内存吃紧;构图慢 [易变]
IVF聚类分桶,只搜最近的几个桶大规模、内存受限需要调 nprobe;桶边缘会漏
IVF-PQIVF + 向量压缩超大规模、内存优先量化有损,精度下降
DiskANN / 对象存储 + 懒加载索引放磁盘/对象存储冷数据多、成本敏感冷启动延迟上升 [易变]

若你的数据已经在 PostgreSQL 里,不必另起一套向量库——pgvector 原生支持 HNSW/IVFFlat,且与业务数据同事务。 索引怎么建、参数为何这么调、过滤为何会悄悄失效,见 DB11 · pgvector。

调参只有三个旋钮 ​

python
# 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)是默认选择,理由是只用排名、不用分数,因此不需要归一化,几乎无参数:

python
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 生成一个假想的答案文档,拿它去检索。

python
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 只认字面,不认识同义词;稠密向量懂语义,但丢掉精确词。 学到的稀疏检索想两头都要:产出稀疏向量(能被倒排索引服务),但权重是学出来的,能表达语义。

BM25SPLADE 类稠密向量
精确词匹配✅✅❌ 常丢
同义/改写❌部分 ✅✅
索引形态倒排倒排ANN 索引

什么时候该看它:dense + BM25 已经调到位、但仍有明显召回缺口时。 不要在起步阶段就上——先把便宜的两路做好。


五、元数据过滤:生产躲不开的一关 ​

Demo 里"检索全库"没问题,生产里必须按租户、权限、时间、文档类型过滤。 这是安全与正确性问题,不是优化项。

两种实现路径:

方式做法优点风险
Pre-filter先按条件筛掉,再在小集合上做 ANN结果严格正确条件很严格时,候选集太小会破坏 HNSW 图结构,召回骤降
Post-filter先 ANN 取回,再丢掉不符合的快可能"取回来的全被丢掉",最终不足 k 条
python
# 关键:过滤后要检查「够不够」,不够要有兜底
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 评测集怎么来 ​

优先级从高到低:

  1. 真实线上失败案例(最有价值,且天然防回归)
  2. 人工构造的分类型问题(标识符类 / 语义类 / 多跳类 / 应拒答类)
  3. LLM 生成 + 人工抽检(生成的问题常不真实,必须筛)
python
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 里,各有多少来自"仅稠密""仅稀疏""两路都有"。

python
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 条普通用例——它检验的是系统会不会撒谎。


延伸方向 ​