Skip to content

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 是默认起点。这里补三个实操点:
    1. 为什么用 RRF 而不是加权求和:BM25 分数无上界且随语料变化,稠密相似度在 [0,1],两者直接加权必须做归一化,而归一化参数很脆;RRF 只用排名(score = Σ 1/(k + rank),k 常取 60),与分数尺度无关,鲁棒得多;
    2. k=60 不是魔法数字:它控制「头部优势」的衰减速度,候选集大或两路质量差异明显时才需要调;
    3. 两路都要召回上限:各取 50-100 条再融合,融合后截断到重排能吃下的量。

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]

三个结论,比「融合一定更好」这种空话有用得多:

  1. 融合的价值来自「两路的失败不重合」:第 5 条查询「缓存 键值 存储」,稠密路把 PostgreSQL 排到了 Redis 前面(主题空间里两者「数据」维都偏高,细粒度区分不够),而稀疏路靠「缓存」「键值」两个精确词项把 Redis 拉回第一——融合后正确;
  2. 融合救不了「两路都错」:第 6 条查询「缓冲 高速 读取」是同义改写,稀疏路得分为 0(语料里只有「缓存」没有「缓冲」,bigram 完全不重合),稠密路同样被 PostgreSQL 抢走,融合结果依然是错的。这时该换的是某一路本身(真正的语义嵌入、同义词扩展或 query 改写),而不是再加第三路;
  3. 「没有分数」的伪排序会污染融合:稀疏路全 0 分时 sorted 保持原序,等于把语料第一条(Python)当成第一名交给了 RRF,而 RRF 只看排名不看分数,照样给它加了权重。工程上要给每路设最低分数门槛——这一路宁可不贡献候选,也不要交一份噪声排名进来。

3.3 Query 变换的组合与成本

RAG 与 Agent 列了改写 / multi-query / HyDE 三种。工程上的组合原则:

手段检索次数适用注意
查询改写1口语化问句、代词指代改写后要保留原词项,别改丢专有名词
多查询(multi-query)2-3一个问题有多种表述必须去重合并,否则上下文塞满同一段
子查询分解2-5多跳问题(「A 和 B 谁更早,各自特点」)子问题答案要分别标注来源,最后合并
Step-back1问得太具体导致召回不到先问抽象层概念,再落回具体
HyDE1问题与文档语言差异大假设答案错了会带偏检索,慎用于事实精确场景
  • 成本意识:每次 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」。证据片段是检索层能否自动评测的关键;
  • 消融实验顺序(每次只改一项,跑完记录再下一项):
    1. 基线(切分 A + 嵌入 A + 纯向量)→ 记 Recall@k;
    2. 换切分(结构块 / 句子窗口)→ 看召回;
    3. 加 BM25 混合 → 看术语类 query 的召回;
    4. 加 query 变换 → 看口语化 query 的召回;
    5. 加重排 → 看 top-5 命中与端到端正确率;
    6. 调上下文组装 → 看忠实度与成本。
  • 文档更新后全量回归,把金标集回放做成 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 与最大检索轮次

八、踩坑清单

  1. 没建金标集就调参:没有 Recall@k 基线,所有改动都无法判断好坏,只能靠「感觉好像好点了」;
  2. 一次改多个变量:效果好不知道归谁,效果差无法回滚,实验等于白做;
  3. 召回不足就堆 top-k:把 top-k 从 5 加到 20 只是把噪声塞进上下文,正确解法是提召回质量或加重排;
  4. 直接加权融合稀疏与稠密分数:两者尺度不同,融合结果被 BM25 的大分数主导;用 RRF 这类基于排名的方法;
  5. 权限过滤放在生成层:无权限内容已进上下文,属于数据泄漏,不是权限控制;
  6. 缓存键不含语料版本号:文档更新了还在返回旧答案,且极难排查;
  7. 重排模型跨语言直接用:中文语料配英文重排模型,精度反而不如不重排;
  8. 为了「先进」上 GraphRAG:全局性问题在金标集里占比很低的话,建图成本远大于收益;
  9. multi-query 不做去重:三路召回拿到同一个片段三次,白白挤占上下文预算;
  10. Agentic RAG 不给检索轮次上限:模型反复换词重查,一次问答烧掉几十次调用;
  11. 只测端到端不测检索层:答案错了不知道是召回问题还是生成问题,定位成本翻倍。

九、检查清单

  • [ ] 有 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 编排

基于 VitePress 构建 · 内容以知识共享方式沉淀