简介:这份资源是面向高校学生与知识图谱入门者的《水浒传》人物关系可视化及问答系统完整项目包,可作为毕业设计或课程实践参考,涉及知识图谱构建、中文自然语言处理与Web可视化等方向。项目采用LTP中文自然语言处理模型、Python Flask框架与Neo4j图数据库,raw_data为人工合成数据,spider目录存放爬取的人物图片与基本信息。压缩包共197个文件,约22.84MB,包含8个py源码文件、4个html页面、11个css与8个js前端资源、129张jpg人物图片,以及说明文档、PPT、示例图片和pdf资料,覆盖数据采集、图谱存储、问答接口与前端展示的完整链路。目前已有507人学习下载。读者可据此掌握从原始文本抽取人物关系、写入Neo4j并实现自然语言问答的完整流程,同时获得可复用的目录结构与前端模板,便于二次开发与答辩展示。
1. 从一张人物关系图说起:Neo4j 做水浒传知识图谱到底能落地什么
把《水浒传》里一百单八将加上高俅、蔡京、宋江他爹这些角色,全塞进一张图数据库里,然后让用户用自然语言问「宋江和卢俊义之间隔了几个人」「谁同时认识武松和林冲」,这件事听起来像课程设计,但它背后是一套完整的图数据建模 + 图查询 + 问答意图解析链路。Neo4j 在这里不是拿来炫技的,它解决的是关系型数据库做多跳查询时 JOIN 爆炸的问题——你要查「李逵的兄弟的兄弟的兄弟」,MySQL 得写三层自连接,Neo4j 一行 Cypher 就出来了。这个标题对应的项目,本质是把水浒传人物关系可视化、图数据库存储、Python 后端问答三件事串起来,适合想入门知识图谱、又不想拿「公司-员工-项目」这种烂大街数据集练手的人。你拿到源码和文档之后,真正要理解的是:节点怎么设计、关系怎么抽、问答怎么把中文问题翻译成 Cypher。
2. 水浒传人物图谱的数据建模:节点、关系与属性怎么定
2.1 为什么用图模型而不是三张 MySQL 表
很多人第一反应是建三张表:人物表、关系表、事件表。查「宋江的直接关系」没问题,但查「宋江到晁盖之间所有路径」就要递归 CTE,MySQL 8 之前根本不支持,写起来痛苦。图模型把「关系」提升为一等公民,遍历是原生操作,深度几跳都不怕。水浒传这个场景天然适合图:人物是节点,亲缘、师徒、上下级、仇杀、同僚都是边,而且边有方向、有类型、有属性(比如「结拜」发生在哪一回)。常见做法是节点标签用Person,关系类型用中文拼音或英文枚举,属性挂name、nickname、rank、faction。我一般会把「梁山排名」作为节点属性而不是关系,因为它是人物固有属性,不是两个人之间的东西。
2.2 节点与关系的具体字段设计
下面这张表是我实际建库时会用的字段定义,你可以直接照着在 Neo4j Browser 里建约束。
| 对象 | 字段 | 类型 | 说明 |
|---|---|---|---|
| Person 节点 | name | string | 人物姓名,唯一约束 |
| Person 节点 | nickname | string | 绰号,如「及时雨」 |
| Person 节点 | rank | int | 梁山座次,非梁山人物为 -1 |
| Person 节点 | faction | string | 阵营:梁山、朝廷、方腊等 |
| 关系 | type | string | 关系类型,如 KNOWS、KILLS |
| 关系 | chapter | int | 首次出现回目 |
| 关系 | weight | float | 关系强度,用于可视化粗细 |
建唯一约束的 Cypher 如下,先建约束再导数据,否则重复导入会炸。
// 给 Person 节点的 name 建唯一约束,防止重复导入 CREATE CONSTRAINT person_name_unique IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE; // 建索引加速按阵营查询 CREATE INDEX person_faction_index IF NOT EXISTS FOR (p:Person) ON (p.faction);逻辑说明:CREATE CONSTRAINT是幂等写法,IF NOT EXISTS保证重复执行不报错。参数上,REQUIRE p.name IS UNIQUE会同时创建唯一性约束和背后索引,导入时如果两个人同名会直接失败,这其实是好事——水浒传里同名情况少,但「宋江」和「宋清」这种要区分清楚。索引建在faction上是因为问答系统里「梁山好汉有哪些」这类问题会频繁按阵营过滤。
2.3 从文本到图谱:关系抽取的三种落地方式
第一种是手工整理 CSV,适合课程设计,一百多个人物、两三百条关系,Excel 就能搞定。第二种是用 Python 正则从原文抽「A 与 B 结拜」「A 杀了 B」这类句式,召回率一般但能跑通流程。第三种是调大模型做关系抽取,成本高但准。我一般建议先手工 CSV 跑通全链路,再考虑自动化。CSV 格式建议两列:source,target,relation,chapter,导入脚本用LOAD CSV。
# load_water_margin.py # 用 py2neo 把 CSV 里的人物关系导入 Neo4j from py2neo import Graph, Node, Relationship import csv # 连接本地 Neo4j,默认 bolt 端口 7687 graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) def load_persons(csv_path): """读取人物表,创建 Person 节点""" with open(csv_path, encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: # merge 保证幂等,重复执行不会建重复节点 node = Node("Person", name=row["name"], nickname=row.get("nickname", ""), rank=int(row.get("rank", -1)), faction=row.get("faction", "未知")) graph.merge(node, "Person", "name") def load_relations(csv_path): """读取关系表,创建关系边""" with open(csv_path, encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: # 先找到两端节点 a = graph.nodes.match("Person", name=row["source"]).first() b = graph.nodes.match("Person", name=row["target"]).first() if not a or not b: print(f"跳过:{row['source']} 或 {row['target']} 不存在") continue rel = Relationship(a, row["relation"], chapter=int(row["chapter"])) graph.create(rel) if __name__ == "__main__": load_persons("persons.csv") load_relations("relations.csv") print("导入完成")逻辑说明:graph.merge(node, "Person", "name")是关键,它按name做匹配,存在就更新属性,不存在才创建,避免重复导入产生多个「宋江」。参数上,auth里的密码要换成你安装 Neo4j 时设的,社区版默认用户是neo4j。Relationship的第二个参数是关系类型,建议用英文大写如KNOWS、KILLS,中文类型在 Cypher 里要加反引号,容易踩坑。失败时先看 CSV 编码,Windows 下 Excel 存的 CSV 常是 GBK,读出来乱码,用encoding="utf-8-sig"能兼容 BOM。
3. 用 Cypher 把「谁认识谁」查明白:从一跳问到多跳路径
3.1 问答系统背后其实是 Cypher 模板
用户问「宋江的手下有哪些」,系统不能直接理解,得先做意图识别,再套 Cypher 模板。常见做法是维护一个意图-模板映射表,用关键词匹配或轻量分类模型判断用户问的是「直接关系」「多跳路径」还是「属性查询」。比如问句里出现「之间」「隔几个人」就归到路径查询,出现「是谁」「绰号」就归到属性查询。这一步不需要上大模型,正则加同义词表就能覆盖八成问题。
3.2 三类高频查询的 Cypher 写法
第一类:查某人的直接关系。第二类:查两人之间的最短路径。第三类:查满足多条件的节点。下面分别给出来。
// 1. 查宋江的所有直接关系,返回关系类型和对方姓名 MATCH (s:Person {name: '宋江'})-[r]-(other:Person) RETURN type(r) AS relation, other.name AS name, other.nickname AS nickname ORDER BY relation; // 2. 查宋江和卢俊义之间的最短路径,最多 6 跳 MATCH p = shortestPath( (a:Person {name: '宋江'})-[*..6]-(b:Person {name: '卢俊义'}) ) RETURN p; // 3. 查既是梁山阵营又排名前 20 的人物 MATCH (p:Person {faction: '梁山'}) WHERE p.rank > 0 AND p.rank <= 20 RETURN p.name, p.nickname, p.rank ORDER BY p.rank;逻辑说明:第一条用-[r]-不带箭头,表示双向都查,因为「认识」是对称的。第二条shortestPath是 Neo4j 内置函数,*..6限定最大跳数,不设上限在稠密图上会跑很久。第三条WHERE里rank > 0是为了排除非梁山人物(他们 rank 是 -1)。参数上,跳数上限要根据图密度调,水浒传人物关系图大概 200 个节点、500 条边,6 跳足够覆盖任意两人。如果查询超时,先看有没有建索引,name上的唯一约束自带索引,但faction要单独建。
3.3 把 Cypher 包成 Python 函数供问答调用
问答系统需要一个执行层,把识别出的意图转成 Cypher 并返回结果。下面这个函数封装了「查两人关系路径」的逻辑。
# qa_engine.py from py2neo import Graph graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) def find_path(person_a, person_b, max_hops=6): """查询两个人物之间的最短路径,返回路径上的姓名列表""" cypher = """ MATCH p = shortestPath( (a:Person {name: $a})-[*..%d]-(b:Person {name: $b}) ) RETURN [n IN nodes(p) | n.name] AS path_names """ % max_hops # 用参数化查询防止注入,$a $b 是占位符 result = graph.run(cypher, a=person_a, b=person_b).data() if not result: return None return result[0]["path_names"] def get_person_info(name): """查询人物属性""" cypher = """ MATCH (p:Person {name: $name}) RETURN p.name AS name, p.nickname AS nickname, p.rank AS rank, p.faction AS faction """ result = graph.run(cypher, name=name).data() return result[0] if result else None逻辑说明:shortestPath里的跳数用字符串格式化拼进去,因为 Cypher 不支持把跳数作为参数传,这是 Neo4j 的限制,注意max_hops必须是整数,别让用户输入直接拼,会有注入风险。graph.run的第二个参数是参数字典,$a、$b会被安全替换。返回的path_names是列表推导式[n IN nodes(p) | n.name]生成的,直接就是路径上的人物姓名序列。失败时如果返回None,说明两人不连通,问答系统要回「未找到关系路径」而不是报错。
4. 可视化不是画个图就完事:前端渲染与后端接口的配合
4.1 选 vis.js 还是 ECharts 关系图
前端可视化常见两个选择:vis.js 的 Network 模块和 ECharts 的 graph 系列。vis.js 对力导向布局支持更好,节点拖拽、缩放流畅,适合几百个节点的图。ECharts 配置项多、文档中文友好,但节点多了会卡。我一般用 vis.js 做交互探索,用 ECharts 做静态展示图。水浒传这个规模,vis.js 完全够用。后端只需要提供一个接口,返回nodes和edges两个数组,前端直接喂给 vis.js。
4.2 后端接口设计:一次返回全图还是按需查询
全图返回简单,但 200 个节点、500 条边一次性传给前端,首次加载会慢。按需查询体验好,但每次交互都要请求后端。折中方案是:首屏返回核心人物(排名前 36 的天罡星)及其关系,用户点击某个节点再异步加载该节点的邻居。下面是一个 Flask 接口示例。
# app.py from flask import Flask, jsonify, request from py2neo import Graph app = Flask(__name__) graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) @app.route("/api/graph/core") def core_graph(): """返回天罡星及其关系的子图""" cypher = """ MATCH (p:Person) WHERE p.rank > 0 AND p.rank <= 36 OPTIONAL MATCH (p)-[r]-(other:Person) WHERE other.rank > 0 AND other.rank <= 36 RETURN collect(DISTINCT { id: p.name, label: p.name, rank: p.rank }) AS nodes, collect(DISTINCT { source: startNode(r).name, target: endNode(r).name, type: type(r) }) AS edges """ data = graph.run(cypher).data()[0] return jsonify(data) @app.route("/api/graph/neighbors") def neighbors(): """按节点名查询其邻居,用于点击展开""" name = request.args.get("name") cypher = """ MATCH (p:Person {name: $name})-[r]-(other:Person) RETURN other.name AS id, other.name AS label, type(r) AS rel_type LIMIT 50 """ data = graph.run(cypher, name=name).data() return jsonify(data) if __name__ == "__main__": app.run(debug=True, port=5000)逻辑说明:OPTIONAL MATCH保证即使某个人物没有关系边也会返回节点,不会丢数据。collect(DISTINCT {...})把结果聚合成数组,前端直接拿。startNode(r)和endNode(r)取关系两端的节点,注意如果关系是双向匹配的,source 和 target 可能重复,前端去重即可。参数上,LIMIT 50防止某个节点邻居过多拖垮前端。失败时如果返回空数组,检查 Neo4j 是否启动、密码是否正确,以及 Cypher 里的属性名是否和实际建库一致。
4.3 前端 vis.js 最小可运行页面
<!DOCTYPE html> <html> <head> <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script> </head> <body> <div id="graph" style="width: 100%; height: 600px;"></div> <script> // 从后端拉取核心子图数据 fetch('/api/graph/core') .then(res => res.json()) .then(data => { // vis.js 要求 nodes 有 id 和 label,edges 有 from 和 to const nodes = new vis.DataSet(data.nodes.map(n => ({ id: n.id, label: n.label, value: n.rank }))); const edges = new vis.DataSet(data.edges.map(e => ({ from: e.source, to: e.target, label: e.type }))); const container = document.getElementById('graph'); const options = { physics: { stabilization: true }, // 开启力导向稳定 edges: { arrows: 'to', font: { size: 10 } } }; new vis.Network(container, { nodes, edges }, options); }); </script> </body> </html>逻辑说明:vis.DataSet是 vis.js 的数据容器,支持动态增删。value: n.rank会让排名靠前的节点显示更大,这是 vis.js 的默认行为。physics.stabilization开启后首次渲染会先算布局再显示,节点多了会卡几秒,可以设stabilizationIterations: 100限制迭代次数。参数上,arrows: 'to'给边加箭头表示方向,但「认识」这种对称关系其实不需要箭头,可以去掉。失败时如果图不显示,先看浏览器控制台有没有跨域报错,Flask 默认端口 5000 和前端页面如果不同源,要加 CORS 头。
5. 问答系统踩坑记录:从意图识别到 Cypher 生成的五个翻车现场
5.1 坑一:用户问「宋江的绰号」却返回了所有属性
现象:问答系统对「宋江的绰号是什么」返回了姓名、绰号、排名、阵营全部字段,用户只想要绰号。原因:意图识别只判断了「查询人物属性」,没有细分到具体属性。解决:在意图模板里加属性关键词映射,「绰号」对应nickname,「排名」对应rank,Cypher 里RETURN只取对应字段。
5.2 坑二:多跳查询没设跳数上限,Neo4j 直接 OOM
现象:用户问「宋江和方腊之间有什么关系」,系统执行了不带跳数限制的shortestPath,图里节点虽然不多,但关系稠密,查询跑了十几秒后 Neo4j 内存爆了。原因:shortestPath不写*..N时默认不限深度,稠密图上是指数级展开。解决:强制加*..6,并在后端设查询超时,graph.run可以传timeout参数。
5.3 坑三:CSV 导入时中文关系类型报语法错误
现象:Relationship(a, "结拜", chapter=1)执行时报Invalid input。原因:Cypher 里关系类型如果是中文,必须用反引号包起来,py2neo 拼 SQL 时没加。解决:关系类型统一用英文枚举,如SWORN_BROTHER,前端展示时再做中文映射。如果非要用中文,在 Cypher 里写`结拜`。
5.4 坑四:Neo4j 社区版默认内存太小,导入几百条就卡
现象:导入到一半报There is not enough memory to perform the current task。原因:社区版默认堆内存 512MB,neo4j.conf里dbms.memory.heap.max_size没调。解决:改成 2G,dbms.memory.pagecache.size也调到 1G,重启生效。注意社区版单机内存上限就是物理内存,别设超过机器实际内存。
5.5 坑五:问答系统把「武松打虎」识别成人物关系查询
现象:用户问「武松打虎是怎么回事」,系统试图查「武松」和「虎」之间的关系,返回空。原因:「打虎」是事件不是人物关系,图谱里没有「虎」这个节点。解决:意图识别里加事件类兜底,识别到「打」「杀」「事件」等词且目标不在人物表里,走事件查询或直接回「该问题暂不支持」。这也是知识图谱问答的边界——图里没有的实体,再聪明的 Cypher 也查不出来。
6. 让问答更准一点:意图模板的扩展与查询结果的自然语言包装
问答系统跑通之后,真正决定体验的是两件事:意图识别覆盖率和结果包装。意图识别我一般用「关键词 + 正则 + 同义词表」三层,先匹配同义词(「绰号」=「外号」=「诨号」),再匹配句式(「A 和 B 之间」触发路径查询),最后兜底给一个通用查询。这套方案不需要训练模型,改起来快,缺点是遇到复杂问句会翻车。如果你要上分类模型,建议先用几百条标注数据训一个轻量文本分类,别一上来就上大模型,成本和延迟都不划算。
结果包装这块,Cypher 返回的是结构化数据,直接丢给用户很生硬。我习惯写一个format_answer函数,按意图类型拼自然语言。比如路径查询返回["宋江", "吴用", "卢俊义"],包装成「宋江通过吴用连接到卢俊义,中间隔了 1 个人」。属性查询返回字典,包装成「宋江,绰号及时雨,梁山排名第 1」。下面是一个简单的包装函数。
def format_answer(intent, data): """把查询结果包装成自然语言""" if intent == "path" and data: names = data if len(names) == 2: return f"{names[0]}和{names[1]}直接相连" middle = names[1:-1] return f"{names[0]}通过{'、'.join(middle)}连接到{names[-1]},中间隔了{len(middle)}个人" elif intent == "attribute" and data: return f"{data['name']},绰号{data['nickname']},排名第{data['rank']},属于{data['faction']}阵营" elif intent == "neighbors" and data: names = [d["id"] for d in data] return f"共找到{len(names)}个直接关系:{'、'.join(names[:10])}" return "没有找到相关信息,换个问法试试"逻辑说明:intent是意图识别模块传进来的字符串,data是 Cypher 查询结果。路径查询里names[1:-1]取中间节点,len(middle)就是间隔人数。参数上,邻居查询可能返回几十个,只展示前 10 个避免刷屏。这个函数是纯字符串拼接,没有外部依赖,改起来方便。失败时如果返回「没有找到相关信息」,先确认意图识别对不对,再确认 Cypher 有没有查错字段。
最后说一个我自己的习惯:每次改完意图模板或 Cypher,我都会拿十个固定问题跑一遍回归,包括「宋江的绰号」「宋江和卢俊义之间隔几个人」「梁山排名前五是谁」「武松杀了谁」这种边界问句。这十个问题就是我的后悔药,改坏了立刻能发现。图谱问答这东西,玄学的地方在于用户永远会问出你没想到的问法,但把高频意图覆盖住,体验就能到及格线以上。希望帮到你。
本文还有配套的精品资源,点击获取