问一个文本块检索器:“如果 Redis 挂了,什么会出问题?”它会把语料库里那个包含 Redis 这个词的句子交给你。

  • 但那个句子并没有回答问题。真正的答案分散在四份彼此没有互相提及的文档里,而相似度搜索没有办法从一份文档走到下一份文档。
图 1:文本块检索无法走过多跳关系

这就是 RAG 的天花板,而且它不是一个调参问题。嵌入向量检索的是“提到”你所问对象的文本。它无法检索对象本身,也无法检索真正承载答案的关系链。对单一事实查询来说,这没问题;但凡涉及多跳推理,它在结构上就不是合适的工具。

  • 修法不是继续检索段落,而是开始检索一张图:实体是节点,关系是边,答案是路径。下面是一条从文本块走向可遍历、可引用的上下文图的 9 步路线图。不需要框架。下面这个引擎大约 150 行代码。
图 2:从文本块到上下文图

第一层:抽取

图 3:第一层,抽取

01. 从 RAG 回答不了的问题开始

不要一上来就构建图。先写下当前检索器回答失败的那个查询,因为这个查询会定义图真正需要的实体和关系。

失败查询有一个形状:它们是多跳的。“如果 Redis 挂了,什么会出问题?”真正问的是:“依赖某个东西的那个东西,又依赖 Redis 支撑的那个东西,那么上游会有哪些受影响?”每一跳都是一个关系,答案是一条路径,而不是一个段落。如果你最难的问题只是单跳查找,你不需要图。如果它们是链条,你就需要图。

02. 抽取实体和关系,而不是嵌入向量

RAG 会把文本块转成嵌入向量。上下文图会读取每个文本块,并抽取三元组:主体、关系、客体。在生产环境里,抽取器通常是一次 LLM 调用。这里用的是少量确定性模式,因此输出可复现。

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" -> 两条边
                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”其实是同一个实体。

如果图把它们当成三个实体,那么每条边都会落到不同节点上,路径永远连不起来。规范化是一个不显眼的步骤,但它决定了这到底是一张图,还是三个互不相连的碎片。

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 返回不了有用结果最常见的原因:节点都在那里,只是彼此从未接触。

第二层:连接

图 4:第二层,连接

04. 构建边列表:主体、关系、客体

图本身由两张邻接表组成,一张正向,一张反向,因为你需要双向遍历:正向回答“X 依赖什么”,反向回答“什么依赖 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)

同时存储两个方向会让内存翻倍,但也会消除一整类“我只能朝一个方向搜索”的缺陷。对知识图来说,这始终是值得的取舍。

05. 根据多少来源达成一致来给边加权

如果两个不同文本块都说 auth service 依赖 token cache,那么这条边就比单一来源断言的边更强。这就是佐证:把智能体群体变成验证器的同一个思想,应用到图上。

@staticmethod
def _merge(side, a, rel, b, src) -> None:
    for e in side[a]:
        if e.rel == rel and e.dst == b:      # 同一条边再次出现 = 佐证
            e.weight += 1
            e.sources.add(src)
            return
    side[a].append(Edge(rel, b, 1, {src}))

在真实运行中,auth-service 到 token-cache 这条边来自两个文本块,因此权重为 2。当两条路径都可能回答同一个问题时,由被多方佐证的边构成的路径更值得信任,而这个权重就是第 8 步里的排序依据。

06. 给每条边附上来源

第二道关卡。

每条边都携带它来自哪些文本块。正是这个属性,让上下文图可以回答问题,而不只是给出看似合理的建议:当图返回一条路径时,它也会返回每一跳的收据。

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

输出会像这样,而这就是整件事的重点:

login flow -depends_on-> auth service  [c1]  x1
auth service -depends_on-> token cache  [c2,c8]  x2
token cache -backed_by-> redis  [c3]  x1

三跳,三条引用,其中一条得到了佐证。如果一张图不能引用自己的边,那它只是包装更好的断言堆。

第三层:遍历

图 5:第三层,遍历

07. 按 k 跳邻域检索

现在,检索不再是相似度计算,而是一次行走。一个问题的上下文,不是最相似的 top-k 文本块,而是问题中提到的实体在 k 跳以内的子图。

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 挂了,就沿着反向边走,找出所有会随之出问题的上游部分。

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

结果不是一组排序后的文档,而是一组排序后的路径,每一条都是你可以阅读和检查的因果链。

token cache   via redis <- token cache
auth service  via redis <- token cache <- auth service
login flow    via redis <- token cache <- auth service <- login flow
存活:billing, platform team, postgresql, user database

三个东西会坏,四个东西会存活,而图可以同时说出这两组。文本块检索器完全无法生成“存活”列表,因为语料库里没有任何地方直接写了这件事。它只存在于结构里。

09. 带引用地组装子图

最后一步交给模型的不是一堵文本墙,而是一张子图。答案路径上的节点、连接它们的边,以及每条边背后的文本块 ID,会一起进入上下文窗口。模型的任务从“在这一堆东西里找答案”缩小成了“读这条链,然后把它表达出来”。

这种缩小才是真正的收益。你已经把困难部分,也就是连接分散事实,从提示词里移出来,放进了一个你自己构建并能检查的结构里。模型不再猜测连接,而是阅读连接。

图真正回答了什么

我用八个单事实文本块构建了图,在同一个问题上同时运行文本块检索器和图遍历,并打印了两边结果。

把第一个输出块和最后一个输出块对照看。文本块检索器返回的是包含 Redis 这个词的句子,而那个句子不包含答案。图返回的是从 login flow 一路到 Redis 的链条,每一跳都有引用,而且两个来源一致的那条边权重为 2。同一个语料库,一个检索器找到了词,另一个找到了答案。

结语:RAG 检索的是提到答案的内容,图检索的是答案本身

九个步骤,三层结构,三道关卡。

第一层把自然语言文本转成实体和关系,并让它们对齐。第二层把它们连接成一张带权重、可引用的图。第三层不再测量相似度,而是沿图行走来检索,并返回你可以阅读和验证的路径。

注意这些关卡的位置。规范化结束第一层,来源追踪结束第二层,排序路径结束第三层。这和循环、图、智能体群体是同一种形状,原因也一样。没有引用的上下文图,只是多绕了几步的 RAG。

  • 过去两年,检索领域一直在推销更好的嵌入向量:更大的模型、更长的上下文、更聪明的分块。这些工作有天花板,而天花板不是嵌入向量质量。问题在于,一个文本块只能告诉你它自己说了什么,而多跳答案永远不在任何单个文本块里。结构性缺口无法靠相似度补上。

但诚实的说法不是每个人都应该构建图。大多数检索都是单跳的;对单跳查找来说,RAG 更简单、更便宜,而且是正确的。测试就在第 1 步:你最难的问题到底是查找,还是链条?如果是查找,就给文本块做嵌入向量,然后继续往前走。如果是链条,你已经在问图的问题了,只是你还没有把图建出来。

图 6:判断何时需要上下文图
  • 如果你通过了这个测试,就从小处开始。一个关系类型,一张规范实体表,一种遍历。先让“什么依赖 X”返回一条带引用的路径,再加入第二种边类型。顺序很重要:抽取喂给连接,连接喂给遍历,而在未规范化节点上做遍历,什么都不会返回。

重点从来不是更好地搜索文本,而是检索结构,而不是检索自然语言文本。构建图,返回路径。