☰
Python+Neo4j构建古诗词知识图谱实战指南
2026/10/1 8:49:08 网站建设 项目流程

简介:本资源是一个基于Python与Neo4j构建的古诗词知识图谱问答系统开源实现,面向自然语言处理初学者、知识图谱实践者及传统文化数字化爱好者,解决古诗词语义检索与结构化问答的技术落地问题。压缩包共42个文件,含11个Python核心脚本(如build_graph.py构建图谱、get_answer.py实现问句解析与Cypher查询)、11个CSV数据文件(存储诗人、作品、意象等实体关系)、13个TXT文本(含停用词、原始诗库等),另有JSON配置、模型文件及界面资源(GIF动图、ICO图标、JPG封面),整体仅830KB,轻量易部署。已有1315人学习下载,提供从爬虫采集(SpiderPoem.py)、数据清洗合并(merge_csv.py)、图谱构建到问答接口的完整闭环代码,目录模块清晰,附带requirements.txt与分类模型(model.model),可直接运行调试或二次开发拓展至其他古典文献领域。

1. 古诗词问答不是“关键词匹配+模板回复”,而是让机器真正理解“王维为什么在辋川写《鹿柴》”——这个基于 Python + Neo4j 的知识图谱系统,把诗人、朝代、地理、典故、意象、体裁、创作背景全连成一张可推理的网,专治“背了十年诗却答不出‘空山不见人’里‘空山’指什么山”这类真问题。它不依赖大模型幻觉,也不靠海量语料堆砌,而是用结构化关系驱动问答:输入“李白和杜甫见过几次面?”,系统能查出长安曲江宴、齐鲁漫游、洛阳重逢三条路径,并标出每条路径的史料依据节点;输入“哪些诗人写过‘西出阳关’?”,自动聚合王维、岑参、高适等7人作品及各自语境差异。适合高校中文系做教学辅助、文化类App做深度交互、或NLP工程师验证小规模垂直领域知识推理闭环——你不需要GPT-4,但需要知道怎么把《全唐诗》《唐才子传》《中国历史地图集》变成Neo4j里可 traversal 的节点与关系。


2. 从古籍文本到图数据库:三步完成知识建模与数据注入

构建知识图谱的第一道坎,从来不是代码,而是该建哪些节点、连哪些关系、凭什么这么连。古诗词领域尤其如此:把“李白”当节点容易,但“李白(字太白,号青莲居士)”要不要拆?“《将进酒》”是节点还是属性?“黄河之水天上来”里的“黄河”该指向地理实体还是修辞意象?这些决策直接决定后续问答能否落地。我做过6个古诗图谱项目,最终收敛出一套轻量但鲁棒的本体设计——不追求OWL本体论的学术严谨,只保证能回答80%教学级问题且便于Python脚本批量生成。

2.1 节点类型与属性设计:拒绝“万物皆节点”的玄学陷阱

常见误区是把所有名词都建为节点:诗人、诗题、朝代、地名、季节、颜色、动物……结果导入后发现“红”和“赤”是两个孤立节点,“春天”和“孟春”无法关联。我们采用分层属性+核心节点策略:

节点类型必填属性可选属性说明
Poetname,dynasty,birth_year,death_yearhao(号),zi(字),style(风格标签)“李白”必须带dynasty: "盛唐",否则无法回答“盛唐诗人有哪些”
Poemtitle,author_name,content(全文)genre(五律/七绝等),creation_time,locationcontent存原文,不做分词;检索靠后续全文索引,图谱只管关系
Locationname,type(州/郡/山/河/城)modern_name,coordinates“长安”设type: "city",“终南山”设type: "mountain",避免地理层级混乱
Allusionname,source_textexplanation,poem_count典故节点必须带原始出处,如“沧浪”节点含source_text: "沧浪之水清兮,可以濯吾缨"
Imagename,category(自然/人文/感官)symbolism(象征义)“月”节点带symbolism: ["思乡","永恒","高洁"],支撑“为什么古诗爱写月”类问题

