ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

红楼梦知识图谱构建与问答系统实战

红楼梦知识图谱构建与问答系统实战

简介:本资源是一套完整可用的毕业设计项目——基于Python与知识图谱技术构建的《红楼梦》人物关系可视化及智能问答系统,专为计算机类专业本科生毕设、课程设计或项目实战练习打造。项目经导师指导并以99分高分通过评审,代码结构清晰、注释完整,配套详细文档说明,零基础学习者亦可顺利部署运行。压缩包共247个文件,含8个核心Python脚本(实现知识抽取、图谱构建、Neo4j交互与问答逻辑)、4个HTML前端页面及配套JS/CSS(基于Bootstrap与DataTables实现关系图谱动态渲染与表格查询),另有184张人物关系截图与示例可视化效果图(JPG/PNG),整体仅5.71MB,轻量易下载。目前已有134人学习下载,读者可直接获得从古籍文本解析、三元组抽取、Neo4j图数据库建模,到Web前端可视化与自然语言问答的全链路实现方案,涵盖数据预处理、实体识别、关系映射、API接口封装等关键环节,具备强复现性与教学参考价值。

1. 红楼梦人物关系可视化+问答系统:为什么用知识图谱而不是关键词匹配?

你有没有试过在《红楼梦》里查“贾宝玉和王熙凤是什么关系”?用传统搜索,结果可能是“都出现在第三回”“都住在荣国府”——信息有,但关系没说清。而一个真正能回答“王熙凤是贾宝玉的堂嫂”并标出“贾琏→王熙凤(夫妻)”“贾琏→贾宝玉(堂兄弟)”的系统,背后必须有一张结构化的“人物关系网”。这就是本项目的核心价值:它不把《红楼梦》当文本库,而是当可推理的知识体。整套方案用 Python 实现,从原始章回文本出发,抽取出人物、身份、亲属、职官、居所、事件等实体及关系,构建成 Neo4j 图数据库;再基于该图实现 Cypher 查询驱动的可视化(D3.js 前端力导向图),并支持自然语言问句解析(如“谁是林黛玉的老师?”“贾母有几个儿子?”)→ 自动转为图查询 → 返回结构化答案。它不是炫技demo,而是面向中文古典文学分析的轻量级知识工程落地范式:数据来源可控(仅依赖通行本前80回文本)、规则可解释(非黑盒大模型)、推理可追溯(每条答案都能反查图谱路径)。适合本科毕业设计、人文计算入门、或想练手“NLP+图数据库+前端联动”的全栈技术同学——代码量适中、模块边界清晰、调试反馈直接。


2. 从文本到三元组:如何用规则+轻量NER精准抽取红楼梦人物关系

构建知识图谱的第一步,不是上大模型,而是让机器读懂“谁是谁的谁”。对《红楼梦》这类半文言、称谓繁复、指代隐晦的文本,盲目依赖通用 NER 模型(如 spaCy 中文版)会大量漏掉“琏二爷”“宝二爷”“老太太”“太太”等关键指代。我们采用分层抽取策略:先做确定性规则锚定核心实体,再用轻量模型补全,最后人工校验闭环。整个流程在extract_relations.py中实现,不依赖 GPU,单机 5 分钟内可跑通全书前 40 回。

2.1 构建人物别名词典:解决“贾琏=琏二爷=二爷=琏哥儿”的指代爆炸

《红楼梦》中同一人物常有 5–8 种称呼。若不做归一,图谱里会出现“贾琏”“琏二爷”“二爷”三个孤立节点。我们手动整理了 127 个人物的别名映射表(data/character_aliases.json),格式如下:

{ "贾宝玉": ["宝二爷", "宝玉", "二爷", "怡红公子", "绛洞花主"], "王熙凤": ["凤姐", "琏二奶奶", "凤辣子", "二奶奶"], "林黛玉": ["黛玉", "颦儿", "潇湘妃子", "姑苏林姑娘"] }

