简介这是一份基于Neo4j构建《水浒传》人物关系图谱与智能问答系统的毕业设计源码包面向计算机、人工智能等专业的高校学生与从业者可用于课程实践、学期项目或毕业设计参考。整套代码采用Python开发模块化划分数据层、算法层与Web服务层数据层用Cypher完成关系路径检索算法层封装共现分析、紧密度计算与社区发现前端基于ECharts展示交互式关系网络后端以Flask提供RESTful API并借助Docker实现便捷部署。资源共237个文件约23.56MB以jpg图片界面截图/演示素材、py源码、css/js前端资源、html页面等为主另含pptx答辩演示文稿和部署说明。已有72人学习下载。除了可直接运行的完整项目外包内还提供毕业答辩PPT、部署指南与接口说明便于快速理解架构并二次扩展适合初次接触图数据库的初学者跟进完整流程也适合进阶者在此基础上增加时序分析、动态可视化等能力。1. 基于 Neo4j 的水浒人物关系图谱与智能问答系统一份能跑到 98 分的毕设源码拿到这份基于 Neo4j 的水浒传人物关系图谱与智能问答系统源码时我第一反应是去翻知识抽取逻辑而不是先跑前端页面——这类毕设项目很多套了个 ECharts 壳本质还是拿 SQL 查关联表。这个项目不一样的地方在于人物属性、社会关系、事件关联是真实落进图数据库的Cypher 负责路径检索APOC 插件做社区发现和关系紧密度计算问答模块把自然语言问题解析成 Cypher 去执行不是关键词匹配。它适合正在做图数据库方向毕业设计、又不想纯写理论的同学也适合想快速看懂 Flask Neo4j ECharts 这条技术栈怎么串成闭环的开发者。整套资源是完整 Python 源码加答辩演示文稿毕业设计评审给了 98 分这个分数说明评委认可的是这条可运行的图分析链路不只是页面效果。2. 实体关系抽取与数据建模把《水浒传》文本拆成节点和关系边2.1 先定实体边界人物主名、别名与属性字段任何图谱项目的第一步都不是写代码而是确定「什么能成为一个节点」。《水浒传》的难点在于人物别名极多宋江又叫及时雨、呼保义、宋公明武松也叫武二郎、行者。如果不做别名归一化后面的共现统计会把宋江和及时雨当成两个实体图谱里冒出大量重复节点问答模块也会出现「宋江的绰号是什么」答不出来的翻车现场。我一般建议先把人物实体表建成一份 Python 字典主名做键别名列表做值。这份源码里也是这么处理的常见做法是CHARACTERS { 宋江: [宋江, 及时雨, 呼保义, 宋公明, 孝义黑三郎], 武松: [武松, 武二郎, 行者], 鲁智深: [鲁智深, 鲁达, 花和尚], 林冲: [林冲, 豹子头], 李逵: [李逵, 黑旋风, 铁牛], # 其余人物按需补充 } ALIAS_TO_NAME {} for name, aliases in CHARACTERS.items(): for alias in aliases: ALIAS_TO_NAME[alias] name这段代码解决的问题是清洗所有别名统一映射到主名后续无论从文本里抽出的是「及时雨」还是「呼保义」都归到宋江节点。注意名单要覆盖主要人物和配角只放一百单八将容易漏掉高俅、潘金莲这类推动情节的关键角色关系图谱会缺一大块。节点属性方面原著给了很规整的半结构化信息可以直接落成字段属性名含义示例name人物主名宋江alias绰号及时雨star星号天魁星rank座次1camp阵营梁山 / 朝廷 / 方腊ending结局被毒杀 / 善终关系类型要控制粒度太细会稀疏太粗没信息量。常见粒度是共现、结义、师徒、兄弟、主仆、敌对、同僚、夫妻这几类外加一条可选的共现关系作为默认边。关系表定义如下字段weight在 2.2 节的共现统计里会用到关系类型方向性权重含义CO_OCCUR无向共现次数BROTHER无向结义亲密度ENEMY有向敌对强度SERVE有向主仆关系这里有一个常见的认知误区关系类型不是越多越好。新手容易把「认识」「见过」「打过」「帮过」全部拆开最后图上一堆稀疏边社区发现算法跑出来全是单点社区。高频关系收敛到八类左右是经过调参验证的合理粒度。2.2 共现分析算关系权重滑动窗口统计的 Python 实现图谱的边有了类型还要有权重。权重从哪来原著文本里没有现成的数字常见做法是人物共现分析同一回里同时出现的两个人大概率存在某种关联出现次数越多关系越强。这是后续关系紧密度计算的基础输入。核心实现是滑动窗口统计。思路可以拆成三步先把全书按「第 X 回」切成章节再在每章里找出所有人物名出现的位置最后统计同一章节内距离小于阈值的两个人共现次数。import re from collections import defaultdict def load_chapters(pathshuihu.txt): 按『第X回』切分原著过滤过短的片段。 with open(path, encodingutf-8) as f: text f.read() chapters re.split(r第[一二三四五六七八九十百零]回, text) return [c for c in chapters if len(c) 200] def count_cooccurrence(chapters, alias_to_name, window30): 统计人物共现同章节内两个名字出现位置的距离小于 window 个字符记一次共现。 co defaultdict(int) for chapter in chapters: positions [] # (位置, 主名) for alias, name in alias_to_name.items(): for m in re.finditer(alias, chapter): positions.append((m.start(), name)) positions.sort() for i in range(len(positions)): for j in range(i 1, len(positions)): dist positions[j][0] - positions[i][0] if dist window: key tuple(sorted([positions[i][1], positions[j][1]])) co[key] 1 return co逻辑说明load_chapters先把全书拆成章回避免整本书一次性遍历导致内存和匹配噪声失控。count_cooccurrence对每个别名做正则匹配命中后统一映射成主名位置排序后两两比较距离。window30的含义是两个人名在原文中相隔不超过 30 个字符约一到两个分句长度就算一次共现小于这个值会把同回但无关的人物强行连线大于这个值会丢失短距离强关联。这个阈值取舍很影响图谱质量。window设太大鲁智深和林冲这种同回配角都会被连成强关系设太小跨章回的老关系又捕捉不到。我通常先跑一遍输出共现次数分布看中位数再回来调不要一上来就信默认参数。2.3 写入 Neo4jMERGE、UNWIND 与唯一约束共现矩阵算出来后下一步就是写入图数据库。这一步最容易翻车先用一条CREATE测试没问题批量导入就开始刷重复节点——因为CREATE不看节点是否已存在跑两遍数据就翻倍。正确的做法是先建唯一约束再用MERGE幂等写入。// 先建约束name 唯一这是 MERGE 不产生重复节点的前提 CREATE CONSTRAINT ON (p:Person) ASSERT p.name IS UNIQUE; // 批量写入节点UNWIND 把 Python 传来的列表展开成多行 UNWIND $nodes AS row MERGE (p:Person {name: row.name}) SET p.alias row.alias, p.star row.star, p.rank row.rank, p.camp row.camp, p.ending row.ending;逻辑说明UNWIND是 Cypher 里的列表展开语法配合 Python 驱动传参一次请求写入几百条数据比逐条CREATE快一个量级。MERGE会先按name查节点存在就跳过不存在才创建配合唯一约束保证幂等。注意MERGE匹配的属性越少越好只匹配name其他属性用SET追加否则后续要更新ending字段时MERGE会去匹配整个属性快照匹配不上又复制一份。边写入是同样的套路只是需要先确定两端节点存在UNWIND $edges AS row MATCH (a:Person {name: row.source}) MATCH (b:Person {name: row.target}) MERGE (a)-[r:CO_OCCUR {type: row.type}]-(b) SET r.weight coalesce(r.weight, 0) row.weight;MATCH两个端点是保证外键合法如果源数据里有没录入的人物名这里会直接跳过而不是创建悬空节点——这一点很重要悬空节点是后期社区发现结果里出现无名社区的常见来源。coalesce处理首次写入时weight为空的情况累加逻辑让多次导入的数据可以合并。提示导入前检查一下驱动用的是不是 Neo4j 官方 Python 驱动neo4j包而不是只支持 HTTP 的旧版 py2neo。前者能传$nodes这种参数化列表后者对 UNWIND 传参支持比较别扭会出现参数类型对不上的玄学报错。3. Cypher 查询与 APOC 图算法路径检索、社区发现怎么落地3.1 深度路径查询宋江和武松之间隔了几层关系图数据库相比关系型数据库的核心优势就是多跳关系查询不需要反复 JOIN。《水浒传》人物关系里最典型的提问是「宋江和武松是怎么产生关联的」在关系型表结构里你要把边表自连接好几次而在 Neo4j 里一条 Cypher 就能拿结果。// 找宋江到武松的最短路径边数不超过 4 MATCH p shortestPath( (a:Person {name: 宋江})-[*1..4]-(b:Person {name: 武松}) ) RETURN p, length(p) AS depth;逻辑说明shortestPath返回两点间经过边数最少的路径[*1..4]限定可变长度关系的范围是 1 到 4 跳。不写这个上限的话全图都是连通的任何两点都能找到路径查询也会随图规模膨胀变慢。length(p)用来显示路径深度前端展示时可以按深度染色。如果要看所有路径而不是最短路径把shortestPath去掉直接MATCH p(a)-[*1..4]-(b)即可但注意结果集是组合爆炸的宋江和武松这种核心人物之间的 4 跳路径可能有几十上百条建议加LIMIT 20再看。实际项目里我更常用的是限定关系类型的方向版本MATCH p (a:Person {name: 宋江})-[:BROTHER|CO_OCCUR*1..3]-(b:Person {name: 武松}) RETURN p LIMIT 10;这段表达的含义是只走BROTHER和CO_OCCUR两类边路径最多 3 跳。这样能把「敌对—师徒」这种语义上不算亲近的路径过滤掉返回的路径更有解释力。做问答系统时这个查询就是「宋江和武松是什么关系」这类问题的最终执行体。另一个值得提的点是查询性能。4 跳以内的小图查询几乎秒回但如果你把上限放大到 6 跳甚至 10 跳核心人物的路径会指数增长。我一般先跑PROFILE MATCH p (a:Person {name:宋江})-[*1..4]-(b) RETURN count(p)看一眼行数超过几千条就缩跳数或者加关系类型过滤不要硬查。3.2 APOC 接入社区发现与关系紧密度计算图谱建好只是第一步这个项目能拿 98 分很大程度靠 APOC 插件提供的图算法。社区发现能回答「水浒传里有哪些派系」关系紧密度计算能回答「宋江最铁的兄弟是谁」这两个问题都是纯 Cypher 很难高效完成的需要算法库支撑。Neo4j 3.5 / 4.x 时代常见的做法是调用 APOC 的社区发现过程。以标签传播算法为例CALL apoc.algo.community( 25, // 迭代次数 MATCH (n:Person) RETURN id(n) AS id, // 全量节点 MATCH (a:Person)-[r:CO_OCCUR]-(b:Person) RETURN id(a) AS source, id(b) AS target, r.weight AS weight, // 带权边 out ) YIELD communityId, nodeId RETURN communityId, collect(nodeId) AS members;参数说明第一个参数25是标签传播的迭代上限一般 20 到 30 次就能收敛第二个和第三个参数分别是节点集和边集的 Cypher 查询注意这里传的是「查询字符串」不是直接执行最后一个out表示只按边的出方向传播。跑完之后同一个communityId里的节点就是算法判定的一伙人。这里我要特别提醒apoc.algo.community在新版本 Neo4j 里被逐步废弃官方重心移到了 GDSGraph Data Science库。如果打开的是 Neo4j 5.x直接调用 APOC 会报Procedure not found需要换成gds.labelPropagation.stream()的写法。这份源码大概率用的是 3.5 或 4.x 时代的 APOC 接口跑不起来时先检查版本不要怀疑逻辑。关系紧密度计算更直接本质是加权度数一个节点连接的所有边的weight之和。Cypher 原生就能算适合放在问答接口里做实时查询MATCH (a:Person {name: 宋江})-[r]-() RETURN a.name AS person, sum(r.weight) AS closeness, count(r) AS degree ORDER BY closeness DESC;这段查询统计的是宋江所有关系边的权重总和closeness以及关联人数degree。为什么用权重和而不是直接用count因为共现次数能体现相处密度武松和宋江共现 50 次、杨志和宋江共现 5 次前者关系更紧密更符合直觉。这个值喂给前端就能渲染「关系热力」效果答辩时展示出来比静态图有说服力得多。4. Flask 问答接口与 ECharts 可视化把图谱变成能对话的系统4.1 意图解析从自然语言问题到 Cypher 模板智能问答模块是这个项目的另一个核心亮点。原理不复杂先把用户问题归类成几种意图每种意图对应一个 Cypher 查询模板再用正则从问题里抽出人物名和属性名填进模板执行。模板匹配方案比训练一个 NER 模型轻量得多对毕业设计来说性价比最高也不容易失控。意图分类表是核心配置结构如下意图类型问题示例生成的 Cypher 模式attribute宋江的绰号是什么MATCH (n:Person {name:X}) RETURN n.aliasrelation宋江和武松什么关系MATCH (a:Person {name:X})-[r]-(b:Person {name:Y}) RETURN type(r)path宋江和武松怎么认识的MATCH pshortestPath((a)-[*1..4]-(b)) RETURN prank武松排第几MATCH (n:Person {name:X}) RETURN n.rankgroup梁山有哪些天罡MATCH (n:Person {camp:梁山}) WHERE n.rank36 RETURN n.name意图解析的 Python 实现写成规则列表逐个匹配import re INTENTS [ { type: attribute, pattern: r(.?)的(绰号|星号|座次|阵营|结局)是什么, cypher: MATCH (n:Person {{name:{0}}}) RETURN n.{1} AS value, }, { type: relation, pattern: r(.?)和(.?)什么关系, cypher: (MATCH (a:Person {{name:{0}}})-[r]-(b:Person {{name:{1}}}) RETURN type(r) AS relation, r.weight AS weight), }, { type: path, pattern: r(.?)怎么(认识|联系).*?(.?), cypher: (MATCH pshortestPath((a:Person {{name:{0}}})-[*1..4]- (b:Person {{name:{1}}})) RETURN p), }, ] def parse(question): for intent in INTENTS: m re.search(intent[pattern], question) if m: groups [ALIAS_TO_NAME.get(g, g) for g in m.groups()] return intent[type], intent[cypher].format(*groups) return unknown, None逻辑说明INTENTS里每条规则由pattern和cypher组成pattern用正则捕获人物名和属性名cypher字符串里的{0}、{1}是占位符用format填充。填充前先经过ALIAS_TO_NAME做别名归一化用户问「及时雨的绰号」和「宋江的绰号」走同一条查询。注意 Cypher 里的花括号需要写成双花括号{{name:{0}}}转义否则format会跟 Cypher 的参数语法打架这个细节很容易踩。正则方案的上限是固定的但封闭式问题的准确率足够高。实际测试里用户问「宋江的座次是多少」「李逵的星号是什么」这类问题基本能命中开放式问题会落到 unknown返回兜底话术即可。另外一个值得注意的点是如果用户问「宋江的结局」而pattern里没有匹配结局字段解析会失败所以属性类规则要覆盖所有字段别漏。4.2 RESTful API 与前端关系图联动后端采用 Flask 搭建 RESTful API 服务整体是 B/S 架构前端 ECharts 渲染后端 Flask 执行 Cypher 并返回 JSON。这个分层让前后端可以独立测试也是答辩时容易被提问的点。from flask import Flask, request, jsonify from neo4j import GraphDatabase app Flask(__name__) driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, password)) app.route(/api/qa, methods[POST]) def qa(): data request.get_json() question data.get(question, ) intent, cypher parse(question) if not cypher: return jsonify({intent: unknown, answer: 暂时答不上来换个问法试试}) with driver.session() as session: result session.run(cypher) records [record.data() for record in result] return jsonify({intent: intent, answer: records[:5]})参数说明GraphDatabase.driver连接的是 Bolt 端口 7687不是 HTTP 端口 7474这个区分在本地调试时经常让人卡住。auth里填的是 Neo4j 初始账号密码默认neo4j/neo4j首次登录会强制要求修改。每个请求打开一个session用完自动关闭避免连接泄漏。records[:5]限制返回条数防止路径类问题的结果集把响应体撑爆。这里补充一个常见的工程坑如果你用npm start起前端 dev server 在 5000 端口Flask 也在 5000 端口会直接冲突。我一般把 Flask 改成 5001前端请求加跨域头from flask_cors import CORS CORS(app)前端 ECharts 关系图的数据结构是固定三件套nodes、links、categories。后端额外提供一个图谱全量接口前端拿到后直接填充fetch(/api/graph) .then((res) res.json()) .then((data) { option { series: [{ type: graph, layout: force, data: data.nodes, links: data.links, categories: data.categories, force: { repulsion: 300, edgeLength: 80 }, label: { show: true, fontSize: 10 }, }], }; myChart.setOption(option); });layout: force表示力导向布局节点之间根据边的权重互相排斥或吸引权重大的两个人会被拉得更近视觉效果上直接能看出派系聚集。repulsion控制排斥力值越大图越散节点多的时候要往大调否则挤成一团看不清标签。提示前端返回的links里一定要带value字段ECharts 在 force 布局下会用value做连线的粗细映射没带value的图所有边一样粗看不出亲疏远近答辩时视觉效果大打折扣。5. Neo4j 避坑指南安装、乱码、约束、APOC、Docker 五个翻车现场这套源码我前后跑了两遍第一遍从零搭环境第二遍补数据导入和问答调试踩过的坑基本都在 Neo4j 这一层。下面五条按「现象 → 原因 → 解决」整理对照检查能省下至少半天。5.1 Neo4j 安装后本地连不上JDK 版本与端口认证现象neo4j start后进程退出浏览器打开 7474 端口白屏或者 7474 能开Python 驱动连 Bolt 7687 报Service unavailable。原因Neo4j 3.5 这个常见教学版本强依赖 JDK 8装成 JDK 11 之后服务虽然能起但 Web 管理界面和 Bolt 协议部分接口会异常另外 Neo4j 4.x 之后默认开启强认证首次连接要用初始密码neo4j修改后才放行。解决先执行java -version确认当前 JDK 版本3.5 就老老实实配 JRE 8。本地调试用 Neo4j Desktop 最省事它会自动管理对应版本的 JDK社区版 bin 目录下手动neo4j start也可以但注意neo4j.conf里dbms.connectors.default_listen_address如果被改成局域网地址本地localhost反而连不上。如果 7474 能开而 7687 连不上检查防火墙和端口映射Docker 部署时最常见。5.2 导入后全是中文乱码现象Python 读原著文本正常写入 Neo4j 后浏览器里看到的节点名变成江湖一类乱码。原因经典编码问题。Neo4j 导入 CSV 时会按系统默认编码解析Linux 默认 UTF-8 没问题Windows 下默认 GBK而源文件通常是 UTF-8两边对不上就乱码。Python 侧如果open()没显式指定encodingutf-8在 Windows 上同样会读错。解决Python 读写文件一律显式encodingutf-8。导入 CSV 前检查有没有 BOM 头有 BOM 的话 Neo4j 会把\ufeff带进第一个字段节点名开头多个不可见字符用utf-8-sig重新写一遍即可。最稳妥的办法是绕过 CSV直接用 Python 驱动传 UNWIND 参数全程走内存字符串从源头避免编码问题。5.3 MERGE 导入两遍数据翻倍现象同一批节点数据导入两次查询count(n)从 108 变成 216节点全部重复。原因没建唯一约束之前MERGE的匹配逻辑会退化成全属性匹配属性快照稍有差异就会新建节点更隐蔽的是如果两条记录写入顺序不同MERGE匹配不到就各自创建一个产生「看起来一样但 id 不同」的重复节点。解决导入前先执行CREATE CONSTRAINT ON (p:Person) ASSERT p.name IS UNIQUE;约束建立后MERGE才会真正按name去重。如果已经产生了重复数据需要先清理再建约束清理脚本长这样MATCH (p:Person) WITH p.name AS name, collect(p) AS nodes WHERE size(nodes) 1 FOREACH (n IN tail(nodes) | DELETE n);这段的逻辑是按name分组收集该组所有节点只保留第一个其余全部删除。注意执行前先备份数据库这个操作不可逆。5.4 APOC 插件不生效Procedure not found现象调用CALL apoc.algo.community报There is no procedure with the name apoc.algo.community但插件目录里明明放了 jar 包。原因APOC 的版本必须和 Neo4j 主版本严格对应Neo4j 3.5 要用 3.5 系列的 APOC 包4.4 要用 4.4 系列。放错版本不会报安装失败只是过程找不到。另外有些 APOC 过程被dbms.security.procedures.whitelist黑名单拦截需要显式放行。解决按 Neo4j 版本号去 APOC 官方仓库选对应包下载后放进plugins/目录重启 Neo4j 生效。手动部署模式下检查neo4j.conf里有没有dbms.security.procedures.whitelistapoc.*配置没有就补上。验证是否加载成功执行一句RETURN apoc.version()能返回版本号就说明装对了。5.5 Docker 部署后数据丢失现象按 Docker 方式部署 Neo4j容器每次重启后图谱数据全空之前导入的几百条关系不见了。原因容器是无状态的官方neo4j镜像默认把数据写在容器内/data没有挂载宿主目录时容器删除或重建数据就一起没了。解决启动时加卷挂载参数把/data和/logs都挂到宿主机docker run -d \ --name neo4j \ -p 7474:7474 -p 7687:7687 \ -v $PWD/neo4j_data:/data \ -v $PWD/neo4j_logs:/logs \ neo4j:3.5端口方面两个端口都要映射少映射 7687 会导致 Python 驱动连不上而浏览器正常这种「页面能开但接口报错」的现象最容易让人误判。6. 进阶技巧把关系图升级成带权图跑一次真正的最短加权路径6.1 权重写入与迪杰斯特拉查询图建好、问答能跑通之后如果想在答辩里多一个加分点把 2.2 节算出来的共现次数正式写成边的weight然后跑迪杰斯特拉算法查「宋江认识武松的最铁路径」——注意是最铁不是最短。这两个概念的区别是普通shortestPath只数边数带权最短路径综合了每条边的亲密度能回答「宋江通过谁认识武松最顺理成章」这种更有解释力的问题。先把权重显式更新到边上。虽然导入时weight已经累加过但为了演示完整流程补一次聚合写入MATCH (a:Person)-[r:CO_OCCUR]-(b:Person) WITH a, b, sum(r.weight) AS w MERGE (a)-[r2:CO_OCCUR]-(b) SET r2.weight w;这段的含义是重新按端点聚合所有权重写回同一条边。执行前先确认没有重复边否则MERGE会把两条平行边合并成一条weight只保留一份数据会变少。稳妥做法是先查MATCH (a)-[r:CO_OCCUR]-(b) RETURN a.name, b.name, count(*)看有没有大于 1 的组。然后跑加权最短路径。Neo4j 3.5 时代用 APOC 的迪杰斯特拉实现4.x 之后建议用 GDSCALL apoc.algo.dijkstra( MATCH (a:Person {name:宋江}) RETURN a, // 起点 MATCH (b:Person {name:武松}) RETURN b, // 终点 CO_OCCUR, // 关系类型 weight // 权重属性 ) YIELD path, weight RETURN path, weight;参数说明前两个参数是返回单节点的 Cypher 查询不能用MATCH ... RETURN a, b这种多行写法第三个参数限定走CO_OCCUR边避免路径里混入ENEMY这类语义相反的关系第四个参数指定权重字段名。返回的weight是路径总权重数值越小代表路径越「近」。如果想让亲密度高反而数值小可以在写入时用1.0 / r.weight做一次倒数变换。跑出来的路径喂给前端把路径上的节点和边高亮其他节点做透明度区分。这个展示效果很直观宋江 → 李逵 → 武松 和 宋江 → 柴进 → 武松 两条路摆在一起评委一眼就能看出图数据库的价值比口头解释「多跳查询」有说服力得多。最后补一个习惯从那以后我每次导入数据都会强制走一遍「约束检查 → 幂等重导 → 权重聚合 → 路径验证」四步流程前两步保数据不脏后两步保查询有结果。这套流程本身也是答辩时能讲清楚的内容。这份源码本来就是网络分享出来供学习交流用的拿来当毕设参考正合适。希望这份实战拆解能帮到你下载后对照第 5 章的五个坑先过一遍能少走不少弯路。本文还有配套的精品资源点击获取