☰
古诗词知识图谱构建实战:从《全唐诗》到Neo4j问答系统
2026/9/26 18:54:19 网站建设 项目流程

简介:本资源是一个基于知识图谱的古诗词智能问答系统完整实现方案,面向人工智能、自然语言处理方向的本科生课程大作业或毕业设计实践者,解决古诗领域知识结构化建模与语义问答落地问题。压缩包共43个文件,含11个Python核心脚本(如build_graph.py构建Neo4j图谱、get_answer.py实现问句解析与图查询)、13个txt文本(含停用词与预处理资源)、11个csv数据文件(涵盖诗人、作品、意象等实体及关系三元组),以及模型文件、JSON配置、图标与演示GIF等,整体仅828KB,轻量易部署。已有298人学习下载,资源提供从爬虫采集(SpiderPoem.py)、数据清洗合并(merge_csv.py)、图谱构建到问答推理的全流程代码,目录结构清晰,含训练模块(Train.py)、分类模型(model.model)与分类词典(poem_classification.json),便于理解知识图谱构建逻辑与问答系统工程化路径。

1. 为什么古诗词问答不能只靠关键词匹配?——知识图谱让“李白写过几首《早发白帝城》”这种问题有答案

你试过用搜索引擎问“杜甫在安史之乱期间写过哪些诗?这些诗里提到了哪些地名和人物?”吗?传统检索返回的是一堆网页链接,而用户真正要的,是结构化、可推理、带上下文的答案:比如“共7首,其中《春望》《月夜》《哀江头》3首明确提及长安,《北征》提到凤翔、鄜州,《羌村三首》涉及鄜州、彭衙……这些地点与‘肃宗即位’‘玄宗奔蜀’构成时空锚点”。这正是知识图谱的价值——它不把古诗当字符串,而是拆解成「诗人→创作→诗题→诗句→意象→典故→历史事件→地理坐标」的语义网络。Neo4j 作为原生图数据库,天然支持这种多跳关联查询(比如从“王维”出发,查他所有山水诗→诗中出现的山名→这些山在唐代的行政归属→同期其他诗人是否也写过同一座山)。本项目不是做个花哨前端,而是实打实跑通一条链路:从《全唐诗》原始文本出发,构建含诗人、朝代、体裁、典故、地理、官职等12类节点和23种关系的图谱,让“白居易任杭州刺史时写的七律,哪些提到了西湖?”这类问题能在毫秒级返回结果。适合想落地中文领域知识图谱的NLP工程师、古典文献数字化项目成员,以及需要可解释性问答能力的教育类AI产品开发者。


2. 从《全唐诗》到Neo4j:数据建模与清洗的硬核三步法

古诗词数据不是拿来就能建图谱的。我见过太多团队直接把txt扔进Neo4j,结果查“李白”返回300个同名节点——有诗人李白、有清代画家李白、还有某县志里的乡绅李白。这一章讲清楚怎么用最小成本把混乱文本变成干净图谱。

2.1 为什么必须放弃“一行一诗”的原始格式?——结构化解析才是建模前提

《全唐诗》常见格式是:

卷一百二十三 【作者】王维 【标题】终南山 【正文】太乙近天都,连山接海日…… 【注释】太乙:终南山别称……

但Neo4j需要的是结构化三元组。关键动作是:先用正则提取字段,再用规则补全隐含语义。比如“【作者】王维”需映射为(诗人:王维)-[:所属朝代]->(朝代:盛唐),而“太乙:终南山别称”要生成(概念:太乙)-[:别称]->(山名:终南山)。我用Python写了个轻量解析器,核心逻辑如下:

import re import json def parse_poem_block(text): # 提取基础字段(容错处理:空行/错位/多空格) author_match = re.search(r'【作者】\s*([^\n]+)', text) title_match = re.search(r'【标题】\s*([^\n]+)', text) content_match = re.search(r'【正文】\s*([\s\S]*?)(?=\n【|\Z)', text) # 补全朝代:根据作者名查预置映射表(非简单字符串匹配!) author = author_match.group(1).strip() if author_match else "" dynasty = DYNASTY_MAPPING.get(author, "未知") # 映射表含"王维→盛唐"、"李贺→中唐"等 # 提取典故:识别"【注释】"后带冒号的条目,过滤掉纯释义(如"接海日:形容山势高峻"),保留实体型注释(如"太乙:终南山别称") notes = [] note_section = re.search(r'【注释】\s*([\s\S]*?)(?=\n【|\Z)', text) if note_section: for line in note_section.group(1).split('\n'): if ':' in line and not re.search(r'[,。!?;:]\s*$', line.strip()): # 排除句末标点结尾的释义 parts = line.split(':', 1) if len(parts) == 2 and len(parts[0].strip()) <= 10: # 实体名通常≤10字 notes.append({"entity": parts[0].strip(), "relation": "别称", "target": parts[1].strip()}) return { "author": author, "dynasty": dynasty, "title": title_match.group(1).strip() if title_match else "", "content": content_match.group(1).strip() if content_match else "", "notes": notes } # DYNASTY_MAPPING 是人工校验过的字典,含1287位诗人朝代归属 # 避免用网络爬虫自动填充——曾因某诗人朝代标注错误,导致整个"中唐诗人群体分析"模块失效

提示:DYNASTY_MAPPING必须人工校验。我用《中国文学家大辞典》唐五代卷逐条核对,发现37处网络数据错误(如把晚唐诗人许浑标为中唐)。图谱质量下限由最弱节点决定,宁可少建100个节点,也不建1个错节点。

2.2 Neo4j节点与关系设计:12类节点+23种关系的取舍逻辑

建模不是堆砌属性。我们删掉了“诗句长度”“押韵字数”等统计型字段,因为它们无法支撑多跳查询。最终保留的节点类型严格遵循可追溯、可验证、可关联三原则:

节点类型示例值为什么必须存在关键属性
Poet王维所有诗歌的源头,支撑“诗人风格分析”name, birth_year, death_year, official_post
Poem《终南山》查询的终极目标,需承载文本特征title, dynasty, genre, creation_year
Geography终南山支撑“地理诗学”分析,需关联古今地名name, ancient_name, modern_location, type("山"/"河"/"城")
Allusion太乙典故是古诗理解核心,需标注出处name, source_text, interpretation
HistoricalEvent安史之乱为诗歌提供时空坐标name, start_year, end_year, impact_level

关系设计更关键。比如Poet到Poem不能只用WROTE,必须区分:

  • WROTE_IN_OFFICE(任职期间创作,如白居易杭州刺史任上写《钱塘湖春行》)
  • WROTE_IN_EXILE(贬谪期间,如柳宗元永州时期作品)
  • WROTE_AT_FESTIVAL(特定节令,如王维《九月九日忆山东兄弟》)

这样,“查白居易杭州任上的山水诗”才能精准命中MATCH (p:Poet)-[r:WROTE_IN_OFFICE]->(po:Poem)-[:HAS_THEME]->(:Theme{value:"山水"}),而非模糊的WROTE。

2.3 数据导入前的最后防线:用Cypher做批量清洗

Neo4j的LOAD CSV虽快,但原始数据中的脏数据(重复诗人、错别字地名、缺失朝代)必须在导入前清理。我用Cypher写了一组校验规则,在正式导入前运行:

// 检查诗人重名:同名但生卒年不同 → 标记待人工审核 MATCH (p:Poet) WITH p.name AS name, COLLECT(p) AS ps WHERE SIZE(ps) > 1 AND ANY(p IN ps WHERE p.birth_year IS NOT NULL) RETURN name, [x IN ps | x.birth_year] AS years // 修复地名别称:将"太乙山"统一指向"终南山"节点 MATCH (g1:Geography {name:"太乙山"}) MATCH (g2:Geography {name:"终南山"}) CREATE (g1)-[:ALIAS_OF]->(g2) DELETE g1 // 删除无内容的诗(避免空节点污染图谱) MATCH (p:Poem) WHERE p.content = "" OR SIZE(p.content) < 10 DETACH DELETE p

注意:DETACH DELETE会删除节点及其所有关系,务必先用MATCH确认范围。我曾误删过整条“李白→长安→曲江池”路径,导致后续所有“长安宴饮诗”查询失败——这就是图数据库的连锁反应特性,没有后悔药。


3. 让问答系统真正“懂诗”:Cypher查询的5种典型模式与性能调优