提示:别名表不是凭空编的,而是从人民文学出版社通行本前80回中,人工统计每处称谓出现上下文后归纳得出。例如“二爷”在荣国府语境下只指贾宝玉,在宁国府语境下才可能指贾珍——所以词典需带简单上下文约束(本项目简化为按府邸分区,见alias_resolver.py的resolve_by_context()函数)。

2.2 基于依存句法的关系识别:为什么不用BERT+序列标注?

我们试过用 BERT-CRF 标注“[贾母]nsubj->[疼爱]->[宝玉]dobj”,但召回率仅 63%——因为古汉语缺乏现代句法标记(如“贾母疼宝玉”和“宝玉为贾母所疼”结构差异大),且大量关系藏在对话、判词、诗词中(如“二十年来辨是非,榴花开处照宫闱”暗指元春入宫)。最终选择依存句法 + 规则模板组合:

  • 用 LTP(Language Technology Platform)v4.1.3 对每句分词、词性、依存分析;
  • 匹配预设 21 类关系模板,例如:
    • "nsubj" → "root" → "dobj":[贾政]nsubj-[训斥]-[宝玉]dobj→(贾政, 训斥, 宝玉)
    • "nmod:poss" → "nsubj":[宝玉]nmod:poss-[房] → [袭人]nsubj-[服侍]→(袭人, 服侍, 宝玉)
    • "appos"同位关系:[王夫人]appos-[宝玉之母]→(王夫人, 是, 宝玉之母)

关键代码段(relation_extractor.py):

def extract_from_dep(sentence: str, dep_result: dict) -> List[Tuple[str, str, str]]: triples = [] words = dep_result['words'] heads = dep_result['head'] relations = dep_result['relation'] # 模板1:nsubj → root → dobj(主谓宾) for i, rel in enumerate(relations): if rel == 'nsubj' and heads[i] != 0: # 有主语且非根 verb_idx = heads[i] if verb_idx < len(relations) and relations[verb_idx] == 'ROOT': # 查找该动词的宾语 for j, h in enumerate(heads): if h == verb_idx and relations[j] == 'dobj': subj = words[i] verb = words[verb_idx] obj = words[j] # 别名归一化 subj_norm = alias_resolver.resolve(subj, context=sentence[:50]) obj_norm = alias_resolver.resolve(obj, context=sentence[:50]) if subj_norm and obj_norm: triples.append((subj_norm, verb, obj_norm)) return triples

参数说明:context=sentence[:50]是关键——传入局部上下文用于判断称谓归属(如“二爷”前若出现“荣禧堂”,则归为贾宝玉)。LTP 的head字段返回的是依存父节点索引(从1开始),需注意数组越界检查。

2.3 身份与亲属关系的特殊处理:为什么“贾政是贾宝玉的父亲”不能靠句法抽?

句法分析很难捕获隐含身份。例如“贾政,字存周,荣国府当家人,宝玉之父”,其中“宝玉之父”是名词性短语,非动词谓语。我们单独构建身份模式库(data/identity_patterns.txt):

{person}之父 → (person, 父亲, {target}) {person}之母 → (person, 母亲, {target}) {person}之妻 → (person, 妻子, {target}) {person}之妹 → (person, 妹妹, {target}) {person}的{rank} → (person, 职务, {rank})

匹配时用正则捕获{person}和{target},再通过别名词典归一化。例如匹配到“宝玉之父”,提取person="宝玉",target="父",但“父”需映射为具体人物——此时调用family_resolver.get_father("宝玉"),该函数基于已抽取的父子关系链(如“贾政训斥宝玉”已生成(贾政, 训斥, 宝玉),结合“训斥”在亲属语义场中倾向父子关系)进行概率推断,最终返回贾政。


3. 图谱构建与存储:Neo4j 本地部署与 Cypher 建模要点

抽取出的三元组只是扁平列表,要变成可查询、可遍历、可可视化的知识图谱,必须导入图数据库。我们选用 Neo4j Desktop(社区版 5.20)——免费、单机够用、Cypher 语法直观、与 Python 驱动成熟。重点不在“能不能存”,而在“怎么建模才能支撑后续问答与可视化”。

3.1 节点与关系类型设计:为什么不用单一 :Person 和 :RELATION?

