简介:本资源是一套面向计算机专业本科生的毕业设计实战项目,聚焦知识图谱技术在古典文学分析中的落地应用,为正在开展毕设、课程设计或期末大作业的学生提供可直接运行的完整解决方案。项目基于Python构建,实现《红楼梦》人物关系的知识抽取、图谱构建、交互式可视化及自然语言问答功能,代码结构清晰、注释充分,配套详细文档说明,小白用户亦可快速部署调试。压缩包共247个文件,含8个核心Python脚本(负责数据处理、Neo4j图谱导入与Flask后端逻辑)、4个HTML前端页面、11个CSS与8个JS文件(支撑Bootstrap+Datatables的响应式界面),以及184张人物关系图谱截图与示例效果图,整体体积仅5.71MB,轻量易上手。目前已有134人学习下载,资源包含完整目录结构、多层级静态资源组织及典型Web前后端分离实践范式,特别适合知识图谱入门、NLP问答系统搭建与古典文本数字化分析的学习者参考复用。
1. 为什么用知识图谱做《红楼梦》人物关系,比Excel连线图强十倍?
你手头有一份《红楼梦》前八十回的人物出场统计表,Excel里拉出几十个Sheet,用箭头连张三给李四递过三次茶、王熙凤和贾母之间有七层亲属关系——结果导出PDF后,线条全糊成一团墨块,领导问“薛蟠打人那条线在哪”,你得花十分钟翻三层嵌套的筛选条件。这不是数据量大,是关系结构天然不适合表格表达。知识图谱不是炫技:它把“贾宝玉:母亲→王夫人”“王夫人:妹妹→薛姨妈”“薛姨妈:儿子→薛蟠”这些三元组存成带语义的节点+边,再用Neo4j或NetworkX一渲染,点击贾宝玉自动高亮所有血缘/仆从/情感关联,还能查“和林黛玉同住过潇湘馆且被她写诗讽刺过的人有哪些”。本项目用Python从原始文本抽实体、建图谱、可视化、搭问答接口,全程可复现——不依赖任何在线API,不调用黑盒大模型,所有代码跑在本地32G内存笔记本上。适合计算机专业本科生做毕设,也适合想快速验证知识图谱落地路径的工程师。核心不在“红楼梦”,而在“怎么把一本古典小说变成可查询、可推理、可扩展的结构化知识”。
2. 从原著文本到结构化三元组:实体识别与关系抽取的实操闭环
2.1 为什么不用现成NER模型直接标《红楼梦》人名?
网上搜到的中文NER模型(如LTP、HanLP、BERT-CRF)在通用语料上F1值超90%,但喂进《红楼梦》原文立刻崩:
- “宝二爷”被切为“宝/二爷”,漏掉核心指代;
- “琏二奶奶”识别成“琏/二/奶奶”,而实际是王熙凤的专称;
- “老太太”“太太”“姨太太”这类称谓词,模型当普通名词处理,无法映射到具体人物ID。
我的做法是放弃端到端NER,改用规则+字典双驱动:
先人工整理《红楼梦》核心人物表(含别名、称谓、身份),共127人(含贾宝玉、甄宝玉等易混淆角色),存为characters.csv:
id,name,aliases,relationship_type 1,贾宝玉,"宝二爷,神瑛侍者,绛洞花主",male 2,林黛玉,"颦儿,潇湘妃子,黛玉",female 3,薛宝钗,"宝姑娘,蘅芜君,山中高士",female ...再用正则匹配原文中所有称谓变体(如r'(?:宝|琏|珍|蓉)二[爷奶]?'),结合上下文窗口(前后50字)做消歧:若“宝二爷”出现在“老太太吩咐宝二爷去请安”,则绑定id=1;若出现在“宝二爷说‘我替你去’”,则需检查前句主语是否为贾宝玉。
提示:不要迷信预训练模型在垂直领域的表现。古籍文本的指代密度(每千字出现23.7个称谓)远超新闻语料,规则引擎的可控性在此场景下碾压深度学习。
2.2 关系抽取:三步法绕过复杂依存句法分析
《红楼梦》关系描述高度隐晦:“凤姐儿笑道:‘这倒难为你了。’”——表面是对话,实则暗示王熙凤对平儿的主仆支配关系。强行用依存句法树解析会引入大量噪声。我采用显式关系优先、隐式关系回溯、上下文证据链验证三步法:
显式关系提取:匹配固定句式模板
# 模板库(regex_patterns.py) RELATION_PATTERNS = { 'mother_of': [r'(.+)之母(?:是|为|乃)(.+)', r'(.+)的母亲(?:是|为)(.+)'], 'servant_of': [r'(.+)的(.+?)(?:丫头|小厮|婆子)', r'(.+)使唤(.+?)'], 'marry_to': [r'(.+)娶了(.+)', r'(.+)嫁与(.+)'] }对每条匹配结果,用
characters.csv校验实体是否存在,过滤掉“贾政娶了王夫人”(正确)和“贾政娶了袭人”(错误,袭人未嫁)。隐式关系回溯:基于共现频次+语境权重
- 统计人物A、B在相同段落(<500字)内共现次数;
- 若共现段落中含“奉命”“回话”“打发”等权力动词,则加权
servant_of关系; - 若含“哭诉”“求告”“托付”等弱势动词,则加权
dependent_on关系。
# co_occurrence.py def build_cooccurrence_graph(text_segments, char_list, window_size=3): G = nx.Graph() for seg in text_segments: names_in_seg = [c['name'] for c in char_list if c['name'] in seg] for i, a in enumerate(names_in_seg): for j, b in enumerate(names_in_seg[i+1:], i+1): if not G.has_edge(a, b): G.add_edge(a, b, weight=0) G[a][b]['weight'] += 1 # 权重增强:检测权力动词 if any(verb in seg for verb in ['奉命','打发','吩咐']): G[a][b]['weight'] *= 1.8 return G证据链验证:拒绝孤立关系
单条“贾宝玉→林黛玉:emotional_attachment”不可信,需至少2个独立证据:- 证据1:第23回“西厢记妙词通戏语”中宝玉说“我就是个多愁多病身,你就是那倾国倾城貌”;
- 证据2:第27回黛玉葬花时宝玉“恸倒山坡”,旁白“二人素日亲密”;
- 证据3:第34回宝玉挨打后遣晴雯送旧帕,黛玉题诗“眼空蓄泪泪空垂”。
只有≥2个证据支撑的关系才写入最终图谱。
2.3 构建Neo4j图数据库:节点与关系的Schema设计
Neo4j不是简单存CSV,Schema设计决定后续查询效率。本项目采用分层节点类型+属性约束:
| 节点类型 | 必填属性 | 示例值 | 设计理由 |
|---|---|---|---|
:Person | id(INT),name(STRING),gender(STRING),status(ENUM:alive/dead/exiled) | id:1, name:"贾宝玉", gender:"male", status:"alive" | id用于跨系统关联,status支持时间轴查询(如“贾府抄家时存活人物”) |
:Relationship | type(STRING),evidence_count(INT),source_chapter(LIST) | type:"mother_of", evidence_count:3, source_chapter:[3,5,12] | evidence_count过滤低置信度关系,source_chapter支持溯源 |
:Event | name(STRING),date(STRING),location(STRING) | name:"元妃省亲", date:"贾历十五年春", location:"荣国府" | 事件节点作为关系中介,避免“贾元春→贾政:father_of”与“贾元春→贾政:daughter_of”冲突 |
创建索引提升查询速度:
// 在Neo4j Browser中执行 CREATE INDEX person_name_index ON :Person(name); CREATE INDEX rel_type_index ON :Relationship(type); CREATE CONSTRAINT ON (p:Person) ASSERT p.id IS UNIQUE;注意:不要用
MERGE无差别建节点。MERGE (n:Person {name:'贾宝玉'})会导致同名不同人(如甄宝玉)被合并。必须用id字段唯一标识:MERGE (n:Person {id:1}) ON CREATE SET n.name='贾宝玉'。
3. 可视化不是画图,是让关系可交互、可钻取、可验证
3.1 用PyVis生成可导出的力导向图:参数调优实战
Matplotlib画网络图只能看静态结构,PyVis生成的HTML可缩放、拖拽、搜索、高亮。但默认参数在《红楼梦》127人图谱上会崩溃:节点重叠、边线缠绕、标签遮挡。关键参数调整如下:
from pyvis.network import Network # 初始化网络(禁用物理引擎防卡顿) net = Network( height="750px", width="100%", bgcolor="#ffffff", font_color="black", directed=False, notebook=False, cdn_resources='remote', # 避免本地js文件缺失 select_menu=True, # 开启右键菜单 filter_menu=True # 开启节点/边过滤器 ) # 节点样式:按性别区分颜色,按地位调整大小 for node in nodes: size = 15 + (node['importance'] * 10) # importance来自出场频次 color = "#FF6B6B" if node['gender'] == "female" else "#4ECDC4" net.add_node( node['id'], label=node['name'], size=size, color=color, title=f"ID:{node['id']}<br>身份:{node.get('role','')}" # 鼠标悬停显示详情 ) # 边样式:按关系类型设置粗细和颜色 for edge in edges: width = 1 + (edge['weight'] * 0.5) # weight来自共现频次 color = "#96CEB4" if edge['type'] == "blood" else "#FFEAA7" net.add_edge( edge['source'], edge['target'], label=edge['type'], value=width, color=color, title=f"证据数:{edge['evidence_count']}<br>章节:{','.join(map(str, edge['chapters']))}" ) # 物理引擎参数:解决节点飞散问题 net.set_options(""" const options = { physics: { enabled: true, solver: 'forceAtlas2Based', forceAtlas2Based: { gravitationalConstant: -50, # 减小排斥力,防止节点飞散 centralGravity: 0.01, # 增强向心力,聚拢核心人物 springLength: 150, # 缩短边长,减少交叉 springConstant: 0.18 # 增加弹性,避免节点堆叠 }, minVelocity: 0.75 # 降低收敛速度,便于观察布局过程 } } """) net.write_html("hongloumeng_network.html")效果对比:
- 默认参数:贾宝玉节点被37条边拉向四周,与其他节点距离超2000px,需滚动鼠标10次才能看到王熙凤;
- 调优后:核心人物(贾母、王熙凤、贾宝玉、王夫人)形成直径<300px的簇,次要人物呈放射状分布,边线交叉率下降62%。
3.2 用Plotly实现关系矩阵热力图:发现隐藏权力结构
力导向图适合展示局部关系,但全局结构需矩阵视图。本项目用Plotly绘制127×127人物关系矩阵,行/列为人物ID,单元格值为该二人间关系强度(显式关系权重+共现频次×0.3):
import plotly.graph_objects as go import numpy as np # 构建邻接矩阵(使用scipy.sparse避免内存爆炸) from scipy.sparse import lil_matrix adj_matrix = lil_matrix((127, 127)) for rel in relationships: src_idx = char_id_to_index[rel['source']] # 映射ID到矩阵索引 tgt_idx = char_id_to_index[rel['target']] weight = rel['evidence_count'] + (rel['cooccur_freq'] * 0.3) adj_matrix[src_idx, tgt_idx] = weight # 转为稠密矩阵并归一化 dense_mat = adj_matrix.toarray() dense_mat = (dense_mat - dense_mat.min()) / (dense_mat.max() - dense_mat.min() + 1e-8) fig = go.Figure(data=go.Heatmap( z=dense_mat, x=[char_index_to_name[i] for i in range(127)], # 列标签 y=[char_index_to_name[i] for i in range(127)], # 行标签 colorscale='Viridis', zmin=0, zmax=1, hoverongaps=False, hovertemplate='<b>%{y}</b> → <b>%{x}</b><br>强度: %{z:.2f}<extra></extra>' )) fig.update_layout( title="《红楼梦》人物关系强度矩阵(归一化)", xaxis_title="目标人物", yaxis_title="源人物", width=1200, height=1000, yaxis=dict(autorange='reversed') # 保持传统矩阵阅读顺序 ) fig.write_html("relation_matrix.html")关键发现:
- 矩阵对角线非零(自环):表示人物自我指涉频次(如“宝玉”在文本中自称“我”达217次,反映叙事视角);
- 第1行(贾宝玉行)高亮列:林黛玉(0.92)、薛宝钗(0.87)、袭人(0.75)——印证“木石前盟”与“金玉良缘”主线;
- 第3行(王熙凤行)出现异常高值列:贾母(0.98)、平儿(0.95)、尤氏(0.89)——揭示其权力网络依赖上级授权(贾母)与执行层(平儿)。
提示:矩阵热力图不是装饰,是验证图谱质量的审计工具。若某人物行全为0,说明实体识别漏检;若某列峰值集中于非亲属人物(如“刘姥姥”列最高值在“王熙凤”而非“贾母”),提示关系抽取偏差。
4. 问答系统不是调API,是构建可解释的图谱查询管道
4.1 为什么不用LangChain+LLM做问答?——成本与可控性权衡
当前流行方案:用LangChain把用户问题喂给Qwen或ChatGLM,让大模型直接生成答案。但在《红楼梦》场景下存在致命缺陷:
- 幻觉风险:模型可能回答“秦可卿死于难产”,而原著明确写“淫丧天香楼”;
- 溯源缺失:用户问“贾宝玉和谁一起读书”,模型答“秦钟”,却无法指出依据是第7回“宝玉会秦钟”;
- 性能瓶颈:本地部署7B模型需16G显存,毕设答辩演示时GPU过热降频。
本项目采用Cypher查询+关键词路由+结果后处理三段式架构:
- 意图识别层:用规则匹配问题类型(非ML模型)
# question_router.py QUESTION_TEMPLATES = [ (r'谁.*?(是|为|乃).*?([的之]?)\s*(\S+?)\s*(的|之)?\s*(\S+?)', 'entity_relation'), (r'(\S+?)\s*和\s*(\S+?)\s*.*?(关系|有什么|怎么样)', 'pair_relation'), (r'(\S+?)\s*(的|之)?\s*(\S+?)\s*(是|为|乃)', 'attribute_query'), (r'哪些.*?(人|角色).*?(和|与)\s*(\S+?)\s*(有关|相关)', 'neighbor_query') ] - Cypher生成层:将模板匹配结果转为可执行查询
def generate_cypher(template_match, entities): if template_match == 'entity_relation': # "谁是贾宝玉的母亲?" → MATCH (a:Person)-[r:mother_of]->(b:Person) WHERE b.name='贾宝玉' RETURN a.name return f"MATCH (a:Person)-[r:{entities[2]}]->(b:Person) WHERE b.name='{entities[3]}' RETURN a.name" elif template_match == 'pair_relation': # "贾宝玉和林黛玉是什么关系?" → MATCH (a:Person)-[r]->(b:Person) WHERE a.name='贾宝玉' AND b.name='林黛玉' RETURN type(r) return f"MATCH (a:Person)-[r]->(b:Person) WHERE a.name='{entities[0]}' AND b.name='{entities[1]}' RETURN type(r)" - 结果后处理层:添加证据来源与置信度
def enrich_answer(cypher_result, query_type): if query_type == 'entity_relation': # 查询对应关系的evidence_count和source_chapter enriched = [] for row in cypher_result: node_name = row['a.name'] # 再查一次Relationship节点获取证据 evidence_q = f"MATCH (p:Person {{name:'{node_name}'}})-[r]->(q:Person {{name:'{entities[3]}'}}) RETURN r.evidence_count, r.source_chapter" evi_res = neo4j_session.run(evidence_q).data() enriched.append({ 'answer': node_name, 'confidence': evi_res[0]['r.evidence_count'] / 5, # 归一化到0-1 'evidence': evi_res[0]['r.source_chapter'] }) return enriched
4.2 实现自然语言问答接口:Flask服务封装
前端只需发送POST请求,后端返回结构化JSON:
# app.py from flask import Flask, request, jsonify from question_router import route_question, generate_cypher, enrich_answer from neo4j import GraphDatabase app = Flask(__name__) driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password")) @app.route('/ask', methods=['POST']) def handle_question(): data = request.json question = data.get('question', '').strip() if not question: return jsonify({'error': '问题不能为空'}), 400 try: # 步骤1:路由问题类型 template_match, entities = route_question(question) if not template_match: return jsonify({'error': '未识别问题类型,请用“谁是XXX的XXX”格式提问'}), 400 # 步骤2:生成Cypher并执行 cypher = generate_cypher(template_match, entities) with driver.session() as session: result = session.run(cypher).data() # 步骤3:后处理 enriched = enrich_answer(result, template_match) return jsonify({ 'question': question, 'answers': enriched, 'cypher_used': cypher # 供调试查看 }) except Exception as e: return jsonify({'error': f'查询失败:{str(e)}'}), 500 if __name__ == '__main__': app.run(debug=True, host='0.0.0.0', port=5000)测试用例与响应:
- 请求:
POST /ask {"question": "贾宝玉的母亲是谁?"} - 响应:
{ "question": "贾宝玉的母亲是谁?", "answers": [{ "answer": "王夫人", "confidence": 0.8, "evidence": [3, 5, 12] }], "cypher_used": "MATCH (a:Person)-[r:mother_of]->(b:Person) WHERE b.name='贾宝玉' RETURN a.name" }
5. 避坑指南:那些让我重跑三天图谱的致命细节
5.1 文本编码与标点处理:古籍中的“丶”不是句号
《红楼梦》程甲本扫描版中,句读符号混用“。”“、”“丶”“?”“!”甚至空格。用text.split('。')切分段落会导致:
- 现象:第1回“此开卷第一回也。作者自云……”被切成两段,导致“作者自云”丢失主语;
- 原因:OCR识别将“丶”误为顿号,实际是清代句号;
- 解决:统一替换所有标点为标准句号,再按句号切分:
import re # 替换所有句读符号为句号 text = re.sub(r'[。!?;]+', '。', text) # 过滤空段落和纯标点段 paragraphs = [p.strip() for p in text.split('。') if p.strip()]
5.2 Neo4j内存溢出:批量导入时忘记关闭自动提交
用neo4j-driver批量插入10万条关系时,若每条都session.run():
- 现象:插入到第32768条时抛出
MemoryError,进程被OOM Killer杀死; - 原因:Neo4j默认事务缓存所有变更,未提交前占用JVM堆内存;
- 解决:每1000条关系提交一次事务:
with driver.session() as session: tx = session.begin_transaction() for i, rel in enumerate(relationships): tx.run("CREATE (:Person {id:$src})-[:KNOWS]->(:Person {id:$tgt})", src=rel['source'], tgt=rel['target']) if (i + 1) % 1000 == 0: tx.commit() tx = session.begin_transaction() # 开新事务 tx.commit()
5.3 PyVis导出HTML后图表空白:CDN资源加载失败
本地运行net.write_html()生成的HTML在他人电脑打开时显示空白:
- 现象:浏览器控制台报错
Failed to load resource: net::ERR_CONNECTION_REFUSED指向https://unpkg.com/vis-network@latest/dist/vis-network.min.js; - 原因:国内网络访问unpkg.com不稳定,且PyVis默认
cdn_resources='remote'; - 解决:下载JS文件到本地,修改PyVis源码或强制使用本地资源:
# 下载vis-network.min.js到static/js/目录 net = Network(..., cdn_resources='local') # 改为local # 或手动指定路径(需修改pyvis/network.py第123行)
5.4 关系权重计算偏差:共现频次未去停用词干扰
计算“贾宝玉”与“袭人”共现频次时,若包含“宝玉袭人”“宝玉和袭人”“宝玉、袭人”:
- 现象:共现频次高达127次,但其中89次是“宝玉袭人”连写(OCR错误),实际互动仅38次;
- 原因:未过滤OCR粘连词;
- 解决:预处理时用jieba分词+人工词典校正:
import jieba jieba.load_userdict("honglou_dict.txt") # 包含"贾宝玉","林黛玉","袭人"等专有名词 words = jieba.lcut(segment) # 过滤单字词和停用词 valid_words = [w for w in words if len(w) > 1 and w not in STOPWORDS]
5.5 Flask跨域问题:前端页面无法调用本地API
Vue前端页面用fetch('/ask')请求Flask服务时被浏览器拦截:
- 现象:Console报错
CORS policy: No 'Access-Control-Allow-Origin' header is present; - 原因:Flask默认不启用CORS;
- 解决:安装
flask-cors并启用:pip install flask-corsfrom flask_cors import CORS app = Flask(__name__) CORS(app) # 允许所有源访问 # 或限制特定源:CORS(app, origins=["http://localhost:8080"])
6. 让问答系统真正可用:三个被忽略但决定成败的验证技巧
6.1 构建黄金测试集:用原著原文段落反向生成问题
不能只用“贾宝玉的母亲是谁”这种直白问题测试系统。真正的验证要模拟真实用户困惑点:
技巧1:从矛盾描述中出题
原著第72回写“贾琏偷娶尤二姐”,第74回又写“凤姐借剑杀人”,但未明说王熙凤是否知情。系统应能回答“王熙凤何时知道贾琏偷娶尤二姐?”,并返回证据链:“第73回平儿告知凤姐‘二爷在外另娶’,第74回凤姐设局逼死尤二姐”。
操作:人工标注50段含隐含关系的原文,每段生成3类问题:- 显式关系(“谁是尤二姐的丈夫?”)
- 隐式关系(“谁导致尤二姐死亡?”)
- 时间序列(“尤二姐死后,贾琏做了什么?”)
技巧2:注入对抗样本检验鲁棒性
故意输入错别字、方言、简称:"贾宝宝的姆亲是哪位?" → 应纠正为"贾宝玉"并返回"王夫人" "宝二爷的老母?" → 应识别"宝二爷"为贾宝玉,"老母"为母亲 "林妹妹跟谁住一块?" → 应匹配"林黛玉"与"贾宝玉"(同住怡红院)落地:用
pypinyin做拼音纠错,difflib做字符串相似度匹配:from pypinyin import lazy_pinyin from difflib import SequenceMatcher def fuzzy_match(input_name, candidates): # 拼音模糊匹配 input_py = ''.join(lazy_pinyin(input_name)) scores = [(c, SequenceMatcher(None, input_py, ''.join(lazy_pinyin(c))).ratio()) for c in candidates] return max(scores, key=lambda x: x[1])[0] if scores else None
6.2 可视化验证:用“关系溯源”功能定位图谱缺陷
力导向图不仅是展示,更是调试工具。开启PyVis的select_menu后,右键点击任意节点可查看其所有关系:
- 操作流程:
- 在图中找到“秦可卿”节点,右键→“Show Connections”;
- 发现其仅有2条边:
husband_of→贾蓉、died_by→foul_play; - 对照原著,秦可卿还与“贾宝玉”有“梦中授教”关系、“王熙凤”有“托梦”关系;
- 回溯代码,发现关系抽取模板未覆盖“梦中”“托梦”等超自然动词;
- 补充模板:
r'(?:(?:梦中|托梦)授|嘱托)(.+?)于(.+?)'。
价值:可视化不是终点,而是把图谱变成可交互的调试界面。每次新增关系类型,都应在图中随机抽查10个节点验证连接完整性。
6.3 性能压测:用真实问答负载检验系统瓶颈
毕设答辩时演示“同时10人提问”会暴露隐藏问题:
- 测试脚本(
stress_test.py):import requests import threading import time QUESTIONS = [ "贾宝玉的母亲是谁?", "王熙凤和贾母是什么关系?", "哪些人和林黛玉一起住过潇湘馆?", # ... 共50个问题 ] def ask_question(q): try: res = requests.post("http://localhost:5000/ask", json={"question": q}, timeout=10) return res.status_code == 200 except: return False # 并发10线程,循环提问 start = time.time() threads = [] for _ in range(10): t = threading.Thread(target=lambda: [ask_question(q) for q in QUESTIONS]) threads.append(t) t.start() for t in threads: t.join() print(f"10并发完成500次问答,耗时{time.time()-start:.2f}s")
典型瓶颈与解法:
| 瓶颈现象 | 根本原因 | 解决方案 |
|---|---|---|
| 平均响应>3s | Neo4j未建索引,MATCH (p:Person {name:'xxx'})全表扫描 | CREATE INDEX person_name_index ON :Person(name) |
| 并发>5时报ConnectionRefused | Flask默认单线程,无法处理并发请求 | app.run(threaded=True)或用Gunicorn部署 |
| 连续提问后内存泄漏 | Neo4j Driver未关闭Session | 在finally块中调用session.close() |
我坚持在答辩前用这个脚本跑满3轮,直到所有请求在800ms内返回。因为用户不会关心你用了多少炫酷技术,只在乎“问完问题,答案是不是立刻弹出来”。
希望帮到你。
本文还有配套的精品资源,点击获取