从零构建本地知识图谱——GraphRAG + Neo4j 全链路实战
一、项目起源
最近在学习GraphRAG,开始感觉很高级。最后慢慢了解才发现优点比较多,于是在本地从头搭建一套知识图谱系统——从文档处理到图谱构建、从索引到查询,每个环节都可以自己控制和调优,而不是依赖某个封装好的 SaaS API。手头正好有一本百万字的网络小说《序列:吃神者》,人物众多、势力交错、序列能力体系复杂,很适合作为测试知识图谱边界能力的素材。于是以它为输入,搭了一套 GraphRAG + Neo4j 的完整链路。
二、技术选型
三件套分工明确:
1 | ┌──────────┐ ┌───────────────┐ ┌──────────┐ |
Microsoft GraphRAG 负责从文本中抽取实体和关系并进行社区发现;Neo4j 通过 Docker 本地部署,承担图数据持久化和可视化;FastAPI 封装图谱查询和 AI 问答为 REST 接口。LLM 选用 Qwen3-235B-A22B,Embedding 使用 BAAI/bge-m3。
三、基础索引搭建
先执行 graphrag init 初始化项目,配置 .env 和 settings.yaml,将小说原文放入 input/ 目录。
第一个需要注意的地方:GraphRAG 默认的 entity_types 是 [organization, person, event, geo],面向的是英文世界的文档类型。如果不做调整直接使用,从中文网络小说中抽取出的实体类型基本不可用。这里改成了 [人物,势力,地点,序列,道具],与小说的世界观对齐。这点需要开发者对于文档资料的理解先行构建。
提示词也做了定制。默认的抽取提示词是为新闻和百科类文档设计的,网络小说的人称和叙事风格差异较大,直接使用效果不理想。
百万字全量索引耗了数小时——LLM 逐段抽取实体和关系确实慢。产物位于 output/artifacts/:entities.parquet 为抽取出的实体(含描述、类型、度数),relationships.parquet 为实体间关系(含权重和排序),community_reports.parquet 为社区聚合报告。
四、Neo4j 导入与 API 封装
Parquet 文件里的数据需要导入 Neo4j 才能进行图查询。
编写了 import_to_neo4j.py 完成数据库初始化。几个优化点:批量写入使用 UNWIND 一次 MERGE 500 条,相比逐条插入提速明显;提前创建 entity_id 唯一约束和 entity_name 索引,后续查询避免全表扫描;GraphRAG 不同版本的列名存在差异(human_readable_id vs id),加了一层 _safe() 做兼容处理。
导入完成后基于 FastAPI 封装了以下查询接口:
/api/v1/search:按实体名搜索,支持自定义邻居跳数/api/v1/path:两实体间最短路径/api/v1/graph:子图数据导出,供前端可视化使用/api/v1/ask:自然语言问答,支持多轮对话


五、AI 问答优化
问答是投入时间最多的部分。初始版本只能处理单轮——问”秦思洋是谁”可以正常回答,但追问”他和李天明什么关系”就无法理解”他”指代的是谁。
5.1 多轮对话记忆
多轮对话记忆的实现比较直接:用内存 dict 维护每个会话的上下文,设置 1 小时 TTL 自动过期清理,每轮保存实体池。
5.2 指代消解
指代消解是真正的难点。网文中的代词使用非常灵活——“他们俩””他””那个人””此人”等等。写了一个 _resolve_pronouns() 函数,用正则匹配常见代词模式,回溯对话历史中的实体进行解析。早期版本的处理方式比较简单——直接取 turn_entities[0] 作为代词所指的实体。测试几轮后发现问题很明显:用户问”秦思洋和周处长的关系”,本轮实体池为 [秦思洋, 周处长],下一轮问”他后来怎样了”——取 turn_entities[0] 会将”他”误判为秦思洋,但用户的意图显然是指周处长。
改进方案是利用每轮用户问题原文来定位核心实体:将本轮实体名按长度降序排列,检查哪些出现在用户当时的提问中,优先匹配最长的。这样”他和李天明什么关系”中的”李天明”会被优先锚定,而”他”的解析则回退到上一轮的核心实体。这一改动对指代消解的准确率有实质性帮助。
5.3 相邻 Chunk 扩展
另外做了相邻 chunk 扩展:当图谱中某个实体的描述信息不足时,按 (source_file, chunk_index) 索引自动提取前后相邻的文本块,将原文上下文也纳入回答。
六、评估体系:LLM-as-Judge
没有量化评估就没有优化方向。搭建了一套 LLM 自动评分体系,答案是我自己对于小说的理解所提出的问题。对每次问答从四个维度打分:忠实度(是否基于图谱上下文,有无编造)、相关性(是否精准回应用户问题)、完整性(关键信息点覆盖程度)、实体召回(预期涉及的实体是否被检索到)。
出了 10 道测试题,覆盖人物关系、势力格局、序列能力、地理位置等维度,平均回答延迟 33 秒——图谱查询本身很快,主要耗时在 LLM 生成回答和评分环节。
10 道题的规模显然不够,如果要让评估结果有统计意义,至少需要上百道题。但这个评估框架的价值在于:每次调整 prompt 或修改检索策略后,可以立刻看到各维度分数的变化方向,取代凭感觉调参的方式。
七、社区标题修复与可视化
GraphRAG 默认生成的社区名称是 Community 0、Community 1 这类编号,对阅读没有任何帮助。
编写了 fix_community_titles.py,从 parquet 中读取每个社区的成员实体列表,调用 LLM 生成 8-15 字的中文标题。提示词中约束了”要有辨识度””不能与其他社区重名””优先使用最有代表性的实体来命名”。实际效果:Community 12 被重命名为”联合政府·序列管理局势力网”,Community 7 变为”居安学校·安全区边缘势力”——标题本身就能传达社区的主题。
前端可视化基于 Neo4j Browser 的原生能力,套了暗色科幻风格的主题,侧边栏搜索配合节点展开式图谱浏览。
八、踩过的坑
并发限流。大模型 API 对并发极其敏感。concurrent_requests 设为 2 就容易触发 429,设为 1 才稳定。百万字索引必须配合指数退避和 max_retries: 10,否则中途报错意味着从头再来。
社区粒度。默认 max_cluster_size=10 导致社区过于碎片化,报告缺乏可读性。调整到 20 后每个社区的实体覆盖范围更合理,生成的社区报告信息量明显提高。
百万字场景的参数调整。max_gleanings 从默认的 1 调到 3——每个实体做三轮补充拾取,显著减少了实体遗漏。max_length 调到 1200,核心实体的描述更完整。这两个参数对最终图谱质量影响很大。
费用参考。gpt-4o-mini + bge-m3 处理几十篇文档只需几毛钱。百万字规模建议用国产模型的 API(DeepSeek 或硅基流动的 Qwen),效果相当但成本更低。
九、后续方向
接入 RAGAS 做更细粒度的自动化评估,这是优先级最高的一项——LLM-as-Judge 虽然能跑,但评分主观性偏强。增量索引也需要实现,目前更换文档需要全量重跑,效率太低。问答部分考虑引入 Agentic RAG,让 agent 自主决定查询的邻居跳数和是否需要改写 query 重查。