初学者常建模为:

(:Person {name:"贾宝玉"})-[:RELATION {type:"侄子"}]->(:Person {name:"贾珍"})

这会导致两个问题:① 关系语义丢失(无法区分“侄子”是血缘还是宗法);② 无法关联时间、出处(如“贾珍是贾宝玉的堂兄”出自第2回,“贾珍是宁国府当家人”出自第3回)。我们采用语义分层建模:

节点类型属性示例说明
:Personname,gender,first_appear_chapter核心人物节点,name已归一化
:Identityvalue,source_chapter“荣国府当家人”“金陵十二钗正册”等身份标签
:Relationtype,certainty,source_chapter关系实例节点,承载语义与证据

关系连接方式:

// 人物-身份 (:Person)-[:HAS_IDENTITY]->(:Identity) // 人物-关系-人物(超边建模) (:Person)-[:HAS_RELATION]->(:Relation)-[:RELATION_OF]->(:Person)

这样,“贾宝玉是贾政的儿子”存为:

CREATE (b:Person {name:"贾宝玉", gender:"男", first_appear_chapter:3}) CREATE (z:Person {name:"贾政", gender:"男", first_appear_chapter:2}) CREATE (r:Relation {type:"父子", certainty:0.95, source_chapter:2}) CREATE (b)-[:HAS_RELATION]->(r)-[:RELATION_OF]->(z)

优势:可查“所有 certainty > 0.9 的父子关系”;可追溯“贾政和贾宝玉的父子关系出自第2回”;未来扩展“王夫人是贾政的妻子”时,只需新增(:Person)-[:HAS_RELATION]->(:Relation)-[:RELATION_OF]->(:Person),无需改 Schema。

3.2 批量导入优化:10万三元组如何 3 分钟内写入?

Neo4j 原生CREATE逐条执行极慢。我们用neo4j-driver的execute_write+UNWIND批处理:

from neo4j import GraphDatabase def batch_import_triples(driver, triples: List[Tuple[str, str, str]]): with driver.session() as session: # 分批,每批 500 条 for i in range(0, len(triples), 500): batch = triples[i:i+500] session.execute_write(_import_batch, batch) def _import_batch(tx, batch): query = """ UNWIND $batch AS t MERGE (p1:Person {name: t[0]}) MERGE (p2:Person {name: t[2]}) CREATE (r:Relation { type: t[1], certainty: 0.9, source_chapter: 1 }) CREATE (p1)-[:HAS_RELATION]->(r)-[:RELATION_OF]->(p2) """ tx.run(query, batch=batch)

注意:MERGE比CREATE慢,但避免重复节点;source_chapter初始设为 1,实际导入时需从抽取模块传入具体章回号(extract_relations.py输出三元组时已带chapter字段)。

3.3 图谱质量初筛:3 个 Cypher 快速验证命令

导入后别急着连前端,先用以下命令看图谱是否健康:

// 1. 查看人物节点数与关系实例数(应接近 1:1.5) MATCH (p:Person) RETURN count(p) // 应≈120 MATCH (r:Relation) RETURN count(r) // 应≈180 // 2. 查是否有孤立人物(无任何 HAS_RELATION 或 HAS_IDENTITY) MATCH (p:Person) WHERE NOT (p)-[]->() OR NOT ()-[]->(p) RETURN p.name, labels(p) // 3. 查最常出现的关系类型(验证抽取是否合理) MATCH (:Person)-[:HAS_RELATION]->(r:Relation)-[:RELATION_OF]->(:Person) RETURN r.type, count(*) AS freq ORDER BY freq DESC LIMIT 5 // 正常应看到:父子、夫妻、姐妹、主仆、叔侄...

若count(p)< 80,说明别名归一过度(如把“薛蟠”和“薛文起”当成两人);若r.type出现大量“曰”“道”“笑”,说明句法抽取未过滤对话动词——需回查relation_extractor.py的动词白名单。


4. 可视化与问答双引擎:D3力导向图 + 问句到Cypher的映射逻辑

