图数据库为什么查关系快:免索引邻接,以及怎么把小说抽成图——Graph RAG 系列之二

一句话价值:图数据库的"快"不是玄学,是存储引擎把"连接"物理存成了指针(免索引邻接)。这篇讲清这个原理,再给你一段能跑的知识抽取代码,把《天龙八部》抽成一张实体关系图------换底座的建库侧,就这一步。


先看一个现实的坑:join 的连接爆炸

用关系型数据库存"人物 + 师徒关系",再查"乔峰的师父的徒弟是谁",你会写出这样的 SQL:

sql 复制代码
SELECT ... FROM 人物 A
JOIN 师徒 R1 ON A.id = R1.from_id
JOIN 人物 B ON R1.to_id = B.id
JOIN 师徒 R2 ON B.id = R2.from_id ...

每一步 join 都是一次索引查找。跳数越多,join 越多,表越大越慢------这叫"连接爆炸"。关系库不是不能存图,是它的存储引擎压根没为"沿边走"设计:连接是"查出来的",不是"存出来的"。


三种图存储实现

实现 做法 多跳能力
内存图 应用层用 Map/邻接表存 节点→邻居 教学够用
关系库硬模拟 实体表 + 关系表,靠 join 能存,多跳越来越慢
原生图数据库(Neo4j) 存储引擎物理上把连接存成指针 多跳快,看家本领

所以答案是:"图存储"不一定非要 Neo4j,但 Neo4j 是"原生图数据库"的代表------它的价值不在"能存图",而在存储引擎天生为"沿着边跳"设计。


免索引邻接:连接是"存出来的"不是"查出来的"

这是 Neo4j 和关系库最本质的区别,也是你问"怎么实现的"时最该记住的一个词:免索引邻接(Index-Free Adjacency)

它把存储拆成几类定长记录

vbnet 复制代码
节点记录(Node Store):        节点ID | 标签 | 属性指针 | → 指向【第一条关系】的物理地址
关系记录(Relationship Store):关系ID | 类型 | 起点ID | 终点ID | → 指向前/后关系的指针(双向链表)
属性记录(Property Store):     单独存每个节点的属性(如原文 chunk)

关键在于:每个节点直接存着"我的第一条关系在哪",每条关系存着"我的前后关系在哪、连到哪个节点"。于是每个节点的所有边串成一条双向链表,遍历就是:

scss 复制代码
乔峰 ──指针──▶ [师徒关系] ──指针──▶ 汪剑通 ──指针──▶ [师徒关系] ──▶ ...
  每跳一步都是 O(1) 的指针解引用,不扫索引、不 join

这就是"免索引邻接" :跳到邻居不需要查索引,因为地址已经写在节点里了。多跳成本只和"沿途的度数(边多不多)"有关,和整个库的数据总量无关------一亿个节点还是十万个节点,"乔峰→师父→徒弟"都是几步指针。

类比:关系库像"每问一次都得重新翻一遍通讯录去 join";Neo4j 像"每张名片上直接印好了他认识谁、在哪能找到"。


真正的难点不在"存",在"抽"

很多人以为换 Graph RAG 难在图数据库,其实最难的一步是把文本变成「实体-关系」三元组 。这恰好又是 withStructuredOutput + zod 的老本行(和路由、评估节点一个套路):

js 复制代码
const TripleSchema = z.object({
  triples: z.array(z.object({
    head: z.string(),
    head_type: z.enum(["人物", "门派", "武功", "地点", "事件", "物品", "其他"]),
    relation: z.string(),
    tail: z.string(),
    tail_type: z.enum(["人物", "门派", "武功", "地点", "事件", "物品", "其他"])
  }))
});

把每个切片喂给 LLM,抽出 (乔峰)-[师父]->(汪剑通) 这类边,累积进图。这就是建库侧 graph-writter.mjs 干的事:

javascript 复制代码
EPUB → 切片 → LLM 抽三元组 → 存 JSON 图文件

对照你原来的 ebook-writter.mjsEPUB → 切片 → 向量化 → 存 Milvus),只有"中间那一步"变了:向量化换成了知识抽取。


建库代码:三段就够

js 复制代码
// 1. 抽取:一段文本 → 三元组列表
async function extractTriples(chunk) {
  const extractor = llm.withStructuredOutput(TripleSchema);
  const out = await extractor.invoke(`
你是《天龙八部》知识图谱抽取器,请从文本中抽取实体及其关系。
1. 只抽文本中明确出现、有依据的关系,不要臆造。
2. 关系用短短语:师父、结拜、帮主、师从、使出。
3. 同一实体全书统一称呼(统一用「乔峰」,不要一会儿「萧峰」)。
文本:
${chunk}`);
  return out.triples ?? [];
}

// 2. 累积:三元组 → 图(节点带原文片段,边去重)
function addTriplesToGraph(graph, seenEdges, triples, chunk) {
  for (const t of triples) {
    const head = normalize(t.head), tail = normalize(t.tail);
    const relation = (t.relation ?? "").trim();
    if (!head || !tail || !relation) continue;

    if (!graph.nodes[head]) graph.nodes[head] = { name: head, type: t.head_type, chunks: new Set() };
    if (!graph.nodes[tail]) graph.nodes[tail] = { name: tail, type: t.tail_type, chunks: new Set() };
    graph.nodes[head].chunks.add(chunk);   // 存原文,检索后回捞用
    graph.nodes[tail].chunks.add(chunk);

    const key = `${head}->${relation}->${tail}`;
    if (!seenEdges.has(key)) { seenEdges.add(key); graph.edges.push({ head, relation, tail }); }
  }
}

