ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

知识图谱驱动的古诗词问答系统构建指南

知识图谱驱动的古诗词问答系统构建指南

简介:面向计算机相关专业毕业生、课程设计与项目实战学习者,这份源码以知识图谱为核心,实现古诗词问答系统的完整流程,涵盖知识图谱构建、问句解析、答案检索与前端交互等关键环节。压缩包共521个文件、约50.58MB,核心为98个Python源码文件与18个pyc,配合24个JSON数据文件、8个HTML页面以及CSS/JS前端资源,另有129个txt文本和183张图片,便于查看运行说明、界面效果与项目目录,适合在毕设、课设、期末大作业或初期项目演示中直接参考。已有332人学习浏览,资源包同时提供前端页面、Python后端代码和JSON/文本数据文件,解压后可先通过README了解项目结构并快速启动。代码经过运行测试,评审分为96.5,可在已有问答逻辑上替换古诗词语料、扩展知识图谱实体与关系,也能作为二次开发和答辩演示的基础。

1. 古诗词问答系统:本科毕设里“投入产出比”最高的知识图谱方向

A同学拿到“基于知识图谱的古诗词问答系统python源码(本科毕设)”这个题目时,第一反应通常是慌:又要处理古诗文数据,又要建知识图谱,还得做问答和界面,听着像三个大作业的量。但实际拆开看,这个方向的每一块技术都有成熟套路,数据有公开来源,图谱有图数据库承接,问答用规则模板就能覆盖大多数问法,真正难的其实是“把几块串起来”而不是“发明新算法”。它适合有Python基础、想在毕设里同时展示数据工程能力和系统开发能力的人,也适合想用最少的价格买一个完整项目做参照、然后二次开发的选手。这篇笔记就按“数据怎么备 → 图谱怎么建 → 问答怎么写 → 坑在哪”的顺序,把这条路走一遍。

2. 数据工程:把古诗词JSON拆成知识图谱三元组的三步走

2.1 本体设计:实体、关系、属性先定好,省得返工

知识图谱的本质是“实体-关系-实体”的三元组,所以动手写解析器之前,先要把本体(Ontology)想清楚。古诗词问答系统里最常见的实体类型其实就五类:诗人、诗作、朝代、诗句、意象。关系则是这些实体之间的连接,比如“诗人写诗作”“诗作属于朝代”“诗作包含诗句”“诗句蕴含意象”。

我一般会先用一张表把本体定下来,发给导师确认后再写代码。这个动作看起来慢,实际是省时间的——很多同学做到一半发现“意象”这个实体没设计进去,回头补数据,比一开始花半天设计更痛苦。

实体类型关键属性示例
Poet(诗人)name、dynasty、style、birth_year某唐代诗人、山水田园派
Poem(诗作)title、dynasty、genre(诗/词/曲)《溪上偶题》
Dynasty(朝代)name、start_year、end_year唐、宋
Line(诗句)content、poem_id“山月照人归,溪风送客远”
Image(意象)name、meaning月、柳、流水

关系上,我习惯用“主谓宾”命名法:(Poet)-[:WRITE]->(Poem)、(Poem)-[:BELONG_TO]->(Dynasty)、(Poem)-[:CONTAINS]->(Line)、(Line)-[:EVOKE]->(Image)。命名别看是小事,后面写Cypher查询全靠它,大小写和方向错了,查询结果就是空的。

2.2 解析器编写:从原始JSON到三元组的Python实现

数据源通常是一份JSON文件,每首诗包含标题、作者、朝代、正文段落。解析器的任务就是循环这份JSON,输出一组组(头实体, 关系, 尾实体)。这个环节我一般直接用Python标准库加一个简单的清洗函数,不需要引入pandas,量级不大时反而更直观。

import json def parse_poetry(raw_path: str, output_path: str): """把原始古诗JSON解析为三元组CSV""" with open(raw_path, 'r', encoding='utf-8') as f: poems = json.load(f) triples = [] for poem in poems: author = poem.get('author', '').strip() title = poem.get('title', '').strip() dynasty = poem.get('dynasty', '').strip() # 三元组1:诗人 -> 写 -> 诗作 if author and title: triples.append((author, 'WRITE', title)) # 三元组2:诗作 -> 属于 -> 朝代 if title and dynasty: triples.append((title, 'BELONG_TO', dynasty)) # 三元组3:诗作 -> 包含 -> 诗句 for line in poem.get('paragraphs', []): line = line.strip().replace(',', '').replace('。', '') if line: triples.append((title, 'CONTAINS', line)) with open(output_path, 'w', encoding='utf-8') as f: for h, r, t in triples: f.write(f"{h}\t{r}\t{t}\n") print(f"生成 {len(triples)} 条三元组")