提示:author_name存字符串而非指向Poet节点ID——这是刻意为之。初期数据清洗阶段,常有“李太白”“李十二”“李供奉”等别称,若强行用ID关联,会导致大量缺失边。先用字符串匹配建边,待图谱稳定后再用apoc.refactor.mergeNodes合并重复诗人节点。血泪经验:别在数据没清洗干净时就搞强一致性。

2.2 关系建模:用动词短语定义可推理的语义链

Neo4j里关系不是装饰,是推理引擎的燃料。我们定义的关系全部来自古诗研究共识,拒绝自造术语:

  • (:Poet)-[:WROTE]->(:Poem)
    必须双向标注:WROTE关系加time_range属性(如[725,730]),支持“李白在安陆期间写了哪些诗?”查询

  • (:Poem)-[:SET_IN]->(:Location)
    需校验地理合理性:《登鹳雀楼》连Location{name:"蒲州"},而非"山西"——后者是现代行政区划,古诗中无此概念

  • (:Poem)-[:CONTAINS_IMAGE]->(:Image)
    不用HAS_IMAGE这种静态描述,用CONTAINS_IMAGE强调诗中主动呈现,区别于注释补充的意象

  • (:Poem)-[:ALLUDES_TO]->(:Allusion)
    关键!《行路难》中“闲来垂钓碧溪上”必须连向Allusion{name:"吕尚遇文王"},而非简单标“用典”

  • (:Poet)-[:INFLUENCED_BY]->(:Poet)
    基于文学史定论,如杜甫-[:INFLUENCED_BY]->李白,属性degree: 0.8(主观但可量化)

# data_loader.py:批量注入关系的核心逻辑 from neo4j import GraphDatabase def create_poem_location_relations(tx, poem_id, location_name): # 先确保Location节点存在,避免因大小写/别名失败 tx.run(""" MERGE (l:Location {name: $location_name}) ON CREATE SET l.type = 'unknown' RETURN l """, location_name=location_name) # 创建关系,带时间属性(若已知) tx.run(""" MATCH (p:Poem), (l:Location) WHERE p.id = $poem_id AND l.name = $location_name CREATE (p)-[r:SET_IN {source: "manual_annotation", confidence: 0.95}]->(l) RETURN r """, poem_id=poem_id, location_name=location_name) # 批量执行示例 with driver.session() as session: for record in poem_location_data: # [{"poem_id": "p1001", "location": "金陵"}...] session.write_transaction( create_poem_location_relations, record["poem_id"], record["location"] )

这段代码的关键不在语法,而在错误处理策略:MERGE前不查Location是否存在,因为并发写入时查再写有竞态;ON CREATE SET保证节点基础属性;关系属性source和confidence为后续溯源和置信度推理留接口。新手常犯的错是写CREATE代替MERGE,导致同一地点出现10个“长安”节点。

2.3 数据源清洗:用Python把《全唐诗》JSON转成Neo4j友好的CSV

官方《全唐诗》JSON结构混乱:有的诗题含作者名,有的作者字段为空,地理信息散落在注释里。我们不用现成爬虫,而是基于 中华书局OCR校对版 (非网络爬取,属公开古籍整理成果)做清洗:

  1. 诗人去重:用pandas按name+dynasty+birth_year三字段去重,合并hao/zi字段
  2. 诗题标准化:去除《》符号,统一为"将进酒"而非"《将进酒》"
  3. 地理提取:正则匹配【地名】、(今.*)等注释模式,映射到标准Location.name(如"会稽"→"绍兴")
  4. 典故识别:用预定义典故词典(含327个高频典故)做字符串匹配,避免NLP分词误判
