简介:面向自然语言处理与知识图谱初学者的《红楼梦》人物关系可视化与问答系统项目,完整演示了从人物数据采集、命名实体识别、关系抽取到图数据库构建与问答交互的实现流程。压缩包共297个文件,大小约5.98MB,包含184张人物图片、Python源码、HTML页面、CSS/JS脚本及zbak备份数据等,前端与后端代码按目录清晰分层。目前已有92人学习,适合复现项目或作为课程设计参考。项目采用前后端分离架构,包含爬虫采集模块、raw_data结构化三元组存储、neo4j图数据库初始化与查询,以及KGQA问答模块;其中ltp.py专门负责分词、词性标注与命名实体识别,四个HTML页面分别对应欢迎、人物关系检索、全量关系网络和问答交互。数据流经过严格规范化处理,从原始数据采集到可视化展示形成完整闭环,借助该资源可系统掌握知识图谱构建全链路,并理解LTP在关系抽取与问答系统中的应用。
1. 为什么选《红楼梦》做知识图谱:先想清楚要解决什么问题
如果你要从《红楼梦》原文里回答“贾宝玉的母亲是谁”“林黛玉和贾府是什么关系”,纯文本搜索基本做不到,因为关系分散在几十回里,不在一页纸上。常见做法是把原文切句后,用哈工大LTP做分词、命名实体识别和依存分析,先把人名捞出来,再用规则抽关系,落到Neo4j形成知识图谱,前端用关系图插件做人物关系可视化,问答走模板映射查询。这条管线对做领域知识图谱的人很有参考价值,换到工业场景知识图谱、学科知识体系,只是换本体定义和触发词表,骨架完全一致。
2. 红楼梦人物数据准备与本体建模:先把“关系”定义清楚
做这套系统的第一件事不是写代码,而是把“什么是人物”“什么是关系”打开定义清楚。否则后面LTP抽出的实体、Neo4j建出的边都会是一团乱麻。
2.1 文本来源与切句预处理:先给LTP一个干净输入
原著文本通常拿公开的全本TXT,120回,几十万字。原始文本里混着回目、批注、空行和繁体字,直接丢进LTP处理会非常痛苦。我一般会先做一轮清洗:去掉“第X回”标题,把换行合并,再按句末标点切句。
import re def split_sentences(raw_text: str, max_len: int = 80) -> list[str]: # 去掉章回标题,只保留正文 text = re.sub(r"第[一二三四五六七八九十百千零]+回.*?[\n。]", "", raw_text) text = text.replace("\n", "").replace("\u3000", "") # 按句末标点切句 parts = re.split(r"[。!?;]|\.{3,}|……", text) sentences = [] for p in parts: p = p.strip() if len(p) < 4: continue # 长句按逗号二次切分,避免LTP处理过长输入 if len(p) > max_len: for sub in re.split(r"[,,::]", p): if len(sub) > 4: sentences.append(sub) else: sentences.append(p) return sentencesmax_len设成80是个经验值。LTP按句子建模,句子越长,分词和依存分析的准确率掉得越快,内存占用也会上去。我在实际跑全文时发现,把超过80字的句子二次切分后,实体抽取的完整度反而明显提升。
这一步的输出是一份纯文本句子清单,后面LTP的命名实体识别和关系抽取都以句子为单位处理。不要试图一次传一整段进去,这是后面会踩的坑。
2.2 人物别名体系:把“宝玉”和“贾宝玉”归一化
《红楼梦》的称呼极其密集。贾宝玉在书里叫“宝玉”“宝二爷”“怡红公子”,林黛玉叫“黛玉”“颦儿”“林妹妹”,王熙凤叫“凤姐”“凤辣子”。LTP的命名实体识别能把词标成人名标签,但它不知道“宝玉”和“贾宝玉”是同一个人。
这一步需要建一张人物别名映射表,在实体识别之后、入库之前做归一化。
person_alias = { "贾宝玉": ["宝玉", "宝二爷", "怡红公子", "绛洞花主"], "林黛玉": ["黛玉", "颦儿", "潇湘妃子", "林妹妹"], "薛宝钗": ["宝钗", "蘅芜君", "宝姐姐"], "贾母": ["贾母", "史太君", "老祖宗"], "王熙凤": ["王熙凤", "凤姐", "凤辣子", "琏二奶奶"], "贾政": ["贾政", "存周"], } def normalize_person(name: str) -> str: name = name.strip() for canonical, aliases in person_alias.items(): if name == canonical or name in aliases: return canonical return name # 不在表里的先保留原样注意这个函数要放在实体抽取和关系抽取的出口统一调用。我在项目里是把整条流水线串成pipeline.py,每个中间步骤的输出都过一遍normalize_person,这样图谱里的实体名称就不会出现同一个人两三个节点的问题。
2.3 本体关系设计:把关系类型控制在15个以内
知识图谱的“本体”在红楼梦这个场景里不需要太玄,其实就是定义清楚关系类型和方向。我在这里踩过教训:一开始设计了三十多种关系,结果关系抽取规则写不过来,问答模板也越写越复杂。后来压缩到十几种,整个项目才顺畅起来。
| 关系类型 | 有向边语义 | 触发词示例 | 三元组示例 |
|---|---|---|---|
| father_of | A是B的父亲 | 父亲、爹、老爷 | (贾政, father_of, 贾宝玉) |
| mother_of | A是B的母亲 | 母亲、娘 | (王夫人, mother_of, 贾宝玉) |
| spouse_of | A是B的配偶 | 夫人、妻子、丈夫 | (贾琏, spouse_of, 王熙凤) |
| elder_brother_of | A是B的哥哥 | 哥哥、兄长 | (贾珠, elder_brother_of, 贾宝玉) |
| younger_sister_of | A是B的妹妹 | 妹妹、妹子 | (探春, younger_sister_of, 贾环) |
| master_of | A是B的主人 | 主子、老爷、奶奶 | (贾母, master_of, 鸳鸯) |
| servant_of | A是B的仆人 | 丫鬟、小厮、下人 | (袭人, servant_of, 贾宝玉) |
方向的约定要统一。我的约定是:图谱里的有向边从“主语”指向“宾语”,边类型读作“主语是宾语的X”。回答“贾宝玉的父亲是谁”时,查询语句就是反向匹配:MATCH (p:Person)-[:father_of]->(j:Person {name:"贾宝玉"})。
这个本体设计直接决定后面的问答系统能回答什么。你要问“谁和谁是亲戚”,就得在关系类型里设计“兄弟”“姐妹”这类同辈关系;如果你只关心亲子和夫妻,那模板就少很多。把关系控制在15个以内,不是为了偷懒,而是保证抽取规则和问答模板都可维护。工业场景下的知识图谱设计也是同理,本体越克制,图谱越耐用。
3. LTP命名实体识别与人物实体抽取:环境搭建、模型调用与别名归一化
LTP是哈工大开源的中文语言技术平台,分词、词性标注、命名实体识别、依存分析都能做。这章只讲它在本项目里最核心的两个用途:人物实体识别和后续关系抽取的句法支撑。
3.1 LTP环境安装与模型初始化
pip install ltpLTP 4.x 的Python包安装很简单,首次在脚本里初始化时会把模型参数下载到本地缓存目录,需要等一小段时间。模型比较大,如果机器内存紧张,建议单独在一个进程里做推理,不要和Web服务抢内存。
from ltp import LTP ltp = LTP() SENT = "贾政是贾宝玉的父亲。" seg, hidden = ltp.seg(SENT) pos = ltp.pos(hidden) ner = ltp.ner(hidden) dep = ltp.dep(hidden) print("分词:", list(seg)) print("词性:", list(pos)) print("NER:", list(ner))不同小版本的LTP接口略有出入,有的版本seg返回的是(segments, hidden_states),有的返回结果顺序不同。以你安装版本的实际输出为准,打印出来看一眼再往下写。这里的hidden是词级隐状态向量,pos、ner、dep都要用它作为输入,顺序别搞错。
3.2 用LTP抽取人物实体的最小代码
LTP的NER标签中,人名实体是Nh前缀的BIO标签。我的抽取逻辑很简单:遍历NER标签,遇到B-Nh开始拼接,遇到I-Nh继续拼,遇到其他标签就结束一个实体。
def extract_persons(sentence: str) -> list[str]: # 分步调用LTP,seg返回分词结果和隐层状态 seg, hidden = ltp.seg(sentence) ner = ltp.ner(hidden) persons = [] cur = "" for word, tag in zip(seg, ner): if tag.startswith("B-Nh"): cur = word elif tag.startswith("I-Nh") and cur: cur += word elif cur: persons.append(cur) cur = "" if cur: persons.append(cur) return persons print(extract_persons("贾政是贾宝玉的父亲。")) # 预期输出 ['贾政', '贾宝玉']这个函数是整个系统的地基。后面关系抽取要用它确认“句子里的两个候选实体是不是人”,问答系统也要用它从问句里提取人名。
参数层面有一个关键点:LTP默认的NER标签空间里,Nh是人名,Ns是地名,Ni是机构名。如果你只关心《红楼梦》人物,宁可只保留Nh,把地名和机构名全部丢掉,避免“荣国府”这样的地名被误当成实体进入图谱。
3.3 结果清洗:别名归一化与白名单兜底
光靠LTP的NER不够。我跑过全文之后发现,“颦儿”“凤辣子”这类文学别名经常被LTP识别成普通词,而不是人名。这时候就需要白名单兜底。
KNOWN_PERSONS = set(person_alias.keys()) | {alias for aliases in person_alias.values() for alias in aliases} def clean_person(raw_name: str) -> str: normalized = normalize_person(raw_name) if normalized != raw_name: return normalized if raw_name in KNOWN_PERSONS: return raw_name return None # 过滤掉非人物实体这段代码在流水线里的作用是把LTP的输出和全局人物名单合并。只要词命中名单,即使LTP没打Nh标签,也保留为候选人物;反过来,LTP标成Nh但不在名单里的名字,如果不是书里重要角色,直接丢弃。这一步能有效控制图里的节点质量。
我一般在关系抽取之前做这个过滤,因为关系规则的匹配需要对人物实体做身份确认。宁可漏掉一些次要人物,也不要让图谱产生大量莫名其妙的人名噪音。
4. 关系抽取与Neo4j知识图谱构建:从依存句法到前端可视化
这一章是整个系统的落地点。前面处理完人物实体,现在要做关系抽取、入库,并把图展示出来。我的经验是:先用规则模板把准确率做上去,再考虑更复杂的泛化模型。知识图谱的构建不是一次性的,后面要一直加数据、补关系。
4.1 基于依存句法加规则模板的三元组抽取
关系抽取不一定要跑复杂模型。对《红楼梦》这种关系表达有明显句式的文本,用“X是Y的R”和“X的R是Y”两种模板就能覆盖大量亲子、夫妻关系。加上LTP的分词结果作为规整输入,效果很稳定。
import re # 关系触发词表,key是文本里可能出现的关系词,value是本体的关系类型 RELATION_TRIGGERS = { "父亲": "father_of", "爹": "father_of", "母亲": "mother_of", "娘": "mother_of", "哥哥": "elder_brother_of", "兄长": "elder_brother_of", "妻子": "spouse_of", "夫人": "spouse_of", } def both_are_persons(name1: str, name2: str) -> bool: return clean_person(name1) is not None and clean_person(name2) is not None def extract_relation_by_rules(sentence: str): seg, hidden = ltp.seg(sentence) text = "".join(seg) results = [] for trigger, rel_type in RELATION_TRIGGERS.items(): # 模式1: X是Y的R pat1 = re.compile(rf"([\u4e00-\u9fa5]{{2,4}})是([\u4e00-\u9fa5]{{2,4}})的{trigger}") m1 = pat1.search(text) if m1 and both_are_persons(m1.group(1), m1.group(2)): results.append((normalize_person(m1.group(1)), rel_type, normalize_person(m1.group(2)))) # 模式2: X的R是Y pat2 = re.compile(rf"([\u4e00-\u9fa5]{{2,4}})的{trigger}是([\u4e00-\u9fa5]{{2,4}})") m2 = pat2.search(text) if m2 and both_are_persons(m2.group(1), m2.group(2)): results.append((normalize_person(m2.group(1)), rel_type, normalize_person(m2.group(2)))) return results print(extract_relation_by_rules("贾政是贾宝玉的父亲。")) # 输出 [('贾政', 'father_of', '贾宝玉')]这里依赖了LTP分词结果做正则匹配,为什么还要额外过一层both_are_persons?因为“贾政”和“贾宝玉”本身在别名表里,一旦确认都是人物,这条规则才可信。如果模板匹配到的人名不在清单里,我不会直接入库,而是先人工看一眼。
参数上最值得调的就是RELATION_TRIGGERS。我建议每个关系类型至少配3个同义词,并且把同义词统一映射到同一个本体关系上。这张表本身就是知识资产,换到工业场景里的“设备-故障-原因”关系,就是把关系词换成“导致”“因为”“故障原因是”而已。
4.2 Neo4j数据模型与批量导入
入图的方式直接用Neo4j的MERGE。人物节点用Person标签,关系统一用REL边,关系词放在边的type属性里。这样做的优点是不同关系类型不需要每次建新的边标签,查询时按属性过滤。
from neo4j import GraphDatabase def save_triples_to_neo4j(triples, uri="bolt://localhost:7687", user="neo4j", password="yourpassword", batch_size=500): driver = GraphDatabase.driver(uri, auth=(user, password)) with driver.session() as session: # 先建唯一约束,防止同一人物多次MERGE出重复节点 session.run("CREATE CONSTRAINT person_name IF NOT EXISTS " "FOR (p:Person) REQUIRE p.name IS UNIQUE") for i in range(0, len(triples), batch_size): batch = [ {"x": x, "rel": rel, "y": y} for x, rel, y in triples[i:i + batch_size] ] session.run( """ UNWIND $batch AS pair MATCH (a:Person {name: pair.x}), (b:Person {name: pair.y}) MERGE (a)-[r:REL {type: pair.rel}]->(b) RETURN count(*) """, batch=batch ) driver.close() triples = [('贾政', 'father_of', '贾宝玉')] save_triples_to_neo4j(triples)批量插入的 batch_size 取500是一个稳妥值。逐条MERGE一万条关系可能要几分钟,UNWIND批量提交能让速度提升一个数量级。前提是节点必须先存在,所以上面先MATCH再MERGE边。
顺带说一个实践:如果抽取出了 (贾政, father_of, 贾宝玉) 和 (贾宝玉, son_of, 贾政) 这种反向冗余,不要都存。我的做法是只存语义方向明确的一种,比如统一存长辈指向晚辈,问答时根据模板做反向匹配。这样图里的边数量能少一半,查询逻辑也清晰。
4.3 前端可视化:关系图插件选型与数据接口
图谱建好之后,可视化是验收的关键。你不可能靠Cypher命令跟业务方讲解人物关系。我的做法是后端查Neo4j,把结果转成前端关系图能用的JSON结构。
const graphData = { nodes: neo4jNodes.map(n => ({ id: n.name, name: n.name, category: 0 })), links: neo4jRels.map(r => ({ source: r.start, target: r.end, relation: r.type })) };前端插件我对比过三个:ECharts的关系图、Neovis.js、Neo4j Bloom。如果要做成嵌入式页面,ECharts最省心,几行配置就能得到可拖拽的关系图;如果是快速原型验证,直接用Neovis.js连Neo4j,几乎不用写后端;如果是给领导演示图谱探索能力,Neo4j Bloom最直观但没法嵌进自己的系统。
ECharts的配置核心是graph系列:
option = { series: [{ type: 'graph', layout: 'force', data: graphData.nodes, links: graphData.links, roam: true, force: { repulsion: 300 } }] };force布局的 repulsion 参数控制节点间斥力,值越大节点分得越开。人物多的时候我一般调到500,防止节点叠成一团。可视化本身不是难点,真正难的是图谱里的关系要干净,否则画出来全是混乱的连线。
5. 从LTP到图谱落地的高频坑:5个典型问题排查与解决
这段写项目里真实遇到的坑,按“现象、原因、解决”的顺序记录,基本都是复现别人也容易遇到的老问题。
5.1 Neo4j里出现“宝玉”和“贾宝玉”两个节点
现象:图谱可视化里同一人物被拆成多个节点,关系断成好几条,查询“贾宝玉的母亲”只能查到一个方向。
原因:直接拿LTP识别的原始人名入库,没有经过别名归一化。“宝玉”“宝二爷”和“贾宝玉”在LTP眼里是三个不同词。
解决:所有实体在入Neo4j前统一过normalize_person,并且给Person节点建name唯一约束。这样即使脚本重复执行,MERGE也能保证同一个规范名只有唯一节点。
5.2 LTP把“颦儿”“凤辣子”识别成非人名
现象:这些别称在NER结果里经常被标成O,导致关系抽取时因为both_are_persons判断失败而漏掉三元组。
原因:LTP的NER词典对文学类别称覆盖不足,“颦儿”这种称呼在通用语料里出现频率太低。
解决:用全局人物名单做白名单兜底。分词后只要词命中别名表,就强制作为人物候选;同时维护一个known_persons列表,在抽取前对句子中的人物做显式标记。
5.3 关系抽取误把“打”“骂”“说”当成关系
现象:如果从依存句法泛化抽取,会抽出一堆“贾政打宝玉”“宝玉说黛玉”这种动作关系,图谱语义被污染。
原因:依存句法只给出主谓宾结构,不等于语义上的亲属或社会关系。动词“打”和“父亲”在句法地位上是一样的。
解决:关系入库前过触发词表。只有RELATION_TRIGGERS里定义的关系词才允许生成三元组,其他动词一律丢弃。这个约束看起来保守,但对知识图谱来说是必要的,宁可漏抽,不能乱抽。
5.4 LTP处理长句耗时剧增甚至内存溢出
现象:把一整段原文塞进ltp.seg,推理时间从几十毫秒涨到好几秒,偶尔进程卡死。
原因:LTP以句子为处理单位,序列长度变长后,复杂度非线性上升。
解决:在切句阶段做两个限制,单句不超过80字符,超过部分按逗号二次切分。处理大文本时用split_sentences先跑一遍再进LTP,不要为省事直接把全文拼接。
5.5 Neo4j逐条插入关系太慢
现象:一万条三元组逐条session.run,跑了十几分钟还没结束,中途还偶发连接断开。
原因:每条MERGE都是一个独立事务,事务开销占了大头。
解决:用UNWIND批量提交,一批500条,配合唯一约束,速度能提升数倍。分批大小不要设太大,超过2000条单条语句体积会过大,反而拖慢执行。
6. 智能问答系统的模板实现与验证:把自然语言问题转成Cypher查询
最后一件事是把图谱变成能回答问题的接口。我不做端到端的生成式问答,而是用模板匹配,把“贾宝玉的母亲是谁”这类问句解析成人名和关系词,再映射成Cypher查询。
6.1 问题模板与Cypher映射
先写一个简单的关系词识别函数。问句里的“母亲”“父亲”“丈夫”都能直接对应到本体的关系类型,问句里出现的人名用LTP再抽一遍,因为问句也是中文句子。
def answer(question: str, driver) -> str: persons = extract_persons(question) if not persons: return "没识别到问题中的人物" target = normalize_person(persons[0]) match = re.search(r"(父亲|母亲|丈夫|妻子|哥哥)", question) if not match: return "暂不支持这类问题" rel = { "父亲": "father_of", "母亲": "mother_of", "丈夫": "spouse_of", "妻子": "spouse_of", "哥哥": "elder_brother_of", }[match.group(1)] # 问“X的母亲是谁”,需要在图里反向找指向X的母亲 cypher = ( "MATCH (p:Person)-[:REL {type:$rel}]->(x:Person {name:$name}) " "RETURN p.name AS answer LIMIT 5" ) with driver.session() as session: records = session.run(cypher, {"rel": rel, "name": target}) answers = [r["answer"] for r in records] return answers if answers else "未找到答案"这里的一个关键参数是查询方向。因为图谱里边方向定义为长辈指向晚辈、主人指向仆人,问“贾宝玉的母亲是谁”时要反向匹配:(p)-[:mother_of]->(贾宝玉)。如果问“贾政是谁的父亲”,才是正向匹配(贾政)-[:father_of]->(p)。问答模板的本体和方向定义必须和可视化、抽取三处保持一致。
6.2 验证方法与经验收尾
我准备了一套50条测试问句,覆盖十个主要人物和五种关系类型,每次改完规则就全量跑一遍准确率。第一次只有62%的准确率,后来把“娘”“爹”“老爷”等口语化称呼补进触发词表,人工校验了一些错抽关系,准确率才提到85%左右。
如果再做一次,我会把别名表和问题模板做成外部配置文件,而不是散在代码里。这个项目值不值得做?如果只想快速演示,Neo4j加ECharts两个小时就能出原型;要做到能持续用,LTP的实体识别和模板问答只是及格线,真正要花时间的是关系词表与人工校验。希望帮到你。
本文还有配套的精品资源,点击获取