代码逻辑不复杂,但有几个点值得强调。第一,.strip()必须加,很多开源数据的作者名和标题自带空格或换行。第二,诗句合并时去掉标点,是因为问答场景里用户大概率不会带着逗号问,去掉标点能让检索更宽松。第三,输出用\t分隔,是为了后面导入Neo4j时统一用CSV格式处理。

这段代码跑完之后,检查一下三元组的条数和样本,看看有没有明显的脏数据。不要急着进下一步,数据错了后面全错,到时排查起来才是真正的耗时大头。

2.3 清洗三规则:去重、对齐、补全,一个都不能省

数据清洗是知识图谱项目里最不性感但最决定成败的环节。我总结成三条硬规则,每条都吃过亏。

去重是第一条。JSON里可能出现同一首诗被抓了两次,或者同一诗人名字格式不一样(“王维”和“王 维”)。我通常在解析器里维护一个集合,(头实体, 关系, 尾实体)三元组整体去重,因为图数据库导入时重复执行CREATE会生成一模一样的多条边,到后面统计“写了多少首诗”就会翻一倍。

对齐是第二条。数据源里朝代可能写“唐”也可能写“唐朝”、“唐代”,这会导致图谱里出现“两个唐朝节点”。我一般准备一个简单的别名映射表,把“唐代”“唐”统一映射到唐这个规范名,解析时直接查表替换。

补全是最容易被忽略的。很多JSON里的诗没有朝代、没有作者,这时候不能直接把空字符串写进三元组,否则图谱里会出现("", "WRITE", "诗名")这种无效边。处理方式就是跳过或者打标记,在日志里输出缺失数量,做到心中有数。

3. 存储与检索:Neo4j导入与前几类查询的Cypher写法

3.1 为什么选Neo4j:多跳查询对比MySQL的JOIN写法

这个项目叫“知识图谱”,存储层就绕不开图数据库。对比MySQL,Neo4j的核心优势体现在“多跳查询”上——比如用户问“某诗人的诗里用了哪些意象”,在MySQL里要先查诗作、再查诗句、再关联意象,三张表JOIN两次;在Neo4j里一条Cypher就能从诗人节点沿着关系走到意象节点。更关键的是,当查询路径再深一层(“与某诗人同朝代的诗人,写过的诗有哪些意象”),MySQL的SQL会变得非常冗长,而Neo4j只是在路径模式里多加一个节点层级而已。

选Neo4j还有一个答辩层面的好处:它自带可视化界面,Neo4j Browser里可以直接展示图谱路径,演示效果比贴SQL查询结果强太多。数据集不大的情况下,社区版足够用,不需要上并发集群那些企业功能。

3.2 LOAD CSV 批量导入:文件准备与Cypher命令

数据进Neo4j,最常见的做法是把解析好的三元组分成节点表(poets.csv、poems.csv、lines.csv)和关系表(relations.csv),然后用LOAD CSV批量导入。这里有个新手常犯的错:把三元组直接一股脑塞进一个CSV,导入时每个字段都建节点,结果产生一堆重复实体。

我习惯的做法是先导入实体节点,用MERGE而不是CREATE。MERGE会先查找节点是否存在,存在就不重复建,这正好和前面清洗阶段的“去重规则”呼应。

// 导入诗人节点,MERGE保证同一诗人只出现一次 LOAD CSV WITH HEADERS FROM 'file:///poets.csv' AS row MERGE (poet:Poet {name: row.name}) SET poet.dynasty = row.dynasty, poet.style = row.style
// 导入诗作-诗人关系,先MATCH到已有节点,再MERGE边 LOAD CSV WITH HEADERS FROM 'file:///relations.csv' AS row MATCH (poem:Poem {title: row.title}) MATCH (poet:Poet {name: row.author}) MERGE (poet)-[:WRITE]->(poem)

参数说明:WITH HEADERS表示第一行是列名;file:///指向Neo4j安装目录下的import文件夹,CSV必须放那里;MERGE在节点上防重复,在关系上防重复边。如果导入时提示找不到节点,九成是CSV里实体名和导入阶段不一致,注意检查空格和别名映射。

3.3 查询模板设计:先定问题类型,再写Cypher才不走弯路

