ARTICLE DETAIL

资讯详情

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

基于LTP与Neo4j的知识图谱构建:以《红楼梦》人物关系为例

基于LTP与Neo4j的知识图谱构建:以《红楼梦》人物关系为例 简介面向知识图谱与自然语言处理学习者这份zip压缩包提供了一套完整的《红楼梦》人物关系可视化与问答系统实现。项目以LTP作为命名实体识别与关系抽取工具覆盖数据采集、知识图谱构建、前端交互及KGQA问答等环节目录包含app.py启动入口、templates页面模板、neo_db图数据库模块以及spider爬虫脚本适合用于课程设计或毕业设计参考。包内共297个文件其中jpg图片素材占多数py/html/css/js共同构成系统主体另含zbak备份与配置文件压缩包整体约5.98MB。已有92人学习下载可供快速了解工程结构并部署运行。整套代码结构清晰前后端分离数据流从原始采集到最终可视化形成闭环便于二次开发与学习工程完整呈现了从爬虫到可视化问答的技术链路配合图片素材与模型备份可支撑本地复现。1. 从“宝黛钗”到图谱这套《红楼梦》知识图谱系统在解决什么问题很多人第一次接触知识图谱都是从“人物关系”这类场景入手的因为人物关系足够直观又天然适合用图结构来表达。但真把《红楼梦》全本txt丢给NLP工具后你会发现一个尴尬的事实LTP能把“贾宝玉”“林黛玉”这种典型人名识别出来却会把“颦儿”“二哥哥”当普通词跳过能抽取出一堆“宝玉说”“黛玉笑道”这种动作三元组但真正有价值的“父子”“夫妻”“主仆”关系反而淹没在噪声里。这套基于LTP命名实体识别与关系抽取的《红楼梦》知识图谱可视化与问答系统解决的就是这条完整链路用中文NLP工具把小说原文转成结构化三元组再把三元组落成Neo4j图谱最后通过一个模板问答系统让用户用自然语言查“宝玉和黛玉是什么关系”。适合做NLP课程设计、毕设以及想在真实中文语料上跑通“知识图谱构建→存储→问答”全流程的从业者。2. LTP分词与命名实体识别人物实体怎么从原文里被捞出来2.1 为什么选LTP而不是斯坦福CoreNLP或jieba中文命名实体识别的工具选择第一轮就会排掉jieba——它的分词和词性标注做得不错但标准的NER标签体系不是它的强项拿它识别小说人名等于自己写一套规则去猜。斯坦福CoreNLP的中文模型确实能用但对《红楼梦》这种白话文和文言夹杂的语料维护频率低、模型偏新闻风格跑出来的Nh人名标注经常漏掉古风称谓。LTP的优势在于它是完整流水线分词、词性标注、命名实体识别、依存句法分析全部串在一起而且词性和NER结果可以直接喂给后续的关系抽取逻辑不用自己拼接口。这也是标题里点名的原因。这里要区分两套写法。老工程里用的是pyltp接口是Segmentor、NamedEntityRecognizer这类独立对象需要手动加载模型文件。新版ltp把接口统一成了一个LTP类加载模型、跑流水线都更方便。我下面的代码以新版ltp的Python接口为准老版本读者可以按对应类名替换流程不受影响。2.2 LTP流水线分词、词性、NER的调用方式LTP的调用方式很直接先初始化模型再传入文本四个任务依次执行。from ltp import LTP ltp LTP() text 宝玉见黛玉来了忙迎上去笑道妹妹可好。 seg, hidden ltp.seg([text]) pos ltp.postag(hidden) ner ltp.ner(hidden) print(分词, seg) print(词性, pos) print(NER, ner)代码逻辑是先用ltp.seg做分词返回两个值分词结果seg和隐层向量hidden。后面的postag和ner都直接接收hidden好处是只编码一次文本后续任务复用特征不会因为多次编码导致性能浪费。ner返回的是BIO格式的标签序列比如“宝玉”对应B-Nh和I-NhNh是LTP的人名标签“黛玉”同样会被标为Nh。但注意小说里“忙迎上去笑道”这种描写会被分出一堆动词和副词它们的NER标签全是O这一步暂时不用管后面关系抽取会用谓词白名单过滤。拿到BIO序列后不能直接用要合并连续片段才算完整实体。def merge_entities(ner_tags): entities [] current_type None start None for i, tag in enumerate(ner_tags): if tag.startswith(B-): if current_type is not None: entities.append((start, i - 1, current_type)) current_type tag[2:] start i elif tag.startswith(I-) and current_type is not None: continue else: if current_type is not None: entities.append((start, i - 1, current_type)) current_type None start None if current_type is not None: entities.append((start, len(ner_tags) - 1, current_type)) return entities逻辑说明B-打头表示实体开始I-是实体延续O表示非实体。函数里遇到新的B-时先把上一个实体收尾遇到O或非I-标签时强制结束当前实体最后循环结束再补一次收尾防止最后一个实体漏掉。返回的(start, end, type)三元组直接可以用来切片分词结果把连续的token拼回人名。参数说明ner_tags是LTP返回的单句话标签列表类型是Python list。实际处理整本小说时要按句切分后逐句调用不要把整个txt一次性丢进去否则句子太长会让解析结果退化。2.3 小说人名识别的坑别名与词典融合LTP在新闻语料上训练对《红楼梦》的人名识别有两个明显短板。第一“颦儿”“凤辣子”“二哥哥”这类称谓在模型眼里不是标准人名第二“林如海之女林黛玉”这种句式里“林如海”和“林黛玉”连在一起模型可能只认出后半段。解决思路不是改模型而是做词典融合。先维护一个人物别名表把标准名和所有别称对应起来然后在NER结果上做一层映射。alias_map { 黛玉: 林黛玉, 颦儿: 林黛玉, 林妹妹: 林黛玉, 宝二爷: 贾宝玉, 凤辣子: 王熙凤, 凤姐: 王熙凤, } def normalize_name(name): return alias_map.get(name, name)逻辑说明normalize_name只做标准名归一化不改变NER流程。实际操作中我一般会把这个别名表同时喂给LTP的分词词典这样分词阶段就不会把“颦儿”切成“颦”和“儿”后续NER更容易把它标成人名。参数说明alias_map的key是原文中可能出现的所有变体value是知识图谱里的标准节点名。这一层非常重要否则后面导入Neo4j时“黛玉”和“林黛玉”会被建出两个节点整个图谱直接就裂开了。3. 关系抽取与三元组构建从“共现”到“有语义的关系”3.1 关系三元组的定义关系类型与属性设计知识图谱的底层单位是三元组也就是(subject, relation, object)。但《红楼梦》的人物关系有一个特点类型不能太粗也不能太细。全部用“认识”这种粗粒度关系图谱看起来就是一团互相连接的毛线球全部用“表姐妹”“堂兄妹”“姑表亲”这种细粒度关系又会导致关系类型爆炸查询时根本没法写Cypher。我建议把关系类型控制在10个以内父子、母子、夫妻、兄弟、姐妹、主仆、亲戚、好友、敌对、对话。这组类型已经能覆盖前八十回绝大多数人物关系而且每个类型的语义足够清晰问答系统也好解释。三元组除了三个核心字段还要带两个属性出处章节和原文片段。这个设计在很多人做课程设计时会被忽略但实际回溯验证时极其有用。没有原文片段你抽出来的“宝玉—对话—黛玉”到底是哪一回里的哪句话完全黑匣子出了错只能干瞪眼。relation_triple { subject: 贾宝玉, relation: 主仆, object: 袭人, chapter: 3, source: 宝玉之婢袭人本名珍珠。 }参数说明relation字段的值必须是预定义的类型白名单之一不能在抽取过程中临时造出新类型。chapter是数值类型方便后续做按章节过滤的查询。3.2 基于谓词模板与依存句法的关系抽取关系抽取的实现业界常规做法是先用深度学习模型做联合抽取但对小说这种口语化语料标注训练数据的成本远高于规则收益。我自己更倾向“正则模板为主、依存句法为辅”的路线。正则模板的思路很直白找出原文中的“X是Y的母亲”“X娶了Y”“X是Y的哥哥”这类句式用占位符匹配人名再映射成关系类型。import re rel_templates [ (r(?Px[^。]{1,6})是(?Py[^。]{1,6})的母亲, 母子), (r(?Px[^。]{1,6})是(?Py[^。]{1,6})的父亲, 父子), (r(?Px[^。]{1,6})娶了(?Py[^。]{1,6}), 夫妻), (r(?Px[^。]{1,6})嫁给了(?Py[^。]{1,6}), 夫妻), ] def extract_by_template(sentence): results [] for pattern, relation in rel_templates: for m in re.finditer(pattern, sentence): x normalize_name(m.group(x)) y normalize_name(m.group(y)) if x and y and is_person(x) and is_person(y): results.append((x, relation, y)) return results逻辑说明[^。]{1,6}的意思是匹配1到6个非标点字符用来卡住人名位置。normalize_name负责把别称映射成标准名is_person再校验一遍防止“林如海之女”这种片段里把“女”字当成实体放进三元组。正则模板的局限也很明显同样一个“是”放在“林黛玉是贾母的外孙女”里是亲戚关系放在“袭人是宝玉的丫鬟”里是主仆关系。这种语义差异用正则很难泛化所以关系类型要细化为“亲戚”“主仆”等更清晰的类别而不是一个笼统的“亲属”。LTP的依存句法在这里可以做一个辅助校验。把句子跑一遍ltp.sdp看“是”这个动词的主语和宾语能防止正则匹配到倒装或者插入语误导出来的错误三元组。不过依赖依存句法的前提是分词足够准一旦分词错了依存关系也跟着错。因此我的习惯是“正则抽取结果必须过一遍谓词白名单白名单外的一律丢弃”。3.3 从原文抽不出来的关系用结构化底表补全正则模板能解决“贾母是黛玉的外祖母”这种显式句子但《红楼梦》里大量关系是隐性的。比如“元春是宝玉的姐姐”书中很少用“元春是宝玉的姐姐”这种句式直接描述正则模板全跑到死胡同里。这个阶段就不要硬抽了直接做结构化底表。把《红楼梦》人物关系表整理成CSV字段就是三元组加上出处。底表数据来源于原著读法和权威关系资料不属于NLP抽取任务但它是图谱完整性不可或缺的部分。subjectrelationobjectchaptersource贾元春姐妹贾宝玉18贾妃乃长姊宝玉为弱弟贾探春姐妹贾宝玉56探春道我们这社里没有贾宝玉主仆袭人3宝玉之婢袭人王夫人母子贾宝玉23王夫人见宝玉表格里的每一行都是图谱的种子数据。实际操作时我会让脚本先加载底表再跑正则模板两者结果合并后统一做去重。底表是“保底”模板是“增量”两条腿走路图谱的边才不会稀疏得没法看。4. Neo4j图谱建模与导入人物关系如何落成可查询的图结构4.1 节点与边的建模标签设计和本体映射把三元组导入Neo4j之前先想清楚节点标签和边类型。很多初学者急着写LOAD CSV结果导入完发现所有节点都堆在一个标签下图谱页面一打开就是一片蜘蛛网。我用的建模方案是人物节点用Person标签地点用Place物品用Object另外建一个Category标签用来挂“金陵十二钗”这类分类属性。人物节点上除了name再挂gender、alias、category三个属性这样查询“金陵十二钗里和宝玉有对话关系的人物”直接走属性过滤不用再造一层关系。如果你研究过本体建模会发现这里的设计已经很接近一个轻量本体Person、Place、Object是类关系边是属性category是属性约束。区别在于本体层面能做推理这里只做存储和查询。对这个项目来说这个程度已经够了本体建模的语义推理在《红楼梦》场景里属于过度设计。关系边只使用第3章定义的白名单类型。注意边不能带type这种属性去区分因为Neo4j的关系类型本身就是边标签应该直接用父子、夫妻、主仆这种值作为关系类型而不是建一个统一的RELATION再把类型放属性里。后者写查询会非常难受每次都要WHERE r.type 父子。4.2 数据落库从CSV到Neo4j的批量导入准备好人物的CSV和关系的CSV后用LOAD CSV导入是最高效的方式。关系文件我一般保留三列subject、relation、object另外带chapter和source两个属性列。:auto USING PERIODIC COMMIT 500 LOAD CSV WITH HEADERS FROM file:///characters_relations.csv AS row MERGE (a:Person {name: row.subject}) MERGE (b:Person {name: row.object}) MERGE (a)-[r:RELATION {type: row.relation}]-(b) ON CREATE SET r.chapter toInteger(row.chapter), r.source row.source逻辑说明前两个MERGE负责建节点MERGE的语义是“有就匹配没有就创建”防止同一个名字被插成两个节点。第三个MERGE负责建边这里也用了MERGE而不是CREATE因为CREATE会把完全相同的边重复插入跑两次脚本图里就出现双倍边。参数说明USING PERIODIC COMMIT 500表示每处理500行提交一次数据量几万行时能明显降低内存压力。toInteger(row.chapter)把CSV里的字符串转成整数这样才能按章节做数值范围查询。导入之前必须给人物名字建唯一约束否则MERGE的性能会大打折扣而且无法拦截脏数据CREATE CONSTRAINT person_name_unique FOR (p:Person) REQUIRE p.name IS UNIQUE新版Neo4j用REQUIRE语法老版本是ON (p:Person) ASSERT p.name IS UNIQUE。这条约束建完后如果CSV里有两个不同的人恰好名字相同导入会直接报错逼你去排查别名表这其实是好事。4.3 图谱可视化与数据导出Neo4j本身自带的Browser就是最直接的可视化工具跑一句MATCH (a:Person)-[r]-(b:Person) RETURN * LIMIT 200就能看到局部关系图。但Browser渲染几百个节点时性能急剧下降而且它的布局算法对“人物关系网”这种密集图并不友好。更可控的方案是把查询结果导出成JSON交给前端图表库渲染。这里我一般会导成nodes和links两个数组兼容生态最广。from neo4j import GraphDatabase driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, password)) def export_graph(limit300): query MATCH (a:Person)-[r]-(b:Person) RETURN a.name AS source, type(r) AS relation, b.name AS target LIMIT $limit with driver.session() as session: result session.run(query, limitlimit) nodes, node_set [], set() links [] for record in result: for name in [record[source], record[target]]: if name not in node_set: node_set.add(name) nodes.append({id: name}) links.append({source: record[source], target: record[target], relation: record[relation]}) return {nodes: nodes, links: links}逻辑说明node_set用来去重每个名字只在nodes数组里出现一次links里保留source、target和relation三个字段前端拿到relation可以直接作为连线标签展示。参数说明limit控制返回边数上限我在开发阶段习惯设300这个数量级前端布局还算流畅。如果后续要全量导出建议按关系类型分批查询否则图一渲染就是上千条边肉眼根本看不出结构。5. 常见问题与避坑实体错标、关系冗余与问答翻车现场5.1 实体错标与别名分裂现象LTP把“林如海之女林黛玉”识别成了“林如海”加“林黛玉”两个实体但“林如海”在另一句里又没被识别更常见的是“颦儿”“二哥哥”这类别称压根没有NER标签。原因LTP的NER模型在新闻和通用语料上训练小说里的古风称谓和角色关系式描述超出了模型的泛化范围这是模型和数据分布不匹配造成的不是代码bug。解决维护一份项目级别名表在分词前把别称统一替换成标准名或在NER结果上做后处理归一化。我的习惯是两条路都走LTP的分词词典加别称让分词阶段不再切碎NER的标签结果再过一层normalize_name映射。只有这两层同时生效Neo4j里才不会出现“黛玉”和“林黛玉”两个孤立节点。5.2 关系冗余和无意义三元组爆炸现象抽取结果里出现“宝玉—回身—笑”“黛玉—听—说”这种三元组图谱一眼看去全是动词真正的人物关系完全被淹没。原因正则匹配没有做谓词白名单约束或者直接拿依存句法的SBV到VOB主干去抽三元组。小说叙事句里大量动作动词每个都产出一条边自然会爆炸。解决关系类型白名单是唯一的过滤器。所有抽取结果必须落在第3章定义的关系类型集合里不在白名单内的直接丢弃。对“回身”“笑道”这类动词不要在模板里枚举直接靠白名单拦截是最省事的。5.3 导入重复边和属性丢失现象同一个LOAD CSV脚本跑了两次图谱里的“父子”边数量翻倍或者边存在但chapter属性是null。原因脚本里用了CREATE而不是MERGE建边重复执行必然累积属性丢失多半是CSV列名和row.chapter的key对不上常见的是CSV表头有大小写或空格差异。解决建边统一用MERGE并且先建唯一约束。属性为null时在LOAD CSV语句后加一个WITH row WHERE row.chapter IS NOT NULL做过滤或者导入完成后跑一条MATCH ()-[r]-() WHERE r.chapter IS NULL RETURN COUNT(r)检查漏网之鱼。导入前先看表头确认列名一个字符都不差这个习惯能省掉大量排错时间。5.4 问答解析在“反向问题”上失效现象用户问“谁是贾母的丫鬟”模板解析成(贾母)-[主仆]-(?)去查询结果什么也查不到。因为图谱里的边方向是“丫鬟—主仆—贾母”也就是(鸳鸯)-[:主仆]-(贾母)方向正好相反。原因上一章导入时用(subject)-[:主仆]-(object)的方向定了边但自然语言里“谁是贾母的丫鬟”和“贾母是谁的主人”表达的是同一对关系模板没有做方向反转查询。解决问答模块查询时所有关系都做双向匹配。先按原方向查询查不到就反转方向再查一次。更稳的写法是导入时就约定“主仆”关系的subject永远是仆人、object永远是主人这样模板解析方向就固定了但实际项目里总会有漏网的数据双向查询才是兜底方案。6. 问答系统实现与图谱效果验证一个模板匹配问答的完整闭环问答系统不接大模型用模板匹配就够用。把自然语言问句解析成Cypher查询交给Neo4j执行返回结果后拼成自然语言答案。意图分三类查关系、查属性、查人物。意图问句示例解析规则关系查询宝玉和黛玉是什么关系取两个人物名双向查边属性查询黛玉的母亲是谁取主语和关系词查指向主语的人人物查询谁是贾母的丫鬟取宾语和关系词查指向宾语的人import re def parse_question(question): m re.search(r(.{1,6})和(.{1,6})是什么关系, question) if m: x, y normalize_name(m.group(1)), normalize_name(m.group(2)) return (relation, x, y) m re.search(r(.{1,6})的(.{1,4})是谁, question) if m: x, rel normalize_name(m.group(1)), m.group(2) rel_map {母亲: 母子, 父亲: 父子, 丈夫: 夫妻, 哥哥: 兄弟, 姐姐: 姐妹} return (attr, x, rel_map.get(rel, rel)) return None逻辑说明第一个正则处理“A和B是什么关系”第二个正则处理“A的母亲是谁”这类属性查询。rel_map把自然语言里的称谓词映射到图谱中的关系类型这是模板问答的关键一环映射表不准Cypher就查不到数据。验证方法很简单。我整理20个测试问句人工标注标准答案用一个脚本循环调用parse_question和Neo4j查询比对结果算准确率。把这个测试集固定下来后面每次改动关系抽取规则或图谱数据后重新跑一遍能立刻发现回归问题。从那以后我每次搭这类图谱项目都会先跑通这个测试集再去调可视化页面不然前端看起来是通的查出来的答案却错得毫无头绪。这个顺序很土但确实救我很多次希望帮到你。本文还有配套的精品资源点击获取
返回列表