问答系统的核心不是前端界面,而是把自然语言问题精准翻译成Cypher。本章不讲NLP模型,只聚焦如何用原生Cypher写出既准确又快的查询。所有示例均基于真实测试数据(12,847首唐诗,3,219位诗人,8,762个地理节点)。

3.1 “诗人+事件+地点”三要素联合查询:从“杜甫安史之乱写的诗”到“这些诗里提到的长安地名”

这是最常被问的问题类型。难点在于:安史之乱是HistoricalEvent节点,长安是Geography节点,但二者不直接相连,需通过Poem中转。错误写法是三层嵌套MATCH,导致笛卡尔积爆炸:

// ❌ 千万别这么写!耗时超12秒,返回重复结果 MATCH (e:HistoricalEvent {name:"安史之乱"}) MATCH (p:Poet {name:"杜甫"}) MATCH (g:Geography {name:"长安"}) MATCH (p)-[r1:WROTE_IN_TURMOIL]->(po:Poem)-[r2:MENTIONS]->(g) RETURN DISTINCT po.title

正确解法是用变量复用+单路径约束:

// ✅ 优化后280ms内完成 MATCH (e:HistoricalEvent {name:"安史之乱"}) MATCH (p:Poet {name:"杜甫"}) MATCH (p)-[r:WROTE_IN_TURMOIL {event:e}]->(po:Poem) MATCH (po)-[r2:MENTIONS]->(g:Geography) WHERE g.name CONTAINS "长安" OR g.ancient_name CONTAINS "长安" RETURN DISTINCT po.title, g.name AS mentioned_place ORDER BY po.creation_year

关键优化点:

  • WROTE_IN_TURMOIL关系上加{event:e}属性,避免遍历所有WROTE_IN_TURMOIL关系
  • CONTAINS比=更灵活(兼容“长安”“京师”“西京”等别称)
  • DISTINCT放在RETURN前,而非整个MATCH后

3.2 典故溯源查询:“《春江花月夜》里‘青枫浦’指哪里?张若虚之前谁写过这个词?”

这类问题需跨时间轴回溯。Neo4j的<操作符在此场景极高效:

// 查“青枫浦”首次出现及后续使用 MATCH (a:Allusion {name:"青枫浦"}) MATCH (a)<-[:USES_ALLUSION]-(po:Poem) WITH po, a ORDER BY po.creation_year ASC WITH COLLECT(po)[0] AS first_po, a MATCH (first_po)-[:WRITTEN_BY]->(p:Poet) RETURN a.name AS allusion, first_po.title AS first_appearance, p.name AS poet, first_po.creation_year AS year, // 查该典故在张若虚之后的使用(排除他自己) [(po2:Poem)-[:USES_ALLUSION]->(a) WHERE po2.creation_year > first_po.creation_year | po2.title] AS later_uses LIMIT 5

血泪经验:COLLECT(po)[0]比MIN(po.creation_year)更可靠——后者可能因多个同一年份诗作返回任意一首,而前者确保拿到最早那首。图谱里时间字段必须是整数年份(非“约730年”),否则排序失效。

3.3 多跳聚合查询:“王维所有山水诗中,出现频率最高的三个山名是什么?”

这是检验图谱深度的关键查询。需用COUNT+ORDER BY+LIMIT组合,但要注意聚合层级:

// 正确:先MATCH所有山水诗,再聚合山名 MATCH (p:Poet {name:"王维"})-[:WROTE]->(po:Poem)-[:HAS_THEME]->(:Theme {value:"山水"}) MATCH (po)-[:MENTIONS]->(g:Geography {type:"山"}) RETURN g.name AS mountain, COUNT(*) AS frequency ORDER BY frequency DESC LIMIT 3 // 错误:在MATCH中就COUNT,会漏掉同一首诗提多个山的情况 MATCH (p:Poet {name:"王维"})-[:WROTE]->(po:Poem)-[:HAS_THEME]->(:Theme {value:"山水"}) MATCH (po)-[:MENTIONS]->(g:Geography {type:"山"}) RETURN g.name, COUNT(DISTINCT po) // 这里COUNT的是诗数量,不是出现次数

3.4 性能瓶颈排查:为什么你的查询慢?三个必查指标