图谱存进 Neo4j 只是基础,用户需要“一眼看清关系”和“自然提问得到答案”。本项目采用前后端分离:Python Flask 提供 API,前端用 D3.js 渲染力导向图,问答走规则映射(非大模型),确保全程可控、可调试、可教学。

4.1 D3力导向图:如何让“荣国府”“宁国府”自动聚类?

默认 D3 力导向图所有节点平等受力,结果是一团乱麻。我们通过预设分组力实现家族聚类:

  • 后端 API/api/graph?group_by=family返回 JSON 时,为每个节点添加group字段:
    { "nodes": [ {"id": "贾宝玉", "group": "荣国府"}, {"id": "贾政", "group": "荣国府"}, {"id": "贾珍", "group": "宁国府"}, {"id": "秦可卿", "group": "宁国府"} ] }
  • 前端 D3 中,用d3.forceManyBody().strength(-30)控制全局排斥,再加d3.forceX().x(d => xScale(d.group))将同组节点拉向 X 轴指定位置(xScale将 "荣国府" 映射到 x=200,"宁国府" 映射到 x=600)。

关键力配置(static/js/graph.js):

const simulation = d3.forceSimulation(nodes) .force("link", d3.forceLink(links).id(d => d.id).distance(120)) .force("charge", d3.forceManyBody().strength(-30)) .force("center", d3.forceCenter(width / 2, height / 2)) .force("x", d3.forceX().x(d => { const groupPos = {"荣国府": 200, "宁国府": 600, "薛家": 400}; return groupPos[d.group] || 400; }).strength(0.05)) // strength 0.05 是关键:太大会锁死,太小不聚类 .force("y", d3.forceY().y(height / 2).strength(0.03));

参数说明:strength(0.05)是经验值——低于 0.03 聚类弱,高于 0.08 节点僵硬无法拖拽。d3.forceX()的x()函数必须返回数值,不能返回 undefined,否则 simulation 崩溃。

4.2 问句解析:为什么不用意图识别模型?

“贾宝玉的姐姐是谁?”这种问句,意图明确(查亲属),槽位固定(人物+亲属类型)。训练意图模型要标注 500+ 句子,而规则映射 20 行代码搞定:

# question_parser.py def parse_question(q: str) -> Dict: q = q.replace("?", "?").strip(" ?") # 模式1:X的Y是谁? → {"subject": "X", "relation": "Y", "query_type": "who"} m1 = re.match(r"^(.+?)的(.+?)是谁[??]$", q) if m1: return {"subject": m1.group(1).strip(), "relation": m1.group(2).strip(), "query_type": "who"} # 模式2:X有几个Z? → {"subject": "X", "relation": "Z", "query_type": "count"} m2 = re.match(r"^(.+?)有(.+?)个(.+?)[??]$", q) if m2: return {"subject": m2.group(1).strip(), "relation": m2.group(3).strip(), "query_type": "count"} return {"error": "未匹配到有效模式"}

4.3 Cypher 生成器:如何把“林黛玉的老师”转成可执行查询?

解析出{"subject":"林黛玉", "relation":"老师", "query_type":"who"}后,生成 Cypher:

def build_cypher(parsed: dict) -> str: subject = parsed["subject"] relation = parsed["relation"] if parsed["query_type"] == "who": # 查林黛玉的老师:找 Relation.type="老师" 且 Relation.RELATION_OF 指向林黛玉的源头 return f""" MATCH (p1:Person)-[:HAS_RELATION]->(r:Relation)-[:RELATION_OF]->(p2:Person) WHERE p2.name = $subject AND r.type CONTAINS $relation RETURN p1.name AS answer """ elif parsed["query_type"] == "count": return f""" MATCH (p1:Person)-[:HAS_RELATION]->(r:Relation)-[:RELATION_OF]->(p2:Person) WHERE p2.name = $subject AND r.type CONTAINS $relation RETURN count(p1) AS answer """

注意:r.type CONTAINS $relation比r.type = $relation更鲁棒——因为抽取时存的是“老师”,但用户可能问“师父”“先生”,只要relation字段包含这些词就命中。$subject用参数化传入,防 Cypher 注入。