问答系统的核心不是“直接执行任意自然语言”,而是先把常见的问法抽象成有限个类型,每个类型对应一条Cypher模板。我按毕设场景拆了六类,覆盖了绝大多数需求:

意图用户问法示例Cypher模板
QUERY_AUTHOR“《溪上偶题》的作者是谁”MATCH (p:Poem {title:$title})-[:WRITE]->(a:Poet) RETURN a.name
QUERY_POEMS“某唐代诗人写过哪些诗”MATCH (p:Poet {name:$name})-[:WRITE]->(poem:Poem) RETURN poem.title
QUERY_DYNASTY“某位诗人是哪个朝代的”MATCH (p:Poet {name:$name})-[:BELONG_TO]->(d:Dynasty) RETURN d.name
QUERY_IMAGE“某首诗里有哪些意象”MATCH (p:Poem {title:$title})-[:CONTAINS]->(l:Line)-[:EVOKE]->(i:Image) RETURN i.name
QUERY_SAME_DYNASTY“和某诗人同朝代的有谁”MATCH (p:Poet {name:$name})-[:BELONG_TO]->(d:Dynasty)<-[:BELONG_TO]-(other) RETURN other.name
QUERY_LINE“某意象出现了哪些诗句”MATCH (i:Image {name:$name})<-[:EVOKE]-(l:Line) RETURN l.content

建议把$title、$name统一用参数传递,一是防注入,二是Neo4j可以缓存执行计划,查询更快。写模板时注意关系方向:Neo4j的关系是有向的,(p)-[:WRITE]->(poem)和(poem)<-[:WRITE]-(p)等价,但手写时方向反了会直接返回空结果,这个问题我在避坑章里还会细说。

4. 问答链路:意图识别、槽位填充与Flask接口的完整实现

4.1 问句解析:规则模板为什么比深度学习更合适毕设场景

问答系统里最容易被过度设计的是“意图识别”这一环。很多同学一看“问答”两个字就想上BERT、微调大模型,实际做出来效果不稳定不说,答辩时也讲不清“你的模型为什么在这个数据集上有效”。古诗词问答的提问方式非常固定,翻来覆去就是“作者是谁”“哪个朝代”“写过什么诗”,用规则模板做意图匹配,准确率能到95%以上,而且每一个判断逻辑都可以直接贴到论文里,评审一看就懂。

规则模板的核心思路是两步:先匹配意图关键词(“作者”“朝代”“意象”),再抽取实体(诗名、诗人名)。实体抽取用词典匹配就够了,把清洗后的所有诗人名、诗名、意象名加载成一个列表,问句里哪个词命中列表,就作为槽位值填入模板。这套方法对毕设数据规模来说是“刚刚好”的复杂度。

4.2 问答核心实现:从问句到Cypher再到答案的Python代码

我习惯把问答核心单独放一个模块,输入是用户问句,输出是(答案文本, 证据Cypher, 证据路径)三元组,这样后面做前端展示和答辩演示都方便。核心代码如下:

# -*- coding: utf-8 -*- from py2neo import Graph import re graph = Graph("bolt://localhost:7687", auth=("neo4j", "password")) # 加载实体词典,来自清洗阶段的输出 def load_dict(path: str) -> list: with open(path, 'r', encoding='utf-8') as f: return [line.strip() for line in f if line.strip()] poem_dict = load_dict('poem_titles.txt') poet_dict = load_dict('poet_names.txt') image_dict = load_dict('image_names.txt') def extract_entity(question: str, candidates: list): """基于词典匹配,返回第一个命中的实体名""" for entity in candidates: if entity in question: return entity return None def parse_question(question: str): """规则匹配:先定意图,再抽实体,返回Cypher模板和参数""" # 问作者 if ('作者' in question and '是谁' in question) or '谁写的' in question: title = extract_entity(question, poem_dict) if title: cypher = ("MATCH (p:Poem {title:$title})-[:WRITE]->(a:Poet) " "RETURN a.name AS answer") return {'intent': 'QUERY_AUTHOR', 'cypher': cypher, 'params': {'title': title}} # 问诗作 if '写过哪些诗' in question or '作品有哪些' in question: poet = extract_entity(question, poet_dict) if poet: cypher = ("MATCH (p:Poet {name:$name})-[:WRITE]->(poem:Poem) " "RETURN collect(poem.title) AS answer") return {'intent': 'QUERY_POEMS', 'cypher': cypher, 'params': {'name': poet}} # 问意象 if '意象' in question or '表达了什么' in question: title = extract_entity(question, poem_dict) if title: cypher = ("MATCH (p:Poem {title:$title})-[:CONTAINS]->(l:Line)" "-[:EVOKE]->(i:Image) RETURN collect(i.name) AS answer") return {'intent': 'QUERY_IMAGE', 'cypher': cypher, 'params': {'title': title}} return None def run_answer(question: str): """执行问答流程,返回答案与证据路径""" parsed = parse_question(question) if not parsed: return {"answer": "我没理解这个问题,换个问法试试", "path": []} result = graph.run(parsed['cypher'], **parsed['params']).data() # 同时查一条证据路径,用于前端展示 path_cypher = parsed['cypher'].replace("RETURN", "RETURN path,").split(" AS ")[0] if result: answer = result[0].get('answer', '未找到') else: answer = '未找到答案' return {"answer": answer, "intent": parsed['intent'], "params": parsed['params']}

