☰
基于知识图谱的红楼梦人物关系可视化与问答系统实战
2026/10/11 10:07:00 网站建设 项目流程

简介:这份资源是基于知识图谱的《红楼梦》人物关系可视化与问答系统完整源码,面向计算机、人工智能相关专业的毕业设计学生及知识图谱入门开发者,帮助解决从数据爬取、图谱构建到前端展示与智能问答的全流程实现问题。压缩包共248个文件,约5.83MB,以184张jpg人物图片、8个Python脚本、4个HTML页面及配套css、js样式文件为主,另含三元组数据、LTP模型配置与爬虫代码。系统以app.py为入口,neo_db模块负责图数据库的创建与查询,KGQA模块完成分词、命名实体识别与问答,spider模块已预生成人物资料,前端提供欢迎、搜索、全关系浏览与问答四类页面。目前已有782人学习下载,读者可据此掌握Neo4j图数据库部署、知识图谱三元组构建、自然语言问答与Flask前端整合的完整思路,适合作为毕设参考或知识图谱项目练手模板。

1. 从一份 zip 说起:红楼梦人物关系图谱到底能跑出什么

很多人第一次看到「基于知识图谱的《红楼梦》人物关系可视化及问答系统」这个标题,第一反应是:又是一个套壳的课程设计。但真把这份 zip 解压开、把数据灌进去、把前端点开,你会发现它解决的是一个非常具体的问题——贾府里几百号人,谁和谁是什么关系,光靠读原文根本理不清。这个系统要做的,是把散落在前八十回和后四十回里的称谓、亲属、主仆、姻亲关系抽出来,存进图数据库,再用一张可交互的网络图铺开,最后挂一个能用自然语言问「林黛玉的母亲是谁」的问答入口。

它适合三类人:一是想入门知识图谱但不知道拿什么数据练手的开发者,二是做数据可视化课程设计、需要一套完整可演示链路的学生,三是想给中文文本做实体关系抽取、又不想一上来就啃通用大模型的工程师。红楼梦的好处是人物封闭、关系密集、语料干净,标注成本低,跑通一遍就能把「抽取—存储—查询—可视化—问答」这条链路完整走一遍。这一章先把这套系统里到底有哪些模块、各自吃什么数据、产出什么结果讲清楚,后面几章再逐个拆开动手。

2. 知识图谱构建:从原文到三元组的抽取链路

2.1 为什么红楼梦适合做关系抽取的练手数据

红楼梦的人物关系有几个天然优势。第一,人物数量可控,主要人物加次要人物大约四百人上下,核心圈子不到一百人,不会像处理全网数据那样面对长尾爆炸。第二,关系类型明确,父子、母子、夫妻、兄弟、姐妹、主仆、丫鬟、亲戚这几类占了绝大多数,schema 设计不用太复杂。第三,称谓体系丰富,「老太太」「琏二奶奶」「林姑娘」这些称呼指向同一个人,正好用来练实体对齐和指代消解。

常见做法是先定一个关系 schema,再拿原文逐段抽。schema 不用一上来就追求完备,先覆盖四类:亲属关系(父、母、子、女、兄、弟、姐、妹、夫、妻)、主仆关系(主、仆)、姻亲关系(岳父、岳母、儿媳、女婿)、社交关系(朋友、师徒)。这四类能覆盖原文里八成以上的人物互动。剩下的「同窗」「诗社成员」这类弱关系,可以后面再补。

2.2 用 Python 做规则抽取的最小可跑脚本

规则抽取适合红楼梦这种半文半白、句式相对规整的文本。核心思路是:先做分句,再在句子里匹配「人名 + 关系词 + 人名」的模式。下面是一个能直接跑的最小脚本,输入是分好句的文本列表,输出是三元组列表。

