资讯详情 从文本到图谱:构建《三国演义》人物关系知识图谱与问答系统
📅 2026/10/30 1:26:58
简介一份基于知识图谱的《三国演义》人物关系可视化与问答系统项目包面向计算机相关专业学生及项目实战学习者适合作为课程设计、期末大作业或毕业设计参考。项目覆盖实体识别、关系抽取、图谱存储、前端可视化和自然语言问答等完整链路可帮助读者理解知识图谱的落地流程。压缩包共361个文件体积仅8.49MB其中306张jpg图片人物头像、可视化截图、12个Python脚本数据清洗、图谱构建、问答接口等与11个CSS、8个JS文件基于Bootstrap/Nifty等前端框架搭建交互界面共同组成完整应用另含JSON/CSV知识数据、ipynb分析Notebook及字体资源目录结构清晰。已有53人学习下载。这一项目系导师指导认可的高分项目评审98分不仅提供可直接运行的源码和可视化页面还包含数据文件与前端样式配置便于对照理解图数据建模、关系检索与问答逻辑尤其适合短时间内完成完整实战作业的学习者。1. 一部小说凭什么装进图数据库这个标题到底在解什么做《三国演义》人物关系可视化的人不少但大多数作品停在“用 ECharts 画一张关系大图”这一步点开之后满屏红线绿线既分不清主次也回答不了“曹操手下哪些武将没跟过他打官渡”这种带条件的问题。这个标题藏着一个更完整的闭环先按知识图谱的方式把人物和关系建模入库再拿同一份图数据同时喂给可视化和问答系统。换句话说图谱在这里不是画图的原料而是查询的底座问答的答案不是靠模板匹配硬凑而是靠图遍历推出来的。适合谁想完整走一遍知识图谱落地流程的开发者、拿课程设计或简历项目练手的在校生以及被“关系可视化”这个伪需求坑过、想看看正确姿势的人。图谱类项目的难点从来不在算法而在数据清洗、关系建模和查询边界这三件事。下面按一个可复现的路径展开从原文抽取三元组到 Neo4j 建模导入再到前端可视化和问答接口最后给出一套能验证效果的评测方法。2. 人物关系数据从哪来从原文到三元组的抽取流程知识图谱有一个常被忽略的真相80% 的工程量在数据准备而不是建模和查询。《三国演义》这种章回体小说更是如此人物字号、官职、阵营归属散落在不同回目里直接拿现成的网上下载“三国人物关系表”看似省事但你会失去最宝贵的东西——关系发生的回目出处。没有出处后续要做“官渡之战前谁在曹操阵营”这种带时间条件的问题时就完全没有依据。所以这里不绕路直接把目标定为从原文文本抽出一张带出处的三元组表再入库。2.1 先建人物清单而不是直接抓所有实体很多人第一步就跑命名实体识别结果把“汉中王”“大将军”“丞相”全当成了人名清洗成本反而更高。我一般会先手工维护一份核心人物清单控制在 120 到 150 人。这个数字不是拍脑袋覆盖全书出场频次前 150 位的人物后《三国演义》绝大多数关系问答都能命中而再往上加人边际收益会迅速下降。清单的字段设计会影响后面所有环节。最基础的四列是姓名、别名、字、号。比如曹操这一行就是“曹操 / 曹孟德 / 孟德”刘备是“刘备 / 刘玄德 / 玄德”。如果后面想做阵营统计再加一列初始阵营。这份清单的另一个用途是给实体识别做自定义词典避免分词器把“刘备”切碎。提示别贪多。把全书每一个人名都收进来只会让可视化变成一团无法阅读的毛线球让问答系统陷入指代歧义。做图谱项目先学会做减法。2.2 实体识别与指代归并用 HanLP 加自定义词典跑原文拿到人物清单后就可以对原文做实体识别了。常用的做法是用 HanLP 的命名实体识别配合自定义词典强制召回人名。HanLP 在古汉语上的表现比通用分词器好一些但仍然需要词典兜底。下面这段代码做了三件事加载自定义词典、按回目切分原文、输出每一回出现的人物列表。这段数据后面会用来过滤关系三元组。# entity_extract.py from hanlp import HanLP # 加载自定义词典词典格式为词\t词性\t频次 # 例如曹孟德\tnr\t100 HanLP.Config.user_dict_path user_dict.txt def load_characters(path): 从人物清单读取姓名与别名集合 names set() with open(path, encodingutf-8) as f: for line in f: parts line.strip().split(,) for alias in parts[1:] or parts[0:1]: names.add(alias.strip()) return names def extract_characters_per_chapter(text, names): 按回目拆分文本返回每回出现的人物集合 chapters text.split(第) result [] for ch in chapters: if len(ch) 100: # 跳过回目标题行 continue char_set set() for name in names: if name in ch: char_set.add(name) result.append(char_set) return result这段代码的逻辑其实是在做“约束条件下的召回”与其依赖模型把所有名词都识别出来再筛选不如先用人物清单做白名单匹配。这样做牺牲了识别出非清单人物的可能但换来了极高的准确率而且每一回出现的所有人物都会被打上回目标签。参数说明user_dict.txt的频次会影响分词优先级建议把所有别名都设为不低于 100确保人名不会被切开names集合同时包含姓名和别名这样“曹孟德”也会被归到曹操的人物集合里但这里只做了出现检测真正的指代归并要在下一步完成。2.3 关系抽取基于句式模板的三元组生成搞定“谁在哪个回目出现”之后进入最关键的一步治抽关系。常见做法是维护一组句式模板配合人物清单做匹配。对《三国演义》这种文言白话混杂的文本模板规则比通用机器学习模型可控得多因为关系类型是预设的漏召回可以通过补模板迭代而模型方法一旦错乱排查成本极高。这里只抽取四类足以支撑大多数问答的关系类型关系触发词示例示例句隶属投奔、归、降、随、从张郃乃曹操部下血缘之子、之弟、兄、父曹丕乃曹操之子敌对斩、败、破、攻关羽斩颜良谋臣献计、谏、谋士荀彧为曹操谋士以下代码对每一回做句式模板扫描生成带回目号的三元组# relation_extract.py import re RELATION_PATTERNS [ (r(?Pperson1[\u4e00-\u9fa5]{2,4})乃(?Pperson2[\u4e00-\u9fa5]{2,4})之(?Prel子|弟|兄|父), 血缘), (r(?Pperson1[\u4e00-\u9fa5]{2,4})(?Prel投奔|归顺|降于)(?Pperson2[\u4e00-\u9fa5]{2,4}), 隶属), (r(?Pperson1[\u4e00-\u9fa5]{2,4})(?Prel斩|杀)(?Pperson2[\u4e00-\u9fa5]{2,4}), 敌对), (r(?Pperson1[\u4e00-\u9fa5]{2,4})(?Prel献计|进言)于(?Pperson2[\u4e00-\u9fa5]{2,4}), 谋臣), ] def extract_triples(chapter_text, chapter_no, names): triples [] for pattern, rel_type in RELATION_PATTERNS: for m in re.finditer(pattern, chapter_text): p1, p2 m.group(person1), m.group(person2) if p1 in names and p2 in names and p1 ! p2: triples.append({ person1: p1, rel: m.group(rel), person2: p2, type: rel_type, chapter: chapter_no, }) return triples这段模板有一个容易被忽略的设计点person1和person2都只匹配 2 到 4 个汉字并且必须存在于names白名单里。这样可以挡掉“关羽斩颜良”中把“颜良”错判为地名的情况也能避免“刘备乃中山靖王之后”这种包含事件修饰的长主语被错误切分。参数说明chapter_no是从回目顺序生成的递增整数后面做“官渡之战前”这类查询时直接用这个数值做区间过滤即可正则里的[\u4e00-\u9fa5]{2,4}限制了人物姓名长度三字名如“公孙瓒”可以匹配四字名如“司马仲达”边界需要单独加规则。抽取完成之后务必导出 CSV 并做一次人工复核重点看关系方向模板匹配无法判断“曹操之子”到底是“曹丕是曹操之子”还是“曹操是曹丕之子”这个时候只能靠人工或者上下文规则修正。这一步是血的教训后面问答系统的所有准确性都依赖这里。3. 实体落库与关系建模用 Neo4j 把《三国演义》装进图数据库三元组 CSV 只解决了“有哪些关系”接下来要决定“关系怎么存”。这一步用 Neo4j 几乎是行业默认选择因为后续问答系统的多跳查询和前端可视化的关系展开都以图遍历为核心。对《三国演义》这个规模的数据量关系型数据库也能装下但查询“曹操手下谁与袁绍交战过”这种条件时SQL 的 join 链会把人和人之间的边界条件写成一堆难以维护的中间表图数据库的 Cypher 则能直接用路径表达。3.1 本体设计节点类型、关系类型和属性字段建模之前先想清楚一件事《三国演义》知识图谱只需要一种节点人物还是需要阵营、战役等更多节点这里建议保持克制——人物节点、阵营节点、战役节点共三种就够。原因很实际节点类型越多可视化筛选逻辑越复杂问答意图识别也要跟着膨胀。人物节点的属性设计如下字段说明示例name唯一键用于合并曹操alias别名集合逗号分隔曹孟德,孟德camp初始阵营可空魏birth_year出生年可空155death_year死亡年可空220description一句话简介东汉末年权相关系类型我最终压缩成五种隶属、血缘、敌对、谋臣、同僚。其中同僚是从同回目出现频率推导出来的“弱关系”如果前端可视化觉得边太多第一个要删的就是它。每条关系都带chapter属性和source属性前者指向回目号后者记录抽取依据的原文片段排查问题时不用回翻书籍。3.2 从 CSV 到 Neo4j用 LOAD CSV 加 MERGE 做全量导入数据文件不大直接用LOAD CSV是最稳的。很多人初次接触 Neo4j 会纠结CREATE和MERGE这里务必用MERGECREATE会在重复导入时生成重复节点而 CSVD 人物别名带来的重复记录几乎是必然的。// import_graph.cypher USING PERIODIC COMMIT 500 LOAD CSV WITH HEADERS FROM file:///characters.csv AS row MERGE (p:Person { name: row.name }) ON CREATE SET p.alias row.alias, p.camp row.camp, p.description row.description; // 关系导入跳过源点和终点相同的关系 LOAD CSV WITH HEADERS FROM file:///relations.csv AS row MATCH (p1:Person { name: row.person1 }) MATCH (p2:Person { name: row.person2 }) WHERE p1 p2 MERGE (p1)-[r:REL { type: row.type, chapter: toInteger(row.chapter) }]-(p2) SET r.source row.source;这段 Cypher 有两个值得留意的点。USING PERIODIC COMMIT 500是旧版本常用的批量提交控制新版 Neo4j 已不建议显式使用但如果你仍在 4.x 版本它对大文件导入仍然有效batch size 500 到 1000 之间通常是安全和性能的平衡点。第二个点是MATCH两次后再MERGE关系这样不会创建出自环也能保证两端的 Person 节点一定存在避免关系指向空节点。参数说明chapter用toInteger()转成整数类型是为了后续“小于某回目”的范围查询WHERE p1 p2挡掉了“A 乃 A 之子”这种模板误匹配。导入完成后建议执行CALL apoc.schema.assert或手动对Person.name建唯一索引否则后续重复运行导入脚本时会产生重复节点。3.3 导入后验证用图谱找到“曹操的第一跳关系”数据入库不等于数据正确。我一般会先做一次冒烟查询把曹操的一跳关系拉出来人工核对数量级是否合理。MATCH (p:Person { name: 曹操 })-[r:REL]-(target:Person) RETURN target.name, r.type, r.chapter ORDER BY target.name LIMIT 20;这一步能快速暴露三类问题是不是有大量的“同僚”关系把查询结果塞满是不是存在“曹操-曹操”的自环没被过滤以及回目号是否全部为 null——如果大量关系都缺回目说明导入时 CSV 字段名和 Cypher 里的列名对不上程序会静默写 null 而不是报错。跑通这条查询后可视化阶段接到的就是一个干净、可追溯的图结构。4. 可视化链路里的四个常见坑节点漂移、查不出关系和属性丢失这是整个项目里最容易把人磨到没脾气的阶段。原因在于知识图谱的数据问题会在可视化里被放大成视觉问题而不是报错信息。以下四条来自实践中高频出现的踩坑记录按现象、原因、解决三步写每一条都值得在动手前读一遍。4.1 节点全部挤成一团拖拽后变成“蜘蛛网”现象前端把全量关系返回给 ECharts 力导向图后画面上所有节点挤在中心拖开任何一个节点整个图像蛛网一样乱颤没法看。原因力导向图的布局收敛依赖斥力和引力的迭代当节点数超过 300、关系数超过 500 时默认的repulsion和edgeLength参数不足以让无关节点分开。很多教程不会告诉你ECharts 的 force 布局是前端实时计算的数据量变大后布局质量会指数级下降。解决不要全量渲染先按度数过滤出子图。做法是写一个 Cypher 查询只返回关系数大于阈值的节点和它们之间的关系然后用repulsion: 300、edgeLength: [80, 150]起步调整。MATCH (n:Person)-[r:REL]-() WITH n, count(r) AS deg WHERE deg 5 MATCH (n)-[r2:REL]-(m) WHERE m.deg 5 RETURN n, r2, m4.2 前端图形正常但点“曹操”查“手下武将”一直查不出现象关系图全部渲染成功曹操与其他人物之间也有连线但问答框输入“曹操的手下有哪些”时系统返回空结果。原因数据建模时把“隶属”关系存成了(曹操)-[:隶属]-(夏侯惇)但查询时写的方向是(曹操)-[:隶属]-(武将)。Cypher 的箭头方向是严格的方向写反查不到结果不会报错只会静默返回空数组。这是图数据库新手最容易掉进去的坑。解决决定关系方向时统一采用“从属到主”的原则(武将)-[:隶属]-(主公或阵营)。查询“曹操的手下”时用匹配方向(p:Person)-[:隶属]-(曹操)即可。同时建议给隶属关系加属性rank标注主从级别方便按官职细分。4.3 浏览器控制台没有报错但节点的 tips 全是空白现象ECharts 的 tooltip 能触发但显示的人物简介和阵营信息全是 undefined 或空白。原因Neo4j 浏览器返回的 JSON 结构中节点属性位于properties子对象里而前端代码写的node.name取到的是数据库内部 id不是业务字段。这种情况在 Neo4j 3.x 后用 bolt 协议连接时尤为常见因为返回结构变了但很多示例代码还停留在旧版本字段。解决在后端接口里做一次字段映射把 Neo4j 返回的n.get(name)、n.get(description)显式转成扁平 JSON 再交给前端。这一步虽然繁琐但能避免前端处理多层次嵌套后续扩展字段也更省心。4.4 全量节点渲染后浏览器直接卡死或白屏现象图是完整了但页面滚动和缩放都卡顿CPU 占用拉满甚至直接被浏览器判定为无响应。原因节点数量超过 500 时力导向图的迭代计算成本呈指数级增长。ECharts 的 force 布局没有自带分页或按需渲染机制全量渲染必然导致性能雪崩。解决给可视化界面加一个“度数阈值”滑杆默认只显示度数大于 10 的核心人物用户放大阈值时再动态请求子图。这会带来一个附加好处用户反而更容易一眼看到“曹操、刘备、孙权”这些关键枢纽而不是淹没在几百个小人物的连线里。5. 问答系统为什么不等于模板匹配意图判别与 Cypher 生成很多人拿到三元组表之后会基于关键词做问答比如问句里有“父亲”就查血缘关系有“手下”就查隶属。这种做法的缺陷在标题里早就埋下了你要做的是基于知识图谱的问答系统而不是关键词检索系统。真实提问中“曹操他爹是谁”和“曹操之父”是两个句子但对应同一个查询意图。如果只靠死模板每加一种问法就要写一条规则最后规则数膨胀到无法维护。这里给出一个相对务实的中间方案意图判别加同义词归一再加 Cypher 生成。5.1 意图判别先确定问题类型再决定查询语句把问答系统的问题归为四类覆盖这个项目应该能应付的所有情况意图问法示例查询目标关系查询曹操的父亲是谁返回某个关系路径上的实体属性查询诸葛亮的字是什么返回某节点的属性值统计查询哪个阵营武将最多返回聚合结果事件查询赤壁之战谁赢了返回事件节点关联的实体意图判别可以用一个极简的分类函数实现核心是先把问句做同义词归一再做规则匹配。比如“父亲、他爹、老爸”统一归为“父亲”“手下、部下、将领”统一归为“隶属武将”。这一步是问答系统的灵魂因为后续的 Cypher 生成完全依赖意图和归一后的关系类型。# qa_intent.py SYNONYM_MAP { 父亲: [父亲, 他爹, 老爸, 之父], 儿子: [儿子, 之子, 孩子], 手下: [手下, 部下, 武将, 将领], 谋士: [谋士, 军师, 智囊], } RELATION_TYPE_MAP { 父亲: 血缘, 儿子: 血缘, 手下: 隶属, 谋士: 谋臣, } def detect_intent(question): for rel, synonyms in SYNONYM_MAP.items(): for s in synonyms: if s in question: return relation_query, RELATION_TYPE_MAP[rel] if 字 in question: return attribute_query, name if 多少 in question or 最多 in question or 哪个 in question: return statistics_query, None return fallback, None这段代码的逻辑是优先匹配关系类意图因为关系查询的覆盖面最大。fallback兜底意味着系统能在不确定时承认自己不会而不是硬答导致用户对系统丧失信任。参数说明SYNONYM_MAP和RELATION_TYPE_MAP必须同步维护后续每发现新的问法只需加同义词不需要改分支逻辑这就是意图归一带来的可维护性收益。5.2 从意图到 Cypher生成查询并做参数化意图识别完成后下一步是把意图转成 Cypher 语句。这里要强调参数化防止 Cypher 注入尤其当你的问句是直接从 URL 参数里取的时候。def generate_cypher(intent, relation_type, target_person): if intent relation_query: # 以人物为目标查关系方向按关系类型定制 if relation_type 隶属: return ( MATCH (p:Person)-[:隶属]-(target:Person {name: $name}) RETURN p.name AS name ), {name: target_person} return ( MATCH (target:Person {name: $name})-[:血缘]-(p:Person) RETURN p.name AS name ), {name: target_person} elif intent statistics_query: return ( MATCH (p:Person)-[:隶属]-(c:Camp) RETURN c.name AS camp, count(p) AS cnt ORDER BY cnt DESC LIMIT 1 ), {} return None, None这个函数目前只处理了三种情况但已经能看出设计原则Cypher 模板和参数放在一起查询时动态拼接。relation_type决定方向target_person作为参数传入可以避免把用户输入直接拼进查询字符串。需要注意生成 Cypher 的边界关系类型向前端暴露时必须做白名单校验不能把用户的任意输入映射为关系名否则可能导致查询语义混乱。5.3 问答接口的兜底策略与日志埋点最后一步也是实际项目里最容易漏的部分当意图识别落到 fallback 或生成出的 Cypher 查不到结果系统应该直接返回“暂未收录该问题的答案”而不是返回空列表让用户以为是 bug。同时一定要在日志里把这类问句记录下来。这些问句是最好的优化素材每周看一次日志把重复出现但答不上的问法补进同义词归一表里问答命中率会以肉眼可见的速度提升。这个环节属于低成本高回报统计查询答不上来多半是问法里混入了超出四类意图的新类型属性查询答不上来多半是人物清单里的description字段为空。先给查询日志加分再去扩充数据和模板优化效率会高很多。6. 让问答结果可被验证一份最小评测方案问答系统做完之后最难回答的问题变成“你的系统到底准不准”。相比可视化问答系统有明确的输入输出完全可以在发布前做一套量化评测。我强烈建议在项目目录里留一份qa_eval_set.json把预期问题、预期返回实体的列表固化下来每次改动后跑一遍防止修了一个 bug 又引入新问题。[ {question: 曹操的父亲是谁, expected: [曹嵩], type: relation}, {question: 诸葛亮的字是什么, expected: [孔明], type: attribute}, {question: 哪个阵营武将最多, expected: [魏], type: statistics}, {question: 夏侯惇是曹操的什么, expected: [隶属武将], type: relation} ]评测脚本按精确匹配和宽松匹配两个口径计算。精确匹配要求返回结果和 expected 完全一致宽松匹配只要求 expected 中的主力数字出现在返回结果里。前者用于严格回归测试后者用于评估用户体验的底线。跑完评测后我通常只看两条曲线未命中问题的分布情况和哪种问法占比最高以此决定下一步优化方向。做这个项目最大的教训就是可视化让人看见知识图谱的形问答系统才是检验知识图谱质量的试金石。数据建模阶段偷的懒最终都会在问答环节以“查不到结果”的形式暴露出来。把评测集越做越厚、把兜底策略越做越细比追求炫酷的图谱交互更能让项目真正落地。希望这份从数据抽取到评测闭环的路径能帮到你。本文还有配套的精品资源点击获取