代码里值得注意的几个参数和设计选择。第一,graph.run(cypher, **params)用的是参数化查询,$title和$name是Cypher里的占位符,传参方式保证了安全性。第二,collect()把多行结果聚合成一个列表,这样前端直接显示["诗A","诗B"]就行,不用再处理后端返回结构。第三,这里刻意不在parse_question里执行查询,而是把Cypher和参数返回给上层,好处是以后想加日志、加证据路径展示、或者做批量测试,都不用改逻辑。

4.3 接口与前端:Flask路由与演示页面的取舍

毕设演示环节,系统至少要有一个网页能让评委输入问题并看到答案。后端我用Flask搭两个路由就够:一个服务静态页面,一个接收问题并返回JSON。没必要上重型前端框架,一个HTML页面加一点原生JavaScript就能完成交互。

from flask import Flask, request, jsonify, render_template app = Flask(__name__) @app.route('/') def index(): # 渲染前端页面 return render_template('index.html') @app.route('/api/ask', methods=['POST']) def ask(): data = request.get_json() question = data.get('question', '') result = run_answer(question) return jsonify(result) if __name__ == '__main__': app.run(host='0.0.0.0', port=5000, debug=False)

前端页面这部分,我强烈建议在index.html里放一个“示例问题”区域,把预设的六类问题做成按钮,点击就能填充到输入框。这个设计不是偷懒,而是答辩时的刚需——评委不可能想得出合适的问法,如果你让他自由输入,他问出系统没覆盖的句型,场面就尴尬了。给几个示例问题让他点,既能保证演示流畅,又展示了你对意图类型的完整覆盖。

5. 避坑手册:跑通古诗词知识图谱问答的5个高频翻车点

5.1 py2neo版本升级引发的“玄学”报错

现象:按网上的老教程写的from py2neo import Node, Relationship, Graph,在本地能跑,换了台电脑就报ImportError。代码没动过,报错却变了。

原因:py2neo的4.x和5.x版本API不兼容。5.x里NodeMatcher被挪到了py2neo.matching模块,Graph.run()的返回结果也从RecordList变成了Cursor,很多老代码直接照搬就会出事。

解决:最稳的办法是把版本锁死。项目里建一个requirements.txt,明确写py2neo==4.3.0或neo4j==5.14.0(按你用的Neo4j版本选),不要用pip install py2neo裸装。如果代码已经报了NodeMatcher的错,把导入改成from py2neo.matching import NodeMatcher就能兼容5.x。

5.2 中文CSV导入Neo4j的乱码与路径问题

现象:LOAD CSV导入后,节点名字变成æåº或者“唐”显示成乱码,更诡异的是有些行导入成功,有些行失败。

原因:Windows下导出的CSV默认是GBK编码,Neo4j的LOAD CSV只认UTF-8。另一个常见坑是CSV里的字段值带着双引号或换行符,被解析成了新行。

解决:先用记事本或Python把CSV统一转成UTF-8(可以带BOM),再放进Neo4j的import目录。字段值里如果含逗号,记得用引号包起来,否则会被当成新列。检查方式很简单,导入前先用LOAD CSV WITH HEADERS FROM 'file:///poets.csv' AS row RETURN row LIMIT 5预览几行,能看到中文就说明编码没问题。

5.3 查询超时:图谱规模多大才会卡

现象:单条查询正常,但用户连续问几个问题后,接口响应越来越慢,甚至Neo4j Browser里出现Neo.DatabaseError.General.UnknownError,日志提示查询超时。