import re # 关系词到关系类型的映射,按实际语料补充 RELATION_MAP = { "父亲": "father", "母亲": "mother", "儿子": "son", "女儿": "daughter", "哥哥": "brother", "弟弟": "brother", "姐姐": "sister", "妹妹": "sister", "丈夫": "husband", "妻子": "wife", "丫鬟": "servant", "仆人": "servant", } # 简单的人名表,实际项目里从人物词典加载 NAMES = ["贾宝玉", "林黛玉", "薛宝钗", "贾母", "王熙凤", "贾政", "王夫人", "贾琏"] def extract_triples(sentences): triples = [] for sent in sentences: # 找出句子里出现的所有人名 found = [n for n in NAMES if n in sent] if len(found) < 2: continue # 找出关系词 for word, rel in RELATION_MAP.items(): if word in sent: # 取关系词前后最近的人名作为主语和宾语 idx = sent.index(word) subj = [n for n in found if sent.index(n) < idx] obj = [n for n in found if sent.index(n) > idx] if subj and obj: triples.append((subj[-1], rel, obj[0])) return triples if __name__ == "__main__": sents = [ "贾政是贾宝玉的父亲。", "王夫人是贾宝玉的母亲。", "林黛玉的母亲是贾敏。", "王熙凤是贾琏的妻子。", ] for t in extract_triples(sents): print(t)

这段代码的逻辑很直白:先用人名表在句子里定位实体,再用关系词定位关系,最后按位置取最近的主语和宾语。参数上最需要调的是NAMES和RELATION_MAP,前者决定召回,后者决定精度。实际跑的时候你会发现两个问题:一是「贾敏」不在人名表里,抽不出来;二是「贾宝玉的父亲是贾政」这种倒装句,前后位置反了。解决办法是把人名表补全,并在抽取后加一步方向校验——如果关系是 father,主语应该是子女,宾语才是父亲。

2.3 实体对齐:把「林姑娘」和「林黛玉」合并成一个人

红楼梦里同一个人有多个称呼,这是实体对齐要解决的核心问题。常见做法是维护一张别名词典,把「林姑娘」「黛玉」「颦儿」都映射到「林黛玉」。别名词典可以手工整理,也可以从原文里用共现统计自动发现——如果两个称呼经常出现在同一段且指向同一动作,大概率是同一个人。

ALIAS = { "林黛玉": ["林姑娘", "黛玉", "颦儿", "潇湘妃子"], "贾宝玉": ["宝玉", "宝二爷", "怡红公子"], "薛宝钗": ["宝钗", "宝姑娘", "蘅芜君"], } def normalize(name): for std, aliases in ALIAS.items(): if name == std or name in aliases: return std return name

对齐之后,所有三元组里的实体名都统一成标准名,图谱才不会出现「林黛玉」和「林姑娘」两个孤立节点。这一步不做,后面可视化和问答都会出问题——你问「林黛玉的母亲是谁」,系统答不出来,因为数据里存的是「林姑娘的母亲是贾敏」。

3. 图数据库选型与存储:Neo4j 还是 NetworkX

3.1 两种存储方案的适用边界

红楼梦人物关系图谱的数据量不大,核心三元组大概几千条,这个规模下 Neo4j 和 NetworkX 都能跑。区别在于:Neo4j 是图数据库,支持 Cypher 查询,适合做问答系统的后端,能直接写「查某人的所有子女」这种查询;NetworkX 是内存图库,适合做分析和可视化,但不适合做持久化查询服务。

我的建议是两者都用:用 NetworkX 做数据清洗和初步分析,确认图谱结构没问题后,导入 Neo4j 做查询后端。如果只是做课程设计、不要求在线问答,NetworkX 加一个前端就够了,省去装数据库的麻烦。

3.2 用 Cypher 建节点和关系

Neo4j 的建图语句很直观,节点用MERGE避免重复,关系用CREATE或MERGE都行。下面是一段可以直接在 Neo4j Browser 里跑的 Cypher。

// 建人物节点,name 作为唯一约束 CREATE CONSTRAINT person_name IF NOT EXISTS FOR (p:Person) REQUIRE p.name IS UNIQUE; // 批量建节点 UNWIND ["贾宝玉", "林黛玉", "薛宝钗", "贾母", "王熙凤", "贾政", "王夫人", "贾琏"] AS name MERGE (p:Person {name: name}); // 建关系,注意方向:子女 -> 父母 MATCH (a:Person {name: "贾宝玉"}), (b:Person {name: "贾政"}) MERGE (a)-[:FATHER]->(b); MATCH (a:Person {name: "贾宝玉"}), (b:Person {name: "王夫人"}) MERGE (a)-[:MOTHER]->(b); MATCH (a:Person {name: "林黛玉"}), (b:Person {name: "贾敏"}) MERGE (a)-[:MOTHER]->(b);

