一句话价值:图数据库的"快"不是玄学,是存储引擎把"连接"物理存成了指针(免索引邻接)。这篇讲清这个原理,再给你一段能跑的知识抽取代码,把《天龙八部》抽成一张实体关系图------换底座的建库侧,就这一步。
先看一个现实的坑: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.mjs(EPUB → 切片 → 向量化 → 存 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 全部复用。所以换底座 = 两个动作:
- 建库侧 :写一个
graph-writter.mjs(本文上面的抽取代码)。 - 查询侧 :把
retrieveRelevantContent内部从"向量相似度"换成"图检索"。 - 编排侧:一行不动。
这就是"正交"带来的红利:Agentic 的大脑和图的底座,各换各的,互不牵连。
本篇小结
- 关系库查多跳会"连接爆炸",因为连接是 join 出来的;原生图库把连接物理存成指针。
- 免索引邻接:节点存"第一条关系的地址",关系存"前后关系 + 两端节点",遍历是 O(1) 指针跟随,和数据总量无关。
- 换 Graph RAG 最难的不是图数据库,是知识抽取 (LLM 抽三元组),这又是
withStructuredOutput + zod。 - 建库 = EPUB → 切片 → 抽三元组 → 存图;节点带原文片段、边去重、实体名归一化。
- 检索从"碰"(向量相似)变成"走"(实体锚定 + 沿边扩展 + 回捞原文)。
- 换底座的接缝就一个函数:
retrieveRelevantContent的契约不变,上面的 Agentic 编排一行不动。
系列回顾
Graph RAG 系列两篇到此:
- 07 讲选型------向量还是图,看查询是"找内容"还是"找关系";
- 08 讲原理与实现------免索引邻接为什么快,以及怎么把文本抽成图。
到此,RAG 的完整版图就齐了:上层的 Agentic(01--06 篇,管"怎么查"的编排)+ 下层的 Graph(07--08 篇,管"怎么存"的范式)。两条线正交,可各自演进,也可叠加成一个"用图做底、用 LLM 做脑"的完整 Agentic GraphRAG。