☰
基于知识图谱的古诗词问答系统源码拆解:从Neo4j构建到Python问答实现
2026/10/10 14:25:31 网站建设 项目流程

简介:这是一套面向计算机相关专业本科生与项目实战学习者的古诗词问答系统源码,以知识图谱为核心技术路线,可作为毕业设计、课程设计或期末大作业的参考实现。项目经导师指导并通过答辩评审,平均分达96.5分,代码均经测试运行成功后才上传,适合具备一定Python基础、希望理解知识图谱构建与问答推理流程的同学进阶练习。压缩包共521个文件,约50.58MB,其中98个py文件承载核心逻辑,24个json与129个txt用于图谱数据与语料存储,另有jpg、css、js、html等前端与静态资源,以及xlsx、md等辅助文档,整体结构完整、模块划分清晰。目前已有331人学习下载。读者可从中获得一套可运行的知识图谱问答系统完整方案,涵盖实体关系抽取、图谱存储、问句解析与答案检索等关键环节,便于对照复现、二次修改或迁移到其他垂直领域问答场景。

1. 古诗词问答系统源码拆解:从知识图谱到可运行毕设

大四那会儿帮学弟看毕设,他抱着一份「基于知识图谱的古诗词问答系统」的压缩包来找我,说跑不起来。我打开一看,前端一堆 bootstrap、nifty、font-awesome 的 css 文件,后端是 Python,中间夹着一个 Neo4j 的图谱数据。这类项目在毕设圈里很典型——评审分能到 96.5,说明功能完整、界面能看、答辩能讲,但真正落到「怎么把图谱建起来、问答怎么匹配、前端怎么调后端」这三件事上,很多人是懵的。这份源码解决的就是这个:它把古诗词的实体、关系、属性抽成图谱,再用 Python 做意图识别和答案检索,最后用 Bootstrap 搭一个能演示的问答页面。适合正在做毕设的计算机专业学生,也适合想练手知识图谱构建和 Python 后端的人。下面我按「图谱怎么建 → 问答怎么跑 → 坑在哪」的顺序,把这份资源拆开讲。

2. 知识图谱构建:从诗词文本到 Neo4j 节点关系

2.1 为什么选 Neo4j 而不是 MySQL 存古诗词

古诗词问答的核心不是「查一首诗」,而是「查关系」。比如「李白写过哪些送别诗」「杜甫和李白之间有什么交集」「《静夜思》属于哪个朝代」。这些问题的答案藏在实体和关系的网络里,用关系型数据库做多跳查询会写大量 JOIN,而且每加一类关系就要改表结构。图数据库天然适合这种场景:节点是诗人、诗题、朝代、意象,边是「创作」「属于」「提及」「送别」这类语义关系。

这份源码用的是 Neo4j,常见做法是本地装一个 Neo4j Desktop 或者用 Docker 起一个社区版。Python 侧通过 py2neo 或者官方 neo4j 驱动连接。选 Neo4j 的另一个理由是 Cypher 查询语言对「路径」的表达很直观,比如查「李白 → 创作 → 诗 → 提及 → 月亮」这种两跳关系,一行 Cypher 就能出来,换成 SQL 至少要两层子查询。

注意:Neo4j 4.x 和 5.x 的驱动 API 有差异,源码里如果用的是Graph("http://localhost:7474", auth=...)这种 py2neo 写法,装 py2neo 时版本别超过 2021.2.3,否则Graph的初始化参数会报错。

2.2 图谱 schema 设计与实体抽取脚本

古诗词图谱的 schema 不需要太复杂,本科毕设级别能把下面这几类节点和关系覆盖住就够了:

节点类型属性示例关系类型关系方向
诗人姓名、朝代、字号创作诗人 → 诗
诗标题、正文、体裁属于诗 → 朝代
朝代名称、起止年份提及诗 → 意象
意象名称、类别送别诗人 → 诗人
地点名称、今属任职诗人 → 地点

抽取脚本一般放在data_process/或者graph_build/目录下,读的是data/poetry.json或者poetry.csv。我见过最常见的写法是先用 jieba 做分词,再用规则匹配诗人名和意象词,最后批量写入 Neo4j。下面这段是这类项目里典型的构建代码,我按可运行的逻辑补全了参数说明:

# build_graph.py from py2neo import Graph, Node, Relationship import json # 连接 Neo4j,默认 bolt 端口 7687,http 端口 7474 graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) def create_poet(name, dynasty): """创建或合并诗人节点,避免重复插入""" poet = Node("Poet", name=name, dynasty=dynasty) graph.merge(poet, "Poet", "name") # 按 name 唯一约束合并 return poet def create_poem(title, content, poet_name): """创建诗节点,并建立诗人-创作->诗的关系""" poem = Node("Poem", title=title, content=content) graph.merge(poem, "Poem", "title") poet = graph.nodes.match("Poet", name=poet_name).first() if poet: rel = Relationship(poet, "创作", poem) graph.create(rel) if __name__ == "__main__": with open("data/poetry.json", "r", encoding="utf-8") as f: poems = json.load(f) for p in poems: create_poet(p["author"], p["dynasty"]) create_poem(p["title"], p["content"], p["author"]) print("图谱构建完成,节点数:", graph.nodes.count())

这段代码的关键在merge而不是create。create每次都会新建节点,跑两遍数据就重了;merge会先按你指定的属性查,查不到才建。参数"Poet", "name"的意思是「用 Poet 标签下的 name 属性做唯一性判断」。如果你在 Neo4j 里没建唯一约束,merge在并发下仍可能重复,所以建议先在 Neo4j Browser 里跑一句:

CREATE CONSTRAINT IF NOT EXISTS FOR (p:Poet) REQUIRE p.name IS UNIQUE; CREATE CONSTRAINT IF NOT EXISTS FOR (p:Poem) REQUIRE p.title IS UNIQUE;

提示:数据量超过 5000 条时,逐条graph.create会非常慢。常见做法是攒 500 条用一个事务提交,或者直接用neo4j-admin import走 CSV 批量导入,速度差一个数量级。

2.3 图谱数据校验:三个必查项

图谱建完不是能查就行,得先校验。我一般会查三件事:一是孤立节点,二是关系方向反了的边,三是属性为空的节点。孤立节点通常是诗人没有对应诗,或者诗没有对应朝代,这种在问答时会导致「查不到答案」但又不报错。校验 Cypher 如下:

// 查没有创作关系的诗人 MATCH (p:Poet) WHERE NOT (p)-[:创作]->() RETURN p.name LIMIT 20; // 查没有归属朝代的诗 MATCH (p:Poem) WHERE NOT (p)-[:属于]->() RETURN p.title LIMIT 20; // 查属性为空的诗人 MATCH (p:Poet) WHERE p.dynasty IS NULL OR p.dynasty = "" RETURN p.name;

如果孤立节点多,说明抽取脚本里的实体对齐没做好。比如「李白」和「李太白」被当成两个人,或者诗题里带了书名号导致 merge 失败。这类问题在毕设答辩时容易被老师追问,提前跑一遍校验能省很多解释成本。

3. 问答模块实现:意图识别与 Cypher 模板匹配

3.1 问句分类:规则匹配还是模型分类

古诗词问答的问句类型其实很有限:问作者、问朝代、问诗句、问意象、问诗人关系。本科毕设级别不需要上 BERT,用「关键词 + 正则」做意图分类就够,而且可解释性强,答辩时能讲清楚。常见做法是维护一个意图模板表,每个意图对应一组触发词和一个 Cypher 模板。

比如「《静夜思》的作者是谁」触发词是「作者」,模板是「查诗节点,返回创作它的诗人」;「李白是哪个朝代的」触发词是「朝代」,模板是「查诗人节点,返回 dynasty 属性」。这种写法在qa/intent.py或者qa/classifier.py里,核心是一个字典:

# intent.py INTENT_RULES = [ { "intent": "query_author", "keywords": ["作者", "谁写的", "出自谁"], "cypher": """ MATCH (p:Poet)-[:创作]->(poem:Poem {title: $title}) RETURN p.name AS answer """ }, { "intent": "query_dynasty", "keywords": ["朝代", "哪个朝", "什么朝代"], "cypher": """ MATCH (p:Poet {name: $name}) RETURN p.dynasty AS answer """ }, { "intent": "query_poems_by_poet", "keywords": ["写过哪些", "有哪些诗", "作品"], "cypher": """ MATCH (p:Poet {name: $name})-[:创作]->(poem:Poem) RETURN poem.title AS answer LIMIT 10 """ } ] def match_intent(question): """遍历规则,返回第一个命中的意图和 Cypher""" for rule in INTENT_RULES: for kw in rule["keywords"]: if kw in question: return rule return None

这段代码的逻辑是「先命中先返回」,所以规则顺序有讲究。比如「李白写过哪些送别诗」既命中「写过哪些」也命中「送别」,如果把query_poems_by_poet放在前面,就会返回所有诗而不是送别诗。我一般会把更具体的意图往前放,或者给规则加一个priority字段排序。