参数上要注意的是关系方向。我一般约定:亲属关系里,子女指向父母,妻子指向丈夫,仆人指向主人。这样查「贾宝玉的父亲」就是MATCH (a:Person {name:"贾宝玉"})-[:FATHER]->(b) RETURN b.name,方向统一,写查询的时候不容易搞混。如果方向乱了,后面问答模块的模板就得为每种关系单独写方向,维护成本高。

3.3 从 CSV 批量导入的完整流程

手工写 Cypher 只适合演示,实际项目里三元组是脚本生成的,通常先落成 CSV,再用LOAD CSV导入。CSV 格式建议两列:subject, relation, object,一行一条三元组。

# 把 CSV 放到 Neo4j 的 import 目录下 cp triples.csv $NEO4J_HOME/import/
// 导入三元组,动态创建关系类型 LOAD CSV WITH HEADERS FROM 'file:///triples.csv' AS row MERGE (s:Person {name: row.subject}) MERGE (o:Person {name: row.object}) WITH s, o, row CALL apoc.merge.relationship(s, row.relation, {}, {}, o) YIELD rel RETURN count(rel);

这里用了 APOC 库的apoc.merge.relationship,因为 Cypher 原生不支持动态关系类型。如果没装 APOC,就得把关系类型写死,每种关系一条导入语句。导入完成后跑一句MATCH (n) RETURN count(n)确认节点数,再跑MATCH ()-[r]->() RETURN count(r)确认关系数,两个数对得上再往下做可视化。

4. 可视化落地:ECharts 关系图怎么调才不糊

4.1 从 Neo4j 取数据到前端渲染的链路

可视化的数据流一般是:Neo4j 查询出节点和边,后端(Flask 或 FastAPI)转成 JSON,前端用 ECharts 的 graph 类型渲染。JSON 结构建议固定成{nodes: [{id, name, category}], links: [{source, target, relation}]},这样前端不用做额外转换。

from flask import Flask, jsonify from neo4j import GraphDatabase app = Flask(__name__) driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) @app.route("/graph") def graph(): with driver.session() as session: nodes = session.run("MATCH (n:Person) RETURN n.name AS name") links = session.run("MATCH (a)-[r]->(b) RETURN a.name AS s, type(r) AS r, b.name AS t") node_list = [{"id": r["name"], "name": r["name"]} for r in nodes] link_list = [{"source": r["s"], "target": r["t"], "relation": r["r"]} for r in links] return jsonify({"nodes": node_list, "links": link_list}) if __name__ == "__main__": app.run(debug=True)

这段后端代码只做一件事:把图数据转成前端能吃的 JSON。参数上要注意driver的连接串和密码,本地默认是bolt://localhost:7687,密码在 Neo4j 首次启动时设置。如果连不上,先确认 Neo4j 服务在跑,再确认防火墙没拦 7687 端口。

4.2 ECharts 关系图的三个必调参数

ECharts 的 graph 系列参数很多,但决定「能不能看」的只有三个:layout、repulsion、edgeLabel。

