# From RAG to Context Graphs: the 9-step roadmap
**作者**: Morlex
**日期**: 2026-08-10T10:00:51.000Z
**来源**: [https://x.com/0xMorlex/status/2086754640968732729](https://x.com/0xMorlex/status/2086754640968732729)
---

Ask a chunk retriever - "what breaks if Redis goes down" and it hands you the one sentence in your corpus that contains the word Redis.
- That sentence does not answer the question. The answer was spread across four documents that never mention each other, and similarity search has no way to walk from one to the next.

That is the ceiling of RAG, and it is not a tuning problem. Embeddings retrieve text that mentions the thing you asked about. They cannot retrieve the thing itself, or the chain of relationships that actually holds the answer. For a single-fact lookup that is fine. For anything multi-hop, it is structurally the wrong tool.

- The fix is to stop retrieving passages and start retrieving a graph: entities as nodes, relationships as edges, answers as paths. This is the 9-step roadmap from chunks to a context graph you can traverse and cite. No framework. The engine below is about 150 lines.
# Tier I - Extract

## 01. Start from the question RAG can't answer
Do not begin by building a graph. Begin by writing down the query that your current retriever fails, because that query defines the entities and relations the graph actually needs.
> The failing queries have a shape: they are multi-hop. "What breaks if Redis goes down" is really "what depends on the thing that depends on the thing that Redis backs." Each hop is a relationship, and the answer is a path, not a passage. If your hardest queries are single-hop lookups, you do not need a graph. If they are chains, you do.
## 02. Pull entities and relations, not embeddings
RAG embeds chunks. A context graph reads each chunk and extracts triples: subject, relation, object. In production the extractor is an LLM call. Here it is a handful of deterministic patterns, so the output is reproducible.
```
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)): # "X and Y" -> two edges
obj = obj.strip()
if obj:
out.append(Triple(subj.strip(), rel, obj, chunk_id))
break
return out
```
The "chunk_id" on every triple is not an afterthought. It is what makes the eventual answer citable, and it is the thing RAG throws away the moment it concatenates chunks into a prompt.
## 03. Canonicalize, or the graph fractures
First gate.
- "The Auth Service," "auth service," and "AuthService" are one entity.
If the graph treats them as three, every edge you draw lands on a different node and the paths never connect. Canonicalization is the unglamorous step that decides whether the graph is a graph or three disconnected fragments.
```
ALIASES = {
"authservice": "auth service",
"user db": "user database",
"postgres": "postgresql",
}
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)
```
Lowercase, strip the article, collapse whitespace, apply an alias map. This is the entity-resolution problem in miniature, and getting it wrong is the single most common reason a homegrown GraphRAG returns nothing useful: the nodes are all there, they just never touch.
# Tier II - Connect

## 04. Build the edge list: subject, relation, object
The graph itself is two adjacency maps, forward and reverse, because you will traverse in both directions: forward to answer "what does X depend on" reverse to answer "what depends on X."
```
@dataclass
class Edge:
rel: str
dst: str
weight: int = 1
sources: set = field(default_factory=set)
def add(self, t: Triple) -> None:
s, o = canon(t.subj), canon(t.obj)
self.nodes.add(s); self.nodes.add(o)
self._merge(self.fwd, s, t.rel, o, t.source)
self._merge(self.rev, o, t.rel, s, t.source)
```
Storing both directions doubles the memory and removes an entire class of "I can only search one way" bugs. For a knowledge graph that is always the right trade.
## 05. Weight edges by how many sources agree
If two different chunks both say the auth service depends on the token cache, that edge is stronger than one asserted by a single source. This is corroboration, the same idea that turned a swarm into a verifier, applied to a graph.
```
@staticmethod
def _merge(side, a, rel, b, src) -> None:
for e in side[a]:
if e.rel == rel and e.dst == b: # same edge seen again = corroboration
e.weight += 1
e.sources.add(src)
return
side[a].append(Edge(rel, b, 1, {src}))
```
On the real run, the auth-service-to-token-cache edge came from two chunks and carries weight 2. When two paths could answer a question, the one built from corroborated edges is the one to trust, and the weight is how you rank them in step 8.
## 06. Attach provenance to every edge
Second gate.
Every edge carries the set of chunks it came from. This is the property that makes a context graph answerable rather than merely suggestive: when the graph returns a path, it returns the receipt for each hop.
```
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
```
The output looks like this, and it is the whole point:
Three hops, three citations, one of them corroborated. A graph that cannot cite its edges is a pile of assertions with better packaging.
# Tier III - Traverse

## 07. Retrieve by k-hop neighborhood
Now retrieval stops being similarity and becomes a walk. The context for a question is not the top-k most similar chunks. It is the subgraph within k hops of the entities the question names.
```
def neighborhood(g: ContextGraph, start: str, hops: int) -> set:
seen, frontier = {canon(start)}, {canon(start)}
for _ in range(hops):
nxt = set()
for n in frontier:
for e in g.fwd.get(n, []):
nxt.add(e.dst)
frontier = nxt - seen
seen |= nxt
return seen
```
## 08. Rank paths, not passages
Third gate.
The multi-hop question is answered by reverse reachability: if Redis breaks, walk the reverse edges to find everything upstream that breaks with it.
```
def impacted_by(g: ContextGraph, failed: str) -> list[tuple[str, list[str]]]:
start = canon(failed)
out, seen = [], {start}
q = deque([(start, [start])])
while q:
node, path = q.popleft()
for e in g.rev.get(node, []):
if e.dst in seen:
continue
seen.add(e.dst)
newpath = path + [e.dst]
out.append((e.dst, newpath))
q.append((e.dst, newpath))
return out
```
The result is not a ranked list of documents. It is a ranked list of paths, each one a causal chain you can read and check.
Three things break, four survive, and the graph can name both sets. A chunk retriever cannot produce the "survives" list at all, because nothing in the corpus states it. It is only visible in the structure.
## 09. Assemble the subgraph, with citations
The final step hands the model a subgraph, not a wall of text. The nodes on the answer path, the edges that connect them, and the chunk id behind each edge become the context window, and the model's job shrinks from "find the answer in this pile" to "read this chain and phrase it."
That shrinking is the real win. You have moved the hard part, connecting scattered facts, out of the prompt and into a structure you built and can inspect. The model stops guessing at connections and starts reading them.
## What the graph actually answers
I built the graph from eight one-fact chunks, ran the chunk retriever and the traversal on the same question, and printed both.
Read the first block against the last. The chunk retriever returns the sentence with Redis in it, and that sentence does not contain the answer. The graph returns the chain from the login flow down to Redis, with a citation on every hop and a weight of 2 on the edge two sources agreed on. Same corpus. One retriever found the word, the other found the answer.
## Conclusion: RAG retrieves what mentions the answer. A graph retrieves the answer.
Nine steps, three tiers, three gates.
> Tier 1 turns prose into entities and relations and makes them line up. Tier 2 connects them into a weighted, cited graph. Tier 3 retrieves by walking that graph instead of by measuring similarity, and returns paths you can read and verify.
Notice where the gates sit. Canonicalization ends Tier 1, provenance ends Tier 2, the ranked path ends Tier 3. Same shape as the loop, the graph, and the swarm, and for the same reason. A context graph with no citation is RAG with extra steps.
- For two years the retrieval pitch has been better embeddings: bigger models, longer context, smarter chunking. That work has a ceiling, and the ceiling is not embedding quality. It is that a chunk can only tell you what it says, and a multi-hop answer is never in any single chunk. No amount of similarity closes a gap that is structural.
But the honest version is not that everyone should build a graph. Most retrieval is single-hop, and for single-hop lookups RAG is simpler, cheaper, and correct. The test is step 1: are your hardest questions lookups or chains? If they are lookups, embed your chunks and move on. If they are chains, you are already asking the graph a question. You just have not built it yet.

- If you pass the test, build small. One relation type. One canonical entity table. One traversal. Get "what depends on X" returning a cited path before you add a second edge type. Order matters: extraction feeds connection, connection feeds traversal, and a traversal over un-canonicalized nodes returns nothing at all.
The point was never better search over text. It was retrieving structure instead of prose. Build the graph. Return the path.
## 相关链接
- [Morlex](https://x.com/0xMorlex)
- [@0xMorlex](https://x.com/0xMorlex)
- [1.5K](https://x.com/0xMorlex/status/2086754640968732729/analytics)
- [Upgrade to Premium](https://x.com/i/premium_sign_up)
- [6:00 PM · Aug 10, 2026](https://x.com/0xMorlex/status/2086754640968732729)
- [1,537 Views](https://x.com/0xMorlex/status/2086754640968732729/analytics)
---
*导出时间: 2026/8/10 19:42:39*
---
## 中文翻译
# 从 RAG 到上下文图:9步路线图
**作者**: Morlex
**日期**: 2026-08-10T10:00:51.000Z
**来源**: [https://x.com/0xMorlex/status/2086754640968732729](https://x.com/0xMorlex/status/2086754640968732729)
---

如果你问一个文本块检索器——“如果 Redis 宕机了,什么会出问题?”,它只会把语料库中包含“Redis”一词的那句话交给你。
- 这句话并没有回答你的问题。真正的答案散落在四份互不提及的文档中,而相似度搜索根本无法在它们之间建立联系。

这就是 RAG 的天花板,而且这不是一个仅靠调参就能解决的问题。向量嵌入检索出的是提及你所问事物的文本。它们无法检索出事物本身,也无法检索出真正承载答案的关系链。对于单点事实查询,这没问题。但对于任何多跳查询来说,它在结构上就用错了工具。

- 解决办法是停止检索文本段落,转而检索图:将实体作为节点,关系作为边,答案作为路径。这是一份从文本块走向可遍历、可溯源的上下文图的 9 步路线图。无需任何框架。下面这套引擎的核心代码大约只有 150 行。
# 第一阶段 - 抽取

## 01. 从 RAG 无法回答的问题开始
不要一上来就开始建图。首先,写下你当前检索器无法回答的查询,因为正是那个查询定义了图谱真正需要的实体和关系。
> 那些检索失败的问题都有一个形状:它们是多跳的。“如果 Redis 宕机了,什么会出问题”本质上是在问“什么依赖于那个依赖于 Redis 支撑的事物的那个事物”。每一跳都是一段关系,而答案是一条路径,不是一个文本段落。如果你觉得最难的问题只是单跳查询,那你不需要图。如果它们是一层层的关系链,你就需要它。
## 02. 提取实体和关系,而不是向量嵌入
RAG 对文本块进行嵌入。而上下文图会阅读每个文本块并提取三元组:主语、关系、宾语。在实际生产环境中,提取器通常是通过调用 LLM 实现的。在这里,我们使用少量确定性的正则模式,从而确保输出结果是可复现的。
```python
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)): # "X and Y" -> two edges
obj = obj.strip()
if obj:
out.append(Triple(subj.strip(), rel, obj, chunk_id))
break
return out
```
每个三元组上的“chunk_id”绝不是事后补上的。正是它使得最终答案能够被溯源,而这也是 RAG 在将文本块拼接到提示词中时所丢弃的至关重要的东西。
## 03. 规范化,否则图将支离破碎
第一道关卡。
- “The Auth Service”、“auth service”和“AuthService”其实是同一个实体。
如果图谱将它们视为三个不同的实体,你画的每条边就会落在不同的节点上,路径永远无法连通。规范化是极其枯燥的一步,但它决定了你的图到底是一张连通的图,还是三个毫不相干的碎片。
```python
ALIASES = {
"authservice": "auth service",
"user db": "user database",
"postgres": "postgresql",
}
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 检索不到任何有用信息的最常见原因:节点明明都在那,但它们就是无法连接在一起。
# 第二阶段 - 连接

## 04. 构建边表:主语、关系、宾语
图本身包含两张邻接表,一正一反,因为你需要双向遍历:正向遍历用来回答“X 依赖于什么”,反向遍历用来回答“什么依赖于 X”。
```python
@dataclass
class Edge:
rel: str
dst: str
weight: int = 1
sources: set = field(default_factory=set)
def add(self, t: Triple) -> None:
s, o = canon(t.subj), canon(t.obj)
self.nodes.add(s); self.nodes.add(o)
self._merge(self.fwd, s, t.rel, o, t.source)
self._merge(self.rev, o, t.rel, s, t.source)
```
存储双向边会使内存占用翻倍,但却能消除整整一类“我只能单向搜索”的 bug。对于知识图谱来说,这笔交易永远是划算的。
## 05. 根据信息来源的一致性为边加权
如果两个不同的文本块都指出 auth 服务依赖于 token 缓存,那么这条边就比仅由单一来源声明的边更具说服力。这就是交叉验证,也就是将“群体验证”的思路应用到了图结构上。
```python
@staticmethod
def _merge(side, a, rel, b, src) -> None:
for e in side[a]:
if e.rel == rel and e.dst == b: # same edge seen again = corroboration
e.weight += 1
e.sources.add(src)
return
side[a].append(Edge(rel, b, 1, {src}))
```
在实测中,从 auth 服务到 token 缓存的这条边来源于两个文本块,因此权重为 2。当有多条路径都能回答某个问题时,由经过交叉验证的边构建的路径才是值得信任的,这也是你在第 8 步中对它们进行排序的依据。
## 06. 为每条边附加来源证明
第二道关卡。
每条边都携带着它所来源的文本块集合。正是这个特性让上下文图不仅能提供启发性的建议,还能给出确切的答案:当图谱返回一条路径时,它同时也返回了每一跳的凭证。
```python
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
```
输出看起来像这样,而这正是最核心的意义所在:
三跳,三个引用,其中一个经过了交叉验证。一个无法为其边提供溯源的图谱,不过是一堆包装得更精美的断言。
# 第三阶段 - 遍历

## 07. 通过 K 跳邻域进行检索
现在,检索不再依赖相似度计算,而是变成了一种图上的游走。问题的上下文不再是 Top-K 最相似的文本块。它是问题中提到的实体在 K 跳范围内的子图。
```python
def neighborhood(g: ContextGraph, start: str, hops: int) -> set:
seen, frontier = {canon(start)}, {canon(start)}
for _ in range(hops):
nxt = set()
for n in frontier:
for e in g.fwd.get(n, []):
nxt.add(e.dst)
frontier = nxt - seen
seen |= nxt
return seen
```
## 08. 对路径进行排序,而不是段落
第三道关卡。
多跳问题是通反向可达性来解答的:如果 Redis 崩溃了,就顺着反向边去寻找所有随之一同崩溃的上游节点。
```python
def impacted_by(g: ContextGraph, failed: str) -> list[tuple[str, list[str]]]:
start = canon(failed)
out, seen = [], {start}
q = deque([(start, [start])])
while q:
node, path = q.popleft()
for e in g.rev.get(node, []):
if e.dst in seen:
continue
seen.add(e.dst)
newpath = path + [e.dst]
out.append((e.dst, newpath))
q.append((e.dst, newpath))
return out
```
结果不再是一份排好序的文档列表,而是一份排好序的路径列表,每一条都是你可以直接阅读和检查的因果链。
三个事物崩溃了,四个存活了下来,而图能够同时列出这两份清单。而文本块检索器根本无法生成这份“存活”清单,因为语料库中没有任何文本直接陈述了这一点。它仅仅隐藏在结构之中。
## 09. 组装带引用的子图
最后一步,交给模型的是一个子图,而不是一整坨密密麻麻的文字。答案路径上的节点、连接它们的边以及每条边背后的文本块 ID 共同构成了上下文窗口。模型的任务从“在这堆文本里找答案”缩减成了“阅读这条链并润色措辞”。
这种任务的缩减才是真正的胜利。你已经把连接散落事实这一最困难的部分,从大模型的提示词中剥离出来,转移到了一个你亲自构建且可以检查的结构中。模型不再去猜测事实之间的联系,而是直接阅读它们。
## 图究竟回答了什么
我用了八个只包含单一事实的文本块来构建图,对同一个问题分别运行了文本块检索器和图遍历程序,并打印了两者的结果。
将第一段和最后一段对比着读。文本块检索器返回了包含 Redis 的那句话,但那句话里并没有答案。而图返回了从登录流程一路指向 Redis 的链条,每一跳都带有引用,且在两个来源达成一致的边上权重为 2。同样的语料库。一个检索器只找到了字眼,另一个却找到了答案。
## 结论:RAG 检索的是提及答案的文本,而图直接检索出答案本身。
九个步骤,三个阶段,三道关卡。
> 第一阶段将平铺直叙的文字转化为实体和关系并让它们对齐。第二阶段将它们连接成一张带权重、可溯源的图。第三阶段不再测量相似度,而是通过在图上游走来检索,并返回可供阅读和验证的路径。
注意这三道关卡的位置。规范化终结了第一阶段,溯源终结了第二阶段,路径排序终结了第三阶段。这与闭环、图和集群的形态如出一辙,且理由相同。一个没有溯源的上下文图不过是多走了几步弯路的 RAG。
- 两年来,检索技术的主打卖点一直是更好的向量嵌入:更大的模型、更长的上下文、更智能的文本切分。但这种努力是有天花板的,而且这个天花板不在于嵌入质量。它的天花板在于:一个文本块只能告诉你它字面上说了什么,而多跳查询的答案永远不可能存在于任何单一文本块中。面对这种结构性的鸿沟,再怎么提升相似度也无法将其弥合。
但说实话,并不是所有人都应该去建图。大多数检索需求都是单跳的,对于单点事实查询,RAG 更简单、更便宜、也更准确。考验在于第一步:你最棘手的问题是简单查询还是关系链条?如果是简单查询,对你的文本块做好向量嵌入然后继续干别的就行了。如果它们是链条,那你实际上已经在向图提出问题了。你只是还没有把它构建出来而已。

- 如果你能通过这项考验,请从小处着手建图。只设定一种关系类型。一张标准的实体映射表。一次遍历。在你添加第二种边类型之前,先让“什么依赖于 X”返回一条可溯源的路径。顺序很重要:抽取哺育连接,连接哺育遍历,而对未规范化的节点进行遍历将一无所获。
我们的目的从来不是为了在文本上实现更好的搜索。而是为了检索出结构,而不是文字。去构建图谱吧。返回路径。
## 相关链接
- [Morlex](https://x.com/0xMorlex)
- [@0xMorlex](https://x.com/0xMorlex)
- [1.5K](https://x.com/0xMorlex/status/2086754640968732729/analytics)
- [Upgrade to Premium](https://x.com/i/premium_sign_up)
- [6:00 PM · Aug 10, 2026](https://x.com/0xMorlex/status/2086754640968732729)
- [1,537 Views](https://x.com/0xMorlex/status/2086754640968732729/analytics)
---
*导出时间: 2026/8/10 19:42:39*