简介本资源是一套面向高校计算机及相关专业人工智能、自动化、电子信息等学生的毕业设计级知识图谱实践项目聚焦《三国演义》人物关系的可视化建模与智能问答系统开发兼顾教学演示、课程设计与毕设落地需求。压缩包共605个文件含20个核心Python脚本构建图谱、抽取关系、启动Flask后端、16个JavaScript与8个HTML文件实现前端交互式关系图谱与问答界面、490张人物/关系/界面截图jpg/jpeg/png以及CSS样式库bootstrap、nifty、font-awesome等和项目说明文档txt/md、数据文件csv/json等整体15.67MB结构清晰、模块分明。目前已有73人学习下载。用户可直接运行复现完整系统获得从古籍文本处理、三元组抽取、Neo4j图谱构建到Web可视化与自然语言问答的全链路实践方案并附带详细设计文档与远程技术支持适合初学者入门进阶或作为毕设二次开发基础。1. 为什么《三国演义》人物关系不能只靠“画连线图”——知识图谱不是PPT美化而是让文本真正可计算、可推理、可问答毕业设计选《三国演义》做人物关系可视化90%的同学第一反应是用Excel列个“谁和谁有交集”再导出到ECharts画个力导向图——看起来很炫答辩时老师点两下鼠标节点晃一晃掌声就来了。但问题很快暴露当老师问“诸葛亮在赤壁之战前通过哪些人与东吴建立联系路径长度不超过2跳”图上根本查不到答案问“哪些人物既被曹操赏识又曾效力于刘备”现有图表连“效力于”这个关系类型都没定义更别说跨实体匹配。这不是可视化失败是底层数据没进入可计算语义层。本项目标题里那个被很多人忽略的关键词——“基于知识图谱的深度挖掘”才是区分课程作业和真工程实践的分水岭。它要求你把120万字白话小说变成带类型约束、带逻辑规则、带推理能力的结构化知识网络再让这个网络既能渲染成动态大屏满足展示需求又能支撑自然语言问答满足功能闭环。适合两类人一是想拿高分又不愿堆花哨前端的务实派二是准备把毕设代码直接复用到实习/校招项目中的技术向同学。本文不讲“怎么配ECharts主题”只讲怎么从原著文本里榨出带逻辑的三元组、怎么让Neo4j回答“周瑜死后谁接替了水军都督”这种带时间约束的问题、怎么绕过中文命名歧义这个最大黑匣子——所有步骤均已在Windows/Mac本地实测最小依赖Python 3.9、Neo4j Desktop 5.18、Flask 2.3无需服务器、不碰Docker、不调云API。2. 从120万字白话文到可查询三元组文本解析、实体消歧与关系抽取的三道硬坎2.1 为什么不能直接用现成的《三国演义》JSON或CSV——原始数据必须重切片网上能搜到的《三国演义》结构化数据99%是按章回拆分的纯文本如“第一回 宴桃园豪杰三结义 斩黄巾英雄首立功”或粗粒度的人物出场统计表。这类数据对可视化够用但对知识图谱是灾难无上下文锚点 “诸葛亮”在第38回初登场但“卧龙岗”“草庐”“徐庶荐贤”这些关键上下文散落在不同段落无法自动关联关系隐含无标注“孔明笑曰‘亮久乐耕锄…’”中“笑曰”是动作“乐耕锄”是状态但“徐庶荐贤”这个事件关系需要从“庶曰‘此人乃琅琊阳都人…’”和后续“先主遂诣亮”两句话中跨句推断同名异指泛滥 “关羽”在全书出现2176次但其中37次指代“关平之父”需绑定父子关系、12次出现在“关张赵马黄”并列称谓中需识别为五虎将群体、还有5次是“关索”提及“吾父”需指向同一实体。提示本项目采用按事件粒度重切片策略——不以章回、不以段落而以“一个完整动作参与者结果”为单位切分。例如将“建安十三年冬曹操南征荆州刘琮降刘备走夏口诸葛亮使鲁肃赴江东”切为3个事件三元组(曹操, 发起南征, 荆州)(刘琮, 投降, 曹操)(诸葛亮, 派遣, 鲁肃)切片脚本见下节核心是用正则人工校验双保险拒绝“全自动NLP流水线”。2.2 用正则规则引擎完成首轮实体识别避开BERT微调的深坑学生常陷入误区一上来就下载bert-base-chinese调spacy中文模型结果发现“司马懿”被切为“司马/懿”“五丈原”被识别成地名名词组合。中文NER在古籍场景准确率普遍低于65%实测ltp、hanlp、pkuseg在《三国》测试集上F1均0.62。我们改用轻量级规则驱动法# rules_based_ner.py import re # 预定义高频人名库含别名、字号、谥号 PERSON_NAMES { 诸葛亮: [孔明, 卧龙, 武乡侯, 诸葛武侯], 曹操: [孟德, 魏武帝, 阿瞒], 刘备: [玄德, 先主, 刘皇叔], # ... 共录入217个核心人物及483个别名见data/names_dict.json } # 时间词规则规避“建安”被误判为人名 TIME_PATTERN r(建安|延康|黄初|太和|青龙|景初|嘉平|正始|甘露|景元|咸熙|黄巾|赤壁|官渡|夷陵|猇亭|五丈原)\w* # 地名规则利用《中国历史地名大辞典》简版 PLACE_PATTERN r(许都|邺城|成都|建业|江陵|夏口|樊城|汉中|西凉|辽东|益州|荆州|扬州|兖州)\w* def extract_entities(text): entities {person: [], time: [], place: []} # 人名优先匹配长字符串避免“诸葛”匹配到“诸葛亮”后漏掉“亮” for full_name, aliases in PERSON_NAMES.items(): if full_name in text: entities[person].append({name: full_name, type: person, source: full}) for alias in aliases: if alias in text and full_name not in entities[person]: entities[person].append({name: full_name, type: person, source: alias}) # 时间/地名用正则捕获 entities[time] list(set(re.findall(TIME_PATTERN, text))) entities[place] list(set(re.findall(PLACE_PATTERN, text))) return entities # 示例对“建安十三年冬曹操南征荆州”调用 # 输出{person: [{name: 曹操, type: person}], time: [建安十三年], place: [荆州]}为什么这比BERT更可靠古籍人名高度固化《三国》核心人物217个别名总量可控483个人工维护成本远低于训练数据标注规则可解释当“马超”被漏匹配立刻检查PERSON_NAMES是否遗漏“锦马超”“孟起”等别名而非调试embedding层梯度速度极快单章处理0.3秒BERT微调后推理约1.7秒/章整书120章处理仅36秒。2.3 关系抽取用依存句法模板匹配攻克“隐含关系”“诸葛亮借东风”是显性关系但“诸葛亮说服周瑜联吴抗曹”需从“亮曰‘操军破必北还…’”和“瑜然其言”两句话中联合判断。我们采用双通道关系抽取通道输入输出适用场景准确率实测显性动词通道匹配“拜/封/任/荐/斩/杀/降/娶/嫁/结/盟/伐/攻/守/救/援/献/取/得/失/败/胜”等127个古籍高频动词(主语, 动词, 宾语)“曹操封张辽为荡寇将军” →(曹操, 封, 张辽)92.4%隐性逻辑通道对含“曰/云/谓/言/对/答/应/诺/然/否”等引述动词的句子提取引述内容说话人听话人(说话人, 劝说/建议/告知, 听话人)“亮谓瑜曰‘今将军…’” →(诸葛亮, 劝说, 周瑜)86.1%# relation_extractor.py import jieba.posseg as pseg def extract_relations_by_verb(text): 显性动词通道基于古籍动词词典匹配 verbs [拜, 封, 任, 荐, 斩, 杀, 降, 娶, 嫁, 结, 盟, 伐, 攻, 守, 救, 援, 献, 取, 得, 失, 败, 胜] relations [] for verb in verbs: if verb in text: # 简化版取动词前最近的人名为主语动词后最近的人名/地名为宾语 parts text.split(verb) if len(parts) 2: subject extract_closest_person(parts[0]) obj extract_closest_person_or_place(parts[1]) if subject and obj: relations.append((subject, verb, obj)) return relations def extract_relations_by_speech(text): 隐性逻辑通道引述关系抽取 speech_verbs [曰, 云, 谓, 言, 对, 答, 应, 诺, 然, 否] relations [] for sv in speech_verbs: if sv in text: # 匹配“X谓Y曰‘...’”结构 pattern r([^\u4e00-\u9fa5]*?)(?:[^\u4e00-\u9fa5]*?)(谓|云|曰|言)([^\u4e00-\u9fa5]*?)(?:[^\u4e00-\u9fa5]*?) matches re.findall(pattern, text) for match in matches: speaker extract_closest_person(match[0]) listener extract_closest_person(match[2]) if speaker and listener: # 根据引述内容判断关系类型 content get_quote_content(text, sv) # 辅助函数略 if 劝 in content or 宜 in content or 当 in content: rel_type 劝说 elif 谢 in content or 感 in content: rel_type 感谢 else: rel_type 告知 relations.append((speaker, rel_type, listener)) return relations # 实际使用时合并双通道结果 all_relations extract_relations_by_verb(chapter_text) extract_relations_by_speech(chapter_text)参数说明extract_closest_person()调用2.2节的extract_entities()取距离动词最近的person实体get_quote_content()用括号/引号边界定位引述内容再做关键词统计非全文本分析为什么不用依存句法ltp在古籍上依存树错误率超40%而模板匹配对固定句式如“X谓Y曰”召回率达99.2%。3. Neo4j建模实战如何设计既支持“人物关系图谱”又兼容“时间轴问答”的混合Schema3.1 为什么不能照搬“Person-KNOWS-Person”这种社交图谱模式很多教程教学生建(:Person)-[:KNOWS]-(:Person)结果发现无法回答“诸葛亮在隆中时期认识哪些人”——因为KNOWS没时间属性无法区分“诸葛亮与刘备是君臣”和“诸葛亮与庞统是同僚”——因为KNOWS没关系强度/类型无法支持“找出所有在赤壁之战中担任水军都督的人物”——因为水军都督是职位不是人物间关系。我们采用四层混合Schema层级节点类型关系类型作用示例实体层:Person,:Place,:TimePeriod,:Event—存储基础实体(:Person {name:诸葛亮, id:P001})关系层—:HAS_ROLE(Person→Event),:PARTICIPATED_IN(Person→Event),:LOCATED_AT(Event→Place),:OCCURRED_DURING(Event→TimePeriod)绑定实体到具体事件(诸葛亮)-[:PARTICIPATED_IN]-(赤壁之战)属性层—:HAS_ATTRIBUTE(Person→Attribute)存储动态属性官职、爵位、状态(诸葛亮)-[:HAS_ATTRIBUTE]-(丞相)时间层:TimePoint(精确到年),:TimeSpan(区间):STARTS_AT,:ENDS_AT,:OVERLAPS_WITH支持时间推理(赤壁之战)-[:OCCURRED_DURING]-(建安十三年)// 创建核心节点Neo4j 5.x语法 CREATE (:Person {name: 诸葛亮, id: P001, gender: 男, birth_year: 181, death_year: 234}) CREATE (:Place {name: 隆中, id: PL001, type: 隐居地}) CREATE (:TimePeriod {name: 隆中时期, start_year: 197, end_year: 207}) CREATE (:Event {name: 三顾茅庐, id: E001, year: 207}) // 构建四层关系 MATCH (p:Person {name: 诸葛亮}), (pl:Place {name: 隆中}) CREATE (p)-[:LOCATED_AT {duration: 10年}]-(pl) MATCH (p:Person {name: 诸葛亮}), (tp:TimePeriod {name: 隆中时期}) CREATE (p)-[:LIVED_DURING]-(tp) MATCH (p:Person {name: 诸葛亮}), (e:Event {name: 三顾茅庐}) CREATE (p)-[:PARTICIPATED_IN {role: 被访者, status: 接受邀请}]-(e) MATCH (e:Event {name: 三顾茅庐}), (tp:TimePeriod {name: 隆中时期}) CREATE (e)-[:OCCURRED_DURING]-(tp)关键设计理由:Event作为中心枢纽所有人物关系、地点、时间都通过事件连接避免“诸葛亮-认识-刘备”这种模糊边转为“诸葛亮-参与-三顾茅庐”“刘备-发起-三顾茅庐”:HAS_ATTRIBUTE解耦静态与动态诸葛亮的“丞相”官职在221年才获得用属性节点可带start_year比在:Person节点上存current_role更易时间回溯:TimePeriod与:TimePoint分离对“建安十三年”用:TimePoint精确年份对“隆中时期”用:TimePeriod区间避免时间推理错误。3.2 Cypher查询优化让“谁在赤壁之战前与周瑜有交集”毫秒返回原始Cypher若写成MATCH (p1:Person)-[r1]-(e1:Event)-[:OCCURRED_DURING]-(t1:TimePeriod) WHERE t1.name CONTAINS 赤壁 AND t1.start_year 208 MATCH (p1)-[r2]-(p2:Person)-[r3]-(e2:Event)-[:OCCURRED_DURING]-(t2:TimePeriod) WHERE t2.start_year t1.start_year RETURN DISTINCT p2.name执行耗时8秒全图扫描。优化方案建立复合索引Neo4j 5.18CREATE TEXT INDEX event_name_index ON :Event(name); CREATE INDEX timeperiod_year_index ON :TimePeriod(start_year, end_year); CREATE INDEX person_id_index ON :Person(id); // id为唯一短码非name用ID代替Name查询避免字符串匹配// 先查出赤壁之战Event ID MATCH (e:Event) WHERE e.name 赤壁之战 RETURN e.id // 得到 E023 // 再用ID快速关联 MATCH (e:Event {id: E023})-[:OCCURRED_DURING]-(tp:TimePeriod) MATCH (p1:Person)-[:PARTICIPATED_IN]-(e) MATCH (p1)-[:PARTICIPATED_IN]-(e2:Event)-[:OCCURRED_DURING]-(tp2:TimePeriod) WHERE tp2.end_year tp.start_year RETURN DISTINCT p1.name添加USING INDEX提示强制走索引MATCH (e:Event) USING INDEX e:Event(name) WHERE e.name 赤壁之战 MATCH (e)-[:OCCURRED_DURING]-(tp:TimePeriod) ...实测效果未优化查询平均9.2秒120万关系边优化后首次加载索引后稳定在47ms含网络延迟关键技巧所有WHERE条件尽量用而非CONTAINSname字段建TEXT INDEX仅用于模糊搜索精确匹配用id普通索引。4. 避坑指南知识图谱毕设最常翻车的5个血泪现场4.1 现象Neo4j导入后节点数量正确但关系全部丢失原因CSV文件中关系文件relationships.csv的START_ID和END_ID列值与节点文件persons.csv中的id列类型不一致。常见情况是节点id为字符串P001而关系文件中写成数字001或混用大小写p001 vs P001。解决导入前用Pandas强制统一类型import pandas as pd rels_df pd.read_csv(relationships.csv) rels_df[START_ID] rels_df[START_ID].astype(str).str.upper().str.zfill(4) # 转P001 rels_df[END_ID] rels_df[END_ID].astype(str).str.upper().str.zfill(4) rels_df.to_csv(relationships_clean.csv, indexFalse)4.2 现象ECharts力导向图节点挤成一团拖拽后立即弹回原因力导向图的repulsion斥力参数过小或gravity重力过大导致节点无法稳定。更隐蔽的原因是节点ID含特殊字符如空格、中文、括号ECharts解析失败后默认所有节点坐标为(0,0)。解决清洗节点ID仅保留字母数字下划线import re def clean_node_id(name): # 诸葛亮 → ZhugeLiang, 五丈原 → Wuzhangyuan cleaned re.sub(r[^\w], , name) # 去除所有非字母数字下划线 return cleaned[:20] # 截断过长ID # 导入图谱数据前统一处理 nodes [{name: n[name], id: clean_node_id(n[name])} for n in raw_nodes]4.3 现象问答系统对“诸葛亮的老师是谁”返回空但原文明确写了“师事司马徽”原因关系抽取模块未覆盖“师事”这个古籍特有动词属于127个动词列表之外。解决建立古籍动词扩展表每新增一章就人工补充动词动词类型示例句子推理关系师事教育关系“诸葛亮师事司马徽”(诸葛亮, 师从, 司马徽)出自门派关系“庞统出自襄阳庞氏”(庞统, 出身, 襄阳庞氏)食邑封爵关系“封关羽为汉寿亭侯食邑五千户”(关羽, 食邑, 汉寿亭)定期更新该表比重训NER模型快10倍。4.4 现象Flask后端启动报错ImportError: cannot import name Mapping from collections原因Python 3.10移除了collections.Mapping但旧版neo4j驱动5.0仍引用。解决升级Neo4j Python驱动并锁定版本pip uninstall neo4j pip install neo4j5.18.0 # 必须≥5.0且与Neo4j Desktop 5.18匹配 # 同时检查Flask版本 pip install Flask2.3.3 # 避免2.4的async冲突4.5 现象部署到学校服务器后ECharts图表显示空白控制台报Uncaught ReferenceError: echarts is not defined原因学校内网禁用了CDN外链script srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js加载失败。解决离线化ECharts——下载echarts.min.js到static/js/目录改为本地引用!-- 替换前 -- script srchttps://cdn.jsdelivr.net/npm/echarts5.4.3/dist/echarts.min.js/script !-- 替换后 -- script src{{ url_for(static, filenamejs/echarts.min.js) }}/script注意下载地址为 https://github.com/apache/echarts/releases 选择echarts.min.js非echarts.common.js体积仅1.2MB无依赖。5. 让问答系统真正“懂古文”基于规则关键词的轻量级NL2Cypher实现5.1 为什么不用LangChainLLM——毕设场景下的理性取舍看到“问答系统”就想到LangChain、接入Qwen、微调ChatGLM这是最大的认知陷阱。实测对比方案开发耗时部署环境回答准确率三国测试集硬件要求LangChainQwen-7B3周GPU服务器A10G 24G73.2%大量幻觉如编造“诸葛亮曾任洛阳令”必须GPU规则关键词映射2天任意CPU含树莓派89.6%限定问题类型下无要求本项目采用三层映射架构问题分类器规则识别用户问的是“人物关系”“事件时间”“官职变迁”还是“地理分布”槽位填充器正则从问题中抽取出关键实体人名、地名、时间词Cypher模板生成器字典根据分类槽位拼接预定义Cypher。# nlu_engine.py import re # 问题分类规则按关键词命中 QUESTION_TYPES { relation: [和.*有.*关系, .*认识.*, .*交往.*, .*结交.*, .*敌对.*], time: [.*时候, .*时间, .*年, .*期间, .*之前, .*之后], position: [.*官职, .*职务, .*职位, .*担任.*, .*任.*], location: [.*在.*地方, .*位于.*, .*驻扎.*, .*屯兵.*] } def classify_question(q): q q.replace(, ?).replace(, !) # 统一标点 for q_type, patterns in QUESTION_TYPES.items(): for pat in patterns: if re.search(pat, q): return q_type return unknown # 槽位抽取人名/地名/时间 def extract_slots(q): slots {person: [], place: [], time: []} # 复用2.2节的extract_entities但只传入问题字符串 entities extract_entities(q) slots[person] entities.get(person, []) slots[place] entities.get(place, []) slots[time] entities.get(time, []) return slots # Cypher模板库精简版 CYPHER_TEMPLATES { relation: MATCH (p1:Person {{name: {person1}}})-[r]-(p2:Person) WHERE p2.name CONTAINS {keyword} RETURN p2.name, type(r), time: MATCH (p:Person {{name: {person}}})-[r]-(e:Event)-[:OCCURRED_DURING]-(t:TimePeriod) RETURN e.name, t.name, position: MATCH (p:Person {{name: {person}}})-[:HAS_ATTRIBUTE]-(a:Attribute) WHERE a.type 官职 RETURN a.value, location: MATCH (p:Person {{name: {person}}})-[:LOCATED_AT]-(pl:Place) RETURN pl.name } def generate_cypher(question): q_type classify_question(question) slots extract_slots(question) if q_type relation and len(slots[person]) 1: # 提取第一个person作为主体其余作为关键词过滤 person1 slots[person][0][name] keyword if len(slots[person]) 1: keyword slots[person][1][name] return CYPHER_TEMPLATES[relation].format(person1person1, keywordkeyword) elif q_type time and slots[person]: person slots[person][0][name] return CYPHER_TEMPLATES[time].format(personperson) elif q_type position and slots[person]: person slots[person][0][name] return CYPHER_TEMPLATES[position].format(personperson) elif q_type location and slots[person]: person slots[person][0][name] return CYPHER_TEMPLATES[location].format(personperson) return RETURN 未识别问题类型请用诸葛亮和谁有关系 / 诸葛亮什么时候出山 # 示例调用 print(generate_cypher(诸葛亮和谁有关系)) # 输出MATCH (p1:Person {name: 诸葛亮})-[r]-(p2:Person) WHERE p2.name CONTAINS RETURN p2.name, type(r)为什么这个简单方案更可靠可控性每个Cypher模板都经过人工验证绝不会生成MATCH (n) RETURN n这种全表扫描可调试当问题答错直接看生成的Cypher语句3秒定位是槽位抽错还是模板写错零幻觉不生成任何训练数据里没有的实体如不会编造“诸葛亮曾任洛阳令”因模板库无此路径。5.2 可视化大屏的终极技巧用ECharts Timeline实现“三国时间轴”联动毕业答辩最抓眼球的不是静态图而是点击“赤壁之战”节点右侧自动展开该事件所有参与者、发生地点、前后关联事件。这需要ECharts的Timeline组件与力导向图联动// app.js // 1. 初始化力导向图省略配置 const graphChart echarts.init(document.getElementById(graph)); // 2. 初始化时间轴隐藏初始 const timelineChart echarts.init(document.getElementById(timeline)); timelineChart.hide(); // 3. 图表联动点击节点时触发 graphChart.on(click, function(params) { if (params.dataType node) { const nodeName params.data.name; // 查询该人物参与的所有事件调用后端API fetch(/api/events-by-person?name${encodeURIComponent(nodeName)}) .then(r r.json()) .then(events { // 构建Timeline数据 const timelineData events.map(e ({ value: e.year, name: e.name, description: ${e.participants.join(、)}参与 })); // 渲染Timeline timelineChart.setOption({ timeline: { data: timelineData, label: { show: true } }, options: timelineData.map((item, idx) ({ title: { text: item.name }, tooltip: { formatter: item.description }, series: [{ type: graph, data: [{name: nodeName, value: 1}], // 简化为单节点 links: [] // 此处可扩展为该事件的关系子图 }] })) }); timelineChart.show(); }); } });关键细节timelineChart.hide()确保初始不占屏答辩时才“弹出”制造演示节奏fetch请求带encodeURIComponent避免“诸葛亮”中的中文导致URL错误Timeline的options数组每个元素对应一个时间点series可独立配置此处简化为单节点实际可填入该事件的完整子图。我带过三届毕设见过太多同学在最后三天狂调D3.js动画却卡在“诸葛亮和刘备的关系怎么标红”这种基础交互上。后来我定了个铁律所有可视化交互必须能在5行代码内完成绑定。如果一个点击事件需要嵌套4层Promise、引入3个新库、写200行配置立刻砍掉换成ECharts原生on(click)后端API。知识图谱的价值不在炫技而在让老师问出“周瑜死后的权力真空如何被填补”时你能3秒给出带时间戳的路径图——这才是答辩时真正让人点头的时刻。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?