存出来的 graph.json 长这样:

json 复制代码
{
  "nodes": { "乔峰": { "name": "乔峰", "type": "人物", "chunks": ["......原文......"] } },
  "edges": [ { "head": "乔峰", "relation": "师父", "tail": "汪剑通" } ]
}

两个工程细节值得记:

  • 节点带原文片段(chunks:图里只存"乔峰和汪剑通是师徒"这条边,原文存在节点属性里,检索时沿边找到实体,再把原文捞出来喂 LLM------这就是上一篇说的"图导航 + 向量/原文取内容"。
  • 实体名归一化(normalize :去掉空格、书名号,prompt 里要求"统一用乔峰别用萧峰"。这只是缓解------实体消歧(同一个"乔峰/萧峰"、重名人物)才是图 RAG 真正难啃的骨头,这里先不展开。

检索侧从"碰"变成"走"

建好图之后,检索函数内部就彻底变了:

js 复制代码
// 旧:纯向量------语义相似"碰"出 top-k
similaritySearchWithScore(question, k)

// 新:图检索------抽实体 → 锚定 → 沿边走 → 回捞原文
async function retrieveRelevantContent(question, k) {
  const entities = await extractEntities(question);  // "乔峰的师父是谁" → [乔峰]
  const anchors = entities.map(e => graph.findNode(e));  // 锚定
  const chunks = graph.expand(anchors, { hops: 2 });     // 沿边走两跳
  return chunks.slice(0, k).map(...);                    // 回捞原文
}

一句话:纯向量靠"语义像不像"碰运气,图靠"关系在不在"直接走路径


换底座的接缝:就一个函数

回到你前面那套 Agentic 编排------好消息是:换底座根本不用动它

webfullback.mjs 里,检索是被封装进一个函数的:

js 复制代码
const retrievedDocs = await retrieveRelevantContent(state.question, state.k);

只要这个函数的「入参 (question, k) → 出参 [{content, chapter_num, ...}]」契约不变,外面 route → evaluate → web_search → generate 全部复用。所以换底座 = 两个动作:

  1. 建库侧 :写一个 graph-writter.mjs(本文上面的抽取代码)。
  2. 查询侧 :把 retrieveRelevantContent 内部从"向量相似度"换成"图检索"。
  3. 编排侧:一行不动。

这就是"正交"带来的红利:Agentic 的大脑和图的底座,各换各的,互不牵连


本篇小结

  1. 关系库查多跳会"连接爆炸",因为连接是 join 出来的;原生图库把连接物理存成指针。
  2. 免索引邻接:节点存"第一条关系的地址",关系存"前后关系 + 两端节点",遍历是 O(1) 指针跟随,和数据总量无关。
  3. 换 Graph RAG 最难的不是图数据库,是知识抽取 (LLM 抽三元组),这又是 withStructuredOutput + zod
  4. 建库 = EPUB → 切片 → 抽三元组 → 存图;节点带原文片段、边去重、实体名归一化。
  5. 检索从"碰"(向量相似)变成"走"(实体锚定 + 沿边扩展 + 回捞原文)。
  6. 换底座的接缝就一个函数:retrieveRelevantContent 的契约不变,上面的 Agentic 编排一行不动。

系列回顾

Graph RAG 系列两篇到此:

  • 07 讲选型------向量还是图,看查询是"找内容"还是"找关系";
  • 08 讲原理与实现------免索引邻接为什么快,以及怎么把文本抽成图。

到此,RAG 的完整版图就齐了:上层的 Agentic(01--06 篇,管"怎么查"的编排)+ 下层的 Graph(07--08 篇,管"怎么存"的范式)。两条线正交,可各自演进,也可叠加成一个"用图做底、用 LLM 做脑"的完整 Agentic GraphRAG。

相关推荐
李溪白2 小时前
篇五:RAG —— 让 Agent 拥有外挂知识库
agent
辉夜技术2 小时前
大模型的理解力,用户的解空间:Jev 如何填满一个空白象限
agent
小林ixn2 小时前
从 LangChain 到 LangGraph:用「网状工作流」解锁多 Agent 协作的正确姿势
langchain·llm·agent
武子康2 小时前
自己做一个 Mini Reviewer:让 AI 审到本次准备提交的代码
人工智能·llm·agent
BryceBorder2 小时前
Agent Memory 不只是聊天记录:手搓三大记忆系统
后端·agent·面经·codex·claude code·agent memory
Old Uncle Tom2 小时前
评测即生死:Agent 时代的可靠性重构
人工智能·软件工程·agent
武子康3 小时前
GPU Pod 已经 Running,为什么扩容还没变成推理容量?
人工智能·llm·agent
shionhana3 小时前
AI PPT 生成的两个关键环节:结构生成和视觉生成,以 PPTMaker 为例
人工智能·chatgpt·agent·ppt
SLD_Allen3 小时前
智能聚类:从海量 Trace 中理解 Agent 的行为和表现
agent·trace