1. 从一本书到一张图谱:这个项目到底在解决什么问题
Karpathy 前阵子提了个很有意思的观点,大意是软件正在被重新定义,LLM 不再只是补全代码的工具,而是逐渐变成一种新的“运行时”。他那个 LLM Wiki 的思路,本质上是把知识库做成一个可以被模型反复读写、自我演化的结构。这个方向一出来,圈子里讨论得很热,但我第一反应是:Wiki 这种形态,对“一本书”这种强结构、强逻辑、强因果的内容来说,其实是不够的。
为什么这么说?Wiki 的天然形态是网状链接,一个词条连到另一个词条,适合百科式的知识。但一本书不一样,它有章节、有论证链条、有概念之间的依赖关系。你读《思考,快与慢》,卡尼曼不是随便抛概念,他是先建立系统1和系统2的框架,再用这个框架去解释锚定效应、损失厌恶、峰终定律。这些概念之间不是平铺的链接,而是有层级、有推导、有前提依赖的。你把它拆成 Wiki 词条,等于把一棵树的枝干砍下来插在地上,看起来还在,但根系没了。
所以我们做的这个东西,核心目标就一句话:把一本书变成一张可溯源的关系图谱。注意三个关键词——关系、图谱、可溯源。关系,指的是概念与概念之间的因果、依赖、对比、例证;图谱,指的是这些关系不是线性的,而是有向的、带权重的、可遍历的;可溯源,指的是图谱上每一个节点和每一条边,都能回溯到原书的具体段落,不是模型瞎编的。
这个项目适合谁?如果你正在做 RAG 知识库,发现检索出来的东西总是“差一口气”,答得对但答得浅,那这套思路值得看。如果你在做 Agent 的记忆层,想让 Agent 不只是记住事实,还能理解概念之间的逻辑,那关系图谱是比向量库更合适的底层结构。如果你只是个爱读书的人,想把自己读过的书真正“消化”成自己的知识网络,这套方法也能手动跑通。
我先把结论放前面:纯向量 RAG 解决的是“找得到”,关系图谱解决的是“理得清”。这两个不是替代关系,是互补关系。后面我会详细拆为什么,以及我们具体怎么做的。
2. 为什么纯 RAG 不够用:从“检索增强”到“关系增强”的认知升级
2.1 RAG 的瓶颈到底卡在哪
RAG 这套东西,从 2020 年那篇经典论文出来之后,基本框架就没大变过:文档切块、向量化、存库、查询时做相似度检索、把检索结果塞进 prompt。听起来很合理,但实际用起来,尤其是面对一本书这种内容,问题很快就暴露了。
第一个问题是切块切断了逻辑。你把一本书按 500 字切块,第 37 块讲的是“锚定效应的实验设计”,第 38 块讲的是“锚定效应在谈判中的应用”,这两块在向量空间里可能离得很近,但第 12 块里卡尼曼对“系统1如何产生锚定”的机制解释,可能因为跟第 37 块的字面相似度不够高,反而检索不到。结果就是模型答得出“锚定效应是什么”,但答不出“锚定效应为什么会产生”。
第二个问题是相似度不等于相关性。向量检索的本质是找“语义相近”的文本,但一本书里真正有价值的关系,往往是“语义不相近但逻辑相关”。比如“损失厌恶”和“禀赋效应”,这两个概念在向量空间里可能离得不远,但“损失厌恶”和“前景理论的价值函数曲线”之间的关系,是数学定义上的依赖关系,不是语义相似关系。向量检索抓不住这种依赖。
第三个问题是没有全局结构。你问一个跨章节的问题,比如“卡尼曼在书里是怎么一步步论证人类决策非理性的”,纯 RAG 只能检索出几个相关片段,拼在一起,但拼出来的东西没有论证链条,读者看到的是一堆点,不是一条线。
提示:如果你现在的 RAG 系统回答“是什么”类问题很稳,但回答“为什么”和“怎么推导出来的”类问题很虚,那基本可以确定是缺了关系层。
2.2 知识图谱、RAG 知识库、结构知识库,到底怎么区分
这三个词经常被混着用,但它们的定位完全不同。我画个表说清楚:
| 类型 | 存储核心 | 擅长回答 | 典型瓶颈 |
|---|---|---|---|
| 向量 RAG 知识库 | 文本块的向量表示 | 事实检索、相似内容召回 | 逻辑断裂、跨块推理弱 |
| 知识图谱 KG | 实体-关系-实体三元组 | 关系查询、路径推理 | 构建成本高、自然语言覆盖差 |
| 结构知识库 | 层级化/结构化文档 | 目录导航、章节定位 | 灵活性差、难以语义检索 |
我们做的这个“关系图谱”,本质上是KG 的轻量化变体 + RAG 的溯源能力。它不像传统 KG 那样追求严格的 ontology 定义,也不像纯 RAG 那样只存文本块。它的核心数据结构是:节点 = 概念/论点/证据,边 = 关系类型 + 权重 + 原文出处。
这样做的好处是,你既能做语义检索(因为节点上挂了原文),又能做关系遍历(因为边上有类型),还能做溯源(因为每条边都有出处)。三个能力叠在一起,才真正解决了“理得清”的问题。
2.3 为什么“可溯源”是这条路的生死线
我见过太多知识图谱项目死在“不可信”上。模型抽出来的三元组,看起来有模有样,但你一查原文,发现它把两个不同章节的观点强行连在一起,或者把作者的假设当成了结论。这种图谱,越用越危险,因为你会不知不觉被错误的关系误导。
所以我们在设计之初就定了一条硬规则:图谱上任何一条边,必须能定位到原书的具体段落,且该段落必须能支撑这条边的语义。做不到这一点的边,宁可不加。这条规则听起来简单,但执行起来很痛苦,因为 LLM 抽取关系的时候,天然倾向于“补全”和“推断”,你要不断把它拉回到“只抽原文明确表达的关系”这个约束上。
我后面会讲具体怎么用 prompt 和校验机制来保证这一点。这里先记住一个原则:图谱的价值不在于大,而在于准。一张只有 200 个节点但每条边都可溯源的图谱,比一张有 2000 个节点但一半是幻觉的图谱有用得多。
3. 核心架构拆解:从 PDF 到可遍历图谱的完整链路
3.1 整体流程设计
整个 pipeline 我拆成六个阶段,每个阶段都有明确的输入输出和校验点:
- 文本提取与清洗:从 PDF/EPUB 提取正文,去掉页眉页脚、页码、参考文献格式噪音
- 章节结构识别:识别章、节、小节的层级,建立原始目录树
- 概念节点抽取:从每个章节里抽出核心概念、论点、证据,作为图谱节点
- 关系抽取与分类:识别节点之间的关系类型(因果、依赖、对比、例证、递进等)
- 溯源绑定:为每个节点和每条边绑定原文段落 ID 和字符偏移
- 图谱存储与查询:用图数据库存储,支持语义检索 + 关系遍历 + 溯源回溯
这个流程里,第 3 和第 4 步是最难的,也是决定图谱质量的关键。第 5 步是最容易被忽视但最重要的,因为它决定了图谱可不可信。
3.2 为什么选图数据库而不是向量库
很多人问,为什么不直接在向量库上做关系?比如用 metadata 存关系类型。我试过,不行。原因是向量库的查询范式是“相似度排序”,而关系图谱的查询范式是“路径遍历”。你要问“从概念 A 到概念 B 的最短逻辑路径是什么”,向量库根本答不了,它只能给你一堆相似块,你得自己在应用层拼路径,拼出来的东西既慢又不准。
我们选的是 Neo4j 作为主存储,配合一个轻量向量索引做节点语义检索。Neo4j 的 Cypher 查询语言对路径遍历非常友好,比如查“所有从‘系统1’出发、经过‘启发式’、到达‘偏见’的路径”,一句 Cypher 就搞定。向量索引只负责“找到相关节点”,找到之后的关系统统交给图数据库。
注意:如果你的书规模不大(比如 10 本以内),其实用 SQLite + 邻接表也能跑,不一定非要上 Neo4j。但如果你要做跨书的知识网络,图数据库的查询优势会非常明显。
3.3 节点和边的数据结构设计
节点我们设计了四种类型:
- Concept(概念):书里定义或讨论的核心术语,如“系统1”“损失厌恶”
- Claim(论点):作者提出的判断或主张,如“人类决策受情绪影响大于理性”
- Evidence(证据):支撑论点的实验、案例、数据,如“锚定效应实验”
- Reference(引用):作者引用的外部研究或文献
边我们定义了六种关系类型:
| 关系类型 | 含义 | 示例 |
|---|---|---|
| DEPENDS_ON | A 依赖 B 才能成立 | 损失厌恶 DEPENDS_ON 前景理论 |
| CAUSES | A 导致 B | 系统1激活 CAUSES 锚定效应 |
| CONTRASTS_WITH | A 与 B 对立 | 系统1 CONTRASTS_WITH 系统2 |
| EXEMPLIFIES | A 是 B 的例证 | 锚定实验 EXEMPLIFIES 启发式偏差 |
| ELABORATES | A 是对 B 的展开 | 第 12 章 ELABORATES 系统1机制 |
| PRECEDES | A 在论证顺序上先于 B | 框架介绍 PRECEDES 具体应用 |
每种边都带三个属性:source_paragraph_id(出处段落)、confidence(抽取置信度)、evidence_text(支撑原文片段)。这样任何一条边被查询时,都能立刻回溯到原文。
4. 实操过程:手把手把一本书跑成图谱
4.1 文本提取与章节结构识别
这一步看起来简单,实际上坑很多。PDF 提取最大的问题是排版噪音:页眉页脚、页码、脚注、图表标题混在正文里。我的做法是先用pdfplumber提取带坐标的文本块,然后按 y 坐标聚类,把每页顶部和底部的固定区域标记为“疑似页眉页脚”,再人工确认一次规则。
章节识别我用的是字体大小 + 加粗 + 编号模式三重判断。比如一本书里章标题通常是 18pt 加粗,节标题是 14pt 加粗,小节是 12pt 加粗。提取出所有候选标题后,按层级建树。如果书没有明显编号,就用目录页做锚点,反推正文里的标题位置。
import pdfplumber def extract_blocks(pdf_path): blocks = [] with pdfplumber.open(pdf_path) as pdf: for page_num, page in enumerate(pdf.pages): for block in page.extract_words(extra_attrs=["size", "fontname"]): blocks.append({ "text": block["text"], "size": round(block["size"], 1), "font": block["fontname"], "page": page_num, "top": block["top"] }) return blocks拿到 blocks 之后,按size分布做直方图,找出出现频率最高的几个字号,通常最大的那个就是章标题,次大的就是节标题。这个方法对大多数正规出版物都管用。
实操心得:如果书是扫描版 PDF,先做 OCR,但 OCR 出来的文本没有字体信息,章节识别要改用“行首编号正则 + 空行模式”来判断。我试过 Tesseract 和 PaddleOCR,中文书 PaddleOCR 的版面分析更稳。
4.2 概念节点抽取的 prompt 设计
这是整个流程里最需要反复调的一步。我的核心思路是:不要让模型自由发挥,要给它明确的抽取框架和否定约束。
我用的 prompt 结构大概是这样的:
你是一个知识图谱抽取器。请从以下文本中抽取概念节点。 抽取规则: 1. 只抽取作者明确定义或重点讨论的术语,不要抽取通用词汇 2. 每个节点必须包含:名称、类型(Concept/Claim/Evidence/Reference)、原文出处 3. 如果某个术语在文中只是一笔带过,不要抽取 4. 不要推断,不要补全,只抽原文明确表达的内容 输出格式(JSON): { "nodes": [ {"name": "...", "type": "...", "source_text": "...", "paragraph_id": "..."} ] } 文本: {text_chunk}这里的关键约束是第 3 条和第 4 条。我一开始没加这两条,结果模型把“人类”“研究”“问题”这种词都抽出来了,图谱里全是噪音。加上之后,节点数量大概减少了 60%,但质量提升非常明显。
另一个技巧是分段抽取 + 合并去重。不要一次性把整章塞进去,按小节切,每节单独抽,最后用名称相似度做合并。合并的时候要注意,同一个概念在不同章节可能有不同表述,比如“系统1”和“快思考系统”,要用 embedding 相似度 + 人工规则一起判断。
4.3 关系抽取:怎么让模型只抽“原文说了的”关系
关系抽取比节点抽取更难,因为模型天然倾向于“推理”。你给它两段文本,它很容易自己脑补出一个关系,但这个关系原文根本没提。
我的解法是双阶段抽取 + 反向校验。第一阶段,让模型从文本中抽关系,但要求它必须引用支撑原文;第二阶段,把抽出来的关系和支撑原文一起送给另一个模型实例,问它“这段原文是否明确支持这个关系”,只有两边都通过的才保留。
关系抽取的 prompt 核心约束:
请识别以下文本中两个概念之间的关系。 规则: 1. 关系必须是原文明确表达的,不能是推断的 2. 必须引用支撑该关系的原文片段 3. 如果找不到明确支撑,输出 null 4. 关系类型只能从以下选择:DEPENDS_ON, CAUSES, CONTRASTS_WITH, EXEMPLIFIES, ELABORATES, PRECEDES 输出格式: { "source": "...", "target": "...", "relation": "...", "evidence": "...", "confidence": 0.0-1.0 }反向校验的 prompt 更简单:
以下是一段原文和一个声称的关系。请判断该原文是否明确支持该关系。 只回答 YES 或 NO,并给出理由。 原文:{evidence} 关系:{source} --{relation}--> {target}这个双阶段机制跑下来,关系的准确率从大概 70% 提升到了 90% 以上。代价是抽取速度慢了一倍,但我觉得值,因为图谱的边一旦错了,后面所有查询都会被污染。
4.4 溯源绑定:字符偏移与段落 ID 的精确对齐
溯源这件事,说起来简单,做起来烦。核心问题是:你抽出来的节点和边,怎么精确对应到原文的哪个位置?
我的做法是给每个段落分配一个唯一 ID,格式是{chapter}-{section}-{paragraph},比如03-02-015表示第 3 章第 2 节第 15 段。然后在抽取时,要求模型输出paragraph_id和evidence_text。拿到evidence_text后,用字符串匹配在对应段落里定位字符偏移,存成(start, end)。
def bind_source(paragraph_text, evidence_text, paragraph_id): start = paragraph_text.find(evidence_text) if start == -1: return None # 匹配失败,标记为待人工审核 return { "paragraph_id": paragraph_id, "start": start, "end": start + len(evidence_text), "text": evidence_text }匹配失败的情况大概占 5% 到 10%,主要是模型改写了原文措辞。这些我会单独存到一个“待审核”表里,人工过一遍。不要小看这个步骤,它是整个图谱可信度的最后一道防线。
提示:如果你的书有多个版本(比如中英文对照),溯源 ID 要带上版本标识,否则跨版本查询会串。
5. 查询与使用:图谱建好之后怎么用
5.1 三种查询模式
图谱建好之后,我设计了三种查询模式,对应不同的使用场景:
模式一:语义检索 + 图谱扩展。用户输入一个问题,先用向量检索找到最相关的几个节点,然后从这些节点出发,沿边扩展一跳或两跳,把相关节点和边一起返回。这样既保证了相关性,又补充了逻辑上下文。
模式二:路径查询。用户问“从概念 A 到概念 B 的逻辑路径是什么”,系统用 Cypher 做最短路径查询,返回路径上的所有节点和边,每条边附带原文出处。这个模式特别适合回答“作者是怎么一步步论证的”这类问题。
模式三:溯源回溯。用户点击图谱上任意一条边,系统返回该边对应的原文段落,高亮支撑文本。这个模式是建立信任的关键,让用户能随时验证图谱的准确性。
5.2 和 Agent 记忆层的结合
这个图谱天然适合做 Agent 的长期记忆层。传统的 Agent 记忆要么是纯文本摘要,要么是向量库,前者丢结构,后者丢逻辑。关系图谱刚好补上这块。
我的做法是:Agent 在对话中遇到新概念时,先在图谱里查是否已有相关节点,如果有,就把节点的关系和溯源一起注入 context;如果没有,就标记为“待学习”,后续用同样的 pipeline 抽取入库。这样 Agent 的记忆不是一堆孤立的事实,而是一张不断生长的知识网络。
实操心得:Agent 写入图谱时一定要加“来源标记”,区分“来自书籍抽取”和“来自对话推断”。后者置信度要低一档,查询时默认不返回,除非用户明确要求。
5.3 可视化:让图谱真正“看得见”
图谱这东西,不可视化等于白建。我用的是pyvis做交互式可视化,节点按类型着色,边按关系类型着色,点击节点显示原文出处。对于一本书的图谱,通常有 200 到 500 个节点,直接全量渲染会很乱,所以默认只显示两跳以内的子图,用户可以通过搜索定位到具体节点再展开。
可视化的价值不只是好看,它能帮你发现图谱的结构问题。比如你发现某个概念节点连接了异常多的边,那可能是抽取时把太多关系都挂到它上面了,需要人工检查。或者你发现某个章节的节点几乎是孤立的,那可能是那一章的抽取出了问题。
6. 踩过的坑与排查技巧实录
6.1 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 节点数量异常多 | prompt 约束不够 | 抽样检查节点名称 | 加“只抽明确定义的术语”约束 |
| 关系准确率低 | 模型推断过多 | 反向校验通过率 | 加双阶段校验,提高 confidence 阈值 |
| 溯源匹配失败率高 | 模型改写原文 | 检查 evidence_text | 要求模型逐字引用,或改用模糊匹配 |
| 图谱查询慢 | 边数量过大 | 看 Cypher 执行计划 | 加索引,限制遍历深度 |
| 跨章节关系缺失 | 分段抽取导致 | 检查跨节边数量 | 加一轮跨节关系抽取 |
6.2 三个我踩过的深坑
坑一:一次性抽整本书。我一开始图省事,把整本书塞进一个超长 context 让模型抽。结果模型前面抽得还行,后面就开始“偷懒”,抽出来的节点越来越泛,关系越来越模糊。后来改成按小节抽,每节独立跑,质量立刻稳定了。教训是:LLM 的长 context 不等于长注意力,分段处理永远更稳。
坑二:忽略否定关系。书里经常有“A 不是 B”“A 与 B 无关”这种否定表述。我一开始的关系类型里没有否定边,结果模型把“A 不是 B”抽成了“A 是 B”,完全反了。后来加了NEGATES关系类型,专门处理否定表述,问题才解决。
坑三:溯源 ID 冲突。不同章节的段落 ID 如果只用序号,会冲突。比如第 3 章第 2 节第 15 段和第 5 章第 1 节第 15 段,如果 ID 都是15,溯源就乱了。后来改成三段式chapter-section-paragraph,冲突消失。这个坑很小,但很致命,因为溯源错了,整个图谱的可信度就崩了。
6.3 性能优化的几个实用技巧
图谱规模上去之后,查询性能会下降。我试过几个优化手段,效果比较明显的有:
- 边索引:在 Neo4j 里给
relation_type和source_paragraph_id建索引,路径查询速度提升大概 3 倍 - 子图缓存:对高频查询的子图做缓存,比如“系统1相关的所有节点”,缓存后响应时间从 800ms 降到 50ms
- 分层加载:可视化时先加载节点,边按需加载,避免一次性渲染几千条边卡死浏览器
提示:如果你的图谱要支持多用户并发查询,Neo4j 的读副本机制很有用,但配置起来有点复杂,小规模场景直接用单实例 + 缓存就够了。
7. 这条路还能怎么走
把一本书变成关系图谱,只是起点。我目前在做几个方向的扩展,一个是跨书图谱融合,把同一主题的多本书的图谱合并,看看不同作者对同一概念的关系定义有什么差异。另一个是动态图谱,让图谱随着阅读进度生长,你读到哪,图谱就长到哪,而不是一次性建完。
还有一个我觉得很有意思的方向是反向生成:给定一张图谱,让模型按图谱的关系结构生成一篇新的综述文章。这相当于用图谱做“论证骨架”,模型只负责填充语言。我试过几个小规模实验,生成的文章逻辑连贯性比纯 prompt 生成的好很多,因为骨架是结构化的,模型不会跑偏。
最后分享一个我在实际使用中的体会:图谱的价值不在于你建了多大,而在于你查的时候能不能信任它。我见过太多项目追求节点数量,结果查出来的关系一半是错的,用两次就不敢用了。宁可慢一点、少一点,也要保证每条边都能溯源。这个原则,我觉得比任何技术选型都重要。