基于知识图谱的古诗词问答系统源码拆解:从Neo4j构建到Python问答实现
简介这是一套面向计算机相关专业本科生与项目实战学习者的古诗词问答系统源码以知识图谱为核心技术路线可作为毕业设计、课程设计或期末大作业的参考实现。项目经导师指导并通过答辩评审平均分达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 端口 7687http 端口 7474 graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) def create_poet(name, dynasty): 创建或合并诗人节点避免重复插入 poet Node(Poet, namename, dynastydynasty) graph.merge(poet, Poet, name) # 按 name 唯一约束合并 return poet def create_poem(title, content, poet_name): 创建诗节点并建立诗人-创作-诗的关系 poem Node(Poem, titletitle, contentcontent) graph.merge(poem, Poem, title) poet graph.nodes.match(Poet, namepoet_name).first() if poet: rel Relationship(poet, 创作, poem) graph.create(rel) if __name__ __main__: with open(data/poetry.json, r, encodingutf-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, titleextracted_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, encodingutf-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], titleentity, nameentity).data() answers [r[answer] for r in result if r.get(answer)] return jsonify({answer: answers or 图谱里没有查到对应结果}) if __name__ __main__: app.run(debugTrue, port5000)联调时最容易翻车的是跨域。前端如果直接开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原因通常是没装依赖或者装错了版本。这份源码如果用的是老版 py2neoPython 3.10 以上可能装不上。解决方法是先看requirements.txt里有没有版本号没有就手动指定py2neo2021.2.3用pip install py2neo2021.2.3装。如果还报错检查 Python 版本建议用 3.8 或 3.9别用 3.12。4.2 现象Neo4j 连不上报ServiceUnavailable原因一般是 Neo4j 没启动或者端口不对。Neo4j Desktop 启动后默认 bolt 端口是 7687http 是 7474。如果你改了端口代码里的连接串也要改。另外 Neo4j 4.x 默认要求密码至少 8 位第一次登录会强制改密码改完记得同步到代码里。用 Docker 的话docker run -p 7687:7687 -p 7474:7474 neo4j:4.4起容器密码通过-e NEO4J_AUTHneo4j/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.unrestrictedapoc.*重启。下面这段 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 装不上退而求其次用原生shortestPathMATCH (a:Poet {name: 李白}), (b:Poet {name: 杜甫}) MATCH p shortestPath((a)-[*..5]-(b)) RETURN p;另一个进阶方向是把问答从「模板匹配」升级成「模板 同义词扩展」。比如用户问「诗仙的作品」别名表里把「诗仙」映射到「李白」意图规则里「作品」命中query_poems_by_poet就能返回结果。如果时间够还可以加一个「问答日志」表把用户问过的问题和匹配结果记下来答辩时展示「系统运行期间共处理 XX 条问句命中率 XX%」比空口说「功能完整」有说服力。我自己的习惯是每次交付这类图谱项目前都强制走一遍「清库 → 重建 → 校验 → 问答回归」四步确认从零能跑通再打包。因为图谱项目最怕的就是「本地能跑换台机器就挂」而毕设答辩往往要换机器演示。希望这份拆解能帮你把这份源码真正跑起来而不是只躺在硬盘里。本文还有配套的精品资源点击获取