RAG 演进之路:从检索到上下文图
@Ai_Vaidehi 的一句话对比打底,@0xMorlex 用约 150 行代码走完 9 步路线图。
开篇:一句话建立直觉
先看 @Ai_Vaidehi(2026-08-24)的极简对比,建立整课的直觉:
| RAG | Agentic RAG | |
|---|---|---|
| 流程 | 取相关文档 → 喂给 LLM → 生成答案 | 重写你的 query → 判断缺什么信息 → 选择最佳数据源(DB / API / web)→ 迭代直到答案真正够好 |
| 形态 | 线性(Linear)、单次(One-shot) | 动态(Dynamic)、迭代(Iterative) |
| 驱动 | 有限思考 | 目标驱动(Goal-driven) |
原文结论:对做 AI 产品的人,这个转变不是可选项,而是必然(inevitable)。
但"迭代检索"只是演进的一条线。另一条更硬核的线是 @0xMorlex(2026-08-10)给出的:不用框架、约 150 行代码,从 RAG 到可遍历、可引用的上下文图,9 步实现路线图(含完整代码)。本课主体就按这 9 步展开。
RAG 的结构性局限:多跳查询
以下来自 @0xMorlex。先看 RAG 答不上来的问题:
"如果 Redis 故障,什么会出问题?"
RAG 只能找出含"Redis"词的句子,但答案分散在四份互不关联的文档中——登录流程依赖认证服务,认证服务依赖令牌缓存,令牌缓存后端存于 Redis。相似度搜索无法跨文档跳跃。
这不是调优问题。嵌入能检索相关文本,但拿不到"事物本身",也拿不到构成答案的关联关系。多跳查询在结构上就不适合 RAG。
就像查字典 vs 侦探破案:RAG 是查字典——给你所有含"Redis"的词条;你要的却是侦探的案情板——"谁依赖谁"的红线关系网。字典再厚也长不出红线。
解决方案:停止获取段落,转而获取图
实体为节点、关系为边、答案为路径。9 步分三级:Tier 1 提取 → Tier 2 连接 → Tier 3 穿越。
Tier 1:提取——从散文到三元组
01.从 RAG 答不上来的问题入手
失败查询的特征决定图中需要什么实体与关系。单跳查询不需要图——这一步是过滤器,不是仪式。
02.拉取实体与关系三元组,而非嵌入向量
用正则从句子中提取 (主题, 关系, 对象) 三元组,chunk_id 随三元组保留,保证可引用性:
PATTERNS = [
(re.compile(r"(.+?) depends on (.+)", re.I), "depends_on"),
(re.compile(r"(.+?) is backed by (.+)", re.I), "backed_by"),
(re.compile(r"(.+?) runs on (.+)", re.I), "runs_on"),
(re.compile(r"(.+?) is owned by (.+)", re.I), "owned_by"),
]
def extract(chunk_id: str, text: str) -> list[Triple]:
out = []
for sentence in re.split(r"[.\\n]", text):
s = sentence.strip()
if not s:
continue
for rx, rel in PATTERNS:
m = rx.match(s)
if not m:
continue
subj = m.group(1)
for obj in re.split(r"\\band\\b", m.group(2)):
obj = obj.strip()
if obj:
out.append(Triple(subj.strip(), rel, obj, chunk_id))
break
return out
03.规范化 canon:第一道门
转小写、去冠词、合并空白、应用别名映射:
def canon(name: str) -> str:
n = name.strip().lower()
n = re.sub(r"^the\\s+", "", n)
n = re.sub(r"\\s+", " ", n)
n = re.sub(r"[.]$", "", n)
return ALIASES.get(n, n)
原文说这是"第一道门",也是实体解析问题的缩影:处理不当,图会碎成互不连通的片段——所有节点都在,但彼此没有关联,自制的 GraphRAG 就什么也返回不了。
Tier 2:连接——边、权重与来源
04.构建双向边列表
图由 fwd / rev 两个邻接映射组成:正向遍历回答"X 依赖什么",反向遍历回答"什么依赖 X"。内存翻倍,但避免单向搜索死角。
05.边权重按来源相互印证计数
_merge:同一条边再次出现则 weight + 1。两个来源都认可的边权重为 2——印证本身就是信号。
06.每条边附来源信息 provenance:第二道门
def cite_path(g: ContextGraph, path: list[str]) -> list[str]:
lines = []
for a, b in zip(path, path[1:]):
for e in g.fwd.get(a, []):
if e.dst == b:
src = ",".join(sorted(e.sources))
lines.append(f"{a} -{e.rel}-> {b} [{src}] x{e.weight}")
break
return lines
输出长这样:登录流程 -依赖于-> 认证服务 [c1] x1 ——三次跳跃,三次引用,其中一次被两个来源印证(x2)。
Tier 3:穿越——检索路径而非片段
07.k 跳邻域检索
neighborhood(g, start, hops):上下文 = 实体 k 步内的子图,而不是 top-k 片段。从"找最像的几段话"变成"圈出一片关系网"。
08.路径排序而非通道排序:第三道门
多跳问题用反向可达性解:Redis 故障 → 沿反向边找出所有会一同故障的节点。impacted_by 做 BFS,返回的不是文档排序列表,而是因果链路径的排序列表——每条路径都可查看、可验证。
原文的运行结果:3 样东西会损坏(登录流程 → 认证服务 → 令牌缓存 → Redis 的完整链条),4 样能存活(计费系统、平台团队、PostgreSQL、用户数据库)。
就像停电查线路:RAG 告诉你"3 号楼提过停电";上下文图给你整张电网拓扑——哪条线断了影响哪几栋、哪几栋还在电网上,一目了然。
09.组装子图交模型
最后一步:把答案路径上的节点 + 连接它们的边 + 每条边的块标识一起装进上下文窗口。模型的任务从"从这堆信息中找到答案"变为"阅读这段内容并表达出来"。
原文说这才是真正的收获:最难的部分——把零散事实联系起来——已经从 prompt 里抽出来,放进了一个可查看的结构。模型不再需要猜测联系,直接读取就行。
三道门的位置(原文总结)
- 规范化终结 Tier 1(散文 → 有序的实体与关系);
- 来源终结 Tier 2(实体关系 → 带权重、可引用的图);
- 排序路径终结 Tier 3(图 → 可阅读、可验证的答案路径)。
作者用 8 个单事实片段建图,同一问题跑片段检索 vs 图遍历:相同语料库,一个检索工具找到了相关词汇,另一个找到了答案。结论:RAG 检索出与答案相关的内容,图直接获取答案。
何时停在 RAG:诚实结论
@0xMorlex 的结论非常诚实,值得全文引用:
- 多数检索是单跳——单跳用 RAG 更简单、更便宜、更正确;
- 测试是第 1 步:先判断你最难的问题是 lookup(查)还是 chain(链);
- 如果通过测试,再小步构建:一种关系类型、一张规范表、一次遍历;
- 顺序重要:提取 → 连接 → 穿越,别跳步。
回到 @Ai_Vaidehi 的判断:对做 AI 产品的人,从 RAG 到 Agentic RAG / 上下文图的转变不是可选项而是必然——因为真实世界的答案本来就住在关系里,不住在段落里。
术语翻卡:点卡片看解释
自测一下:点问题展开参考答案
"RAG retrieves information. Agentic RAG thinks about how to retrieve better information." RAG 是线性单次检索;Agentic RAG 会重写 query、判断缺什么信息、选择最佳数据源、迭代直到答案达标,是目标驱动的。
嵌入能检索相关文本,但拿不到"事物本身"和构成答案的关联关系。如"Redis 故障影响谁",答案分散在四份互不关联的文档中,相似度搜索无法跨文档跳跃——这不是调优能解决的。
规范化终结 Tier 1、来源终结 Tier 2、排序路径终结 Tier 3。因为 cite_path 让每条边都可引用、可验证;没有引用的图只是包装更精美的断言,无法区分"相关词汇"和"答案"。
"存活列表":3 样会损坏、4 样能存活(计费系统、平台团队、PostgreSQL、用户数据库)。语料库里没写"谁没坏",这个信息只能从图结构中看出来。