原因:古诗词语料如果导入得很“全”,包括每一句诗、每个意象、每个典故,图谱节点会到几万甚至十几万。这时如果查询里写了不带限制条件的路径匹配,比如MATCH (a:Poet)--(b:Poem)--(c:Image) RETURN c.name,Neo4j会扫描大量中间节点。

解决:第一,所有查询模板的路径里都加上节点类型,避免全表扫描;第二,在RETURN前加LIMIT 10,答案本来就不需要几十条;第三,给高频属性建索引,启动时执行CREATE INDEX FOR (p:Poem) ON (p.title),诗人名、诗名、意象名都各建一个。这三步做完,小数据量下查询基本是毫秒级。

5.4 分词器拆碎诗人名和诗名

现象:用jieba做问句分词,输入“某唐代诗人的代表作有哪些”,分词结果把诗人的三字名拆成了两个字加一个字,实体词典匹配不到,返回“未找到答案”。

原因:jieba默认词典是通用语料,人名的识别能力很差,尤其是古诗人名,本身在通用语料里出现频率就低。

解决:两条路。一种是绕开分词,直接做“子串匹配”,也就是把整个问句和词典里的实体名做in判断,这是我在4.2节代码里用的方法,简单且完全避开分词误差。另一种是必须用分词时,把全部诗人名、诗名加载进jieba的自定义词典:jieba.load_userdict('poet_names.txt'),并在加载时指定词频足够高。我个人更推荐子串匹配,毕设场景的实体名长度短、没有歧义,直接匹配比分词后匹配更可靠。

5.5 答辩现场Neo4j服务没启动的“黑匣子”

现象:答辩前一切正常,上台演示时页面打开,输入问题,结果转圈转了几秒然后报错ServiceUnavailable: The server is not available。

原因:Neo4j是独立服务,电脑重启后它不会自动启动。大部分同学是在自己笔记本上开发,从没模拟过“冷启动”的演示流程,现场才发现服务没起。

解决:写一个启动检查脚本,放在项目根目录,双击就能跑:

# 检查Neo4j状态,未启动则拉起 neo4j status || neo4j start sleep 5 curl -s http://localhost:7474 || echo "Neo4j连接失败,请检查端口"

答辩前把整个流程从“开机→启动数据库→启动Flask→打开网页”完整走一遍。另外把Neo4j的auth密码写进环境变量或配置文件,别hardcode在代码里还临时换密码,现场最容易出这种低级问题。

6. 给答辩加分的最后一步:答案溯源与证据路径回显

如果系统只是“问一句答一句”,评委很难直观感受到知识图谱和普通数据库检索的区别。我建议在最后花半天时间加一个“答案溯源”功能:回答完用户问题后,在前端页面上同时展示一条从问句实体到答案实体的图谱路径,让评委亲眼看到“知识图谱上的推理链路”。

实现方式不复杂。在run_answer里我已经预留了path这个返回字段,只需要在生成Cypher时,用RETURN path替代RETURN answer,然后把路径上的节点和关系解析成前端可渲染的结构。比如问“某诗的作者是哪个朝代的”,图谱路径就是诗作节点 →(WRITE)→ 诗人节点 →(BELONG_TO)→ 朝代节点,前端用三个节点和两个箭头就可以把这个链路画出来。

比折线图更好的还有一个设计:把路径上的中间节点做成可点击的标签,点击诗人节点,自动填充新问题“这个诗人写过哪些诗”。这个交互既自然,又展示了系统具备“多轮引导”的能力,评委对这个细节的印象分往往很高。

再加一个同朝代诗人推荐,比如回答完“某诗人是哪个朝代的”之后,追加一句“同朝代还有哪些诗人”,本质就是把3.3节里的QUERY_SAME_DYNASTY模板串到主查询后面,数据量小的时候一次请求就能返回。这两个功能加起来不超过50行代码,却能让系统从“单点问答”变成“可探索的知识图谱”,答辩时你可以自信地说:“这不仅是问答,还具备沿着图谱关系做发散检索的能力。”

我自己的习惯是,每次给系统加一个功能前都先问一句“这个功能在答辩时能不能用一句话讲清楚”,讲不清楚的就砍掉。知识图谱问答的毕设核心从来不是算法多深,而是链路完整、逻辑清晰、演示稳定。沿着这个标准走,数据清洗做扎实、查询模板设计合理、再把证据路径这个亮点加上,这套系统的完成度已经超过大多数同题目的作品。希望帮到你。

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

RELATED READING

延伸阅读

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