简介:一套基于知识图谱的《红楼梦》人物关系可视化与问答系统完整实现,面向自然语言处理与知识图谱初学者及课程设计开发者。包内含8个Python核心模块,覆盖LTP分词、词性标注、命名实体识别与关系抽取,并集成neo4j图数据库构建、查询及KGQA问答交互。前端提供4个HTML页面,实现欢迎、人物关系检索、全关系网络展示与问答功能,配套CSS与JavaScript完成交互样式,并基于Bootstrap等框架提供响应式布局。资源共297个文件,以184张人物图片、Python脚本、HTML界面及样式脚本为主,含49个zbak备份文件,压缩包整体5.98MB。目录按raw_data、neo_db、spider等模块划分,raw_data存放结构化三元组数据,neo_db内含配置、建图与查询脚本,spider已完成人物信息及图像数据采集,数据流从原始采集、图谱构建到可视化问答形成完整闭环。已有92人浏览学习,适合用于理解知识图谱构建全流程、复现人物关系抽取实验或作为毕业设计参考。
1. 从「共现图」到「关系图」:为什么红楼梦人物图谱必须走 LTP 这条线
很多人第一次做《红楼梦》人物关系图时,第一反应是统计两个人出现在同一段落里的次数,把共现次数当关系强度,画出来的图热闹但不可信:贾宝玉和贾母同屏几十次,可「祖孙」这条边是怎么来的,图里完全说不清。真正能落地的做法是走中文信息抽取的标准路线——先分词、做词性标注,再用 LTP 做命名实体识别把「贾宝玉」「薛宝钗」这样的实体捞出来,接着做关系抽取得到「谁—对—谁—是什么关系」的三元组,最后灌进知识图谱,对外提供可视化和问答查询。这套方案适合手里有长篇中文文本、想快速搭出可演示知识图谱问答原型的人;它不追求大模型式的无所不知,但每一步都能白盒排查,结果可解释、可修正。下面按我平时做这类项目的顺序,把这套链路完整拆开。
2. 先定骨架:从 txt 到 Neo4j 的六步流水线与选型
2.1 别上来就写代码:先画一条能落地的处理管线
拿到一部小说文本就急着装库、跑模型,是最容易翻车的开局。正确做法是先定数据流,明确每一步的输入和输出长什么样,再动手写代码。我一般把全流程拆成六步:文本预处理 → LTP 分词与词性标注 → 命名实体识别 → 关系抽取 → 别名合并与实体对齐 → 写入图数据库。
第一步的文本预处理比想象中重要。公开渠道能拿到的《红楼梦》txt 经常有大量回目信息、批注残留、空行和章节错乱,如果不先按回目切好、再把每个回目切成句子,后面 LTP 处理长文本时要么结果漂移,要么内存吃紧。我习惯先用正则把「第一回」「第二回」之类的回目标题提取出来,再按句号、问号、感叹号分句,以句子为单位进入 NLP 环节。
第二步到第四步是 LTP 的主场。分词和词性标注解决「哪些词是人名」的候选问题,命名实体识别进一步把人名、地名、机构名归类,关系抽取则是把动词和语义角色拼成三元组。这里要先立一个预期:老版小说文本和新闻语料差别很大,LTP 的通用模型不会完美,后面必须有后处理兜底。五六两步是把 NLP 的结果变成知识图谱,涉及实体去重、关系类型归一化和图库导入,是整个项目里最吃细节的部分。
2.2 为什么选 LTP:词典与正则的边界在哪
市面上做中文命名的方案不少,常见的就三种:纯正则加词典、LTP 这类传统 NLP 工具、大模型抽取。纯正则和词典适合「已知名单」的场景——比如你手里有一张完整的红楼人物表,可以把「贾宝玉」「林黛玉」穷举进词典。但小说里人物出场方式极不规律:有全名「贾宝玉」,有「宝二爷」「怡红公子」这种称位,还有大量代词「他」「她」。正则词典面对这种开放集合,召回率会掉得很难看。
大模型抽取是另一个极端,召回和零样本能力确实强,但输出格式不稳定,同一个句子跑两遍可能给出不同的三元组,而且推理成本高、结果黑匣子,出了问题不好定位是哪一层抽错了。对「要搭一个能演示、能讲解、还能逐步修正」的知识图谱问答系统来说,LTP 是三者里性价比最稳的:模型文件不大、完全本地运行、API 清晰,还带语义角色标注(SRL),这是关系抽取最需要的燃料。
一个小建议是,别把 LTP 和大模型看成二选一。我现在的做法是 LTP 做主体抽取,把置信度低的句子挑出来,再用大模型对这批句子做二次校验,两种工具互为后悔药。纯靠大模型清洗全量数据开销太大,没必要。
2.3 架构选型对比:三条路线的适用边界
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 正则 + 人物词典 | 极快、完全可控 | 召回低,别名和指代处理不了 | 只有几十个核心人物的演示 |
| LTP 传统 NLP 管线 | 本地运行、可解释、支持 SRL | 对新词和文言表述有误差 | 长文本知识图谱、需逐层排查 |
| 大模型抽取 | 零样本能力强,理解上下文 | 输出不稳定、成本高、黑匣子 | 小规模精标、复杂关系兜底 |
选 LTP 还有一个实际原因:它能产出词性、依存句法和语义角色多路特征,同一个句子可以从不同角度验证结果。比如「林黛玉进贾府」这句话,光靠分词看不出关系,但 SRL 能标出「进」的施事是林黛玉,这就为后续关系抽取埋好了伏笔。如果你打算做工业知识图谱那套「本体优先、数据清洗先行」的严格流程,小说文本其实不适用——文学作品里模糊表达太多,必须先跑通一个带噪音的快速原型,再逐步往精确方向调。
3. LTP 命名实体识别与关系抽取:拿到的不是孤立词,是上下文
3.1 最小可用代码:LTP 4.x 的分词、词性与 NER 一条龙
用 LTP 做红楼梦 NER,常见做法是直接用 LTP 的 Python SDK。网上大量教程还在讲 pyltp(LTP 3.x 的绑定库),和 LTP 4.x 的 API 完全不同,抄代码前一定要先确认版本。这里以 LTP 4.x 的 Python SDK 为例,先跑通最小流程:
from ltp import LTP # 首次运行会联网下载预训练模型,离线环境请提前准备模型包 ltp = LTP() text = "贾宝玉是贾母的孙子,林黛玉是贾宝玉的表妹。" seg, hidden = ltp.seg([text]) pos = ltp.postag(hidden) ner = ltp.ner(hidden) print("分词:", seg[0]) print("词性:", pos[0]) print("NER:", ner[0])逻辑说明:ltp.seg返回分词结果和 hidden 向量,这个 hidden 是后续 postag、ner、srl 共用的中间表示,不要在每次调用时都重新跑一遍 seg,否则性能浪费严重。pos是词性序列,ner返回命名实体跨度,每个元素是实体类型加实体文本。LTP 的 NER 标签中,Nh代表人名,地名、机构名等标签不同模型版本略有差异,拿到结果后先打印出来看一遍再做规则。
参数说明:ltp.seg接受的是句子列表,不是一整段字符串,所以调用前要把长文本按句切开再传入。如果直接传一整回,LTP 内部会按自己的分句逻辑切分,但这样你就失去了对句子边界的控制。句子边界对关系抽取非常重要,关系只在单句内抽取是后面所有操作的前提。
跑通之后建议先在目录里建一个entity_candidates.txt,把 NER 输出的所有人名落盘,统计频率。这一步能帮你快速发现 LTP 对《红楼梦》文本的识别习惯:哪些人名是稳定识别的,哪些是拆开的,后面做别名合并才有依据。
3.2 人物实体的「别名黑洞」:宝玉、宝二爷、怡红公子怎么合并
《红楼梦》的人名情况比新闻语料复杂得多,同一个角色有多种称呼,这是实体链接和知识图谱落库前必须解决的问题。最典型的是贾宝玉:全名「贾宝玉」常出现,但书里更多叫他「宝玉」;王夫人叫他「二爷」或「宝二爷」;诗社活动里他又叫「怡红公子」「绛洞花主」。黛玉也有「林黛玉」「黛玉」「颦儿」「潇湘妃子」几种叫法。
如果不对齐别名,图谱里会出现十几个节点:一个「贾宝玉」挂着「宝玉」这个节点,另一个「宝二爷」又连着黛玉那边的边。整个图看上去节点数量膨胀,实际信息量却变低了。我之前项目就吃过这个亏,第一版图里有四百多个节点,人工一查,三分之一是同一个人。
解决办法是建立别名映射表,用规范名统一。我一般直接在项目里放一个 python 字典:
alias_map = { "宝玉": "贾宝玉", "宝二爷": "贾宝玉", "怡红公子": "贾宝玉", "绛洞花主": "贾宝玉", "黛玉": "林黛玉", "颦儿": "林黛玉", "潇湘妃子": "林黛玉", }逻辑说明:这个映射表不是一次性写完的。先把 NER 的实体候选按频率排序,人工挑出高频别名,再逐个映射到规范名。映射的原则是「只合并确定指代同一人的称呼」,拿不准的先不合并,宁缺毋滥。
在实际处理时,我还会加一道自动规则:如果某个称谓在文本中同时和多个规范名关联,就先把它挂为「未对齐」节点,等问答系统需要时再人工核对。这类歧义称谓比预想的多,「太太」有时候指王夫人,有时候指邢夫人,不能盲目合并。
3.3 关系抽取:用 SRL 和句法规则拼出「谁—对—谁—做了什么」
有了人物实体,下一步是关系抽取。LTP 提供语义角色标注(SRL),这是抽取「谁对谁做了什么」最省力的路径。常见做法是把 SRL 输出中的ARG0当作施事,ARG1当作受事,谓词动词作为关系类型,拼成三元组:
def extract_triples_from_srl(ltp, sentence): seg, hidden = ltp.seg([sentence]) srl = ltp.srl(hidden) triples = [] for item in srl[0]: pred_idx = item[0] roles = item[1] arg0 = roles.get("ARG0") arg1 = roles.get("ARG1") if arg0 and arg1: head = "".join(seg[0][arg0[0]:arg0[1]]) rel = seg[0][pred_idx] tail = "".join(seg[0][arg1[0]:arg1[1]]) triples.append((head, rel, tail)) return triples逻辑说明:srl返回每个谓词对应的语义角色列表,ARG0和ARG1是角色在句子里的起止下标。把下标的词拼接出来,就得到「贾宝玉」「林黛玉」这样的实体文本。这里的rel是动词本身,类似「叫」「喜欢」「是」,后续要映射成图谱里的关系类型。
参数说明:SRL 抽出来的三元组噪音很大。动词「是」会产出「贾宝玉是贾母的孙子」这种正确三元组,也会产出「林黛玉是贾宝玉的表妹」这类靠上下文才能判断的表述。对于问答系统,我建议把关系类型做一个归一化:把「叫」「唤」「称作」归为「别称」,把「喜欢」保留为「喜欢」,把「是...的..."」按亲属词表映射为「亲属」。用一个字典维护关系映射,不用追求把所有动词都归对,先把高频动词覆盖住。
3.4 关系过滤与置信度阈值:别把所有共现都当关系
LTP 的 SRL 会把很多「伪关系」抽出来。比如「贾宝玉见林黛玉走来」,SRL 可能把「见」的施事标成贾宝玉、受事标成林黛玉,产出「贾宝玉-见-林黛玉」这个三元组。从语法看它没错,但从知识图谱的角度,「见」不是一种值得建边的稳定关系。
给关系定置信度的做法是:按「关系类型 + 谓词」统计频次,再人工定阈值。像「喜欢」「是亲戚」这类关系频次高、语义稳定,直接保留;「见」「说」「看」这类通用感知动词,单次出现不建边,只有同一对实体间的同类型关系累计出现超过三次才考虑建边。这里可以加一条规则:所有动词先进候选池,按人物对聚合,最后只把聚合后频次前 20% 的关系写入图谱。
这一步也是在为后面的问答系统清理路面。知识图谱里如果塞满了几千条「见」「说」这种动作边,回答「贾宝玉的表妹是谁」时会混入大量无关路径,精度会很差。把噪音挡在图谱构建阶段,永远比在问答阶段做排除要省力。
4. 从三元组到问答与可视化:建模、Cypher 导入、前端力导向图
4.1 人物关系的数据模型:节点、边、属性怎么设计
把三元组导入 Neo4j 前,要先定数据模型,这一步决定后面问答查询好不好写。常见的做法是两层设计:节点只有一类,Label 为Person,属性里存规范名canonical_name、别名列表aliases、首次出现回目first_chapter;边的关系类型按语义分,常见的有亲属、主仆、喜欢、朋友等。关系类型不要超过十种,否则问答映射会失控。
设计模型时有一条原则:把「人名」和「称谓」分开存。比如「王夫人」这个节点,属性里存aliases: ["太太", "王夫人"],但图谱的边永远指向规范名。这样在问答系统里,用户说「太太」也能通过别名属性命中王夫人节点。
另一个设计点是为关系边加weight属性。这个权重不是共现次数,而是关系抽取时同一对人物之间有效三元组的去重后频次。权重的作用有两个:可视化时控制边的粗细,问答排序时作为多答案的置信度参考。
4.2 Cypher 批量导入与去重:MERGE 的正确用法
数据清洗完,导入 Neo4j 常用LOAD CSV加MERGE的方式。我这里先给节点建唯一约束,再用 CSV 批量建边:
CREATE CONSTRAINT person_name IF NOT EXISTS ON (p:Person) ASSERT p.canonical_name IS UNIQUE;LOAD CSV WITH HEADERS FROM 'file:///relations.csv' AS row MERGE (a:Person {canonical_name: row.source}) MERGE (b:Person {canonical_name: row.target}) MERGE (a)-[r:REL {type: row.relation_type}]->(b) SET r.weight = toFloat(row.weight);逻辑说明:MERGE是查重加创建的组合操作,节点和边都按指定的键去重。第一次跑导入时别用CREATE,否则同一个「贾宝玉-亲属-贾母」边会重复创建,图里出现多条平行边,查询时 count 出来的数量是虚高的。
参数说明:relations.csv里的source和target必须是别名映射后的规范名,这一步务必在 Python 里提前做完,不要在 Cypher 里去处理别名。relation_type也要提前归一化,比如把「表兄妹」「表妹」等文本统一成「亲属」。权重是浮点数,用toFloat转换,否则 CSV 里的数字会被当作字符串存进属性,排序时会按字典序排,出现「10」小于「9」的荒唐结果。
导入完成后,我一般会跑一条验证查询:按关系类型统计边数,检查是否有某个关系类型数量异常偏高。如果「喜欢」类边数超过三千条,大概率是规则太宽,把「笑道」之类的动词也收进去了,得回头调过滤条件。
4.3 可视化:Neo4j Browser 排除法加 ECharts 力导向图
Neo4j Browser 自带的可视化适合图结构探索,不适合直接对外展示,因为人物一多,图会乱成一团。我一般的做法是两层结合:开发检查时用 Browser 查询局部关系,最终展示页面用 ECharts 的力导向图。
ECharts 力导向图的数据结构是节点列表加边列表:
option = { title: { text: '《红楼梦》人物关系图' }, tooltip: {}, series: [{ type: 'graph', layout: 'force', roam: true, label: { show: true, position: 'right' }, force: { repulsion: 200, edgeLength: 100 }, data: nodes, links: links }] };逻辑说明:nodes数组的元素长这样:{ id: '贾宝玉', category: 0 },links数组的元素是{ source: '贾宝玉', target: '林黛玉', value: 5 }。这里的source和target直接复用 Neo4j 里的规范名。force.repulsion控制节点间的斥力大小,数值越大图越松散;edgeLength控制边长度,数值越小关系紧密的节点越靠近。
参数说明:把全量人物一次性塞进 ECharts,页面会很卡。常见做法是按关系权重或回目范围做筛选,比如只展示与指定人物直接相连的一度、二度关系。演示页面上给一个「核心人物 + 层数」的交互,比一上来画全量图效果要好得多。
可视化阶段最容易踩的坑是「把图做得好看但没法讲」。我建议在节点上多挂一个属性:人物首次出现的回目。这样前端可以按回目做时间轴。从「元春省亲」到「黛玉焚稿」,人物关系网络的变化本身就是一条叙事线索。
4.4 问答系统的主链路:从自然语言问句到 Cypher 查询
问答系统的实现不需要复杂,先做模板匹配,再上图谱查询,这是知识图谱问答最可靠的第一步。问句类型常见的就三种:关系查询(「贾宝玉的母亲是谁」)、属性查询(「林黛玉住在哪里」)、关系路径查询(「贾宝玉和林黛玉是什么关系」)。
我的做法是先用规则把问句里的实体抽出来,再识别意图,最后拼 Cypher。典型代码长这样:
import re intent_patterns = { "relation": re.compile(r"(.+?)的(.+?)是谁"), "relation_path": re.compile(r"(.+?)和(.+?)是什么关系"), } def answer_question(question, alias_map, graph): entity = extract_entity(question, alias_map) for intent, pattern in intent_patterns.items(): match = pattern.search(question) if not match: continue if intent == "relation": rel_type = match.group(2) cypher = ( "MATCH (p:Person {canonical_name: $name})" "-[r:REL {type: $rel}]->(q:Person) " "RETURN q.canonical_name AS answer" ) return graph.run(cypher, name=entity, rel=rel_type) return "这个问题我暂时答不上来,但我可以给你看相关段落。"逻辑说明:extract_entity这一步很关键,它先从问句里找别名映射表内的词,再回退到 NER。如果省略这一步,用户问「二爷的母亲是谁」时,图谱会查不到实体,因为库里只存了「贾宝玉」,没有「二爷」这个节点。
参数说明:意图正则的写法要保守。「X 的 Y 是谁」这种问法是中文里最稳定的句式,先把它覆盖住;「X 和 Y 是什么关系」是第二优先。更复杂的问句,比如「谁喜欢林黛玉」,模板匹配不上,需要用反向查询的模板单独处理。先做到「十句答对八句」,再逐步加模板,比一上来就上语义解析框架要稳妥。
5. 常见问题排查:LTP、Neo4j、问答三层的高频翻车记录
5.1 宝玉和宝二爷是两个节点:别名合并失效
现象:导入 Neo4j 后,查询「贾宝玉」节点,发现还有一个「宝二爷」节点,两者各自连着不同的边,「宝二爷」的边还不少。
原因:角色别名映射表建得太晚。NER 抽出来的是「宝二爷」,直接作为规范名写进了 CSV,而别名表只覆盖了「宝玉 → 贾宝玉」,没有覆盖「宝二爷」。
解决:把别名表从硬编码字典换成外部配置文件,跑 NER 后先对全部实体候选做一次频率统计,再按频率从高到低人工核对别名。对小说类项目,别名覆盖率至少要达到实体总量的 90% 以上再入库。
5.2 SRL 把「他」抽成施事:代词导致三元组飘了
现象:关系三元组里出现了一批「他-喜欢-林黛玉」这样的边,抽象的「他」成了实体节点,图谱里多了很多无意义的代词节点。
原因:SRL 的语义角色标注会给人称代词分配 ARG0 角色,但代词本身没有消解到具体人物。
解决:抽取后增加一道代词过滤,凡是 head 或 tail 命中「他、她、它、之」等代词集合,整个三元组先丢弃。不要试图做全量指代消解,那个坑太深。如果想要追回这部分信息,可以保留句子上下文,在问答环节把含代词的句子作为相关段落返回。
5.3 把整回文本直接喂给 LTP:长文本上下文漂移
现象:某几个回目的 NER 结果明显异常,「贾母」被标成地名,「王夫人」被分成「王/夫人」。
原因:不是模型坏了,而是把整回文本作为一个字符串传给了ltp.seg。LTP 内部虽然会分句,但长段落里的标点不规范、引号嵌套,把句边界搞乱了,实体识别的上下文也跟着错。
解决:强制自己的预处理先按句切开,以句子列表传入。分句时保留「」和引号内的内容,不要粗暴按句号把人物对话切断。截断的对话会导致「宝玉笑道:」后面跟的内容失去主语,关系抽取直接从源头少数据。
5.4 pyltp 和 LTP 4.x 的 API 混着抄:版本地狱
现象:代码报错TypeError: __init__() got an unexpected keyword argument 'path',或者模型加载路径永远不对。
原因:网上教程多数是 pyltp(LTP 3.x)的写法,需要手动下模型文件再传给Segmentor;LTP 4.x 改用统一LTP类,模型自动下载。两个版本的 API 完全不是一回事。
解决:项目初始化时统一锁定一个版本。新项目直接用 LTP 4.x 的from ltp import LTP。如果项目里已经有 pyltp 代码,就不要中途混用。另外离线环境要提前准备模型离线包,否则每次初始化都触发下载。
5.5 「共现次数」被当成了「关系强度」:图变成了统计图
现象:导入图谱后,贾宝玉和秦钟之间出现一条很粗的边,因为他们在同一章共现多次,但实际关系是「同窗朋友」,强度并没有那么高。
原因:把共现权重视为关系强度,混入了不参与建边的场景描写。同一场景里出现的两个人,不一定有真实关系。
解决:边的weight只用「有效关系三元组」的频次,不用共现统计。三元组里的谓词和关系类型映射必须是人工维护的白名单,白名单上没有的动作谓词,一律不进权重计算。可视化上要降低对权重颜色的依赖,优先展示关系类型,权重只作辅助参考。
6. 进阶:怎么验证问答效果,以及下一步值得做的两个方向
图谱和问答跑通后,下一步不是急着加功能,而是做结果验证。我习惯在人力允许的范围内,抽一百个问答对做黄金标注,每一对标「问题、预期答案、系统答案、是否命中」。这个批注表非常小,但能直接算出问答命中率,也能量化每次改规则到底是变好还是变差。
| 样本问题 | 预期答案 | 系统答案 | 命中 |
|---|---|---|---|
| 贾宝玉的母亲是谁 | 王夫人 | 王夫人 | 是 |
| 林黛玉的表哥是谁 | 贾宝玉 | 贾宝玉 | 是 |
| 谁喜欢林黛玉 | 贾宝玉、北静王等 | 贾宝玉 | 部分命中 |
| 晴雯是哪个房的丫鬟 | 怡红院 | 未返回 | 否 |
验证时我会把「部分命中」也单独算一列,因为问答系统的边界本来就不该追求全量覆盖。系统答不出的问题,如果能回退到「返回相关段落」,体验上并不差。真正要避免的是答错且笃定,那比答不出来更伤可信度。
后续值得做的方向有两个。一是给关系加时间维度,把每对人物的建边回目记录下来,这样能回答「大观园初建时谁和谁交往最密」这类带时序的问题。二是把人物实体链接到公开知识库做消歧,比如「贾政」这种名字在别的书里也有,有了实体链接,未来把红楼梦知识图谱和其他数据集合并时就不容易串人物。
我现在接到任何新语料,第一件事永远是抽十个句子做人工标注,用标注结果反推流程的哪一步需要调。这个习惯救了我好几次,也推荐你试试。希望帮到你。
本文还有配套的精品资源,点击获取