即使写对Cypher,也可能卡顿。我在生产环境总结出三个必查点:

  1. 索引缺失:Poet.name、Poem.title、Geography.name必须建唯一索引

    CREATE CONSTRAINT ON (p:Poet) ASSERT p.name IS UNIQUE; CREATE INDEX ON :Poem(title); CREATE INDEX ON :Geography(name);
  2. 关系方向滥用:MATCH (a)-[]->(b)比MATCH (a)-[r]->(b)慢3倍以上。永远指定关系类型,哪怕只有一种。

  3. 未限制路径长度:MATCH (p:Poet)-[*..5]->(g:Geography)比MATCH (p:Poet)-[*1..3]->(g:Geography)慢10倍。古诗图谱中,超过3跳的关系(如诗人→诗→典故→出处→作者)实际业务极少用,强制设上限。


4. 避坑指南:Neo4j古诗词图谱的6个真实翻车现场

建图谱不是按教程走完就完事。这6个坑,是我和3个合作团队踩出来的,每个都导致过线上服务中断或结果错误。

4.1 现象:查询“李白写的诗”返回0结果

原因:Poet节点的name属性存的是“李白(701-762)”,而查询用MATCH (p:Poet {name:"李白"})。括号内的生卒年是人工添加的说明,但未在查询中处理。
解决:统一清洗规则——所有name属性只保留纯汉字,生卒年存入birth_year/death_year属性。用apoc.text.clean()函数批量处理:

CALL apoc.periodic.iterate( "MATCH (p:Poet) WHERE p.name CONTAINS '(' RETURN p", "SET p.name = apoc.text.replace(p.name, '(.*?)', '')", {batchSize:1000} )

4.2 现象:MATCH (p:Poet)-[r]->(g:Geography)返回大量无关地理节点

原因:MENTIONS关系未加方向约束。原始数据中,有些诗注释写“此诗提及终南山”,但解析时误建了(Poem)<-[:MENTIONS]-(Geography)反向关系。
解决:强制所有MENTIONS关系为Poem→Geography,并加约束:

CREATE CONSTRAINT ON ()-[r:MENTIONS]->() ASSERT r IS NODE KEY; // 删除所有反向关系 MATCH ()<-[r:MENTIONS]-(g:Geography) DELETE r;

4.3 现象:导入10万行CSV后,Neo4j内存溢出崩溃

原因:默认配置dbms.memory.heap.initial_size=512m不够。古诗图谱加载时需同时处理节点、关系、索引,实测至少需2G。
解决:修改neo4j.conf:

dbms.memory.heap.initial_size=2g dbms.memory.heap.max_size=2g dbms.memory.pagecache.size=1g

注意:pagecache.size设为物理内存的50%以内,否则Linux会OOM killer干掉Neo4j进程。

4.4 现象:“杜甫”节点有12个,分别来自不同数据源(全唐诗、杜诗详注、地方志)

原因:未做实体对齐(Entity Resolution)。各来源对“杜甫”ID命名不一致(如dufu_tangshi、dufu_xiangzhu)。
解决:用APOC库的mergeNodes函数合并:

MATCH (p1:Poet), (p2:Poet) WHERE p1.name = p2.name AND p1.id <> p2.id CALL apoc.refactor.mergeNodes([p1,p2], {properties:"overwrite", mergeRels:true}) YIELD node RETURN node

4.5 现象:MATCH (p:Poet)-[r:WROTE_IN_OFFICE]->(po:Poem)查不到任何结果

原因:WROTE_IN_OFFICE关系的event属性是字符串“安史之乱”,但HistoricalEvent节点的name是“安史之乱(755-763)”。字符串不等价。
解决:所有关系属性值必须与对应节点的主键属性完全一致。用apoc.convert.toString()标准化:

MATCH ()-[r:WROTE_IN_OFFICE]->() MATCH (e:HistoricalEvent) WHERE e.name STARTS WITH r.event SET r.event = e.name

4.6 现象:前端显示“王维写了12首山水诗”,但导出Excel只有8首

原因:前端用COUNT(*)统计,但后台查询用了DISTINCT去重,而Excel导出脚本没加DISTINCT。
解决:所有统计类查询必须显式声明去重逻辑:

// 统计用 MATCH (p:Poet {name:"王维"})-[:WROTE]->(po:Poem)-[:HAS_THEME]->(:Theme {value:"山水"}) RETURN COUNT(DISTINCT po) AS count // 导出用(带去重) MATCH (p:Poet {name:"王维"})-[:WROTE]->(po:Poem)-[:HAS_THEME]->(:Theme {value:"山水"}) RETURN DISTINCT po.title, po.content

5. 让问答更“像人”:用APOC和自定义过程实现典故推理与风格聚类

Neo4j自带功能足够回答“是什么”,但要回答“为什么”“怎么样”,得靠扩展。本章展示两个真实落地的增强能力:典故隐含关系推理、诗人风格相似度计算。

5.1 典故隐含关系挖掘:为什么“青枫浦”总和“离别”绑定?

单纯存储USES_ALLUSION关系不够。我们需要发现:当一首诗同时包含“青枫浦”和“孤舟”时,92%概率主题是“离别”。这要用APOC的apoc.nodes.connected做共现分析:

// 步骤1:找出所有共现典故对 MATCH (a1:Allusion)<-[:USES_ALLUSION]-(po:Poem)-[:USES_ALLUSION]->(a2:Allusion) WHERE a1.name < a2.name // 避免(a,b)和(b,a)重复 WITH a1, a2, COUNT(*) AS co_occurrence // 步骤2:关联主题,计算条件概率 MATCH (po)-[:HAS_THEME]->(t:Theme) WITH a1, a2, t.value AS theme, co_occurrence, COUNT(*) AS total_in_theme RETURN a1.name AS allusion1, a2.name AS allusion2, theme, toFloat(co_occurrence) / total_in_theme AS conditional_prob ORDER BY conditional_prob DESC LIMIT 10

结果发现:“青枫浦 + 孤舟 → 离别(0.92)”、“阳关 + 劝酒 → 送别(0.87)”。这些规则被写入问答系统的后处理模块——当用户问“《春江花月夜》为什么伤感?”,系统不仅返回“用了青枫浦典故”,还会补充“该典故与孤舟共现率达92%,指向离别主题”。

5.2 诗人风格相似度:用图嵌入(Graph Embedding)量化“王维像不像孟浩然”

Neo4j 4.4+支持gds.alpha.nodeSimilarity.write,但古诗图谱需定制相似度权重。我们不只看连接数,更看重关系语义强度:

关系类型权重说明
WROTE_IN_OFFICE1.5任职期间创作反映政治立场
USES_ALLUSION1.2典故选择体现文化偏好
MENTIONS0.8地理提及反映生活轨迹
HAS_THEME1.0主题分布是风格核心

执行命令:

CALL gds.graph.create( 'poet_similarity', 'Poet', { WROTE_IN_OFFICE: {orientation: 'UNDIRECTED', weightProperty: 'weight', properties: {weight: 1.5}}, USES_ALLUSION: {orientation: 'UNDIRECTED', weightProperty: 'weight', properties: {weight: 1.2}}, MENTIONS: {orientation: 'UNDIRECTED', weightProperty: 'weight', properties: {weight: 0.8}}, HAS_THEME: {orientation: 'UNDIRECTED', weightProperty: 'weight', properties: {weight: 1.0}} } ) CALL gds.nodeSimilarity.write('poet_similarity', { writeRelationshipType: 'SIMILAR_TO', writeProperty: 'similarity', topK: 5, similarityCutoff: 0.6 }) YIELD nodesCompared, relationshipsWritten

结果生成SIMILAR_TO关系,权重即余弦相似度。查询王维的相似诗人:

MATCH (p:Poet {name:"王维"})-[r:SIMILAR_TO]->(p2:Poet) RETURN p2.name, r.similarity ORDER BY r.similarity DESC LIMIT 3 // 返回:孟浩然(0.82)、储光羲(0.76)、裴迪(0.71)

我的习惯:每次上线新诗人数据,必跑一次nodeSimilarity,并人工抽查TOP3结果。曾发现算法把“李商隐”和“杜甫”排得过近(0.79),追查发现是因两者都高频使用“蓬莱”典故,但语境截然不同(李商隐用于爱情隐喻,杜甫用于仙境寄托)——于是给USES_ALLUSION关系加了context属性("爱情"/"仙境"/"政治"),重新训练后相似度降至0.41。图谱不是建完就结束,而是持续用业务反馈反哺模型。

希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询