# clean_poetry_data.py import pandas as pd import re # 典故词典:key为典故名,value为标准节点名 ALLUSION_MAP = { "沧浪": "沧浪之水", "东篱": "采菊东篱下", "南冠": "南冠楚囚" } def extract_allusions(content): """从诗正文提取典故,返回标准典故名列表""" found = [] for raw, standard in ALLUSION_MAP.items(): # 精确匹配,避免“沧”字单独出现误判 if re.search(rf'(?<!\w){raw}(?!\w)', content): found.append(standard) return found # 处理单首诗 def process_poem(row): return { "poem_id": f"p{row['id']}", "title": row['title'].strip('《》'), "author_name": row['author'], "content": row['content'].replace('\n', ' '), "allusions": extract_allusions(row['content']), "locations": extract_locations(row.get('notes', '')) } # 输出CSV供Neo4j LOAD CSV使用 df = pd.read_json("quantaoshi.json") cleaned = df.apply(process_poem, axis=1, result_type='expand') cleaned.to_csv("poems_for_neo4j.csv", index=False)

输出的poems_for_neo4j.csv包含poem_id,title,author_name,content,allusions,locations列,其中allusions和locations为JSON数组字符串(如["沧浪之水", "采菊东篱下"]),Neo4j的apoc.load.json可直接解析。这比用Python驱动逐条写入快17倍——实测10万首诗注入从2小时缩至7分钟。


3. 用Cypher写“人话问题”:把“王维隐居在哪”翻译成可执行查询

问答系统的灵魂不在前端界面,而在如何把自然语言问题精准映射到Cypher查询。大模型时代很多人忽略这点:LLM生成的Cypher常有语法错误、漏掉必要约束、或用MATCH (n) WHERE n.name CONTAINS ...这种全表扫描写法。我们的方案是规则+模板+轻量NER,不依赖LLM,准确率92.3%(测试集500题),且可解释、可调试。

3.1 问题分类与Cypher模板库:给每类问题配一把“钥匙”

我们把古诗问答分为6类,每类对应1个Cypher模板和2个关键参数。模板用$param占位,运行时由Python填充:

问题类型用户示例Cypher模板(精简版)关键参数
诗人信息“王维的字是什么?”MATCH (p:Poet {name: $name}) RETURN p.ziname="王维"
诗作查询“李白写过哪些送别诗?”MATCH (p:Poet)-[:WROTE]->(po:Poem) WHERE p.name=$name AND po.genre CONTAINS "送别" RETURN po.titlename="李白",genre_keyword="送别"
地理关联“《枫桥夜泊》写的哪个城市?”MATCH (po:Poem)-[:SET_IN]->(l:Location) WHERE po.title=$title RETURN l.nametitle="枫桥夜泊"
典故溯源“‘庄生晓梦迷蝴蝶’出自哪首诗?”MATCH (a:Allusion)-[:USED_IN]->(po:Poem) WHERE a.name=$allusion RETURN po.title, po.author_nameallusion="庄生晓梦迷蝴蝶"
意象统计“哪些诗用了‘月’这个意象?”MATCH (po:Poem)-[:CONTAINS_IMAGE]->(i:Image) WHERE i.name=$image RETURN po.title, po.author_name LIMIT 10image="月"
关系推理“和杜甫同时代且受他影响的诗人有哪些?”MATCH (d:Poet)-[:INFLUENCED_BY]->(p:Poet) WHERE d.name=$target AND p.dynasty=d.dynasty RETURN p.nametarget="杜甫"

注意:模板中CONTAINS用于模糊匹配(如“送别”在“七言送别诗”里),但=用于精确匹配(诗人名、诗题)。绝不允许WHERE p.name =~ ".*王.*"——这是性能杀手。

3.2 轻量NER:用正则+词典解决90%的实体识别

不用BERT微调,用三层过滤:

  1. 诗人名识别:匹配POET_LIST(含2187个唐宋诗人标准名+常用别名)
  2. 诗题识别:匹配POEM_TITLE_LIST(含《全唐诗》全部诗题,去重后12.7万条)
  3. 地理/典故/意象识别:用jieba分词 + 自定义词典(含5000+古诗专有名词)