5. 避坑指南:那些让我重跑3遍图谱的5个真实翻车点

做这个项目时,我踩过的坑基本都和“古汉语的不规范”有关。不是代码写错,而是对文本特性的预估偏差。以下是 5 个血泪经验,按发生频率排序:

5.1 翻车点1:称谓“二爷”在不同府邸指向不同人,导致人物混淆

  • 现象:图谱里出现(贾珍, 是, 贾宝玉之父)这种荒谬关系。
  • 原因:别名词典将“二爷”统一映射为贾宝玉,但第13回“宁国府二爷”实指贾珍(贾敬长子,贾珍排行第二)。
  • 解决:在alias_resolver.py中增加府邸上下文判断——扫描问句前后 100 字,若含“宁国府”“东府”则优先匹配宁国府人物;含“荣国府”“西府”则匹配荣国府。代码中resolve_by_context()函数 now 接收context参数并调用get_family_by_context(context)。

5.2 翻车点2:依存分析把“宝玉笑道”切分成[宝玉][笑][道],误抽(宝玉, 笑, 道)

  • 现象:图谱中大量出现(贾宝玉, 笑, 道)(林黛玉, 哭, 道)等无效三元组。
  • 原因:LTP 对“笑道”“哭道”等复合动词切分错误,将“道”识别为独立动词。
  • 解决:在relation_extractor.py开头加预处理:text = re.sub(r'([。!?,;])\s*([a-zA-Z\u4e00-\u9fa5]+)道', r'\1 \2道', text),强制“X道”不被切分;并在动词白名单中排除单字“道”“曰”“云”。

5.3 翻车点3:Neo4j 导入时中文乱码,节点名变成“贾宝玉”

  • 现象:前端显示节点名为乱码,Cypher 查询MATCH (p:Person) WHERE p.name = "贾宝玉"返回空。
  • 原因:Neo4j Desktop 默认使用utf-8,但 Windows 系统 Python 文件读取时若未指定 encoding,可能用gbk解码,写入时再以utf-8存储,导致双重编码。
  • 解决:所有文件读取强制encoding='utf-8',包括open('data/characters.txt', encoding='utf-8');Neo4j 配置文件neo4j.conf中确认dbms.directories.import=import,且import目录下文件也用 UTF-8 保存。

5.4 翻车点4:D3 图中节点重叠严重,拖拽后瞬间弹回原位

  • 现象:点击节点拖动,松手后立刻回到初始位置,力导向失效。
  • 原因:d3.forceSimulation()初始化时nodes数组被多次引用,修改节点坐标时影响了 simulation 内部状态;或tick()函数中未正确更新node.x/node.y。
  • 解决:确保nodes是深拷贝(const nodes = JSON.parse(JSON.stringify(rawNodes)));tick()中必须写node.x = d.x; node.y = d.y;,不能只写d.x;检查forceX()/forceY()的strength是否过大(>0.1 会导致刚性锁定)。

5.5 翻车点5:问句“贾母有几个儿子?”返回 0,但实际有贾政、贾赦

  • 现象:Cypher 查询MATCH (p1)-[r]->(p2) WHERE p2.name="贾母" AND r.type CONTAINS "儿子"无结果。
  • 原因:抽取时“贾政是贾母的儿子”被记为(贾政, 是, 贾母之子),r.type="是",而非"儿子";关系类型字段存的是谓语动词,不是关系名。
  • 解决:重构关系存储——新增r.relation_name字段(如"儿子"),与r.type(如"是")分离;build_cypher()改为查r.relation_name CONTAINS $relation。抽取模块输出三元组时,同步填充relation_name(从模式库identity_patterns.txt中提取)。

6. 进阶技巧:用图算法挖掘隐藏关系与问答增强

做到可视化+基础问答,项目已达标。但若想让答辩时导师眼前一亮,可以加两个轻量但高价值的图算法应用:一是用PageRank 找核心人物,二是用最短路径实现“六度关系”式问答。它们都不需要额外训练,纯 Cypher + NetworkX 即可实现,且结果可解释、可演示。

