水浒人物关系图谱:从共现分析到Neo4j图查询与可视化
简介《水浒传》人物关系图谱构建与智能问答系统以Neo4j图数据库为核心采用Python开发形成一套完整的毕业设计源码与演示资料。面向计算机、信息通信、人工智能、自动化控制等专业的在校师生与从业者适用于课程实践、学期项目、毕业设计等学术场景重点解决从文学文本到知识图谱、再到自然语言问答的应用链路问题。压缩包共237个文件大小约23.56MB包含Python源码、前端HTML/CSS/JS资源、可视化演示文件、答辩PPTX、数据库备份与说明文档各类型按功能模块清晰归类。资源已有71人学习下载。项目采用模块化与B/S架构实现实体关系抽取、人物属性与社会关系建模、Cypher复杂路径检索以及基于自然语言处理的智能问答接口封装了人物共现分析、紧密度计算、社区发现等图算法并配备Docker容器化部署方案与文档便于直接运行、二次开发或作为学术参考。1. 从 MySQL 三表关联到 Neo4j 的一条 Match水浒人物关系系统为什么这样选型同样是查“和武松交过手、又帮过宋江的人”关系型数据库要设计人物表、关系表、事件表再写三层嵌套 JOIN而 Neo4j 里用一次 MATCH 加一个 WHERE 就能把路径直接查出来。这套项目正是沿着这个思路做的把《水浒传》的人物关系整理成图模型用共现统计抽取关系以人物为节点、以结拜/上下级/对手/共现为边存入 Neo4j后端用 Flask 暴露 REST API前端让 ECharts 把关系网拖拽起来并附带一个模板化问答接口。整套代码包含 Python 源码、图谱导入脚本、接口说明和答辩演示文稿适合作为计算机、大数据、人工智能方向的课程设计与毕业设计参考。对于已经写过几年代码的读者仓库里最有价值的是第 2 章的共现权重计算和最后一章的图可视化参数调整对于刚入门图数据库的开发者从第 3 章的 Neo4j 安装与配置开始看会更顺畅。2. 知识图谱构建从原文本到共现矩阵再落成图的节点和关系2.1 图模型设计节点、关系与属性怎么定《水浒传》里的人物关系不是单一“认识/不认识”而是包含结拜、上下级、对立、亲属等多种语义。在设计知识图谱之前先要确定本体节点承接实体关系表示语义属性记录可量化的数值或文本。这里的人物节点标签为 Person属性包括 name、nickname、rank座次、star天罡/地煞。关系类型不追求多而是要让查询能看懂、导入不过度复杂。节点/关系标签/类型关键属性说明人物节点Personname, nickname, rank, star梁山好汉及主要配角共现关系CO_OCCURcount, weight, chapters在同一回或同一场景出现结拜关系SWORN_BROTHERchapter结义行为上下级关系LEADER_OFsince_chapter宋江与头领之间的关系对立关系ENEMYcause打斗或冲突场景选择这个模型的原因很直接关系型数据库适合“二维表格固定模式”但像“宋江和卢俊义之间是否有路径”“谁的介数中心性最高”这类问题用 SQL 写多表关联很难维护Neo4j 的图遍历引擎天然支持这类查询。另外关系可以作为独立实体挂属性比如 CO_OCCUR 上的 weight 和 chapters后续做社区发现时可以直接当边权重用。2.2 基于共现窗口的实体关系抽取完善图谱最快的方式是先把所有人物在同一回、同一段落内的共现关系抓出来再人工补充明确动作关系如“结拜”“斩杀”。常见做法是准备一份纯文本版《水浒传》按回目切分用人物名单做正则匹配统计固定字符窗口内两两出现的次数。下面这段代码可以把“武松-鲁智深”之类的组合从原文里捞出来import re from collections import defaultdict PERSON_NAMES [宋江, 林冲, 武松, 鲁智深, 卢俊义, 吴用, 公孙胜, 关胜, 秦明, 呼延灼, 花荣, 柴进, 李逵, 燕青, 石秀, 杨雄, 戴宗, 刘唐, 史进, 李俊, 张顺, 阮小二, 阮小五, 阮小七, 杨志, 徐宁, 索超, 张清, 朱仝, 雷横, 解珍, 解宝, 孙立, 黄信] def split_chapters(text): # 按“第X回”标题切分返回文本块列表 parts re.split(r第[一二三四五六七八九十百零]回, text) return [p.strip() for p in parts if p.strip()] def extract_occurrences(chapter_text): # 记录人物名在章节中的出现位置 occurrences [] for name in PERSON_NAMES: for m in re.finditer(name, chapter_text): occurrences.append((m.start(), name)) occurrences.sort(keylambda x: x[0]) return occurrences def build_cooccurrence(chapters, window120): counter defaultdict(int) for chapter in chapters: occ extract_occurrences(chapter) for i in range(len(occ)): for j in range(i 1, len(occ)): if occ[j][0] - occ[i][0] window: pair tuple(sorted([occ[i][1], occ[j][1]])) counter[pair] 1 return counter if __name__ __main__: with open(shuihu.txt, encodingutf-8) as f: text f.read() cooc build_cooccurrence(split_chapters(text), window120) for (a, b), cnt in sorted(cooc.items(), keylambda x: x[1], reverseTrue)[:10]: print(a, b, cnt)这里的逻辑是split_chapters用正则把全文分成多个章节块extract_occurrences把每个人名在章节里的位置都记下来并按位置排序build_cooccurrence对同一章节内任意两个人名做窗口判断——当两个名字的字符距离小于等于window时计数加一。window120表示 120 个中文字符内的共现才会被记录避免“宋江”在第 1 回“卢俊义”在第 60 回也被统计进同一个对子。参数调整上window越大得到的共现边越多但噪声也越大。文本质量较差时建议缩到 80想要覆盖完整故事线索可以开到 160。还有一个容易被忽略的问题人物别名。示例里只放了正名实际项目中要把 “武松”和“武行者”、“宋江”和“宋公明”先做归一化否则同一个角色会产生两个节点。2.3 关系权重计算把共现次数变成可对比的权重共现次数只能说明“两个人物经常一起出现”不能直接作为图上的边权重因为人物出现频率差异很大。比如宋江几乎每回都在他和谁都有较高共现数这不代表真实关系强度。常见做法是做一个归一化用共现次数除以两个人物各自出现次数的几何平均数。这样活跃人物不会天然压过配角。import math from collections import defaultdict def normalize_weights(cooc): # 先过滤单次共现的噪声再计算角色总共现数 candidates {pair: cnt for pair, cnt in cooc.items() if cnt 2} person_total defaultdict(int) for (a, b), cnt in candidates.items(): person_total[a] cnt person_total[b] cnt result {} for (a, b), cnt in candidates.items(): weight cnt / math.sqrt(person_total[a] * person_total[b]) result[(a, b)] round(weight, 4) return result与上一段代码不同这里先执行cnt 2的过滤把只出现一次的弱连接剔除掉再统计角色总计避免低频噪声拉高分母。归一化后 weight 范围在 01 之间前端的折线粗细、后端最短路径的权重计算都可以直接使用这个字段。如果想要加入事件语义可以把“结拜”关系单独加权为 1.0“敌对”加权为 0.8再和共现权重叠加这一步需要在导入时对关系类型做条件判断不要混在共现统计里。3. Neo4j 安装与配置从下载社区版到导入图谱并用 Cypher 验证3.1 Neo4j 社区版下载、安装与 JVM 参数调整Neo4j 安装包分企业版和社区版学术项目使用社区版就可以。到官方下载中心选择合适平台下 tar.gz 包解压后直接修改 conf/neo4j.conf。注意下载时要选择与操作系统匹配的压缩包社区版 5.x 需要 JDK 17 及以上版本。下方的配置对 5.x 版本同样适用# conf/neo4j.conf dbms.memory.heap.initial_size1G dbms.memory.heap.max_size2G dbms.memory.pagecache.size1G dbms.default_listen_address0.0.0.0配置项默认值本项目建议影响范围dbms.memory.heap.initial_size512m1GJVM 启动堆大小dbms.memory.heap.max_size512m2G查询执行可用堆dbms.memory.pagecache.size512m1G节点和关系的磁盘缓存dbms.default_listen_addresslocalhost0.0.0.0是否允许外部/容器访问页面缓存不是越大越好要为操作系统留余量云服务器部署时堆内存上限不宜超过物理内存 1/2否则neo4j进程会被 OOM Killer 杀掉。还需要下载与当前 Neo4j 版本匹配的 APOC jar 文件放进 plugins 目录并在配置文件中追加dbms.security.procedures.unrestrictedapoc.*否则部分图算法调用会报“procedures not found”。启动使用bin/neo4j start浏览器访问 7474 端口登录并修改默认密码。3.2 使用 Python 驱动配合 UNWIND 批量导入节点和关系导入方式有两条路线数据量大时用neo4j-admin database import从 CSV 离线导入当前《水浒传》数据只有百级节点和千级关系更适合在 Python 里直接用 UNWIND 批量写入。采用MERGE而不是CREATE可以避免重复运行脚本时产生重复节点。代码如下from neo4j import GraphDatabase driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, your_password)) def load_persons(tx, persons): tx.run( UNWIND $persons AS p MERGE (n:Person {name: p.name}) SET n.nickname p.nickname, n.rank p.rank, n.star p.star , personspersons) def load_relations(tx, relations): tx.run( UNWIND $relations AS r MATCH (a:Person {name: r.source}) MATCH (b:Person {name: r.target}) MERGE (a)-[rel:CO_OCCUR {count: r.count}]-(b) SET rel.weight r.weight , relationsrelations) with driver.session() as session: session.execute_write(load_persons, persons) session.execute_write(load_relations, relations)session.execute_write会自动提交事务回调函数里tx.run执行的是 Cypher 语句。UNWIND把 Python 列表展开成多行相当于一次网络往返写几十条数据比盲目在 for 循环里逐条session.run快得多。MERGE在这里同时承担了“根据 name 找到已有节点”和“不存在则创建”的工作。关系类型后面的{count: r.count}是关系属性如果之后想支持多关系类型可以把CO_OCCUR替换成动态类型。注意r字段名不能写成type或rank这类 Neo4j 内置字符串否则 Cypher 解析时容易混淆。对persons列表中的 date 类型字段建议先格式化成字符串再传入避免 Python driver 与 Neo4j 的时间类型不一致。3.3 用 Cypher 验证导入结果并检查数据质量导入完成后不要急着写业务接口先在 Neo4j Browser 里执行三组查询第一组看总量验证节点是否齐全第二组看节点度分布验证关系是否丰富第三组看两个关键人物是否连通验证图是否形成可达网络。这三组查询对应数据完整性、中心性和可达性能快速暴露导入脚本的方向问题。MATCH (p:Person) RETURN count(p) AS person_count; MATCH (p:Person)-[r]-() RETURN p.name, count(r) AS degree ORDER BY degree DESC LIMIT 10; MATCH path shortestPath((a:Person {name:宋江})-[*..6]-(b:Person {name:卢俊义})) RETURN [n IN nodes(path) | n.name] AS path;count(p)如果大于等于 108说明 Person 基本建全若有重复节点用MATCH (p:Person) WITH p.name, count(p) AS c WHERE c1 DELETE ...去重。第二句里的-[r]-()只匹配有关系的节点能直观看出哪些角色是图的中心第三句的-[*..6]-是无向路径如果结果为空尝试提高跳数或确认两个节点是否在同一连通分量内。这一组查询同时验证了导入脚本的方向设置是否正确。4. Flask 后端与智能问答系统把自然语言问句翻译成 Cypher 查询4.1 Flask 蓝图路由设计与全图 JSON 输出后端采用 Flask Blueprint 拆分模块主要路由有三个GET /api/graph返回节点和边供 ECharts 首屏渲染GET /api/person/name返回指定人物的邻接关系POST /api/qa接收问句并返回答案。路由表如下路由方法入参返回/api/graphGET无nodes 与 edges 的 JSON/api/person/nameGETURL 中的 name人物邻接关系列表/api/qaPOST{question: ...}{answer: ...}{}因为前端和后端可能分属不同端口开发环境要用 flask-cors 解决跨域from flask import Flask, jsonify, request from flask_cors import CORS from app.api.graph import graph_bp from app.api.qa import qa_bp app Flask(__name__) CORS(app) app.register_blueprint(graph_bp) app.register_blueprint(qa_bp) if __name__ __main__: app.run(host0.0.0.0, port5000)CORS(app)允许浏览器直接请求/api下所有接口避免调试时被同源策略卡住。路由实现里要注意ECharts 的力导向图既需要唯一的节点 id又需要 source/target 对应的字符串值输出 JSON 前要做一次节点去重。这里给出/api/graph的核心片段graph_bp.route(/graph, methods[GET]) def full_graph(): query MATCH (n:Person)-[r]-(m:Person) RETURN n.name AS source, type(r) AS relation, m.name AS target, r.weight AS weight records run_query(query) nodes, edges [], [] seen set() for rec in records: for p in (rec[source], rec[target]): if p not in seen: seen.add(p) nodes.append({id: p, name: p}) edges.append({ source: rec[source], target: rec[target], relation: rec[relation], weight: rec.get(weight, 1) or 1 }) return jsonify({nodes: nodes, edges: edges})rec.get(weight, 1)是考虑到部分关系没有归一化权重缺省为 1这样前端折线宽度不会全部变 0。run_query是对 Neo4j Python Driver 的一层薄封装内部统一处理 Session 关闭和异常日志。4.2 问题模板匹配实体识别 意图分类问答模块没有使用 BERT 等模型原因是图谱规模小预先定义意图模板的准确率更高、部署成本更低答辩时也更容易解释。处理流程分两步先用人物词典找出问句里的人名再用正则匹配意图。代码组织成一个 parserimport re PERSON_NAMES [宋江, 林冲, 武松, 鲁智深, 卢俊义, 吴用, 李逵, 花荣, 柴进] def parse_question(question): names re.findall(|.join(PERSON_NAMES), question) names list(dict.fromkeys(names)) # 去重并保持顺序 if len(names) 2: if re.search(什么关系|什么联系, question): return relation, names[:2] if re.search(怎么.*联系|路径|通过, question): return path, names[:2] if names: if re.search(和哪些|与哪些, question): return neighbors, names[0] if re.search(绰号|座次|星号, question): return attribute, names[0] return unknown, []re.findall(|.join(PERSON_NAMES), question)找出问句中所有命中的人物名list(dict.fromkeys(...))在 Python 3.7 中去重且保持顺序。正则分支顺序会影响长词匹配比如“入云龙公孙胜”和“公孙胜”同时存在时要把长词放在人物词典前面。实际项目中还会加一步别名归一化把“黑旋风”映射到“李逵”。找到意图后真正执行 Cypher 的代码按意图分发。以“关系查询”为例def answer_relation(a, b): query MATCH (a:Person {name:$a})-[r]-(b:Person {name:$b}) RETURN type(r) AS rel, r.weight AS weight LIMIT 20 rows run_query(query, {a: a, b: b}) if not rows: return f{a} 和 {b} 之间没有直接关系 return .join( f{a}--{row[rel]}-- {b}权重 {row[weight]} for row in rows )-[r]-表示不关心方向这样“A 认识 B”和“B 认识 A”都能被查出来。type(r)返回关系类型字符串权重作为排序或展示字段。如果有多种关系类型LIMIT 20会截断结果建议在返回前按权重排序前端优先展示强关系。4.3 路径查询和实体不存在时的处理当问句包含“怎么联系”“通过谁认识”时需要走图数据库最擅长的最短路径查询。这里用shortestPath限制最大 5 跳避免“宋江-吴用-卢俊义”这类路径太长导致条件放宽后出现大量冗余结果。如果路径不存在返回文案要说明缺少连通关系而不是直接抛异常。def answer_path(a, b): query MATCH p shortestPath((a:Person {name:$a})-[*..5]-(b:Person {name:$b})) RETURN [n IN nodes(p) | n.name] AS chain, length(p) AS hops rows run_query(query, {a: a, b: b}) if rows: chain - .join(rows[0][chain]) return f{a}到{b}的最短链是{chain}共 {rows[0][hops]} 跳 return f在当前图谱中没找到 {a} 到 {b} 的路径[*..5]中的 5 是跳数上限数值越大查询越慢对于 108 人的图5 跳以内基本都能连通。还需要注意shortestPath返回的nodes(p)是按路径顺序排列的节点对象[n IN nodes(p) | n.name]是 Cypher 的列表推导式等价于把节点列表映射成姓名列表。后端返回后问答接口统一包一层{answer: ...}JSON前端对话框只显示answer字段。5. 可视化性能优化与 Docker Compose 部署几个值得记录的坑5.1 ECharts 力导向图过滤低权重边比调物理参数更有效页面用 ECharts 的 graph 类型渲染人物关系网节点多到两百左右时物理碰撞计算会让浏览器掉帧。常见做法有三个调小 repulsion、减少参与布局的边数、开roam: true。实际项目里效果最明显的是过滤低权重边option { tooltip: {}, series: [{ type: graph, layout: force, roam: true, force: { repulsion: 320, edgeLength: [50, 100] }, data: nodes, links: edges.filter(e e.weight 0.15), lineStyle: { width: 1, opacity: 0.7 } }] };force.repulsion控制节点之间的斥力300 左右适合人物关系图edgeLength给一个范围图会自动做边长弹性。links.filter把共现权重低于 0.15 的边丢弃权重大于 0.15 的边才参与布局这样既保留骨干关系又能大幅减少 ECharts 在每一帧对力导向算法的计算量。实际观察中过滤器阈值从 0.1 到 0.2 之间对互动体验影响最大建议用前端下拉框让使用者动态切换而不是写死。5.2 Docker Compose 编排Flask 连接 Neo4j 时不要写 localhost整个系统可以用 Docker Compose 一条命令启动Neo4j、Flask 后端和 Nginx 前端分别作为三个容器。最常踩的坑是后端环境变量里写了bolt://localhost:7687导致容器内部连不上 Neo4j。在 Compose 网络下服务名就是主机名version: 3.8 services: neo4j: image: neo4j:5-community environment: - NEO4J_AUTHneo4j/your_password - NEO4J_PLUGINS[apoc] ports: - 7474:7474 - 7687:7687 volumes: - neo4j_data:/data backend: build: ./backend environment: - NEO4J_URIbolt://neo4j:7687 ports: - 5000:5000 depends_on: - neo4j volumes: neo4j_data:NEO4J_PLUGINS[apoc]是社区版镜像自带的环境变量写法容器启动时自动下载并启用 APOC 插件省去手动放 jar 的步骤。后端里用bolt://neo4j:7687而不是bolt://localhost:7687因为容器网络内的localhost指向 backend 容器自身。如果部署到云服务器记得在安全组放行 7474、7687 和 5000 端口Neo4j Browser 用公网 IP 访问API 用 5000 端口访问。启动后查看日志用docker compose logs -f neo4j看到Bolt enabled on 0.0.0.0:7687再继续调页面。如果要在该架构上继续扩展把NEO4J_PLUGINS改成[apoc, graph-data-science]就能直接调用 Louvain 社区发现算法做派系划分。本文还有配套的精品资源点击获取