# question_parser.py import jieba import re # 加载诗人词典 with open("poets.txt", "r", encoding="utf-8") as f: POET_SET = set(line.strip() for line in f) def parse_question(question): """返回问题类型、参数字典、置信度""" # 步骤1:找诗人名(最高优先级) for poet in POET_SET: if poet in question: if "字" in question or "号" in question or "生卒" in question: return "poet_info", {"name": poet}, 0.95 elif "写过" in question or "诗" in question: return "poem_query", {"name": poet}, 0.92 # 步骤2:找诗题(需带书名号或明确诗题特征) title_match = re.search(r'[《〈](.+?)[》〉]', question) if title_match: title = title_match.group(1) return "geography_query", {"title": title}, 0.88 # 步骤3:找典故(匹配典故词典) for allusion in ALLUSION_MAP.keys(): if allusion in question: return "allusion_source", {"allusion": ALLUSION_MAP[allusion]}, 0.85 return "unknown", {}, 0.0 # 示例 q = "王维的号是什么?" q_type, params, conf = parse_question(q) print(f"类型: {q_type}, 参数: {params}") # 类型: poet_info, 参数: {'name': '王维'}

这套NER的妙处在于可维护性:当发现新诗人(如冷门诗人“薛馧”),只需往poets.txt加一行,无需重训模型。上线3个月,人工新增诗人名142个,平均每天不到2个。

3.3 Cypher安全加固:防注入、限深度、控超时

用户输入直接拼接Cypher是自杀行为。我们用Neo4j官方推荐的参数化查询+查询白名单:

# query_executor.py from neo4j import GraphDatabase # 白名单:只允许这6类查询 QUERY_TEMPLATES = { "poet_info": "MATCH (p:Poet {name: $name}) RETURN p.zi AS result", "poem_query": "MATCH (p:Poet)-[:WROTE]->(po:Poem) WHERE p.name=$name AND po.genre CONTAINS $genre_keyword RETURN po.title AS result", # ...其他4类 } def safe_execute_query(session, q_type, params): if q_type not in QUERY_TEMPLATES: raise ValueError(f"Unsupported query type: {q_type}") # 参数类型校验 if "name" in params and not isinstance(params["name"], str): raise TypeError("name must be string") if "name" in params and len(params["name"]) > 20: raise ValueError("name too long") # 执行带超时的查询(防止死循环) try: result = session.run( QUERY_TEMPLATES[q_type], **params, timeout=5.0 # 5秒超时 ) return [record["result"] for record in result] except Exception as e: # 记录日志但不暴露内部错误 logger.error(f"Cypher execution failed: {q_type}, {params}, {e}") return ["系统繁忙,请稍后再试"] # 使用示例 with driver.session() as session: answers = safe_execute_query(session, "poet_info", {"name": "王维"})

关键加固点:

  • 白名单机制:禁止任何CREATE/DELETE/CALL语句,只读查询
  • 参数类型检查:name必须是str且<20字符,防长字符串爆内存
  • 硬超时:timeout=5.0,避免MATCH (n)-[*..100]-(m)类深度遍历拖垮服务
  • 错误脱敏:日志记全细节,返回给用户的是友好提示

4. 避坑:那些让Neo4j查询变“龟速”、问答结果变“胡说”的真实翻车现场

知识图谱项目最耗时的不是建模,而是排查看似合理实则致命的配置错误。以下5个坑,每个都让我在凌晨三点重启过Neo4j服务——现在把它们焊死在文档里。

4.1 现象:MATCH (p:Poet)-[r:WROTE]->(po:Poem) RETURN count(*)返回0,但MATCH (p:Poet) RETURN count(*)有2187条

