简介基于Neo4j的水浒传人物关系可视化及问答系统是一套个人毕业设计项目代码经过调试测试可正常运行答辩评审分达到98分。项目围绕人物关系图谱的构建、可视化展示与自然语言问答展开使用Neo4j存储人物关联数据实现人物关系检索与常见问题自动应答适合计算机、人工智能、自动化等专业学生用于毕业设计、课程大作业也适合入门者学习知识图谱与图谱查询技术。压缩包共197个文件大小约22.86MB主要包含Python后端源码、HTML/CSS/JS前端页面、129张人物形象与交互界面截图、答辩PPT以及PDF、MD、TXT等说明文档目录结构清晰便于按需查阅。目前已有274人学习下载。整套资料既有可直接运行的完整源码也有答辩展示材料与效果图读者可快速复现项目并在此基础上扩展问答功能、调整可视化样式或接入更多人物数据整体具有较强的学习借鉴价值。1. 从小说文本到图数据库这个基于Neo4j的水浒传人物关系项目到底在做什么很多第一次选这个题目的人都是被“Neo4j”和“问答系统”两个词吸引的。真正把“基于Neo4j的水浒传人物关系可视化及问答系统”跑通之后才会发现最花时间的不是Cypher而是把小说文本变成能入库的结构化数据再把一句中文问句翻译成一条查询。这套东西的本质是知识图谱的经典三件套——实体、关系、查询——换了一部大家熟悉的章回体小说做载体。如果你正打算做课程设计、毕业设计或者想用Python趟一遍图数据库的落地流程这个方向很划算数据量在百级完全够演示又不会大到让你整天调集群。2. 为什么用Neo4j建人物关系图谱图模型怎么设计才不返工2.1 人物关系数据的三条来源共现、事件抽取与手工标注常见做法是把全书120回里“同一回目同时出现”的两个人物视为一次共现用共现次数做边的权重。我一般会先把一百单八将的名单按章回扫描一遍落在同一回的两个名字就累加一次“共现”权重。这条来源最省事能自动生成但有两个问题第一宋江这样的核心人物几乎和所有人共现权重会虚高第二共现只能回答“认识/关联”回答不了“结义/上下级”这种语义问题。所以要补第二种来源事件抽取或手工标注。比如“鲁智深拳打镇关西”“武松斗杀西门庆”这种事件人工打上“敌对”标签而“宋江-李逵”“武松-鲁智深”打上“结义”。如果是课设手工标三十到五十条语义关系是完全可以接受的答辩也更有说头。第三种来源是使用整理好的座次表、百单八将名单这种半结构化材料——它们能提供好汉们的排名、星号、绰号是天然的节点属性。这一部分跟Neo4j无关属于数据工程。这里就出现了第一个反直觉的地方绝大多数翻车都是因为在导入前没把CSV去重洗干净跟数据库无关。2.2 Person节点与边类型、权重属性的建模约定我这里用了两种标签Person和普通关系类型。Person节点至少要有四个属性id、name、alias、is_major。id是数字主键因为只靠name做唯一键会出事——鲁智深又叫“鲁达”“花和尚”不同来源的写法不一致MERGE会给你生成一堆重复节点。alias是别名数组问答系统要靠它做实体识别。is_major标记是否属于一百单八将防止路人甲比如店小二、公差混进来把图撑大。边的设计分两层。第一层是通用的CO_OCCUR共现属性有weight和source_chapters出现过的回目列表。第二层是语义关系按小说情节标BROTHERHOOD结义、SUPERIOR_SUBORDINATE上下级、ENEMY敌对、MENTOR师徒。问答系统能答出“宋江和吴用谁地位高”就是靠SUPERIOR_SUBORDINATE这条边只有CO_OCCUR是答不出来的。提示Neo4j里关系方向不能乱建。CO_OCCUR是无向关系但Cypher存储上必须给一个方向我一般统一建(a)-[:CO_OCCUR]-(b)查询时用无方向匹配-[r]-这样避免同一对人物产生两条重复边。语义关系则按真实方向建比如上级指向下级、师父指向徒弟查询时再用[:SUPERIOR_SUBORDINATE|MENTOR]-。2.3 关系型模型和图模型问答场景里为什么Cypher更省事这个问答系统里最有含金量的一类问题是多跳查询比如“宋江通过谁认识吴用”“宋江和卢俊义之间隔几个人”。在关系型数据库里做这种查询得自己写递归join跳数一多SQL就难看而且每跳都要扫索引。换到Neo4j里一条Cypher就写完了MATCH (a:Person {name:宋江})-[r*1..4]-(b:Person {name:吴用}) RETURN a.name AS source, [n IN nodes(rel) | n.name] AS path这条查询的实际含义是从宋江节点出发沿着任意关系类型走1到4跳看能不能到达吴用如果能把路径上经过的节点名字列出来。参数1..4把跳数范围写死了超过4跳就不查因为小说人物图的全连通性很强不限制跳数的话任意两个人几乎都能通过几步连上答案就是废话。关系型数据库也能做但你要为每一跳写一个join或者用递归CTE查询计划还未必走你想要的索引。Neo4j把“关系”本身作为一等公民存取多跳查询的写法就是图模型的自然表达。提示问答系统生成的Cypher一定要用参数化传值不要把用户问句直接拼进查询字符串。我们后面第5章会专门讲这条血泪经验。3. 把数据导入Neo4jCSV预处理、LOAD CSV与py2neo批量更新3.1 人物与关系的CSV准备id列、别名列与去重脚本导入前的数据准备决定后面所有步骤能否顺利。我一般会先定两个文件nodes.csv和relations.csv。nodes.csv每行一个人物字段是id,name,alias,title,is_majoralias用竖线分隔比如宋江|及时雨|孝义黑三郎。relations.csv每行一条关系字段是source,target,type,weight,chapterssource和target填人物的id而不是名字。在写CSV之前先用Python把源数据洗一遍。最常踩的坑是同一个人物出现多种写法“阮小二”“阮小五”“阮小七”和“阮氏三雄”这种集合词会被当成另一个人。去重脚本大致长这样import csv raw_nodes {} with open(characters_raw.txt, encodingutf-8) as f: for line in f: parts line.strip().split(\t) pid, name, alias parts[0], parts[1], parts[2] raw_nodes[pid] {name: name, alias: alias, is_major: 1} # 别名用竖线合并后续问答实体识别要用 dedup_names set() for pid, node in raw_nodes.items(): dedup_names.add(node[name]) if node[alias]: for a in node[alias].split(|): dedup_names.add(a)这段代码的逻辑是先把原文读进字典再收集所有正式名和别名到一个集合里后续生成共现关系时只保留集合内出现过的名字忽略路人角色。参数上需要注意人名匹配用全等匹配不要用“包含”因为“宋江”会命中“宋江军”“宋江河”产生大量噪声边。CSV写出时务必用encodingutf-8-sig这样Excel打开不会乱码Neo4j的LOAD CSV也能正常读中文。如果用了普通utf-8Windows下用Excel编辑过一次CSV就会带上BOMNeo4j读进来第一列字段名会多一个不可见字符看起来像\ufeffid查询时字段对不上。3.2 用LOAD CSV和MERGE批量入库约束、脚本与重跑机制进入Neo4j侧第一步是建唯一约束。没有唯一约束MERGE就退化成先查后插性能差一个量级而且并发导入时可能生成重复节点。约束在Neo4j 5.x里的写法是CREATE CONSTRAINT person_id IF NOT EXISTS FOR (p:Person) REQUIRE p.id IS UNIQUE;字段说明person_id是约束名可以随意REQUIRE p.id IS UNIQUE是Neo4j 5.x语法旧版用ASSERT p.id IS UNIQUE。如果你在4.x环境把REQUIRE换成ASSERT即可。这个约束也是“后悔药”——导入跑歪了你可以靠唯一约束发现重复而不是对着几万个节点两眼一抹黑。节点导入脚本LOAD CSV WITH HEADERS FROM file:///nodes.csv AS row MERGE (p:Person {id: toInteger(row.id)}) ON CREATE SET p.name row.name, p.alias split(row.alias, |), p.title row.title, p.is_major toInteger(row.is_major);逻辑说明toInteger把字符串id转成数字避免“1”和1这种类型不匹配split(row.alias,|)把竖线分隔的别名数组放进节点属性。用MERGE而不是CREATE是为了让脚本可以重跑第二次执行时id相同的节点不会重建只会新增缺失属性。如果你这里的CSV路径报错先确认文件放在了Neo4j安装目录的import文件夹下再确认file:///nodes.csv相对路径写对。关系导入脚本LOAD CSV WITH HEADERS FROM file:///relations.csv AS row MATCH (a:Person {id: toInteger(row.source)}) MATCH (b:Person {id: toInteger(row.target)}) MERGE (a)-[r:CO_OCCUR]-(b) ON CREATE SET r.weight toInteger(row.weight) ON MATCH SET r.weight r.weight toInteger(row.weight);这段要重点说明两点。第一MERGE整条边时如果两点之间已经存在CO_OCCUR边新数据不会新建边而是走ON MATCH把权重累加——这是为了处理“两个人物在同一回目出现多次”的情况。第二这里必须先用MATCH定位两个端点如果端点不存在整行会被跳过不会自动创建Person节点。想要“没有端点就自动创建”可以把MATCH换成MERGE但那样会把路人甲也建进节点库一般不建议。还有一个常见问题Neo4j社区版不支持并行LOAD CSV多文件同时导入会互相锁。我一般依次导入先节点后关系不要图省事。3.3 py2neo从Python侧做增量更新与批量提交如果项目代码里需要从Python侧动态加人物、加关系比如问答系统里新增用户输入的关系就别再调LOAD CSV了直接用py2neo。最常见的坑是用for循环逐条graph.run()一百条数据慢得离谱。正确做法是开一个事务攒一批再提交from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, neo4j)) tx graph.begin() batch_count 0 for source, target, rel_type, weight in relations: a graph.nodes.match(Person, idsource).first() b graph.nodes.match(Person, idtarget).first() if a is None or b is None: continue rel Relationship(a, rel_type, b, weightweight) tx.create(rel) batch_count 1 if batch_count 200: # 每200条提交一次避免大事务OOM tx.commit() tx graph.begin() batch_count 0 tx.commit()参数说明bolt://localhost:7687是Neo4j的Bolt协议地址不是你浏览器打开的http://localhost:7474auth(neo4j,neo4j)是默认账号密码生产环境务必改掉。这里用batch_count自己计数每200条tx.create后tx.commit()并重新begin()避免单个事务撑爆内存。如果你用较新的Neo4j 5.xpy2neo版本建议固定在2021.2.4附近新版neo4j官方驱动虽然更底层但课设用py2neo更省事它的Node和Relationship对象直接对应图结构写起来像ORM。4. 人物关系可视化ECharts力导向图与Cypher查询联动4.1 后端接口设计把Cypher结果转成nodes和links可视化选型我一般用ECharts的graph系列因为它自带力导向布局、缩放拖拽、节点label展示不需要引入D3的复杂度。后端用一个Flask接口把Neo4j查出来的结果转成ECharts需要的两个数组nodes和links。from flask import Flask, jsonify, request from py2neo import Graph app Flask(__name__) graph Graph(bolt://localhost:7687, auth(neo4j, neo4j)) app.route(/api/graph) def get_graph(): name request.args.get(name, ) limit int(request.args.get(limit, 50)) if not name: cypher MATCH (p:Person)-[r]-(n) RETURN p.name AS source, n.name AS target, r.weight AS weight LIMIT $limit records graph.run(cypher, limitlimit).data() else: cypher MATCH (p:Person {name:$name})-[r]-(n:Person) WITH p, n, sum(r.weight) AS w WHERE w 3 RETURN p.name AS source, n.name AS target, w AS weight ORDER BY w DESC LIMIT $limit records graph.run(cypher, namename, limitlimit).data() node_dict, edges {}, [] for rec in records: for key in (source, target): nm rec[key] node_dict.setdefault(nm, {name: nm, value: 0}) node_dict[nm][value] 1 edges.append({source: rec[source], target: rec[target], weight: rec[weight]}) return jsonify({nodes: list(node_dict.values()), links: edges})这段代码最关键的地方是查询结果里每一条是一个“source-target”对但ECharts要求所有节点单独出现在nodes数组里。如果直接用列表推导同一个“宋江”会被塞出好几个节点ECharts渲染出来就是一堆重叠的圆点。正确处理是维护一个字典以人物名为key先收节点再建边。另一个要注意的是limit用参数传进Cypher而不是拼字符串一是防注入二是py2neo会帮你做类型转换。4.2 前端力导向图的关键配置与参数拿到接口返回的nodes和links之后ECharts的配置可以精简到核心几项const chart echarts.init(document.getElementById(graph)); chart.setOption({ tooltip: { formatter: p ${p.data.name}: ${p.data.weight || } }, series: [{ type: graph, layout: force, roam: true, label: { show: true, formatter: p p.data.name, fontSize: 12 }, edgeSymbol: [circle, arrow], lineStyle: { color: source, width: 1, opacity: 0.6 }, data: nodes.map(n ({ ...n, symbolSize: n.value ? 8 Math.log2(n.value) * 3 : 10, itemStyle: { color: n.is_major ? #c23531 : #2f4554 } })), links: edges, force: { repulsion: 120, edgeLength: 80, gravity: 0.05 } }] });配置参数说明roam: true让用户可以缩放拖拽人物关系全图节点多这一步必须有否则图一出来就挤成一团。symbolSize按节点度数取对数映射避免一线角色宋江、卢俊义的圈子大得离谱。is_major区分核心好汉和路人用不同颜色答辩截图时对比度明显。4.3 从“一张大图”到“以人物为中心的人物关系子图”全量渲染一百单八将加几千条边ECharts能撑住但力导向布局会掉帧拖拽体验很差。我一般做成两级交互页面初始只渲染核心好汉中度最高的前三十个节点点击某个节点后再请求一次/api/graph?name某某只加载这个节点的一阶邻居并把低权重边过滤掉。对应的Cypher查询里有个很实用的写法把共现权重累加后过滤。比如只显示“武松”身边共现次数大于等于3的人物MATCH (p:Person {name:武松})-[r:CO_OCCUR]-(n:Person) WITH n, sum(r.weight) AS w WHERE w 3 RETURN n.name, w ORDER BY w DESC LIMIT 30;这里的sum(r.weight)是多数人容易漏的细节两个人可能在多个回目里共现Neo4j里会存在多条CO_OCCUR边如果直接按r.weight排序会把武松-武大郎拆成多条边展示图上看就像两个人之间有五行连线。先按对聚合再过滤才是“人物关系子图”的正确形态。5. 问答系统模板匹配加Cypher生成最容易翻车的5个位置5.1 两段式处理流程先认人再“认关系”问答系统的核心不是机器学习而是模板匹配。一个问句进来分两步走。第一步从问句里抽人物实体第二步判断用户在问哪类关系然后拼Cypher。我用的意图分四类关系查询A和B什么关系、关联查询谁认识A、路径查询A怎么联系B、统计查询谁的关系最多。实体识别不用去训模型直接把问句里的词和3.1节的别名集合做匹配import re def extract_persons(text, alias_set): found [name for name in alias_set if name in text and len(name) 1] return sorted(found, keylen, reverseTrue) # 先匹配长名字避免“宋江”吃掉“宋清”这里有一个真实的坑name in text是子串匹配“李逵”会命中“李逵的斧头”“宋江”会命中“宋江州府”这种不存在的表达。我的妥协方案是只匹配长度大于1且别名集合里的人名同时维护一个停用词表把“宋江军”“宋江州”这类词提前排除。课设做到这一步就算及格。5.2 别名映射与关系动词表让问答回答得“像人话”实体匹配出来之后要把别名换算成正式名再根据问句里的动词和名词结构决定Cypher。比如“宋江和吴用是什么关系”命中宋江、吴用词法上“什么关系”对应RELATION意图。再比如“谁和西门庆有仇”命中西门庆“有仇”对应ENEMY意图。alias_map {宋江: 宋江, 及时雨: 宋江, 孝义黑三郎: 宋江, 武松: 武松, 武行者: 武松, 行者: 武松} intent_rules [ (r谁.{0,4}(认识|见过|知道).{0,4}(.{2,4}), NEIGHBOR), (r(.{2,4})和(.{2,4}).{0,4}(什么关系|啥关系|关系), RELATION), (r(.{2,4}).{0,4}(打|杀|骂|捉|害|仇|打败).{0,4}(.{2,4}), ENEMY), (r谁.{0,6}(关系最多|朋友最多|最重要), STATISTICS), ]注意intent_rules的书写顺序有讲究先匹配最具体的关系类谁打谁、谁杀谁再匹配通用关系类最后统计类。原因是“谁打谁”这种带动作的问句如果落到RELATION意图里查出来的是共现关系答非所问。我的习惯是让ENEMY规则排在RELATION前面。这部分没有机器学习规则表就是你的人工知识库这是课设答辩时你要能讲清楚的“可解释性”。5.3 避坑问答系统里最容易翻车的5个位置以下全是真实踩过的坑按“现象、原因、解决”三段式写。1. 问“宋江和吴用谁地位高”答成共现。原因是CO_OCCUR边没有语义这个问句落到RELATION意图只能返回“两人曾在同一回目出现过”。解决在RELATION意图里优先匹配SUPERIOR_SUBORDINATE、BROTHERHOOD这些语义边查不到再退回CO_OCCUR。2. 实体识别把“李鬼”当成“李逵”。原因是子串匹配太粗糙“李鬼”虽然不在别名集合里但问句里含有“李”字开头的词就可能误命中。真正常见的翻车点是“大郎”和“武大郎”这种带称谓的别名子串匹配命中大量无关句。解决问句命中多个别名时优先选长度更长的那个同时把“某某军”“某某府”这类词加入停用词表。3. 生成的Cypher带引号直接语法报错。用户问句里带着英文引号、圆括号比如“谁(最早)认识林冲”拼进查询字符串直接坏掉。解决永远用参数化查询把变量传给graph.run()的named parameters而不是写进Cypher文本。4. 关系方向反了返回空结果。数据库里存的是(a)-[:ENEMY]-(b)问“西门庆的敌人是谁”能查出来问“谁和西门庆有仇”用-方向就查空了。解决凡是不确定方向的问题统一用无方向匹配-[r]-再加COLLECT让数据库自己处理方向。5. 统计类问题写得慢几秒才出答案。原因是全库扫Person节点再count关系没有索引。解决给Person的name加普通索引并在统计查询里先用MATCH (p:Person)限定标签。Neo4j里标签本身就有索引语义但name属性必须显式加索引。6. 从演示到可用三个值得加的Cypher进阶查询与一个性能检查习惯6.1 三个加分项最短路径、中心性、社区划分如果只想把项目停在“能演示”的程度前面几章就够了但要让答辩老师觉得你“真懂图数据库”建议加三个进阶查询。第一个是人物之间的最短路径回答“宋江怎么联系到高俅”这类问题MATCH p shortestPath( (a:Person {name:宋江})-[:CO_OCCUR|SUPERIOR_SUBORDINATE*..6]-(b:Person {name:高俅}) ) RETURN [n IN nodes(p) | n.name] AS path这里限制*..6跳数防止全连通图里出现二三十步的“无意义路径”。第二个是中心性查询用度数找出“梁山泊人脉之王”MATCH (p:Person)-[r]-() RETURN p.name, count(r) AS degree ORDER BY degree DESC LIMIT 10;答辩时你还可以补一句这个结果和小说直觉一致宋江、吴用、卢俊义稳居前三说明共现关系的抽取方式站得住脚。第三个是找关系紧密的小团体用社区发现算法标签传播Neo4j算法库里有现成函数sklearn用户看一眼就能上手。6.2 用验证用例清单而不是“感觉答得不错”问答系统最容易自己骗自己——挑五个问题测一下全答对了就以为完工。我的习惯是建一张用例表覆盖四类意图每类至少三条其中带别名、带同音字、带超纲问句问句意图预期答案实测结果宋江和武松是什么关系RELATION共现/结义通过及时雨的结义兄弟有哪些NEIGHBOR李逵等通过谁和西门庆有仇ENEMY武松通过梁山谁的人脉最广STATISTICS宋江通过这张表同时也是答辩讲稿的目录每讲一个问题你就展示一条真实查询和结果比背PPT有说服力得多。6.3 用PROFILE检查“全表扫描”的习惯最后一个习惯是我自己吃了好几次亏才养成的。任何一条新Cypher先跑EXPLAIN再看执行计划如果NodeByLabelScan出现说明这条查询在扫全库108个节点可能看不出来但你以后换个大数据集就完了。加了name索引的查询执行计划会变成NodeIndexSeek查询时间从几十毫秒降到几毫秒。答辩时老师最常见的追问就是“你这查询优化过吗”——把这条习惯讲出来比讲十页概念都管用。还有一个小经验不要把答案模板写死成“A和B存在某关系”。我一般让系统在答完关系后补一句“出自第X回”数据里存了source_chapters就顺手取出来。这看似不起眼但用户和老师都会觉得系统“真的读过书”。我的原则是一个能被追问出细节的小功能价值大于十个展示但答不出为什么的功能。这个项目做完最值钱的不是那张大屏而是你手里那份能重跑的导入脚本和那套规则表——它们才是真正能复用的东西。希望帮到你。本文还有配套的精品资源点击获取