简介:基于Neo4j的水浒传人物关系图谱构建与智能问答系统,是一套以Python为主的毕业设计完整源码项目,面向计算机、人工智能等专业的学生与从业者,可用于图数据库应用开发、课程设计及毕设参考。压缩包共237个文件,涵盖py源码、html/css/js前端资源、jpg预览图、pptx答辩文稿、pdf文档及zbak备份等,整体约23.56MB,目录结构清晰,便于按模块学习。该系统通过实体关系抽取构建多维人物图谱,利用Cypher实现复杂关系检索,并基于NLP开发智能问答接口;前端采用ECharts交互式展示,后端基于Flask提供API,支持Docker一键部署。包内还包含部署指南、接口说明及核心图算法模块封装,如共现分析、关系紧密度计算、社区发现等。目前已有71人学习浏览,适合需要完整方案借鉴、快速上手Neo4j项目的读者。
1. 水浒传人物图谱:从文本到图模型的落地路径
把《水浒传》里一百单八将以及他们之间的恩怨情仇搬进 Neo4j,这件事听起来像是文科生的数字人文项目,但做起来你会发现它本质上是一次典型的知识图谱工程实践:从非结构化文本里抽实体、抽关系,把离散的“谁认识谁、谁打过谁、谁跟谁有仇”变成可查询的图结构,再基于这张图去回答自然语言问题。整个流程覆盖了信息抽取、数据建模、图查询、自然语言到 Cypher 的转换,恰好是当下知识图谱应用中最常见也是最能出效果的一条链路。
做一个这样的系统,真正有价值的地方不在于“用到了 Neo4j”,而是你在建模过程中被迫回答的一系列问题:人物之间的“关系”在数据里到底存成什么?宋江和吴用的关系与武松和潘金莲的关系是同一类吗?问答系统返回的是节点还是路径?这些决策直接决定了图谱的查询效率和问答的上限。本文我按自己实际做类似项目时的顺序来拆解:先定数据模型,再讲导入与清洗,然后落到查询和可视化验证上,最后用 Python 接一个规则加模板驱动的问答接口,把整条链路打通。
2. 实体与关系建模:决定图谱上限的 4 个设计点
2.1 节点标签与属性的边界:不要一张大表装下所有人
写 Neo4j 的数据模型,第一反应往往是建一个Person标签,把所有角色都装进去。但《水浒传》里的人物有明显的群体分层:梁山好汉、朝廷官员、民间女性、敌方将领。如果只用一个Person标签,后续查询“哪些人是梁山好汉且武艺排名前二十”就得靠属性过滤,写出的 Cypher 既啰嗦又慢。更合理的做法是用多个标签叠加,比如(n:Person:Hero:Liangshan),让Person表示身份,Hero表示入伙身份,Liangshan表示阵营。这样的多标签设计在图数据库里非常自然,一个节点可以拥有多个标签,查询时按标签收敛候选集,性能远比全表扫属性好。
我的习惯是给人物节点配备以下属性:name作为唯一标识,alias存绰号,rank存座次,gender、role存身份描述。注意name必须加唯一约束,否则同一角色的不同写法(比如“武松”和“行者武松”)会在导入时产生重复节点,问答系统里出现两个同名实体就完全没法收敛答案了。rank建议存整数,不要混入“天罡星”这种文字,这样排序查询可以直接用ORDER BY n.rank。
2.2 关系类型的粒度:别把“认识”当万能关系
《水浒传》的关系抽取出来后,大致分成几个类别:亲属关系(如武松和武大郎是兄弟)、上下级关系(如宋江和众好汉的统领关系)、敌对关系(如武松和西门庆)、事件关系(如“醉打蒋门神”中武松与蒋门神的冲突)、结义关系(如“桃园三结义”式的拜把兄弟)。如果你把这些全部归一成RELATED或KNOWS,那后续问答里“谁和谁是结拜兄弟”这类问题就只能在属性里藏着,查不出来。
我的建议是关系类型保持“适中粒度”——既能表达语义类别,又不要细分到每条关系都独一无二。比如用BROTHER_OF表示兄弟关系,SWORN_BROTHER表示结义关系,ENEMY_OF表示敌对,SUPERIOR_OF表示上下级。太过泛化会让知识图谱退化成属性图;太过细化则每类关系的数据量太少,问答模板难以覆盖。在动手写导入脚本之前,先把全书中出现的关系类别列一个清单,统计每类的频次,低于 5 次的弱关系类型考虑并入相近类型。
2.3 约束与索引:百万级节点下查询不卡的底线
图谱数据量只要过了万级节点,有没有索引就是秒回和超时的差别。Neo4j 3.x 以后用CREATE CONSTRAINT自动附带索引,4.x 里约束和索引分开管理,但约束依然会创建配套索引。在导入人物数据前,必须先建好约束:
CREATE CONSTRAINT person_name_unique IF NOT EXISTS FOR (n:Person) REQUIRE n.name IS UNIQUE; CREATE CONSTRAINT hero_rank_unique IF NOT EXISTS FOR (n:Hero) REQUIRE n.rank IS UNIQUE; CREATE INDEX rel_enemy_type IF NOT EXISTS FOR ()-[r:ENEMY_OF]-() ON (r.reason);第一句约束保证Person.name不重复且带索引,第二句给Hero.rank加唯一约束,第三句是给普通关系属性建索引。REQUIRE语法是 Neo4j 5.x 的写法,4.4 及以前用ASSERT,这点在公司旧集群上很常见,写代码前先确认版本。
索引在建好后可以通过SHOW INDEXES查看状态,确认state是ONLINE再跑导入。注意:约束一旦建立,后续导入时如果出现重复实体会直接报错并中止整个事务。我常用的策略是先以 CSV 里的原始名字导入,全部落库后再用 Cypher 合并别名,而不是在导入时做复杂清洗。
2.4 从文本到三元组:先人工定规则,再考虑模型
很多教程一上来就让你用 BERT 做命名实体识别,但对《水浒传》这种古籍文本,预训练模型在古白话上的表现并不稳定。常见的做法是“规则优先、模型补充”。人物名字可以通过词表匹配:把一百单八将的姓名、绰号、表字整理成三个词表,用 Python 的正则做最长匹配。关系抽取稍微麻烦一些,因为“仇人”“兄弟”这类词在原文里不一定直接出现。
我一般会先把原文按章节切分,每一段文本人工标注出“谁与谁发生了什么事”,然后整理成一个操作表:事件描述、参与人物、关系类型、关系属性(如地点、原因)。这一步花时间但收益极大,因为后续问答系统里的模板编写、Cypher 生成规则全都依赖这个干净的关系清单。数据规模不大时,人工整理 200 到 300 条高质量关系,效果远好于用模型抽出 2000 条噪声数据。
3. 数据导入与清洗:CSV 装载、Cypher 合并与一致性检查
3.1 准备节点和关系的 CSV 文件
Neo4j 导入数据有两种主流路径:LOAD CSV适合百万行以内的数据,neo4j-admin import适合千万级以上的离线导入。对于水浒传这个规模的项目,LOAD CSV完全够用,而且支持在导入过程中做复杂的 Cypher 变换。先把节点数据整理成 CSV:
name,alias,rank,gender,role 宋江,及时雨,1,男,梁山泊总兵都头领 卢俊义,玉麒麟,2,男,梁山泊副统军 吴用,智多星,3,男,掌管机密军师 武松,行者,14,男,步军头领 潘金莲,,,女,武大郎之妻注意:不是所有人都有rank和alias,空着就行,Neo4j 对缺失属性是“零存储”处理,不会占空间。关系 CSV 里,我倾向于不存内部 ID,而是存双方的名字作为关联键:
source,target,type,reason,chapter 武松,武大郎,BROTHER_OF,亲兄弟,23 武松,西门庆,ENEMY_OF,杀兄之仇,26 宋江,吴用,SUBORDINATE_OF,上下级,203.2 用 LOAD CSV 分步导入并校验
先建约束,再导节点,最后导关系。导节点的 Cypher 长这样:
LOAD CSV WITH HEADERS FROM 'file:///persons.csv' AS row MERGE (p:Person {name: row.name}) SET p.alias = row.alias, p.rank = toInteger(row.rank), p.gender = row.gender, p.role = row.role这里必须用MERGE而不是CREATE:MERGE会先查询是否已有同名节点,有则匹配、无则创建,配合前面的唯一约束,能有效防止重复。toInteger()处理空的rank字段时会返回null,不会报错,这点很省心。
导关系的写法:
LOAD CSV WITH HEADERS FROM 'file:///relations.csv' AS row MATCH (a:Person {name: row.source}) MATCH (b:Person {name: row.target}) CALL { WITH a, b, row FOREACH (x IN CASE WHEN row.type = 'BROTHER_OF' THEN [1] ELSE [] END | MERGE (a)-[r:BROTHER_OF {reason: row.reason}]->(b) ) }实际上这个写法有点绕,更直接的做法是先导入所有关系为一种通用类型RELATED,后续再按type字段转成具体关系。不过我更推荐的是一种按类型拆分的批量导入方式:在 Python 里把 CSV 按type分组,每种关系类型单独跑一条LOAD CSV。虽然多写几段 Cypher,但在排错时非常清楚——哪个类型导入失败,直接看对应文件的报错即可。
3.3 导入后的三重校验:节点数、孤立点、关系方向
数据全部落地后,我最关心的三件事是:节点是否按预期数量存在、有没有关系指向不存在的节点(这种情况在导入时会报错,但MERGE有时会静默建出空节点)、关系方向是否和业务逻辑一致。
// 统计各标签节点数量 MATCH (n:Person) RETURN count(n) AS person_cnt; // 找孤立人物:没有任何关系边的节点 MATCH (n:Person) WHERE NOT (n)--() RETURN n.name AS isolated_person; // 检查关系方向是否符合预期:比如 ENEMY_OF 是否总是单向 MATCH (a)-[r:ENEMY_OF]->(b) RETURN a.name, b.name, r.reason LIMIT 20;孤立点检查很重要,我在实际项目里发现过人名错字(“阮小二”写成了“阮小2”)导致节点没被任何关系引用,这种错误在问答系统里不会被直接发现,但做全局图谱遍历时会出现“看不到的角落”。方向问题则要回到业务语义来想:BROTHER_OF是双向的,SUBORDINATE_OF从下属指向上级还是反过来,完全取决于你写模板时的习惯,但一定要在导入后抽检几条,避免图谱里的箭头方向和问答模板里的语义理解完全相反,那就不只是数据错误了。
3.4 图谱可视化检查:Neo4j Browser 的第一印象
数据导入后,先别急着写问答,打开 Neo4j Browser 看一眼底图长什么样。执行MATCH (n:Person) RETURN n LIMIT 200,如果出来的一坨节点密密麻麻分不清层次,说明建模有问题——多半是关系太密或者缺少有区分度的标签。这时候用一个反向思路:只查某个核心人物周围的网络,比如宋江一跳范围内的所有人物和关系:
MATCH (n:Person {name:'宋江'})-[r]-(m:Person) RETURN n, r, m LIMIT 100;如果出来的图是一张射线状星图,说明中心节点的度太高,问答系统在回答“宋江和谁有关系”这类问题时就会返回爆炸性结果。这种情况我会在图谱里增加一个IMPORTANCE属性来标记核心人物,或者在问答 SQL 生成层面限制查询半径。
4. 图谱查询与智能问答:把自然语言转成 Cypher 的三种实现策略
4.1 问答系统的整体架构:输入到输出的完整链路
图谱本身只是一张静态的大网,问答系统才是让用户触达图谱价值的入口。我在实现时把整条链路分成四段:问句预处理、意图识别与槽位填充、Cypher 生成与执行、答案封装。问句预处理做的是停用词过滤、同义词替换和分词修正;意图识别的输出是预定义好的若干 query 模板;Cypher 生成负责把模板里的槽位替换为从问句里抽出的具体人名或关系关键词。
这个链路里,最容易被人忽略的是最后一步“答案封装”。Cypher 查出来的可能是一个节点、一条路径、一组关系,甚至是一个聚合数字,直接把这些结果原样返回给用户会非常劝退。我通常会在后端写一个format_answer()函数,把节点转成“人物+身份+座次”的文本串,把路径转成“A→关系→B”的叙事句。
4.2 基于规则模板的意图识别:最有性价比的起点
不要一上来就上大模型,先做一套基于意图模板的规则引擎,能把 80% 的问题接住,剩下再让 LLM 兜底。意图模板就是一组“模式匹配 + 槽位提取”的规则,每个意图对应一到多条 Cypher 查询模板。举个例子:
| 意图 | 问句关键词/模式 | Cypher 模板 |
|---|---|---|
| QUERY_RELATION | 谁和谁是(兄弟|仇人|上下级) | MATCH (a:Person{name:$src})-[r]->(b:Person{name:$dst}) RETURN type(r), r.reason |
| QUERY_HERO_INFO | (某人物)是谁 / 什么座次 | MATCH (n:Person{name:$name}) RETURN n.name, n.rank, n.alias |
| QUERY_HERO_LIST | 梁山好汉有哪些 / 天罡星有哪些 | MATCH (n:Hero:Liangshan) RETURN n.name ORDER BY n.rank |
| QUERY_RELATIONSHIP_CHAIN | 某人与某人什么关系 / 有什么关系 | MATCH p = shortestPath((a:Person{name:$src})-[*..3]-(b:Person{name:$dst})) RETURN p |
意图识别本质上是正则匹配。我先维护一个同义词表,例如“兄弟”可以匹配BROTHER_OF、SWORN_BROTHER,“仇人”匹配ENEMY_OF,“听命于”匹配SUBORDINATE_OF。然后按优先级依次用正则去匹配问句,命中哪个意图就提取对应的人名槽位。人名抽取用前面说的词表最长匹配即可,如果用 jieba 分词,记得把人物表加进自定义词典,不然“宋江”会被切成“宋/江”。
4.3 用 Python 驱动 Neo4j:py2neo 还是官方驱动?
这里有一个很多教程没有讲清楚的坑:py2neo 在 2021 年后维护基本停滞,虽然还能用,但新项目我更推荐官方的neo4jPython driver。举个例子,用官方驱动执行查询并返回结果,代码非常直观:
from neo4j import GraphDatabase class WaterMarginGraph: def __init__(self, uri, user, password): self.driver = GraphDatabase.driver(uri, auth=(user, password)) def query_relation(self, src, dst, relation_type=None): cypher = """ MATCH (a:Person {name: $src})-[r]->(b:Person {name: $dst}) RETURN type(r) AS rel_type, r.reason AS reason """ with self.driver.session() as session: result = session.run(cypher, src=src, dst=dst) records = [record.data() for record in result] return records注意几点:session.run()的参数传递用的是$name占位符,永远不要用 f-string 直接拼接用户输入,这是防止 Cypher 注入的基本要求。返回的records是Record对象,.data()方法能转成字典列表,方便后续 JSON 序列化给前端。如果查询涉及最短路径等图算法,返回的path对象需要手动遍历节点和关系,不能直接序列化。
4.4 模板未覆盖的问题:降级策略与 LLM 兜底
规则模板总会有漏网之鱼。比如“林冲是怎么上的梁山”这种涉及故事情节的问题,模板里压根没有对应的意图。我有两个处理策略,按成本低到高排列:第一,把这类问题转成全文检索,对节点和关系的属性做模糊匹配,返回相关节点让用户点选;第二,接入大模型,把用户问句、图谱 schema、以及几个 Cypher 示例作为提示词,让模型生成 Cypher,再由你的服务执行。
第二种方案现在很常见,效果也的确不错,但要注意大模型生成的 Cypher 有时会语法错误或者用不存在的属性名,所以在执行前要做一次白名单校验:只允许MATCH、WHERE、RETURN、ORDER BY几个子句,且节点标签和属性名必须在你预定义的 schema 集合内。这是我实践中踩过最大的坑之一,不加校验就执行 LLM 生成的 Cypher,等于给用户开了一个只读库的写权限风险窗口,系统上线前一定要封死。
5. 从图谱到应用:可视化,性能优化和查询中的常见问题排查
5.1 把图谱集成到 Web 应用:前端可视化选型
问答系统的后端接口跑通后,如果没有可视化,用户很难直观感知到“这张图谱是活的”。常见的选型组合是后端接 Neo4j、前端用 ECharts 的关系图或者 AntV G6。我的做法是:后端暴露一个接口,接受人物名称,返回该人物一跳或两跳范围内的所有节点和关系,前端用 G6 渲染成力导向图。
from flask import Flask, request, jsonify app = Flask(__name__) graph = WaterMarginGraph("bolt://localhost:7687", "neo4j", "password") @app.route("/api/graph/<name>") def neighbor_graph(name): cypher = """ MATCH (n:Person {name: $name})-[r]-(m:Person) RETURN n.name AS source, m.name AS target, type(r) AS relation """ with graph.driver.session() as session: records = session.run(cypher, name=name).data() return jsonify(records) if __name__ == "__main__": app.run(port=5000)这个接口返回的是扁平的边列表,前端拿到后直接按照source、target、relation三个字段去构图即可。注意 Cypher 里(n)-[r]-(m)没有箭头方向,表示查询双向关系,避免遗漏某个方向的数据。
5.2 查询性能瓶颈:为什么带rank的排序慢
图谱查询的卡顿大多不是图遍历的问题,而是属性过滤和排序没有走索引。一个典型的慢查询是“梁山好汉里谁排名前 20”:
MATCH (n:Hero:Liangshan) WHERE n.rank IS NOT NULL RETURN n ORDER BY n.rank LIMIT 20如果Hero标签下有一千多个节点,这个查询不会慢;但当图谱规模扩大到十来万节点时,没有索引的ORDER BY n.rank就是全表扫描后内存排序。解决方法是创建范围索引或直接在关系导入时把rank放到节点的同时,再维护一个Hero标签专属索引。Neo4j 5.x 支持CREATE RANGE INDEX,专门优化这类数值范围查询,我的经验是这类十亿以下规模的图谱加上索引后排序都是毫秒级。
5.3 常见问题排查:关系重复、路径爆炸、空结果
问答系统上线后最常撞见的问题有三个:
第一个是关系重复。因为LOAD CSV里用了MERGE的话还好,但如果你用了CREATE,同一对人物之间的同类型关系可能出现多次,查询“武松和西门庆什么关系”一口气返回三行一模一样的ENEMY_OF。排查方法是按两端节点分组统计数量:
MATCH (a)-[r]->(b) RETURN a.name, b.name, type(r) AS t, count(*) AS cnt HAVING cnt > 1 LIMIT 20;第二个是路径爆炸。做关系链查询时如果把最大深度设成 6 且没有限制分支,像宋江这种高连接度节点会让路径数指数上涨,直接把查询超时。我一般把[*..3]作为默认深度上限,并且要求两端点间的路径带关系类型过滤。如果需要找“武松和宋江之间有哪些连接关系”,先加WHERE type(r) IN ['BROTHER_OF','SWORN_BROTHER']再展开,比盲目遍历安全得多。
第三个是空结果。很多时候不是图谱里没有数据,而是问答系统提取的人名和数据库里的name不完全一致。比如用户说“武大郎的弟弟是谁”,后端如果提取出“武大郎”并传入查询,但库里的节点名是“武大郎”但关系却是BROTHER_OF方向从武松指向武大郎,查询反向关系时就返回空。这种问题的标准解法是在问答逻辑里加一层“关系方向映射表”,把“弟弟”映射为查询BROTHER_OF反向边。
5.4 让问答系统能回答负向问题:一个容易忽略的进阶技巧
正向问题“谁和谁是兄弟”容易做,但“谁和谁不是兄弟”这类否定问题,如果不加处理会返回一堆无意义结果。一种简单做法是识别问句中的否定词(“不”“没”“非”),在意图分类时单独成类。查询逻辑改为:先找到 A 的所有关系为BROTHER_OF的邻居,然后从全体人物中排除这些人,返回一个“非兄弟”的人物列表。这种处理不一定能覆盖所有自然语言变体,但能显著提升用户对问答系统的“智能感”。
提示:否定问题的图谱答案天然是一个集合而非一条路径,返回给用户时要控制数量,按座次排序取前 5 个即可,不要一股脑返回几十条。毕竟用户问的是“谁和谁不是兄弟”,不是为了拿到一份完整的人员名单。
本文还有配套的精品资源,点击获取