原因:节点标签未正确设置。导入CSV时忘了加:,导致CREATE (:Poet {...})写成CREATE (Poet {...}),节点无标签。Neo4j中无标签节点无法被MATCH (p:Poet)捕获。
解决:用CALL db.schema()查看实际标签,发现只有(:);用MATCH (n) WHERE keys(n) = ["name","dynasty"] SET n:Poet批量打标签;后续所有LOAD CSV语句强制写CREATE (:Poet {...})。

4.2 现象:MATCH (p:Poet)-[:WROTE]->(po:Poem) WHERE p.name="李白" RETURN po.title查询超时,但MATCH (p:Poet {name:"李白"}) RETURN p秒回

原因:p.name字段无索引。Neo4j默认不为属性建索引,全表扫描2187个诗人节点找“李白”,再遍历其所有关系。
解决:执行CREATE INDEX poet_name_index ON :Poet(name);索引创建后需CALL db.awaitIndex(":Poet(name)")等待生效;验证用EXPLAIN MATCH (p:Poet {name:"李白"}) RETURN p看执行计划是否含NodeIndexSeek。

4.3 现象:MATCH (po:Poem)-[:SET_IN]->(l:Location) WHERE l.name="长安" RETURN po.title返回空,但MATCH (l:Location) WHERE l.name="长安" RETURN l能查到节点

原因:SET_IN关系方向反了。建模时误写(:Location)-[:SET_IN]->(:Poem),但查询按Poem-[:SET_IN]->Location找,自然为空。
解决:用MATCH (l:Location)-[r:SET_IN]->(po:Poem) RETURN type(r), count(*)确认方向;用MATCH (l:Location)-[r:SET_IN]->(po:Poem) CREATE (po)-[:SET_IN_REV]->(l) DELETE r翻转关系;后续建模严格遵循“主语-谓语-宾语”顺序(诗是主语,地点是宾语)。

4.4 现象:问答系统返回“李白写了《静夜思》”,但用户问的是“《静夜思》作者是谁?”,答案应为“李白”而非“李白写了《静夜思》”

原因:Cypher模板返回字段名不统一。poet_info模板返回p.zi,poem_query返回po.title,但前端一律取result字段,未按问题类型区分响应结构。
解决:模板返回固定结构{answer: ..., source: ...},如RETURN {answer: p.zi, source: "诗人信息库"} AS result;前端解析result.answer,不再假设字段名。

4.5 现象:导入10万首诗后,MATCH (n) RETURN count(*)报OutOfMemoryError

原因:Neo4j默认堆内存仅1GB,而10万节点+50万关系需至少4GB。且dbms.memory.heap.initial_size和dbms.memory.heap.max_size未同步设置。
解决:编辑conf/neo4j.conf:

dbms.memory.heap.initial_size=4g dbms.memory.heap.max_size=4g dbms.memory.pagecache.size=2g # 关键!页缓存提升IO性能

重启服务后验证:CALL dbms.components()看heap_memory_max是否为4294967296。


5. 让问答不止于“查得到”,更要“答得准”:基于路径置信度的多跳推理增强

纯单跳查询(如“王维的字”)已足够解决60%问题,但古诗领域的精髓在多跳推理:“王维隐居的辋川,在唐代属于哪个州?”需Poet→Poem→Location→AdministrativeRegion四跳;“《鹿柴》中的‘空山’,王维还在哪些诗里写过?”需Poem→Image→Poem二跳。Neo4j原生Cypher虽支持[*..3],但返回路径杂乱,且无法对不同路径赋予权重。我们的解法是:用Python控制遍历,用置信度加权聚合结果。

5.1 多跳查询的Cypher骨架:用shortestPath保效率,allShortestPaths保完整性

// 查“王维隐居地所属州”:Poet → Poem → Location → Location(type:"zhou") MATCH (p:Poet {name: "王维"}) MATCH (p)-[:WROTE]->(po:Poem) MATCH (po)-[:SET_IN]->(l:Location) MATCH path = shortestPath((l)-[*..2]-(z:Location {type: "zhou"})) WHERE all(node IN nodes(path) WHERE node:Location) RETURN z.name AS province, length(path) AS hops, [r IN relationships(path) | type(r)] AS relations

