news 2026/9/13 17:43:06

水浒人物关系图谱:从共现分析到Neo4j图查询与可视化

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
水浒人物关系图谱:从共现分析到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(key=lambda x: x[0]) return occurrences def build_cooccurrence(chapters, window=120): 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", encoding="utf-8") as f: text = f.read() cooc = build_cooccurrence(split_chapters(text), window=120) for (a, b), cnt in sorted(cooc.items(), key=lambda x: x[1], reverse=True)[:10]: print(a, b, cnt)

这里的逻辑是:split_chapters用正则把全文分成多个章节块,extract_occurrences把每个人名在章节里的位置都记下来并按位置排序,build_cooccurrence对同一章节内任意两个人名做窗口判断——当两个名字的字符距离小于等于window时,计数加一。window=120表示 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 范围在 0~1 之间,前端的折线粗细、后端最短路径的权重计算都可以直接使用这个字段。如果想要加入事件语义,可以把“结拜”关系单独加权为 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_size=1G dbms.memory.heap.max_size=2G dbms.memory.pagecache.size=1G dbms.default_listen_address=0.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.unrestricted=apoc.*,否则部分图算法调用会报“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 """, persons=persons) 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 """, relations=relations) 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字段名不能写成typerank这类 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 c>1 DELETE ...去重。第二句里的-[r]-()只匹配有关系的节点,能直观看出哪些角色是图的中心;第三句的-[*..6]-是无向路径,如果结果为空,尝试提高跳数或确认两个节点是否在同一连通分量内。这一组查询同时验证了导入脚本的方向设置是否正确。

4. Flask 后端与智能问答系统:把自然语言问句翻译成 Cypher 查询

4.1 Flask 蓝图路由设计与全图 JSON 输出

后端采用 Flask Blueprint 拆分模块,主要路由有三个:GET /api/graph返回节点和边,供 ECharts 首屏渲染;GET /api/person/<name>返回指定人物的邻接关系;POST /api/qa接收问句并返回答案。路由表如下:

路由方法入参返回
/api/graphGETnodes 与 edges 的 JSON
/api/person/<name>GETURL 中的 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(host="0.0.0.0", port=5000)

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 等模型,原因是图谱规模小,预先定义意图模板的准确率更高、部署成本更低,答辩时也更容易解释。处理流程分两步:先用人物词典找出问句里的人名,再用正则匹配意图。代码组织成一个 parser:

import 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_AUTH=neo4j/your_password - NEO4J_PLUGINS=["apoc"] ports: - "7474:7474" - "7687:7687" volumes: - neo4j_data:/data backend: build: ./backend environment: - NEO4J_URI=bolt://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 社区发现算法做派系划分。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 17:42:33

MCU/MPU/SoC选型陷阱:从物理层抖动到AXI总线瓶颈

1. 为什么工程师总在MCU、MPU、SoC之间反复横跳&#xff1f;我第一次被拉进紧急会议&#xff0c;是因为客户现场的温控设备连续三天凌晨三点自动重启。产线停了&#xff0c;售后电话被打爆&#xff0c;老板盯着我问&#xff1a;“你不是说用这颗STM32H7跑PIDModbusOTA够用了&am…

作者头像 李华
网站建设 2026/9/13 17:41:45

【数据结构】—顺序表专题

&#x1f60a; 笔者主页&#xff1a;ristarry &#x1f4d6; 数据结构专栏&#xff1a;数据结构 &#x1f4be; 本篇代码:顺序表专题 ✨ 纸上谈来终觉浅&#xff0c;觉知此事要躬行 计算机的学习好似登山&#xff0c;你敲下的每一段代码&#xff0c;掌握的每一个算法&#xff0…

作者头像 李华
网站建设 2026/9/13 17:40:02

uni-app教育培训小程序源码双端适配实战指南

简介&#xff1a;这是一套面向教育培训行业开发者的微信小程序与公众号双端源码解决方案&#xff0c;专为中小型培训机构、在线教育机构及教育类创业团队设计&#xff0c;解决课程管理、营销转化与用户运营一体化难题。资源包为77.27MB的ZIP压缩文件&#xff0c;含完整前后端代…

作者头像 李华
网站建设 2026/9/13 17:39:51

gVisor usermem 包详解:Sentry 如何安全访问应用虚拟内存

gVisor usermem 包详解&#xff1a;Sentry 如何安全访问应用虚拟内存 【免费下载链接】gvisor Application Kernel for Containers 项目地址: https://gitcode.com/GitHub_Trending/gv/gvisor 在 gVisor 中&#xff0c;Sentry&#xff08;沙箱内核&#xff09;运行在 Go…

作者头像 李华
网站建设 2026/9/13 17:39:45

嵌入式系统核心知识压缩:2小时直击时钟、GPIO、中断与RTOS

1. 这不是“速成”&#xff0c;是嵌入式系统知识骨架的紧急加固 “2小时期末速成”——看到这个标题&#xff0c;我第一反应不是点开&#xff0c;而是放下手头正在调试的STM32F407开发板&#xff0c;泡了杯浓茶。干了十多年嵌入式教学、企业级固件开发和研究生复试指导&#xf…

作者头像 李华