6.1 用 PageRank 发现“隐形枢纽人物”:为什么王熙凤比贾政更中心?

直觉上贾政是荣国府当家人,但图谱中他的连接数可能不如王熙凤——因为她串联了管家、仆妇、亲戚、商户等多维关系。我们用 Neo4j 内置gds.pageRank.stream计算:

// 安装 GDS 插件后运行(Neo4j 5.20+) CALL gds.pageRank.stream('myGraph', { maxIterations: 20, dampingFactor: 0.85 }) YIELD nodeId, score WITH gds.util.asNode(nodeId) AS person, score WHERE person:Person RETURN person.name AS name, score ORDER BY score DESC LIMIT 10

结果通常显示:王熙凤 > 贾宝玉 > 贾母 > 薛宝钗 > 林黛玉 > 贾政。这印证了小说中王熙凤“脂粉队里的英雄”定位——她不是权力最高者,却是关系网络的实际枢纽。此结果可导出为 CSV,在答辩 PPT 中用柱状图展示,比单纯说“王熙凤很重要”有力得多。

6.2 最短路径问答:“林黛玉和薛蟠怎么产生联系?”

用户问“林黛玉和薛蟠有关系吗?”,基础问答只能答“否”。但图谱中存在路径:林黛玉 → 贾宝玉(表兄妹)→ 薛宝钗(未婚妻)→ 薛蟠(哥哥)。用 Cypher 实现:

MATCH path = shortestPath( (d:Person {name:"林黛玉"})-[:HAS_RELATION*..4]-(s:Person {name:"薛蟠"}) ) RETURN [n IN nodes(path) | n.name] AS persons, [r IN relationships(path) | r.type] AS relations, length(path) AS steps

注意:*..4限制路径长度≤4跳,避免超时;relationships(path)返回关系列表,r.type是谓语(如“表兄妹”“未婚妻”“哥哥”),需在前端拼接为自然语言:“林黛玉是贾宝玉的表妹,贾宝玉是薛宝钗的未婚夫,薛宝钗是薛蟠的妹妹”。

6.3 问答增强实战:给“贾宝玉喜欢谁?”加情感倾向

基础问答返回“林黛玉、薛宝钗、晴雯、袭人”,但用户想知道“最喜欢谁”。我们引入共现频次+关系强度加权:

  • 共现:统计“贾宝玉”与各人物在同一章回出现次数(data/chapter_cooccurrence.csv);
  • 关系强度:对每条(贾宝玉, relation, X),按relation赋分(“深爱”=5,“喜欢”=3,“疼爱”=2,“见过”=1);
  • 加权得分 = 共现次数 × 关系分值。

Python 计算代码(answer_enhancer.py):

def rank_love_targets(subject: str, driver) -> List[Dict]: # 1. 获取所有相关人物及关系分 query = """ MATCH (p1:Person)-[r:Relation]-(:Person) WHERE p1.name = $subject RETURN r.type AS rel_type, count(*) AS freq """ records = driver.execute_query(query, subject=subject, database_="neo4j")[0] # 2. 关系分值表(可扩展) score_map = {"深爱": 5, "喜欢": 3, "疼爱": 2, "牵挂": 3, "思念": 4} targets = [] for r in records: rel_type = r["rel_type"] base_score = score_map.get(rel_type, 1) weighted = r["freq"] * base_score targets.append({"person": rel_type, "score": weighted}) return sorted(targets, key=lambda x: x["score"], reverse=True)[:3]

这样,“贾宝玉喜欢谁?”的答案就变成:“林黛玉(深爱×12次=60分),薛宝钗(喜欢×8次=24分),晴雯(疼爱×5次=10分)”。分数可导出,成为答辩时展示“量化分析能力”的亮点。

我带过几届毕业设计,发现学生最容易卡在“不知道加什么才算‘有深度’”。其实不需要大模型、不需要复杂算法——把 PageRank 和最短路径这两个图数据库自带功能用透,再加一层业务规则(如情感加权),就能让系统从“能用”跃升到“有洞察”。这比堆砌十个前端特效更有说服力。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进