参数$title和$name是 Cypher 的参数化查询占位符,实际执行时用graph.run(cypher, title=extracted_title)传入。这样做的好处是防止 Cypher 注入,也方便复用模板。提取实体时,常见做法是用 jieba 分词后匹配图谱里已有的诗人名和诗题,匹配不到就返回「没听懂」。

3.2 实体链接:把「李白」和「李太白」对上

问句里的实体和谱里的实体经常不一致。用户问「诗仙写过什么」,谱里存的是「李白」;用户问「《静夜思》」,谱里可能存的是「静夜思」不带书名号。实体链接就是解决这个问题的。本科毕设级别不需要做向量相似度,用「别名表 + 去除标点 + 模糊匹配」就能覆盖大部分情况。

别名表可以放在data/alias.json里,结构是{"李白": ["李太白", "诗仙", "青莲居士"], ...}。匹配时先把问句里的书名号、引号、空格去掉,再用别名表做映射。如果别名表里没有,就用difflib.SequenceMatcher做一次相似度匹配,阈值设 0.8 左右。下面是一个可抄的实体链接函数:

# entity_link.py import json import re from difflib import SequenceMatcher with open("data/alias.json", "r", encoding="utf-8") as f: ALIAS = json.load(f) def normalize(text): """去掉书名号、引号、空格,统一全半角""" text = re.sub(r"[《》\"'“”‘’\s]", "", text) return text def link_entity(mention, candidates): """ mention: 问句里抽出的实体词 candidates: 图谱里已有的实体名列表 返回最匹配的图谱实体名,匹配不到返回 None """ mention = normalize(mention) # 先查别名表 for standard, aliases in ALIAS.items(): if mention == standard or mention in aliases: return standard # 再查完全匹配 if mention in candidates: return mention # 最后做相似度匹配 best, score = None, 0 for c in candidates: s = SequenceMatcher(None, mention, c).ratio() if s > score: best, score = c, s return best if score >= 0.8 else None

这里candidates一般从 Neo4j 里查一次全量诗人名和诗题缓存到内存,避免每次问答都查库。阈值 0.8 是经验值,调低会误匹配(比如「李白」和「李商隐」相似度不低),调高会漏匹配。如果答辩时老师问「为什么不用词向量」,可以答「毕设数据量小,别名表加模糊匹配的准确率已经够用,而且可解释」。

3.3 答案生成与前端联调

问答模块跑通后,后端一般用 Flask 或 Django 暴露一个/ask接口,前端用 Ajax 调。这份源码的前端引了 bootstrap、nifty、datatables 这些 css,说明页面里有表格展示和后台管理风格的布局。常见做法是前端一个输入框加一个结果区,用户输入问题,Ajax POST 到/ask,后端返回 JSON,前端渲染成列表或表格。

Flask 侧的接口大概长这样:

# app.py from flask import Flask, request, jsonify from qa.intent import match_intent from qa.entity_link import link_entity from py2neo import Graph app = Flask(__name__) graph = Graph("bolt://localhost:7687", auth=("neo4j", "your_password")) @app.route("/ask", methods=["POST"]) def ask(): question = request.json.get("question", "") rule = match_intent(question) if not rule: return jsonify({"answer": "暂时没听懂这个问题,换个问法试试"}) # 这里简化处理,实际要从问句里抽实体 entity = link_entity(question, ["李白", "杜甫", "静夜思"]) if not entity: return jsonify({"answer": "没找到相关的诗人或诗题"}) result = graph.run(rule["cypher"], title=entity, name=entity).data() answers = [r["answer"] for r in result if r.get("answer")] return jsonify({"answer": answers or "图谱里没有查到对应结果"}) if __name__ == "__main__": app.run(debug=True, port=5000)

联调时最容易翻车的是跨域。前端如果直接开file://或者跑在 8080,后端在 5000,浏览器会拦 Ajax。常见做法是 Flask 装flask-cors,加一句CORS(app),或者前端用 Nginx 反代。另一个坑是 Neo4j 没启动时,graph.run会抛连接异常,接口直接 500,前端只显示「服务器错误」。建议在ask里包一层 try/except,把连接异常转成友好提示。

4. 避坑与排查:跑不起来时先看这五条

4.1 现象:ModuleNotFoundError: No module named 'py2neo'

原因通常是没装依赖,或者装错了版本。这份源码如果用的是老版 py2neo,Python 3.10 以上可能装不上。解决方法是先看requirements.txt里有没有版本号,没有就手动指定py2neo==2021.2.3,用pip install py2neo==2021.2.3装。如果还报错,检查 Python 版本,建议用 3.8 或 3.9,别用 3.12。