option = { series: [{ type: 'graph', layout: 'force', // 力导向布局,适合人物关系 force: { repulsion: 300, // 节点斥力,太小会挤成一团 edgeLength: 120, // 边长,控制整体松散程度 gravity: 0.1 // 向心力,防止节点飘出画布 }, roam: true, // 允许缩放和拖拽 label: { show: true, position: 'right' }, edgeLabel: { show: true, formatter: function (p) { return p.data.relation; } // 边上显示关系名 }, lineStyle: { curveness: 0.2 }, // 边稍微弯曲,避免重叠 data: nodes, links: links }] };

repulsion是最关键的参数。默认值往往太小,红楼梦这种密度稍高的图会挤成一坨黑块。我一般从 300 开始调,节点多就往上加,节点少就往下减。edgeLength控制边的理想长度,配合repulsion一起调,目标是让核心人物(贾宝玉、林黛玉、贾母)在视觉上聚在中间,边缘人物散在外围。curveness设 0.2 左右能让双向关系(比如夫妻)的两条边不重叠。

4.3 大图降噪:只显示核心人物子图

红楼梦全图四百多人,全画出来没法看。常见做法是做子图过滤:只显示度数大于某个阈值的节点,或者只显示某个核心人物的 N 跳邻居。下面这段是在后端做过滤的示例。

@app.route("/graph/<name>") def subgraph(name): with driver.session() as session: # 取该人物两跳以内的邻居 result = session.run(""" MATCH (n:Person {name: $name})-[*1..2]-(m:Person) RETURN DISTINCT m.name AS name """, name=name) names = [r["name"] for r in result] # 再查这些节点之间的关系 links = session.run(""" MATCH (a:Person)-[r]->(b:Person) WHERE a.name IN $names AND b.name IN $names RETURN a.name AS s, type(r) AS r, b.name AS t """, names=names) # 组装返回...

两跳是个经验值。一跳只能看到直系亲属,信息太少;三跳基本就回到全图了。两跳能覆盖「贾宝玉—贾政—贾母」这种隔代关系,也能带出丫鬟和主子的连接,视觉上信息量和可读性平衡得比较好。

5. 问答系统:把自然语言问题翻译成 Cypher

5.1 模板匹配 vs 意图分类的取舍

问答模块有两种做法:模板匹配和意图分类。模板匹配是预先写好「XX 的父亲是谁」对应哪条 Cypher,用户问题命中模板就执行;意图分类是训练一个分类器,把问题分到预定义意图,再填槽执行。红楼梦这种封闭领域,模板匹配足够用,而且可控性强,不会出现分类器把「母亲」错分成「亲戚」的情况。

模板匹配的核心是设计一套覆盖常见问法的正则。下面是一个最小实现。

import re TEMPLATES = [ (r"(?P<name>.+?)的父亲是谁", "MATCH (a:Person {name: $name})-[:FATHER]->(b) RETURN b.name AS answer"), (r"(?P<name>.+?)的母亲是谁", "MATCH (a:Person {name: $name})-[:MOTHER]->(b) RETURN b.name AS answer"), (r"(?P<name>.+?)的妻子是谁", "MATCH (a:Person {name: $name})-[:WIFE]->(b) RETURN b.name AS answer"), (r"(?P<name>.+?)的子女有哪些", "MATCH (a:Person)-[:FATHER|MOTHER]->(b:Person {name: $name}) RETURN a.name AS answer"), ] def answer(question): for pattern, cypher in TEMPLATES: m = re.match(pattern, question) if m: name = m.group("name") with driver.session() as session: result = session.run(cypher, name=name) return [r["answer"] for r in result] return ["暂时回答不了这个问题"]

参数上最关键的是正则里的(?P<name>.+?),非贪婪匹配能正确截出人名。如果写成贪婪的.+,「贾宝玉的母亲是谁」可能把「贾宝玉的母亲」整段当成人名。另外模板顺序有讲究,更具体的模板要排在前面,否则会被泛化模板截胡。

5.2 处理「是谁」和「有哪些」的返回差异

「是谁」期望返回单个答案,「有哪些」期望返回列表。上面代码统一返回列表,前端再根据问题类型决定展示方式。如果问「贾宝玉的父亲是谁」返回了多个,说明数据里有重复关系,需要去重。如果问「贾母的子女有哪些」返回空,先检查关系方向——子女指向父母,所以查贾母的子女应该是MATCH (a)-[:FATHER|MOTHER]->(b {name:"贾母"}),方向反了就查不到。

5.3 问答和可视化的联动

好的问答系统不只是返回文字,还能把答案对应的子图高亮出来。做法是问答接口除了返回答案文本,再返回涉及的节点 ID,前端拿到 ID 后在 ECharts 里把对应节点放大或变色。这样用户问「林黛玉的母亲是谁」,不仅看到「贾敏」两个字,还能在图上看到林黛玉和贾敏之间的连线被点亮,体验完整很多。

6. 避坑与排查:跑这套系统最容易翻车的五个地方

6.1 现象:图谱里同一个人出现两个节点

原因:实体对齐没做,或者别名词典不全。「林黛玉」和「林姑娘」被当成两个人建了节点。

解决:在导入 Neo4j 之前,所有三元组的实体名先过一遍normalize函数。别名词典要覆盖原文里出现过的所有称呼,包括「老太太」「琏二奶奶」这类身份称呼。如果拿不准某个称呼指谁,回原文查上下文,不要猜。

6.2 现象:ECharts 图渲染出来是一团黑

原因:repulsion太小,节点斥力不够,全部挤在中心。

解决:把repulsion从默认值往上调,先试 300,不够再试 500、800。同时把edgeLength调大,让边有伸展空间。如果节点超过 200 个,建议先做子图过滤,不要硬渲染全图。

6.3 现象:问答返回空结果,但数据明明存在

原因:关系方向搞反了。比如查「贾宝玉的父亲」,Cypher 写成了(b)-[:FATHER]->(a),方向反了自然查不到。

解决:统一关系方向约定,子女指向父母,妻子指向丈夫,仆人指向主人。写查询前先MATCH (a)-[r]->(b) RETURN a.name, type(r), b.name LIMIT 10看一眼实际方向,确认后再写查询。

6.4 现象:LOAD CSV 导入报错,提示找不到文件

原因:Neo4j 的LOAD CSV只能读 import 目录下的文件,不能读任意路径。

解决:把 CSV 复制到$NEO4J_HOME/import/目录下,Cypher 里用file:///triples.csv引用。如果还报错,检查文件权限和 Neo4j 的dbms.directories.import配置。

6.5 现象:问答模板匹配到错误的人名

原因:正则用了贪婪匹配,或者模板顺序不对。

解决:人名捕获组用非贪婪的.+?,更具体的模板排在前面。测试时至少覆盖「贾宝玉」「林黛玉」「王熙凤」三个不同姓氏的人物,确认都能正确截取。

7. 进阶技巧:用图谱做人物中心性分析

跑通基础链路之后,一个值得做的进阶方向是人物中心性分析。红楼梦里谁才是真正的核心?直觉上说是贾宝玉,但用图谱算一下度中心性、介数中心性,结果可能不一样。度中心性看谁的关系最多,介数中心性看谁处在最多最短路径上——后者往往能挖出王熙凤这种「关系枢纽」型人物。

import networkx as nx from neo4j import GraphDatabase driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) G = nx.DiGraph() with driver.session() as session: for r in session.run("MATCH (a)-[r]->(b) RETURN a.name AS s, b.name AS t"): G.add_edge(r["s"], r["t"]) # 度中心性:谁的关系最多 degree = nx.degree_centrality(G) # 介数中心性:谁处在最多最短路径上 betweenness = nx.betweenness_centrality(G) top_degree = sorted(degree.items(), key=lambda x: x[1], reverse=True)[:10] top_between = sorted(betweenness.items(), key=lambda x: x[1], reverse=True)[:10] print("度中心性 Top10:", top_degree) print("介数中心性 Top10:", top_between)

跑出来你会发现,度中心性高的往往是贾母、贾政这种长辈,因为亲属关系多;介数中心性高的可能是王熙凤,因为她连接了荣国府和宁国府两条线。这个结果可以直接做成一张对比表放进可视化页面,比单纯画关系图更有分析深度。

指标含义红楼梦里的典型人物
度中心性直接关系数量贾母、贾政
介数中心性最短路径经过次数王熙凤、贾琏
接近中心性到其他节点的平均距离贾宝玉

验证方法很简单:把算出来的 Top10 和原文里的人物戏份对比,如果中心性高但戏份少,说明数据里可能有冗余关系;如果戏份多但中心性低,说明关系抽取有遗漏。这个交叉验证能帮你发现抽取环节的问题。

我自己做这套系统最大的教训是:别一上来就追求全自动抽取。红楼梦的语料量不大,手工整理一份核心人物的关系表,比调半天规则抽取的准确率还高。先把核心一百人的关系跑通,可视化能看、问答能答,再考虑用模型去扩召回。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询