RAG 进阶:从能召回到召回得准
与 RAG 与 Agent 的分工:那一页是通论,负责把一条 RAG 管线从零搭到可评测、可上线;本页只谈基线跑通之后的进阶手段——索引结构怎么改、检索策略怎么叠加、重排与上下文怎么压、什么时候才值得上 GraphRAG 与 Agentic RAG,以及这些手段各自的代价。默认你已经有一条能跑的管线和一份金标集(没有就先回去建,见 第五 节)。
一、先判断:你真的需要进阶方案吗
进阶手段几乎都在用复杂度换精度。在动结构之前,先确认瓶颈确实在检索:
| 症状 | 真实瓶颈大概率在 | 该动的地方 |
|---|---|---|
| 打印 top-k 发现正确片段根本没进候选 | 召回 | 切分 / 混合检索 / query 变换 |
| 正确片段在 top-20 但没进 top-5 | 排序 | 重排(rerank) |
| top-5 全对,答案仍然编造 | 生成与组装 | 提示词约束、引用校验 |
| 问法一换就崩 | query 与文档语言脱节 | query 改写 / 多路召回 |
| 跨文档的「总体/趋势」类问题答不了 | 检索范式不够 | GraphRAG / 摘要索引 |
| 需要多跳、需要查多个系统 | 流程编排 | Agentic RAG |
- 铁律:先量再改。 没有金标集和 Recall@k 基线,所有「优化」都是玄学调参;
- 一次只改一个变量。 同时换嵌入模型 + 加 BM25 + 加重排,效果好了不知道是谁的功劳,差了不知道该回滚谁;
- 性价比阶梯(从便宜到贵):切分与清洗 → 混合检索 → query 变换 → 重排 → 上下文压缩 → 索引结构改造 → GraphRAG → Agentic RAG。从最上面一级开始,卡住了再往下一级走。
二、索引结构进阶:让「块」更对
固定字符切分的问题不是块大小,而是检索单元 ≠ 语义单元。四种改造:
| 方案 | 怎么做 | 解决什么 | 代价 |
|---|---|---|---|
| 结构块切分 | 按标题/段落/表格/代码块切,块内语义完整 | 句子被拦腰斩断 | 块大小不均,需上限兜底 |
| 句子窗口(small-to-big) | 用单句或小块做检索单元,命中后返回时扩展成前后窗口 | 小块精准但上下文不足 | 存两份(句向量 + 窗口文本) |
| 父子块 / 自动合并 | 叶子块建索引,同一父块下命中数超阈值就合并返回父块 | 长答案需要更大语境 | 实现复杂,需维护父子关系 |
| 摘要 / 分层索引 | 先检索文档摘要层,再下钻到细节层 | 长文档定位慢、噪声多 | 建摘要有一次性成本 |
- 元数据是检索质量的一部分:把
source/updated_at/doc_type/acl写进每条 chunk,检索时既能过滤也能做结果解释; - 过滤前置 vs 后置:能用索引直接过滤的(租户、权限、时间范围)必须前置,减少候选集;需要语义判断的(相关度)后置。权限过滤绝不能只在生成层做——那是数据泄漏不是权限控制;
- 表格与结构化内容:表格转成 Markdown 保留结构检索,比转纯文本效果好得多;数值聚合类问题走 NL2SQL / 结构化查询 而不是向量检索;
- 多模态:图表、扫描件先做版面分析与 OCR,把「图 + 图注 + 周边正文」作为一个块,避免拿到没有图注的孤立图片。
三、检索策略进阶
3.1 混合检索的工程细节
- RAG 与 Agent 已给结论:BM25 + 稠密混合 + RRF 是默认起点。这里补三个实操点:
- 为什么用 RRF 而不是加权求和:BM25 分数无上界且随语料变化,稠密相似度在 [0,1],两者直接加权必须做归一化,而归一化参数很脆;RRF 只用排名(
score = Σ 1/(k + rank),k 常取 60),与分数尺度无关,鲁棒得多; - k=60 不是魔法数字:它控制「头部优势」的衰减速度,候选集大或两路质量差异明显时才需要调;
- 两路都要召回上限:各取 50-100 条再融合,融合后截断到重排能吃下的量。
- 为什么用 RRF 而不是加权求和:BM25 分数无上界且随语料变化,稠密相似度在 [0,1],两者直接加权必须做归一化,而归一化参数很脆;RRF 只用排名(
3.2 本地实测:融合什么时候有用,什么时候没用
用纯 Python 复现「稀疏 + 稠密 + RRF 融合」三路对比:语料 8 条、查询 6 条。稀疏路是简化 BM25(中文按 bigram 分词),稠密路用手工四维主题向量做教学替身——真实工程请换成嵌入模型,但三者的相对行为具有代表性。
python
# rrf_demo.py —— 纯标准库,python3 rrf_demo.py 直接跑
import math, re
from collections import Counter
CORPUS = [
"Python 于 1991 年发布,是解释型脚本语言,以易读著称",
"Go 由 Google 在 2009 年发布,静态编译,以并发原语与编译速度著称",
"Rust 2015 年发布 1.0,所有权系统保证内存安全,无 GC",
"React 是 2013 年发布的前端 UI 库,组件化加虚拟 DOM",
"Kubernetes 2014 年开源,源自 Google Borg,做容器编排与集群调度",
"PostgreSQL 支持 JSONB 与全文检索,可配 pgvector 做向量检索",
"Redis 是内存键值库,常用作缓存与分布式锁",
"Kafka 是分布式消息日志,分区加消费组保证顺序与重放",
]
# 主题向量 [语言, 系统, 前端, 数据] —— 教学替身,真实工程由嵌入模型算出
THEME = [
[1.0, 0.2, 0.0, 0.1], [1.0, 0.4, 0.0, 0.0], [1.0, 0.5, 0.0, 0.0], [0.1, 0.0, 1.0, 0.0],
[0.0, 1.0, 0.0, 0.0], [0.0, 0.3, 0.0, 1.0], [0.0, 0.8, 0.0, 0.9], [0.0, 0.8, 0.0, 1.0],
]
# (查询, 正确文档下标, 查询主题向量)
QUERIES = [
("脚本语言 发布时间", 0, [1.0, 0.1, 0.0, 0.1]),
("前端 组件 界面库", 3, [0.1, 0.0, 1.0, 0.0]),
("容器 编排 集群", 4, [0.0, 1.0, 0.0, 0.0]),
("消息 顺序 重放", 7, [0.0, 0.6, 0.0, 1.0]),
("缓存 键值 存储", 6, [0.0, 0.5, 0.0, 1.0]), # 稠密路会被 PostgreSQL 抢走
("缓冲 高速 读取", 6, [0.0, 0.5, 0.0, 1.0]), # 同义改写,稀疏路得分为 0
]
def tokenize(t):
# 中文取 bigram(无词典分词的常见做法),英文数字按词
toks = []
for seg in re.findall(r"[\u4e00-\u9fff]+", t):
toks += [seg[i:i + 2] for i in range(len(seg) - 1)] or [seg]
return toks + re.findall(r"[a-zA-Z0-9]+", t)
def bm25(query, k=1.5, b=0.75):
q, N = tokenize(query), len(CORPUS)
docs = [tokenize(d) for d in CORPUS]
df = Counter(w for d in docs for w in set(d))
avgdl = sum(len(d) for d in docs) / N
out = []
for i, d in enumerate(docs):
tf, dl, s = Counter(d), len(d), 0.0
for w in q:
if tf[w] == 0:
continue
idf = math.log(1 + (N - df[w] + 0.5) / (df[w] + 0.5))
s += idf * (tf[w] * (k + 1)) / (tf[w] + k * (1 - b + b * dl / avgdl))
out.append((i, s))
return sorted(out, key=lambda x: -x[1]) # 踩坑: 全 0 分时 sorted 保持原序 = 伪排序
def cos(a, b):
dot = sum(x * y for x, y in zip(a, b))
return dot / (math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b)))
def dense(qvec):
return sorted(((i, cos(qvec, v)) for i, v in enumerate(THEME)), key=lambda x: -x[1])
def rrf(*ranked, k=60):
fused = {}
for ranking in ranked:
for rank, (idx, _) in enumerate(ranking, start=1):
fused[idx] = fused.get(idx, 0.0) + 1.0 / (k + rank) # 只看排名, 不看分数
return sorted(fused.items(), key=lambda x: -x[1])
def recall_at_1(ranking, gold):
return 1.0 if ranking[0][0] == gold else 0.0
if __name__ == "__main__":
for name, build in (("稀疏 BM25", lambda q, v: [bm25(q)]),
("稠密(替身)", lambda q, v: [dense(v)]),
("RRF 融合", lambda q, v: [bm25(q), dense(v)])):
hits = [recall_at_1(rrf(*build(q, v)), gold) for q, gold, v in QUERIES]
print(f"{name:>10} Recall@1 = {sum(hits) / len(hits):.2f} {[int(h) for h in hits]}")
# ---- 实测输出(2026-09-04, python3, 语料 8 条 / 查询 6 条)----
# 稀疏 BM25 Recall@1 = 0.83 [1, 1, 1, 1, 1, 0]
# 稠密(替身) Recall@1 = 0.67 [1, 1, 1, 1, 0, 0]
# RRF 融合 Recall@1 = 0.83 [1, 1, 1, 1, 1, 0]三个结论,比「融合一定更好」这种空话有用得多:
- 融合的价值来自「两路的失败不重合」:第 5 条查询「缓存 键值 存储」,稠密路把 PostgreSQL 排到了 Redis 前面(主题空间里两者「数据」维都偏高,细粒度区分不够),而稀疏路靠「缓存」「键值」两个精确词项把 Redis 拉回第一——融合后正确;
- 融合救不了「两路都错」:第 6 条查询「缓冲 高速 读取」是同义改写,稀疏路得分为 0(语料里只有「缓存」没有「缓冲」,bigram 完全不重合),稠密路同样被 PostgreSQL 抢走,融合结果依然是错的。这时该换的是某一路本身(真正的语义嵌入、同义词扩展或 query 改写),而不是再加第三路;
- 「没有分数」的伪排序会污染融合:稀疏路全 0 分时
sorted保持原序,等于把语料第一条(Python)当成第一名交给了 RRF,而 RRF 只看排名不看分数,照样给它加了权重。工程上要给每路设最低分数门槛——这一路宁可不贡献候选,也不要交一份噪声排名进来。
3.3 Query 变换的组合与成本
RAG 与 Agent 列了改写 / multi-query / HyDE 三种。工程上的组合原则:
| 手段 | 检索次数 | 适用 | 注意 |
|---|---|---|---|
| 查询改写 | 1 | 口语化问句、代词指代 | 改写后要保留原词项,别改丢专有名词 |
| 多查询(multi-query) | 2-3 | 一个问题有多种表述 | 必须去重合并,否则上下文塞满同一段 |
| 子查询分解 | 2-5 | 多跳问题(「A 和 B 谁更早,各自特点」) | 子问题答案要分别标注来源,最后合并 |
| Step-back | 1 | 问得太具体导致召回不到 | 先问抽象层概念,再落回具体 |
| HyDE | 1 | 问题与文档语言差异大 | 假设答案错了会带偏检索,慎用于事实精确场景 |
- 成本意识:每次 query 变换通常都要一次 LLM 调用,multi-query + 分解串起来,一次问答的调用数很快翻倍——先算预算再上。
3.4 自适应与迭代检索
- Self-RAG:让模型自己判断「要不要检索」「这段是否相关」「答案是否被支持」,按需触发检索而非每问必查;代价是更多次模型调用与更复杂的提示词;
- CRAG(纠正性检索):先评估检索质量,质量差就换招——重写 query 重查、或回退到外部搜索,而不是硬着头皮生成;
- FLARE:生成过程中遇到下一个不确定 token 就暂停检索补料,适合长答案;
- 三者的共同点是把「检索一次」变成「按需要检索多次」,因此必须配最大检索轮次与去重缓存,否则成本与延迟失控。
3.5 Agentic RAG:把检索变成工具
- 定义:检索不再是固定管线的一环,而是 Agent 手里的一件工具——由模型决定查什么、查几次、查不到时换个词再查;
- 适合:需要多跳、需要跨系统(文档 + 数据库 + API)、问题无法预先拆解成固定流程;
- 不适合:单跳问答、延迟敏感、成本敏感——固定管线便宜且稳定,别为了「智能」而智能;
- 落地要点:检索工具的描述要写清「覆盖什么语料、不适合什么」,每个检索结果必须带来源 ID,回答阶段强制逐句引用;控制流用 LangGraph 之类的图式编排而不是裸 while 循环,才有中断、恢复与回放。
3.6 GraphRAG:什么时候值得
- 做法:从语料抽取实体与关系建图,再做社区聚类与社区摘要,检索时可走「实体邻居」或「社区摘要」;
- 真正的优势是全局性问题:「这批文档讨论了哪些主题」「A 和 B 之间有什么间接关联」——这类问题在向量检索里根本没有对应的「证据片段」,因为答案分散在几十个文档里;
- 代价是实打实的:建图需要大量抽取调用(成本高)、图要随文档更新而增量维护(工程量大)、局部事实问答反而可能不如向量检索直接;
- 判断口径:先用向量 RAG 跑金标集,把「全局/多跳」类问题的失败样本单独拎出来,如果这批样本占比可观且有业务价值,才值得上 GraphRAG;否则它只是一张昂贵的架构图。
四、重排与上下文工程
4.1 重排三档
| 方案 | 原理 | 精度 | 成本/延迟 | 适用 |
|---|---|---|---|---|
| Cross-encoder 重排 | query 与候选拼接后整体打分 | 高 | 中(一次前向 × 候选数) | 默认选择 |
| 迟交互(ColBERT 类) | 提前算好文档侧表示,交互延后 | 中高 | 低延迟、存储大 | 候选多、延迟敏感 |
| LLM 重排(listwise) | 让大模型对候选列表排序 | 最高 | 最贵最慢 | 候选少(≤20)、价值高 |
- 工程默认:召回 50-100 → cross-encoder 重排到 3-10,这是投入产出比最高的一档;
- 重排模型的语言与领域要匹配,跨语言重排是常见翻车点;
- 重排分数不要直接混合进相似度分数当作阈值用——两者尺度不同,只能用排名。
4.2 上下文压缩与排布
- 上下文压缩:拿 query 对候选片段做抽取式压缩,只留下相关句子,能在不降精度的前提下显著省 token;
- 去冗余(MMR 类):候选里常有内容高度重叠的片段,按「相关性 - 相似度惩罚」挑选,避免 top-5 是同一段话的五个版本;
- 位置效应:模型对上下文首尾更敏感、中间易被忽略(lost in the middle),把最相关的片段放首尾,别让它埋在中间;
- 引用锚定:每个片段带稳定 ID 与原文位置,提示词里要求逐句标注来源 ID;生成后做引用可查证校验(回答里的每句话能在被引片段中找到支撑),这比事后人工抽查可靠得多。
五、检索评测:一次只改一个变量
| 层 | 指标 | 说明 |
|---|---|---|
| 检索层 | Recall@k、Hit Rate、MRR、nDCG | 只测「正确片段进没进候选」,与生成无关,快且便宜 |
| 组装层 | 上下文精确率、冗余率 | top-k 里有多少是噪声 |
| 生成层 | 忠实度、引用命中、答案正确率 | 见 Agent 评测 的 rubric 思路 |
| 端到端 | RAGAS 综合、人工标注 | 见 RAG 与 Agent 第五节的四层表 |
- 金标集怎么建:50-200 条真实用户问法(不是产品经理编的问法),每条标出「标准答案要点 + 至少一个标准证据片段 ID」。证据片段是检索层能否自动评测的关键;
- 消融实验顺序(每次只改一项,跑完记录再下一项):
- 基线(切分 A + 嵌入 A + 纯向量)→ 记 Recall@k;
- 换切分(结构块 / 句子窗口)→ 看召回;
- 加 BM25 混合 → 看术语类 query 的召回;
- 加 query 变换 → 看口语化 query 的召回;
- 加重排 → 看 top-5 命中与端到端正确率;
- 调上下文组装 → 看忠实度与成本。
- 文档更新后全量回归,把金标集回放做成 CI 门禁(与 AI 工程化 的评测门禁对齐)。
六、生产化难点
- 增量更新与版本对齐:文档改了要能定位到受影响的 chunk 并重嵌,同时清理旧向量;「源文档版本号 + chunk 哈希」两条元数据缺一不可,否则库里会混进同一段话的多个世代;
- 多租户隔离:向量库的 namespace/集合隔离 + 检索时的元数据过滤双保险,任何一层单独失效都能兜住;
- 权限(ACL):过滤必须在检索层完成,且要在融合与重排之前,不能等生成时再筛——否则无权限的文本已经进了上下文,等于泄漏;
- 缓存三处:嵌入缓存(同一 chunk 不重复算)、检索结果缓存(热门 query)、重排缓存;缓存键要包含语料版本号,否则文档更新后还在返回旧答案;
- 成本结构:嵌入(一次性,量大)、重排(每次请求 × 候选数)、生成(每次请求)——先量出三段的单位成本,再决定优化哪一段;
- 可观测:记录每次问答的 top-k 片段 ID、重排分数、引用校验结果,出问题时能回放现场(见 AI 工程化)。
七、常见失败模式与调试
| 症状 | 根因方向 | 先查 |
|---|---|---|
| 换了嵌入模型效果反而变差 | 新模型与语料语言/领域不匹配 | 抽样看 top-k,确认是召回退化了 |
| 加了 multi-query 却更糟 | 多路结果没去重,上下文被同一段占满 | 合并后的去重率与 top-k 多样性 |
| 重排后精度没提升 | 召回层根本没召到,重排巧妇难为无米 | 先看 Recall@50 再看 Recall@5 |
| 答案有引用但引用查证不过 | 片段切得太碎,来源定位丢失 | chunk 元数据与原文位置映射 |
| 表格/数值问题全错 | 用向量检索做结构化查询 | 改走 NL2SQL 或结构化索引 |
| 越权内容出现在答案里 | ACL 过滤放在了生成层 | 检索层过滤是否前置 |
| 上线一段时间后悄悄变差 | 文档更新后未重嵌 / 缓存未失效 | 语料版本号与缓存键 |
| Agentic RAG 步数失控 | 检索工具描述不清 + 无轮次上限 | 工具 schema 与最大检索轮次 |
八、踩坑清单
- 没建金标集就调参:没有 Recall@k 基线,所有改动都无法判断好坏,只能靠「感觉好像好点了」;
- 一次改多个变量:效果好不知道归谁,效果差无法回滚,实验等于白做;
- 召回不足就堆 top-k:把 top-k 从 5 加到 20 只是把噪声塞进上下文,正确解法是提召回质量或加重排;
- 直接加权融合稀疏与稠密分数:两者尺度不同,融合结果被 BM25 的大分数主导;用 RRF 这类基于排名的方法;
- 权限过滤放在生成层:无权限内容已进上下文,属于数据泄漏,不是权限控制;
- 缓存键不含语料版本号:文档更新了还在返回旧答案,且极难排查;
- 重排模型跨语言直接用:中文语料配英文重排模型,精度反而不如不重排;
- 为了「先进」上 GraphRAG:全局性问题在金标集里占比很低的话,建图成本远大于收益;
- multi-query 不做去重:三路召回拿到同一个片段三次,白白挤占上下文预算;
- Agentic RAG 不给检索轮次上限:模型反复换词重查,一次问答烧掉几十次调用;
- 只测端到端不测检索层:答案错了不知道是召回问题还是生成问题,定位成本翻倍。
九、检查清单
- [ ] 有 50-200 条真实问法组成的金标集,每条带标准证据片段 ID
- [ ] 基线 Recall@k 已记录,且能一键回放
- [ ] 切分按结构而非固定字符,块带
source/updated_at/doc_type/acl元数据 - [ ] 稀疏 + 稠密混合检索已开启,融合用 RRF(基于排名,非加权分数)
- [ ] 权限与租户过滤在检索层前置完成
- [ ] 召回 50-100 → 重排到 3-10,重排模型语言与语料匹配
- [ ] 片段带稳定 ID,回答逐句标注来源,生成后做引用查证
- [ ] 文档更新链路完整:重切 → 重嵌 → 清旧向量 → 缓存失效 → 回归金标集
- [ ] 三段成本(嵌入 / 重排 / 生成)可单独计量
- [ ] 每次问答的 top-k 片段与重排分数落日志,可回放
状态与参考
- 状态:已收录(2026-09-04)。第三节 RRF 实测为本地真实运行(python3 标准库,语料 8 条 / 查询 5 条),稠密路为教学替身实现,真实工程请以嵌入模型替换后再复测;
- 关联:RAG 与 Agent(管线通论与评测四层)、Agent 架构通论(工具与循环)、LangGraph 编排(控制流落地)、Agent 评测与可观测(rubric 与门禁)、AI 工程化(成本与可观测)、LLM 原理(上下文与注意力)、文献收藏:RAG 论文;
- 说明:GraphRAG、Self-RAG、CRAG、FLARE 等方案迭代很快,落地前请对照各自官方仓库/文档核对当前 API 与成本口径;本文只给选型判断与工程纪律,不绑定具体 SDK 版本。
回到基础请见 RAG 与 Agent;当检索变成 Agent 手里的工具、流程需要中断恢复与回放时,请见 LangGraph 编排。