ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

三国人物关系知识图谱构建与问答系统实战:从实体抽取到Neo4j可视化

三国人物关系知识图谱构建与问答系统实战:从实体抽取到Neo4j可视化 简介该项目以《三国演义》人物关系为切入点基于知识图谱技术构建可视化展示与智能问答系统适合计算机相关专业学生用于项目实战、课程设计或期末大作业也可作为毕业设计选题参考。资源包共361个文件大小仅8.49MB包含12个Python脚本、4个HTML页面、11个CSS样式、8个JavaScript脚本、3个JSON数据文件及2个Jupyter Notebook另有306张图片展示了图谱构建、关系查询与前端交互的各类运行结果覆盖从数据解析、知识抽取、图谱建模到问答逻辑实现的完整链路。项目结构清晰核心代码与静态资源分开存放前端界面基于Bootstrap等框架搭建可直接运行调试易于二次扩展和复用。作为经导师指导并获评98分的高分项目其设计思路和实现方案具有较高参考价值目前已有53人学习下载适合希望快速上手知识图谱项目或完成相似课题的开发者。1. 拿到这份“三国人物关系可视化及问答系统.zip”你到底能做出什么一个压缩包里同时装下“知识图谱构建、可视化展示、自然语言问答”三件事这本身就是《三国演义》这个经典文本最适合的技术练手项目。别把它当成一个简单的毕业设计——它背后是一条完整的工业链路非结构化文本变成结构化三元组三元组落进图数据库图数据库再支撑交互式问答。这套链路换掉《三国演义》换成企业内部的故障工单、客户对话记录、医疗文献骨架完全不用动。适合谁做想入门知识图谱但不想用“张三-朋友-李四”这种玩具数据的开发者需要给团队做内部技术分享的工程师以及正在为简历缺一个完整NLP项目发愁的学生。这篇笔记的目标是让你拿到这个zip之后知道先跑什么、后改什么、遇到问题看哪里。很多教程会直接甩给你python build_graph.py然后说“跑完就出图了”但真正的坑全在数据进图之前。我的做法是先把整个系统拆成四个可独立验证的模块实体抽取、关系构建、可视化渲染、问答匹配每个模块都先立一个小目标全部跑通后再串起来。2. 先决定“图谱里装什么”本体设计与数据源预处理2.1 本体的最小可用设计三类实体、三种关系不要一上来就设计复杂本体。知识图谱最常见的翻车原因不是技术而是本体里塞了太多概念导致数据根本填不满。对于《三国演义》我建议只用三类实体和三种关系就能支撑90%以上的问答场景。实体类型定三张表人物Person、地点Place、事件Event。地点可以仅指“城池、州郡”这种行政单位事件只保留有明确动词短语支撑的例如“官渡之战”“赤壁之战”。人物需要额外加一个别名字段这是问答系统能否回答“字什么”“号什么”的关键。关系先定三条主干生于/死于人物-地点、参与人物-事件、隶属/交战人物-人物。最后这条是问答的核心用在“谁杀了谁”“谁降了谁”“谁举荐了谁”这些高频问题上。关系不要贪多先把这三类数据质量做扎实后面扩展再加不迟。提示这个设计对应Neo4j里只需要两类节点标签Person/Place/Event和三种关系类型。保持简单是为了让问答模板的编写量可控。2.2 数据清洗从原文到可抽取文本的三个步骤《三国演义》原文来自公开的电子文本最常见的问题是繁简混杂、段落编号参差、以及“玄德”“刘豫州”“先主”这类指代混乱。第一步先把文本统一转成简体并去除所有段落编号只保留完整句号结尾的句子。第二步是构建一个人物别名词典。这里有个很容易被忽略的点词典的构建要分“正式别名”和“临时指代”两层。正式别名就是“字”如孔明和“号”如卧龙这些直接映射到实体属性临时指代如“先主”“丞相”也要建立映射但在问答结果里应该展示全称而不是原名。第三步是按章节切分文本。为什么要按章节因为后面做关系抽取时需要利用“同一章节内出现的人物更可能产生关系”这个先验。不要把所有文本一次性灌入抽取程序那样会导致跨章节关系泛滥——关羽在第80回出现曹操在另一回出现程序会把它们拼出“有关系”的错误结果。2.3 实体识别jieba 自定义词典的实战参数实体识别是决定后续所有环节上限的模块。直接用现成的jieba.analyse提取关键词效果会很差因为人名在分词阶段就被切碎了。正确顺序是先加载自定义词典再分词再匹配词典做实体标注。import jieba # 自定义词典格式词 词频 词性 # 词频不低于50否则会被HMM切碎词性统一用nr CUSTOM_DICT [ 诸葛亮 100 nr, 诸葛孔明 80 nr, 刘玄德 80 nr, 曹操 100 nr, 孟德 80 nr, 关云长 70 nr, ] dict_path sanguo_dict.txt with open(dict_path, w, encodingutf-8) as f: f.write(\n.join(CUSTOM_DICT)) jieba.load_userdict(dict_path) # 人名匹配分词后在词性为nr且存在于人物实体表中的词记一次出现 text 玄德请孔明同往孔明曰曹操必败。 seg_list jieba.lcut(text) persons [w for w in seg_list if w in person_entity_set] print(persons) # 输出: [玄德, 孔明, 孔明, 曹操]这段代码的逻辑是jieba.load_userdict把所有人物别名强制绑定为nr词性避免“孔明”被拆成“孔”和“明”两个字。之后用person_entity_set从实体表加载的全量人物名字集合过滤分词结果只有出现在实体表里的词才会被保留为候补实体。参数说明词典里词频数字建议统一给80~100太小会让jieba在遇到同前缀词时选择不加载太大则可能把非专有名词也切进来lcut比cut好在直接返回list不用再转迭代器调试时能直接打印人物实体表建议从《三国演义》人物列表里提取不要只依赖自动抽取否则漏掉次要人物会让后续问答“哑火”2.4 关系抽取基于依存句法和触发词表的混合方案关系抽取是这个项目里最“黑匣子”的部分。直接上BERT关系分类数据量不够还会过拟合而且训练时间很长。更务实的路线是触发词表做主框架依存句法做辅助角色判断。触发词就是“杀”“攻”“降”“荐”“封”这类高频动词每个动词预先定义关系类型和主语宾语的语义角色。import jieba import re # 触发词表关系类型 - 关键词列表 TRIGGER_RELATION { 交战: [杀, 败, 破, 攻, 斩, 擒], 隶属: [降, 归, 从, 随], 举荐: [荐, 举, 推举], } # 句子示例关羽斩颜良曹操闻之而惧 def extract_relation(text): relations [] for rel, triggers in TRIGGER_RELATION.items(): for trig in triggers: pattern r([\u4e00-\u9fa5]{1,6}) trig r([\u4e00-\u9fa5]{1,6}) matches re.findall(pattern, text) for subj, obj in matches: subj_entities [w for w in jieba.lcut(subj) if w in person_set] obj_entities [w for w in jieba.lcut(obj) if w in person_set] if subj_entities and obj_entities: relations.append((subj_entities[0], trig, obj_entities[0], rel)) return relations print(extract_relation(关羽斩颜良曹操闻之而惧)) # 输出: [(关羽, 斩, 颜良, 交战)]这段代码用正则先找出触发词斩前后的各1~6个字符再用jieba分词过滤出人名。([\u4e00-\u9fa5]{1,6})的宽度是经验值——中文人名加上“字”“将军”之类称谓一般在8个字符以内太宽会把整句都吞进来。参数说明正则窗口1~6是调出来的窗口太大容易匹配到很远的人名产生错误三元组太小则漏掉“车骑将军张飞”这种带官位的表述切完subj之后用person_set过滤这一步强制保证抽取结果是已知人物避免录入“敌将”“小校”这种非实体每条关系带上rel标签至关重要问答系统要靠它决定生成哪种回答格式3. 数据入库与可视化Neo4j建模、批量导入与前端渲染3.1 为什么选Neo4j而不是MySQL或者关系型数据库人物关系查询的本质是“多跳遍历”比如“刘备的结义兄弟的军师是谁”——这在SQL里要自连接三次以上而且每次连接都要写JOIN条件查询语句复杂度随跳跃深度指数飙升。Neo4j的Cypher查询则与图的遍历路径完全一致MATCH写起来几乎就是自然语言的镜像。另外Neo4j的配套生态对这个项目有天然优势py2neo可以直接操作图对象neo4j的HTTP API方便前端调用Neo4j Browser自带可视化调试界面——这意味着在写前端之前就能用浏览器验证图谱数据是否正确。这种“边查边看”的调试体验是其他存储方案给不了的。3.2 Cypher建模与属性设计的三条原则写Cypher前先想清楚节点属性放什么。我的建议是节点的name属性必须用全名比如“诸葛亮”alias属性存放所有别名列表类型这样查询时不管用户问“孔明”还是“诸葛孔明”都能通过WHERE node.name $p OR $p IN node.alias命中间一节点。// 创建约束Person节点的name必须唯一 CREATE CONSTRAINT person_name_unique IF NOT EXISTS ON (p:Person) ASSERT p.name IS UNIQUE; // 创建节点示例 CREATE (p:Person {name: 诸葛亮, alias: [孔明, 卧龙, 诸葛孔明], birth: 181, death: 234}); // 创建关系 MATCH (a:Person {name: 关羽}), (b:Person {name: 颜良}) CREATE (a)-[r:HAS_ACTION {type: 交战, action: 斩, source: 第25回}]-(b) RETURN r;这里有三条原则值得留意。第一条约束必须在导入数据前创建否则重复数据进去之后想清理非常痛苦。第二条关系属性里source字段必须存——它记录这段关系的原文出处是日后验证数据正确性的后悔药。第三条alias用列表而不是字符串因为一个人可以有多个别名列表可以直接被Cypher的IN操作符使用。提示CREATE CONSTRAINT在Neo4j 4.x之后支持IF NOT EXISTS语法旧版本要提前检查约束是否存在否则重复执行会报错。3.3 批量导入用py2neo还是LOAD CSV数据量在几千条级别时两种方式速度差不多但维护成本差异很大。LOAD CSV需要先把数据落成CSV文件再写Cypher脚本路径问题在Windows和Linux的表现也不一致py2neo则可以直接在Python内存里构建图对象一步不到位。我的选择是实体用py2neo关系用LOAD CSV。理由很简单——实体是静态数据一次导入完就不动了关系是迭代更新的数据需要反复验证和追加。from py2neo import Graph, Node, Relationship graph Graph(bolt://localhost:7687, auth(neo4j, password)) # 批量创建人物节点 def create_person_nodes(person_list): tx graph.begin() for p in person_list: node Node(Person, namep[name], aliasp.get(alias, [])) # 先查重再创建 existing graph.nodes.match(Person, namep[name]).first() if not existing: tx.create(node) tx.commit() person_data [ {name: 刘备, alias: [玄德, 刘豫州, 先主]}, {name: 曹操, alias: [孟德, 魏武帝]}, ] create_person_nodes(person_data)逻辑说明tx是事务对象tx.create(node)不立即执行而是等tx.commit()一次性提交这比逐个graph.create()快一个数量级。graph.nodes.match(...).first()做了查重防止重复导入造成实体分裂。这里只用于演示小批量真实数据建议用LOAD CSV配合USING PERIODIC COMMIT每1000行提交一次避免长事务把内存打爆。参数说明bolt://localhost:7687是Neo4j的二进制连接端口HTTP端口是7474两者功能不同操作数据走Bolt浏览器调试走HTTPauth的密码是你在Neo4j安装时设置的忘记的话要去conf/neo4j.conf里重置这个坑后面还会提批量数据超过5万条tx.begin()到commit()的间隔建议设置更小在系统内部循环每1000条提交一次3.4 可视化层从Neo4j Browser到ECharts力导向图的路径Neo4j Browser自带的关系可视化够看数据正确性但交互感和可定制性远不如前端图表库。可选的方案有三个ECharts力导向图、vis.js网络图、G6图可视化。这个项目用ECharts最合理——因为很多人已经熟练使用它做数据可视化学习成本低而且力导向图的布局算法对人物关系有天然的表现力。// 假设后端已经通过Neo4j查询返回了nodes和edges数组 const chart echarts.init(document.getElementById(graph-container)); const option { tooltip: { formatter: function(params) { if (params.dataType node) { return params.data.name br/ (params.data.alias || []).join( / ); } return params.data.source - params.data.target; } }, series: [{ type: graph, layout: force, data: nodes, links: edges, roam: true, draggable: true, force: { repulsion: 300, // 节点斥力调大则人物间距更开阔 edgeLength: [50, 120] // 边的长度范围决定关系紧密程度 }, label: { show: true, formatter: function(params) { return params.data.name; } }, lineStyle: { color: source, curveness: 0.3 } }] }; chart.setOption(option);这里的核心配置是force对象的repulsion和edgeLength。repulsion: 300是经验值对于100~300个节点的三国人物网络足够太大则图完全散开太小则中心区域拥挤成团。edgeLength: [50, 120]让“交战”“隶属”这些不同关系类型的紧凑度产生分层视觉上能看出派系圈层。roam: true允许用户缩放拖拽这个在人物数量超过200时几乎是刚需——不然根本没法看局部关系。可视化层的特殊注意点后端返回nodes和edges时edges的source和target字段必须对应nodes数组里的id字段ECharts不负责自动匹配名称。很多人在这一环节卡住其实是后端返回的是人物姓名而不是节点索引。构建后端返回数据时id统一用人物全名前端直接用名字匹配是最省事的方式。4. 问答系统实现从自然语言到Cypher的转换策略4.1 问题分类先判断用户问的是什么再决定查询怎么写问答系统的骨架是意图识别落脚到《三国演义》这个域里常见的用户问题只有六类人物关系谁杀了谁、人物归属谁是哪国将领、人物生平谁生于哪里、事件参与谁参加了赤壁之战、别名词查询孔明是谁、开放关系他和谁交往最多。用训练集做意图识别在这个项目里不划算——数据量太小分类器容易过拟合。更稳的是规则加正则的意图分类器它能做到95%以上的准确率而且完全可控。import re class IntentClassifier: def __init__(self): self.patterns { relation_action: r谁.*(杀|斩|攻|败|降|擒|荐).*, person_belong: r(属于|是).*(蜀|魏|吴).*(将领|谋士|君主), action_relation: r(.{2,6})和(.{2,6})是什么关系, } def classify(self, question): for intent, pattern in self.patterns.items(): if re.search(pattern, question): return intent return unknown # 使用示例 qc IntentClassifier() print(qc.classify(谁杀了吕布)) # relation_action print(qc.classify(赵云属于哪个国家)) # person_belong # 注正则未匹配到实际输出unknown print(qc.classify(刘备和曹操是什么关系)) # action_relation分类器逻辑很简单按正则顺序逐个匹配命中即止。正则顺序有讲究——relation_action必须在action_relation之前否则“谁杀了吕布”会被“谁…杀…”匹配后提前返回不会继续匹配后续规则。参数说明中文字符区间[\u4e00-\u9fa5]在命名组里可以直接使用但如果用\w会误匹配字母数字所以人名部分必须显式用这个区间(.{2,6})这里的点匹配任意字符对于“诸葛亮的老丈人”这种问题会匹配出“诸葛亮”和“老丈人”但“老丈人”不在人物实体表里后续查询会返回无结果更稳的做法是先把问题里的人物别名替换成PERSON占位符再做正则代码里这一步省略了实际项目必须加4.2 把问题翻译成Cypher模板参数化意图识别只是第一步重点在生成Cypher。这一步的常见做法是预定义一批查询模板每个模板包含两个槽位人物名参数和关系类型参数。生成Cypher前先要用同义词表把用户问题中的“孔明”替换成“诸葛亮”保证查询命中的是标准实体。def generate_cypher(intent, params): if intent relation_action: person params[person] action params[action] return f MATCH (p1:Person)-[r:HAS_ACTION {{action: {action}}}]-(p2:Person) WHERE p1.name {person} OR {person} IN p1.alias RETURN p2.name AS result elif intent person_relation: p1 params[person1] p2 params[person2] return f MATCH (p1:Person)-[r]-(p2:Person) WHERE (p1.name {p1} OR {p1} IN p1.alias) AND (p2.name {p2} OR {p2} IN p2.alias) RETURN type(r) AS relation, r.action AS action 这个模板有个隐藏的安全问题用户输入直接拼接进Cypher字符串存在注入风险。但在这个项目里person已经经过同义词表映射而且只允许匹配已知人物实体所以实际注入面很小。如果想更规范应该用py2neo的参数化查询——把{person}替换为$person然后在.run()时传参数。参数说明HAS_ACTION是我在关系设计时统一的动作关系类型action属性存储具体动词比如斩、攻、降{person} IN p1.alias是别名匹配的关键它利用Cypher的IN操作符检查字符串是否在数组属性中查询结果统一用RETURN ... AS result包装便于后端统一处理4.3 答案生成与兜底策略查不到数据时怎么回复问“谁杀了吕布”图里明明有“曹操杀吕布”的节点但用户问“吕布是被谁杀的”句子结构不同模板匹配失败怎么办兜底策略分三级第一级是同义词表扩大映射“杀死”映射到“斩”第二级是反向查询被杀意味着关系方向反过来第三级是给用户展示相关人物卡而不是生硬回答“未找到”。def answer_relation(db, person, action): # 正向查询 cypher_forward MATCH (a:Person)-[r:HAS_ACTION]-(b:Person) WHERE a.name $person AND r.action $action RETURN b.name AS target result db.run(cypher_forward, personperson, actionaction).data() if result: return [r[target] for r in result] # 反向查询把主语宾语交换 cypher_reverse MATCH (a:Person)-[r:HAS_ACTION]-(b:Person) WHERE b.name $person AND r.action $action RETURN a.name AS subject result_rev db.run(cypher_reverse, personperson, actionaction).data() if result_rev: return [f{person}被{r[subject]}{action}] return [f没有找到关于「{person}{action}」的记录试试换个说法比如「{person}和谁交战时败了」]这里的反向查询是问答系统的高光设计——用户习惯说“谁杀谁”但事实常常是“谁被谁杀”正向查不到时反向一试就有结果。db.run是py2neo的查询入口$person是参数化占位符能有效防止Cypher注入同时规避了中文引号导致的语法错误。最终的答案不是只给一个名字而是给带上下文的短语“吕布被曹操斩”这样用户不查原文就能理解关系。兜底文案不能太技术化因为使用者不一定懂图数据库。5. 避坑指南三国知识图谱最容易翻车的5个地方5.1 人物别名和指代混乱导致实体分裂现象图谱里有“诸葛亮”和“孔明”两个节点各有各的关系查询时数据互相拼不上。原因《三国演义》文本里同一人有多种称呼而我们的抽取脚本在初始版本只认全名导致“孔明杀敌”这类句子被丢弃或新建节点。解决处理别名必须在实体识别的同一层完成不要分两步。代码里先加载别名映射表分词后每个词都映射到标准名再入库。alias属性的设计就是为了应对这个问题——查询时不管输入什么称呼都要通过IN p.alias归一到同一节点。5.2 关系方向错误主语宾语颠倒现象问答系统回答“谁杀了关羽”时输出“关羽杀了某某”方向完全反了。原因触发词表里的“杀”只定义了关系类型没有定义谁是施动者、谁是受动者。正则匹配窗口抓取了触发词前后的两组人名但没有语义角色判断。解决引入依存句法分析。在抽取(subj, trig, obj)三元组时用LTP或hanlp检查subj是不是动作的发起者。一个轻量替代方案是人工规则如果subj是人名且obj也是人名默认subj为施动者——但“曹操被刘备击败”这类被动句必须先做“被”字句的主动化改写否则全部错乱。提示被字句处理的正则可以简单写成“甲被乙动词”转换后将(乙, 动词, 甲)入图。这个规则能覆盖90%的被动表述。5.3 重复导入导致关系成倍增长现象图上“曹操-交战-刘备”出现4条相同的边问答返回结果有重复项。原因批量导入脚本没做事务幂等性检查每次运行都无脑创建新关系。解决创建关系前执行MERGE而不是CREATE。MERGE会先检查是否存在相同起始节点、关系类型、结束节点和关键属性存在则不创建新关系。配合CREATE CONSTRAINT唯一约束能在数据库层面兜底。MATCH (a:Person {name: 曹操}), (b:Person {name: 刘备}) MERGE (a)-[r:HAS_ACTION {action: 攻}]-(b) ON CREATE SET r.source 第X回 RETURN r;ON CREATE SET只在关系首次创建时执行第二次运行脚本不会覆盖已有数据的source字段这是重复导入场景下的可重入设计。5.4 可视化页面卡顿节点超500个就白屏现象ECharts加载后浏览器无响应控制台报大量渲染告警。原因人物实体500关系边2000力导向图默认对所有节点同时计算斥力主线程被密集计算阻塞。解决分层渲染。首屏只显示核心人物出场次数前60名及其直接关系用户点击节点后再动态加载该人物的邻域子图。实现方式是Neo4j查询时加LIMIT 80然后前端用ECharts的appendData增量加入后续节点。另外把force.repulsion在节点数超过300时手动调小计算量会显著下降。5.5 问答系统返回空结果但图里明明有数据现象输入“诸葛亮的师父是谁”系统回答“未找到”但图里有“水镜先生-教导-诸葛亮”的关系。原因意图分类器没有识别“师父”这个词属于关系查询更没把它映射到教导/指导这类图谱关系。解决建立口语词到图谱关系的映射表例如“师父/老师/师傅”映射到教导“朋友/兄弟/结义”映射到结拜“主公/手下/下属”映射到隶属。映射表本身就是知识图谱Ontology的一部分扩展它的维护成本低但问答体验提升最明显。6. 进阶技巧给问答结果加上可信度验证与路径解释问答系统能回答问题是第一步能证明答案是对的才是让人信赖的关键。我的做法是每次查询时把答案对应的三元组和原文出处一并返回。具体实现是在关系创建时source字段里存储“第25回、关羽斩颜良”这样的原文定位问答时把这段文本作为佐证一起渲染。前端展示时答案的下方出现一个展开按钮“查看原文出处”点击后展示原文片段这比冷冰冰的名字列表有说服力得多。更进一步可以做一个“关系路径解释”功能。当用户问“曹操和诸葛亮有什么关系”时图谱给出的最佳路径可能是“曹操-挟持-汉献帝-控制-诸葛亮”这条路径用Cypher的变长路径查询就能拿到MATCH path shortestPath((a:Person {name: 曹操})-[:HAS_ACTION*1..4]-(b:Person {name: 诸葛亮})) RETURN [n IN nodes(path) | n.name] AS chain, length(path) AS depth;shortestPath是Neo4j内置函数[:HAS_ACTION*1..4]限定最多跨4跳避免查询出过长的间接关系。返回的chain数组可以直接在前端用连线展示——这比只给最终答案有更强的可解释性。问答系统还有一个常见的隐蔽问题用户问“诸葛亮和魏延是什么关系”答案是一条“隶属”关系但用户可能更关心的是他们之间的“不和”事件。要处理这种需求在关系类型设计阶段就要增加“情感倾向”属性在HAS_ACTION关系里存一个polarity字段正数代表友好、负数代表敌对。这个属性可以在可视化时给边染色红色代表敌对、绿色代表友好直观又增加了一层信息表达。关于系统评估我习惯保留一份“金标准测试集”包含50~100条带标准答案的问答对比如“刘备的军师是谁”应回答“诸葛亮”。每次修改知识抽取规则或问答模板后跑一遍测试集算准确率。这个测试集的维护成本很低但能让你避免“越改越差而不自知”。做训练集时记得把同义不同说法的问题都写进去“军师”“先生”“军师中郎将”都要覆盖才能暴露别名处理的短板。最后说一句我的个人习惯图数据库里的数据永远保留一份可回溯的原始文本版本不要只存抽取后的三元组。因为问答系统出错时90%的问题都能通过“回看原文”定位但前提是你留了原文。没有这个备份出错了只能望图兴叹。把原文挂在每个关系实体的evidence字段里维护成本不高却是这个系统最值得投资的“后悔药”。希望这篇笔记能让你少走几个弯路把《三国演义》知识图谱做成一个拿得出手的完整作品。本文还有配套的精品资源点击获取
返回列表