1. 从一本书到一张图谱:这个项目到底在解决什么问题
Karpathy 前阵子提了个很有意思的观点,大意是未来的软件形态会变,LLM 会像新的操作系统一样,把各种能力重新组织一遍。他那个 LLM Wiki 的思路,本质上是把知识用大模型能理解的方式重新编排,让模型在回答问题时能顺着一个结构化的入口去查,而不是每次都在一堆碎片里瞎找。这个方向我很认同,但真上手做的时候,你会发现一个很现实的问题:Wiki 这种形态,天然是给人读的,不是给机器读的。
人读 Wiki 没问题,有目录、有超链接、有上下文,跳来跳去也能理解。但机器读的时候,它看到的是一堆文本块,块和块之间的关系是断的。你问它一个跨章节的问题,它要么答不全,要么把不同章节的内容拼在一起,拼出一个看起来合理但实际上是错的东西。这就是现在 RAG 最让人头疼的地方——检索增强听起来很美,实际用起来,检索回来的东西经常是“相关但不准确”,模型再一发挥,就开始编。
所以我一直在想,有没有一种方式,既保留 Wiki 那种人类可读的结构,又能让机器真正理解知识之间的关联。后来我们做了一件事:把一本书拆成一张可溯源的关系图谱。不是简单的知识图谱,是带溯源的关系图谱。每一个节点、每一条边,都能追回到原书的具体位置。这样一来,模型在回答问题时,不只是“找到了相关内容”,而是“找到了内容,并且知道这个内容从哪来、和什么相关、在什么语境下成立”。
这个项目适合谁看?如果你正在做 RAG 相关的应用,被检索准确率折磨过,或者你在搭 Agent 的时候发现知识库这块总是拖后腿,那这篇内容应该对你有用。如果你只是好奇知识图谱和 RAG 到底怎么结合,也能从这里看到一个完整的落地思路。我不打算讲太多虚的,主要说我们怎么想的、怎么做的、踩了哪些坑。
2. 为什么 Wiki 和 RAG 都不够用:核心思路拆解
2.1 Wiki 的天然缺陷:结构是给人看的
Wiki 这种知识组织方式,从 Ward Cunningham 那时候起,核心逻辑就是“让人类方便地创建和链接内容”。它的结构是显式的,目录、标题、段落、超链接,这些都是给人看的导航工具。但当你把 Wiki 喂给 LLM 的时候,这些结构信息大部分都丢了。你可能会说,不是可以按标题切分吗?可以,但切完之后,块和块之间的关系就没了。
举个例子,一本书里第三章讲了一个概念,第五章又提到了这个概念,并且做了补充。人读的时候,看到第五章会自然联想到第三章的内容。但机器拿到的是两个独立的文本块,它不知道这两个块讲的是同一个东西。你问一个需要综合这两章内容才能回答的问题,检索系统可能只召回其中一个块,模型就只能基于半个信息回答。这不是模型笨,是知识组织方式的问题。
2.2 RAG 的瓶颈:检索准了,但理解还是断的
RAG 的核心思路是“先检索,再生成”。这个思路本身没问题,问题出在检索这一环。现在的 RAG 系统,大部分用的是向量检索,把文本块编码成向量,然后算相似度。这个方法在“找相似内容”上很有效,但在“找准确答案”上经常翻车。
为什么?因为向量相似度衡量的是语义相似,不是逻辑相关。你问“A 和 B 有什么关系”,检索系统可能召回一堆提到 A 和 B 的段落,但这些段落可能只是分别提到了 A 和 B,并没有讲它们之间的关系。模型拿到这些段落,只能硬着头皮编一个关系出来。这就是 RAG 的瓶颈:检索回来的东西,缺少结构化的关系信息。
还有一个更隐蔽的问题:溯源。RAG 系统回答完问题,你问它“这个结论从哪来的”,它只能给你一个文本块,你还要自己去读这个块,判断它是不是真的支持这个结论。如果这个块本身是断章取义的,你根本发现不了。没有溯源,就没有信任。
2.3 关系图谱的思路:把知识拆成节点和边
我们的思路很简单:既然文本块之间的关系丢了,那就把它显式地建出来。把书里的核心概念抽出来,做成节点;把概念之间的关系抽出来,做成边。节点和边都带上溯源信息,指向原书的具体位置。这样一来,知识就不是一堆孤立的块,而是一张网。
这张网的好处是,检索的时候不只是找相似的块,而是沿着关系走。你问一个跨章节的问题,系统可以沿着边找到相关的节点,再把节点对应的原文块拿出来。这样召回的内容,天然就是有关联的,不是靠相似度硬凑的。
而且因为每条边都有溯源,模型回答的时候可以明确说“这个结论来自第三章第二节和第五章第一节的关联”,用户可以自己去验证。这就解决了信任问题。
2.4 为什么不用纯知识图谱:Ontology 太重了
你可能会问,这不就是知识图谱吗?是,但不完全是。传统的知识图谱,尤其是那种带 Ontology 的,需要你先定义一套本体,规定好有哪些实体类型、哪些关系类型。这套东西很强大,但也很重。你要么请领域专家来设计本体,要么花大量时间调,成本很高。
我们做的是一个轻量级的关系图谱,不预设复杂的本体,而是从书的内容本身出发,用 LLM 来抽关系和节点。这样做的代价是结构可能没那么严谨,但好处是灵活、可扩展,而且能快速落地。对于大部分 RAG 应用来说,这种轻量级的关系图谱已经能解决大部分问题了。
3. 核心细节解析:节点、边和溯源怎么设计
3.1 节点设计:什么该抽成节点
节点是图谱的基本单元。抽什么当节点,直接决定了图谱的质量。我们试过几种方案,最后定下来的原则是:抽“有独立语义、且可能被多次引用”的概念。
具体来说,节点分几类。第一类是实体,比如人名、地名、组织名、产品名。第二类是概念,比如某个术语、某个方法论、某个理论。第三类是事件,比如某个时间点发生的事。第四类是观点,比如作者提出的某个论断。
这里有个坑:不要抽太细。我们一开始抽得很细,把每个名词都抽成节点,结果图谱巨大无比,检索的时候噪音很多。后来改成只抽“在书中出现两次以上、且对理解核心内容有帮助”的概念,图谱规模降了一个数量级,效果反而更好。
还有一个经验:节点的命名要统一。同一个概念,书里可能用不同的词表达,比如“大模型”和“LLM”,如果不做归一化,就会变成两个节点,关系就断了。我们的做法是用 LLM 做一次实体归一化,把指向同一个概念的词合并到一个节点上。
3.2 边设计:关系怎么抽才靠谱
边是节点之间的关系。抽边比抽节点难,因为关系往往是隐含的,不是显式写出来的。比如“A 导致了 B”,书里可能写的是“因为 A,所以 B”,也可能写的是“B 的出现,离不开 A 的推动”。你要让 LLM 去理解这些表达背后的关系。
我们定义了几种基础关系类型:因果关系、包含关系、对比关系、时序关系、引用关系。不追求覆盖所有关系,先把这几种高频的抽准。抽边的时候,我们用的是“滑动窗口 + LLM 判断”的方式。把相邻的文本块拿出来,让 LLM 判断这两个块里的节点之间有没有关系、是什么关系。
这里的关键是 Prompt 的设计。你不能直接问“这两个节点有什么关系”,太开放了,LLM 会乱答。我们的做法是给出关系类型的定义和例子,让 LLM 做选择题。比如“以下哪种关系最符合这两个节点的关系:因果、包含、对比、时序、引用、无关系”。这样准确率会高很多。
还有一个细节:边的方向很重要。因果关系是有方向的,A 导致 B 和 B 导致 A 完全不同。我们在抽边的时候,会明确要求 LLM 判断方向,并且在存储的时候把方向记下来。
3.3 溯源设计:每条边都要能追回原文
溯源是这个项目的核心。没有溯源,图谱就是一个黑盒,用户没法信任。我们的做法是,每个节点和每条边都带一个 source 字段,记录它来自哪本书、哪一章、哪一段、甚至哪一句话。
具体实现上,我们在切分文本的时候,给每个块分配一个唯一的 ID,这个 ID 包含了章节信息和位置信息。抽节点和边的时候,把对应的块 ID 记下来。这样,当你点击一个节点或一条边的时候,可以直接跳转到原文的对应位置。
溯源还有一个好处:可以做置信度评估。如果一个节点或边有多个来源,说明它在书中被多次提及,置信度就高。如果只有一个来源,而且来源的上下文很模糊,置信度就低。我们把这个置信度也存下来,检索的时候可以作为排序的依据。
3.4 存储选型:为什么用图数据库而不是向量库
存储这块我们纠结了很久。向量库用起来简单,生态也成熟,但它的查询模式是“找相似”,不是“找关联”。你要做多跳查询,比如“A 通过 B 连接到 C”,向量库很难做。
最后我们选了图数据库,具体用的是 Neo4j。Neo4j 的 Cypher 查询语言很适合做关系查询,多跳、路径查找都很方便。而且 Neo4j 支持把向量索引和属性索引结合起来,我们可以先用向量检索找到入口节点,再沿着图走。
当然,图数据库也有代价。写入比向量库慢,运维也更复杂。但对于我们这个场景,查询的灵活性比写入速度更重要。如果你只是做一个简单的问答,向量库可能就够了。但如果你要做复杂的推理和溯源,图数据库是更好的选择。
4. 实操过程:从一本书到一张图谱的完整流程
4.1 文本预处理:切分和清洗
第一步是把书变成可处理的文本。如果是 PDF,先用工具转成纯文本。我们用的是 PyMuPDF,转换质量还不错。转完之后要做清洗,去掉页眉页脚、页码、多余的换行。
然后是切分。切分粒度很关键。切太细,上下文丢失;切太粗,检索不精准。我们的做法是按语义切分,不是按固定字数。具体来说,先用标题做一级切分,然后在每个章节内部,按段落做二级切分。如果一个段落太长,再按句子做三级切分。
每个切分出来的块,都要分配一个 ID,格式是“书名-章节号-块序号”。这个 ID 后面会用来做溯源。
import fitz # PyMuPDF def extract_text(pdf_path): doc = fitz.open(pdf_path) full_text = [] for page_num, page in enumerate(doc): text = page.get_text() # 去掉页码和页眉页脚 lines = text.split('\n') cleaned = [l for l in lines if not is_header_footer(l, page_num)] full_text.append('\n'.join(cleaned)) return '\n'.join(full_text) def is_header_footer(line, page_num): # 简单的启发式判断 if line.strip().isdigit(): return True if len(line.strip()) < 5 and str(page_num) in line: return True return False4.2 节点抽取:用 LLM 做实体和概念识别
文本准备好之后,开始抽节点。我们用的是 LLM 做 few-shot 抽取。给 LLM 几个例子,告诉它我们要抽什么类型的节点,然后让它从文本块里抽。
Prompt 大概长这样:
你是一个知识抽取助手。请从以下文本中抽取核心概念和实体。 要求: 1. 只抽取在文本中有明确语义、且可能被多次引用的概念 2. 每个概念给出名称和类型(实体/概念/事件/观点) 3. 如果同一个概念有不同的表达方式,合并为一个 文本: {text} 输出格式: [{"name": "概念名", "type": "类型"}, ...]抽完之后要做去重和归一化。我们用一个简单的规则:如果两个节点的名称字符串相似度超过阈值,或者 LLM 判断它们是同一个概念,就合并。
4.3 边抽取:滑动窗口加关系判断
边抽取是分两步走的。第一步,找出可能有关联的节点对。我们用的是滑动窗口,把相邻的几个文本块拿出来,看里面有哪些节点。如果两个节点在同一个窗口里出现,就认为它们可能有关联。
第二步,让 LLM 判断这两个节点之间到底是什么关系。Prompt 里给出关系类型的定义和例子,让 LLM 做分类。
def extract_relations(text_blocks, nodes, window_size=3): relations = [] for i in range(len(text_blocks) - window_size + 1): window = text_blocks[i:i+window_size] window_text = '\n'.join([b['text'] for b in window]) window_nodes = [n for n in nodes if n['block_id'] in [b['id'] for b in window]] if len(window_nodes) < 2: continue # 让 LLM 判断关系 prompt = build_relation_prompt(window_text, window_nodes) result = call_llm(prompt) for rel in result: rel['source_block'] = window[0]['id'] relations.append(rel) return relations这里有个经验:窗口大小不要太大。我们试过 window_size=5,结果噪音很多,LLM 经常把不相关的节点扯上关系。后来改成 3,准确率明显提升。
4.4 图谱构建:写入 Neo4j 并建立索引
节点和边都抽好之后,写入 Neo4j。节点用:Concept标签,边用:RELATES标签。每个节点和边都带上source属性,记录来源块 ID。
// 创建节点 CREATE (n:Concept { id: $id, name: $name, type: $type, source: $source, confidence: $confidence }) // 创建关系 MATCH (a:Concept {id: $from_id}) MATCH (b:Concept {id: $to_id}) CREATE (a)-[:RELATES { type: $rel_type, source: $source, confidence: $confidence }]->(b)建完图之后,还要建索引。我们建了两类索引:一类是全文索引,用来做关键词检索;一类是向量索引,用来做语义检索。这样检索的时候可以灵活组合。
4.5 检索与溯源:怎么用这张图谱回答问题
检索的流程是这样的:用户提问,先用向量检索找到入口节点,然后沿着图走,找到相关的节点和边,最后把节点对应的原文块拿出来,交给 LLM 生成回答。
关键是,生成回答的时候,要把溯源信息也带上。比如“根据第三章第二节和第五章第一节的关联,可以得出以下结论”。这样用户就知道这个结论是怎么来的,可以自己去验证。
def retrieve_and_answer(query): # 向量检索找入口节点 entry_nodes = vector_search(query, top_k=5) # 沿图扩展 related_nodes = [] for node in entry_nodes: neighbors = graph_query(node, depth=2) related_nodes.extend(neighbors) # 去重 related_nodes = deduplicate(related_nodes) # 拿原文块 context_blocks = [] for node in related_nodes: block = get_block(node['source']) context_blocks.append(block) # 生成回答 answer = generate_answer(query, context_blocks, related_nodes) return answer5. 常见问题与排查技巧实录
5.1 节点抽太多或太少怎么办
这是最常见的问题。抽太多,图谱噪音大,检索的时候召回一堆不相关的东西。抽太少,关系建不起来,图谱退化成一个个孤岛。
我们的经验是,先抽一遍,然后看统计。如果节点数量超过文本块数量的 3 倍,说明抽太细了,要收紧抽取规则。如果节点数量少于文本块数量的 0.5 倍,说明抽太粗了,要放宽规则。
调整的时候,优先调 Prompt 里的例子。例子比规则更有效。你给 LLM 看几个“该抽”和“不该抽”的例子,它就能理解你的意图。
5.2 关系抽取准确率低怎么排查
关系抽取准确率低,通常有三个原因。第一是窗口太大,节点对太多,LLM 判断不过来。第二是 Prompt 太开放,LLM 不知道该怎么选。第三是节点本身抽得不好,两个不相关的节点被硬凑在一起。
排查的时候,先看窗口大小。如果窗口里节点超过 5 个,就缩小窗口。然后看 Prompt,确保关系类型定义清晰,有例子。最后看节点质量,如果节点本身就有问题,先修节点。
5.3 溯源信息丢失怎么解决
溯源信息丢失,通常是因为在某个环节没有把块 ID 传下去。我们的做法是,在每个环节都强制要求带上块 ID,并且在写入数据库之前做一次校验,确保每个节点和边都有 source 字段。
如果发现丢失,先检查切分环节。有时候切分的时候没有正确分配 ID,后面就全乱了。然后检查抽取环节,确保 LLM 的输出里包含了块 ID。最后检查写入环节,确保 source 字段被正确写入。
5.4 图谱太大查不动怎么办
图谱大了之后,查询会变慢。我们的做法是分层。把节点分成核心节点和边缘节点,核心节点是那些被多次引用、置信度高的节点,边缘节点是那些只出现一次、置信度低的节点。查询的时候,优先查核心节点,边缘节点只在需要的时候才查。
还有一个做法是加缓存。常用的查询结果缓存起来,下次直接返回。我们用的是 Redis,效果还不错。
5.5 常见问题速查表
| 问题 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 节点太多 | 抽取规则太松 | 统计节点/块比例 | 收紧 Prompt,增加过滤条件 |
| 节点太少 | 抽取规则太严 | 检查抽取结果 | 放宽 Prompt,增加例子 |
| 关系不准 | 窗口太大或 Prompt 太开放 | 检查窗口节点数 | 缩小窗口,明确关系类型 |
| 溯源丢失 | 块 ID 未传递 | 检查各环节 ID | 强制传递 ID,写入前校验 |
| 查询太慢 | 图谱太大 | 检查查询耗时 | 分层查询,加缓存 |
| 回答不准 | 召回内容不相关 | 检查召回结果 | 调整检索策略,增加关系权重 |
5.6 几个踩过的坑
第一个坑是过度依赖 LLM。我们一开始什么都让 LLM 做,结果成本高、速度慢、还不稳定。后来改成规则 + LLM 结合,能用规则的地方用规则,LLM 只做它擅长的判断,效果好很多。
第二个坑是忽略文本质量。PDF 转文本的时候,如果转换质量差,后面全白搭。我们后来加了一个文本质量检查环节,转换完先看一遍,有问题先修。
第三个坑是不做评估。我们一开始凭感觉调,调来调去不知道有没有变好。后来建了一个小评估集,每次调整都跑一遍,看指标变化,才慢慢调出效果。
6. 这套方案还能怎么扩展
这套方案目前是针对单本书做的,但思路可以扩展到多本书。多本书的时候,节点归一化会更重要,因为不同书里可能用不同的词表达同一个概念。我们的做法是建一个全局的概念词典,所有书抽出来的节点都往这个词典上映射。
另一个扩展方向是结合 Agent。现在 Agent 做任务的时候,经常需要查知识库。如果知识库是一张关系图谱,Agent 就可以沿着图走,做多跳推理。比如“A 导致了 B,B 又导致了 C,那 A 和 C 是什么关系”,这种问题用图谱回答会比纯文本 RAG 准很多。
还有一个方向是动态更新。书是静态的,但知识是动态的。如果能让图谱随着新信息的加入而更新,那就更有价值了。我们试过用增量抽取的方式,新内容来了之后,只抽新增的节点和边,然后合并到现有图谱里。效果还可以,但合并的时候要注意冲突处理。
最后说一个我个人的体会:做知识图谱这件事,最难的不是技术,是定义“什么算知识”。你把什么抽成节点、什么抽成边,直接决定了图谱长什么样。这个定义没有标准答案,只能根据你的应用场景来调。我们的经验是,先想清楚你要回答什么问题,再倒推需要什么样的图谱。不要为了建图而建图,图是手段,不是目的。