4.2 现象:Neo4j 连不上,报ServiceUnavailable

原因一般是 Neo4j 没启动,或者端口不对。Neo4j Desktop 启动后默认 bolt 端口是 7687,http 是 7474。如果你改了端口,代码里的连接串也要改。另外 Neo4j 4.x 默认要求密码至少 8 位,第一次登录会强制改密码,改完记得同步到代码里。用 Docker 的话,docker run -p 7687:7687 -p 7474:7474 neo4j:4.4起容器,密码通过-e NEO4J_AUTH=neo4j/your_password设。

4.3 现象:问答返回空列表,但图谱里明明有数据

原因多半是实体链接没匹配上。比如问句里是「静夜思」,图谱里存的是「静夜思」但带了空格,或者问句里是「李太白」,别名表里没配。排查方法是先在 Neo4j Browser 里手动跑一遍 Cypher,确认数据在;再在 Python 里打印link_entity的返回值和candidates列表,看匹配到了什么。如果candidates是空的,说明查全量实体名的 Cypher 写错了,或者没缓存成功。

4.4 现象:前端页面样式全乱,css 没加载

这份源码的正文里列了一堆 css 文件:bootstrap.4.6.min.css、nifty.min.css、font-awesome.min.css 等。这些文件如果路径不对,页面会变成纯文本。常见原因是 Flask 的静态目录没配对,或者前端 HTML 里引的是绝对路径/static/css/...但实际文件在static/下。排查时打开浏览器 F12 的 Network 面板,看哪些 css 返回 404,然后去static/目录下核对文件名。注意bootstrap.4.6.min.css和bootstrap.min.css可能同时存在,别引错版本。

4.5 现象:答辩时被问「图谱多少节点多少关系」答不上来

这是血泪经验。很多人跑完项目就不管了,老师一问数据规模就卡壳。建议跑完构建脚本后,在 Neo4j Browser 里执行MATCH (n) RETURN count(n)和MATCH ()-[r]->() RETURN count(r),把节点数和关系数记在 README 里。另外把「诗人数量」「诗数量」「意象数量」也分别查一下,答辩时能说出具体数字,可信度完全不一样。

5. 进阶技巧:用 APOC 做路径查询和问答扩展

图谱跑通之后,如果想在答辩里多拿几分,可以加一个「诗人关系路径」功能。比如问「李白和杜甫之间有什么关系」,用 Cypher 的最短路径查询就能出结果。Neo4j 自带shortestPath,但更灵活的是 APOC 插件里的apoc.path.expandConfig。装 APOC 的方法是下载对应版本的 jar 放到 Neo4j 的plugins/目录,然后在neo4j.conf里加dbms.security.procedures.unrestricted=apoc.*,重启。

下面这段 Cypher 查两个诗人之间的最短关系路径,限制 5 跳以内:

MATCH (a:Poet {name: "李白"}), (b:Poet {name: "杜甫"}) CALL apoc.path.expandConfig(a, { relationshipFilter: "送别|提及|创作", minLevel: 1, maxLevel: 5, terminatorNodes: [b] }) YIELD path RETURN path LIMIT 1;

参数relationshipFilter控制走哪些关系,maxLevel控制最大跳数,terminatorNodes指定终点。返回的path可以直接在前端用 vis.js 或者 echarts 的 graph 渲染成关系图,答辩演示效果很好。如果 APOC 装不上,退而求其次用原生shortestPath:

MATCH (a:Poet {name: "李白"}), (b:Poet {name: "杜甫"}) MATCH p = shortestPath((a)-[*..5]-(b)) RETURN p;

另一个进阶方向是把问答从「模板匹配」升级成「模板 + 同义词扩展」。比如用户问「诗仙的作品」,别名表里把「诗仙」映射到「李白」,意图规则里「作品」命中query_poems_by_poet,就能返回结果。如果时间够,还可以加一个「问答日志」表,把用户问过的问题和匹配结果记下来,答辩时展示「系统运行期间共处理 XX 条问句,命中率 XX%」,比空口说「功能完整」有说服力。

我自己的习惯是,每次交付这类图谱项目前,都强制走一遍「清库 → 重建 → 校验 → 问答回归」四步,确认从零能跑通再打包。因为图谱项目最怕的就是「本地能跑,换台机器就挂」,而毕设答辩往往要换机器演示。希望这份拆解能帮你把这份源码真正跑起来,而不是只躺在硬盘里。

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

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

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

立即咨询