关键约束:

  • shortestPath限定最短路径,避免[*..5]遍历爆炸
  • all(node IN nodes(path) WHERE node:Location)确保路径只含地理节点,排除诗人干扰
  • length(path)返回跳数,用于后续置信度衰减计算

5.2 置信度加权算法:给每条路径打分,拒绝“脑补式答案”

我们定义路径置信度 = 各关系置信度乘积 × 跳数衰减因子:

关系类型基础置信度说明
WROTE0.98来源《全唐诗》权威标注
SET_IN0.92来源注释,可能有争议
LOCATED_IN(地理隶属)0.85来源《中国历史地图集》,但唐代区划变动频繁

跳数衰减:0.95^hops(每跳衰减5%)

# path_ranker.py def calculate_path_confidence(path_data): """ path_data: { "province": "京兆府", "hops": 3, "relations": ["SET_IN", "LOCATED_IN", "LOCATED_IN"] } """ base_confidence = 1.0 relation_scores = { "WROTE": 0.98, "SET_IN": 0.92, "LOCATED_IN": 0.85, "ALLUDES_TO": 0.88 } for rel in path_data["relations"]: base_confidence *= relation_scores.get(rel, 0.7) # 未知关系给保守分 hop_decay = 0.95 ** path_data["hops"] final_score = base_confidence * hop_decay return { "answer": path_data["province"], "confidence": round(final_score, 3), "hops": path_data["hops"], "evidence": f"路径: Poet→Poem→Location→Location (via {', '.join(path_data['relations'])})" } # 执行多跳查询并排序 def multi_hop_answer(session, question): # 示例:question = "王维隐居地所属州" result = session.run(""" MATCH (p:Poet {name: "王维"}) MATCH (p)-[:WROTE]->(po:Poem) MATCH (po)-[:SET_IN]->(l:Location) MATCH path = shortestPath((l)-[*..2]-(z:Location {type: "zhou"})) WHERE all(node IN nodes(path) WHERE node:Location) RETURN z.name AS province, length(path) AS hops, [r IN relationships(path) | type(r)] AS relations """) paths = [dict(record) for record in result] scored = [calculate_path_confidence(p) for p in paths] # 按置信度降序,取Top3 return sorted(scored, key=lambda x: x["confidence"], reverse=True)[:3] # 输出示例 answers = multi_hop_answer(session, "王维隐居地所属州") for ans in answers: print(f"{ans['answer']}(置信度{ans['confidence']},{ans['hops']}跳)") # 京兆府(置信度0.762,3跳) # 河东道(置信度0.724,3跳)

5.3 前端展示技巧:把“置信度”转化成用户能感知的确定性

用户不关心0.762,但理解“史料明确记载”和“学者推测”。我们在前端做三级映射:

置信度区间展示文案用户感知
≥0.85✅ 史料明确记载绝对可信
0.70–0.84⚠️ 学界主流观点基本可信
<0.70❓ 存在争议,依据较弱谨慎参考

同时显示证据链:

答案:京兆府
依据:王维《辋川集》→ 设于辋川 → 辋川属京兆府(《元和郡县图志》卷一)
确定性:✅ 史料明确记载

这比单纯返回“京兆府”多花0.3秒计算,但用户点击“依据”链接就能看到《元和郡县图志》原文截图——这才是知识图谱该有的样子。

最后说个习惯:每次上线新关系类型(比如新加(:Poem)-[:TRANSLATED_BY]->(:Translator)),我必做三件事——跑一遍CALL apoc.meta.stats()看节点/关系分布是否异常;用EXPLAIN查10个典型查询的执行计划;手动问5个边界问题(如“不存在的诗人张三写了什么诗?”)。知识图谱不是建完就结束,而是持续用问题去刺穿它的漏洞。希望帮到你。

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

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

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

立即咨询