基于知识图谱的古诗词问答系统:从源码到实战的毕设指南
简介这是一套面向计算机相关专业本科生与项目实战学习者的古诗词问答系统源码以知识图谱为核心技术路线可作为毕业设计、课程设计或期末大作业的完整参考方案。项目围绕古诗词实体与关系构建图谱并实现自然语言问答交互适合人工智能、软件工程等方向的学生理解知识图谱落地流程。压缩包共521个文件约50.58MB其中98个py文件承载核心逻辑与图谱构建脚本129个txt与24个json用于诗词语料及图谱数据存储另有jpg、css、js、html等前端与静态资源以及少量md说明文档整体结构清晰、模块划分明确。目前已有331人学习下载。代码经测试可正常运行答辩评审平均分达96.5分读者可据此掌握图谱构建、问答匹配与前后端联调思路并在此基础上修改扩展功能。1. 从一份本科毕设源码说起知识图谱怎么把古诗词问答做成能跑的系统很多同学拿到「基于知识图谱的古诗词问答系统」这个题目时第一反应是去搜一份现成源码解压、装依赖、跑起来然后截图交差。但真正动手才会发现古诗词这个领域和通用百科问答完全不是一回事一首诗有作者、朝代、体裁、意象、典故、情感标签诗句之间还有化用、唱和、同题的关系这些关系用一张二维表根本存不下。知识图谱的价值就在这里——它把「李白」和「《静夜思》」和「唐代」和「五言绝句」和「思乡」这些实体用边连起来问答时沿着边走就能推理出答案而不是靠关键词匹配硬猜。这份本科毕设源码要解决的核心问题是让一个 Python 后端能听懂「李白写过哪些思乡的诗」这类自然语言问题把它翻译成对图谱的查询再把结果组织成人话返回。适合谁看正在做毕设、需要一套能讲清楚原理又能跑通的代码的同学也适合想入门知识图谱构建与问答、但被 Neo4j 和 SPARQL 劝退的开发者。下面我按「图谱怎么建 → 问句怎么解析 → 查询怎么执行 → 坑在哪」的顺序把这条链路拆开讲。2. 古诗词知识图谱的构建从 CSV 到 Neo4j 的实体关系落地2.1 为什么古诗词适合用图数据库而不是关系表先想清楚选型理由不然后面写代码全是玄学。古诗词数据的典型特征是「多跳关系密集」想知道「杜甫写给李白的诗里提到了哪些地名」路径是 杜甫 → 赠诗 → 李白 → 诗中地名 → 具体地点这在关系型数据库里要 join 四张表而在图数据库里就是一次两跳遍历。另一个特征是「schema 不固定」有的诗有典故标注有的没有图数据库的边可以按需扩展不用改表结构。常见做法是用 Neo4j 作为存储Python 侧用py2neo或官方neo4j驱动。我一般会先把原始数据整理成两类 CSV节点文件和关系文件。节点包括诗人、诗题、朝代、体裁、意象、情感关系包括「创作」「属于朝代」「属于体裁」「包含意象」「表达情感」「赠予」「唱和」。提示本科毕设的数据量通常在几千到几万条Neo4j 社区版完全够用不要一上来就上分布式图库那是给自己找麻烦。2.2 用 Python 批量导入节点和关系的可复现脚本下面这段脚本做两件事读取 CSV用MERGE语句幂等地写入节点和关系。MERGE而不是CREATE是关键重复执行不会产生重复节点。from py2neo import Graph, Node, Relationship import csv # 连接本地 Neo4j默认 bolt 端口 7687 graph Graph(bolt://localhost:7687, auth(neo4j, your_password)) def load_poets(path): with open(path, encodingutf-8) as f: for row in csv.DictReader(f): # MERGE 保证按 name 唯一不会重复建点 poet Node(Poet, namerow[name], dynastyrow[dynasty]) graph.merge(poet, Poet, name) def load_poems(path): with open(path, encodingutf-8) as f: for row in csv.DictReader(f): poem Node(Poem, titlerow[title], contentrow[content], genrerow[genre]) graph.merge(poem, Poem, title) # 建立 诗人-创作-诗 的关系 poet graph.nodes.match(Poet, namerow[author]).first() if poet: graph.merge(Relationship(poet, 创作, poem)) load_poets(data/poets.csv) load_poems(data/poems.csv)逻辑说明graph.merge(node, label, key)的第三个参数是唯一性约束字段必须和你在 Neo4j 里建的约束一致否则每次都会新建节点。参数上auth里的密码是你初始化 Neo4j 时设的不是默认的neo4j第一次登录会强制改密。Relationship的方向建议统一成「主体→动作→客体」比如「诗人→创作→诗」后面写查询时方向不会乱。2.3 意象和情感这类弱实体怎么挂到图上意象明月、杨柳、孤舟和情感思乡、送别、怀古不是诗本身但它们是问答的高频入口。做法是把它们也建成节点用「包含意象」「表达情感」两条边连到诗上。这里有个坑意象抽取如果只靠分词会把「明月光」切成「明月」和「光」导致误挂。稳妥做法是维护一个意象词典用词典匹配而不是纯分词。IMAGERY_DICT [明月, 杨柳, 孤舟, 落花, 流水, 鸿雁] def tag_imagery(poem_title, content): poem graph.nodes.match(Poem, titlepoem_title).first() for word in IMAGERY_DICT: if word in content: img Node(Imagery, nameword) graph.merge(img, Imagery, name) graph.merge(Relationship(poem, 包含意象, img))参数说明IMAGERY_DICT建议从 50 到 200 个高频意象起步覆盖《全唐诗》里出现频率前 10% 的词即可贪多反而引入噪声。执行完可以用一句 Cypher 验证MATCH (p:Poem)-[:包含意象]-(i:Imagery) RETURN count(*)数字对不上就回去查词典匹配逻辑。3. 问句解析把「李白写过哪些思乡的诗」翻译成图谱查询3.1 意图识别和实体抽取的分工问答系统的第一道关卡是理解用户问了什么。古诗词问答的意图其实不多常见就几类查作者的作品、查某首诗的作者或朝代、查包含某意象的诗、查表达某情感的诗、查诗人之间的关系。意图识别可以用规则模板也可以用轻量分类模型。本科毕设阶段我建议先用规则打底把准确率做稳再考虑上模型。实体抽取要抽的是「诗人名」「诗题」「意象」「情感」「朝代」这几类。难点在于诗人名和意象可能重叠比如「李白」是诗人但「李」也可能是姓氏泛称。解决办法是优先匹配长词并且给每类实体维护独立词典。import jieba POETS {李白, 杜甫, 王维, 白居易, 苏轼} IMAGERY {明月, 杨柳, 孤舟, 落花} EMOTIONS {思乡, 送别, 怀古, 边塞} def extract_entities(question): words jieba.lcut(question) result {poet: None, imagery: None, emotion: None} for w in words: if w in POETS: result[poet] w elif w in IMAGERY: result[imagery] w elif w in EMOTIONS: result[emotion] w return result逻辑说明jieba.lcut做精确模式分词然后逐词比对词典。参数上词典要按长度倒序匹配否则「白居易」可能被「白」先命中。如果问句里同时出现诗人和情感比如「李白写过哪些思乡的诗」两个槽位都要填上查询时用 AND 连接。3.2 意图模板和槽位的映射表把意图和槽位组合成查询模板是这套系统最核心的设计。下面这张表是我实际用的映射关系可以直接抄。意图触发词示例槽位对应 Cypher 片段查作品写过、有哪些诗poet(p:Poet)-[:创作]-(poem)查作者谁写的、作者是谁title(poem)-[:创作]-(p:Poet)查意象含、出现、描写imagery(poem)-[:包含意象]-(i)查情感表达、抒发emotion(poem)-[:表达情感]-(e)查关系和、赠、唱和poet1, poet2(p1)-[:赠予]-(p2)注意触发词要覆盖口语变体比如「写过」和「写了」都要能命中否则用户换个说法系统就哑了。3.3 从槽位到 Cypher 的拼装与参数化拼 Cypher 字符串时千万不要用 f-string 直接插用户输入那是注入漏洞。正确做法是用参数化查询把槽位值作为参数传进去。def build_query(intent, slots): if intent 查作品 and slots[poet] and slots[emotion]: return ( MATCH (p:Poet {name: $poet})-[:创作]-(poem)-[:表达情感]-(e:Emotion {name: $emotion}) RETURN poem.title AS title, poem.content AS content ), {poet: slots[poet], emotion: slots[emotion]} if intent 查作者 and slots.get(title): return ( MATCH (poem:Poem {title: $title})-[:创作]-(p:Poet) RETURN p.name AS author ), {title: slots[title]} return None, None逻辑说明函数返回 Cypher 语句和参数字典两部分执行时用graph.run(cypher, **params)。参数说明$poet这种占位符由驱动负责转义用户输入再离谱也不会破坏语句结构。如果意图和槽位组合没有对应模板返回None上层给用户一个「暂时没理解」的兜底回复而不是硬查。4. 查询执行与答案生成让返回结果像人话4.1 执行查询并处理空结果拿到 Cypher 和参数后执行并判断结果。空结果在古诗词问答里很常见可能是图谱里确实没有也可能是槽位抽错了。两种情况要区分对待如果实体在词典里但图谱查不到提示「图谱中暂无相关记录」如果实体根本没抽出来提示「没听懂换个说法试试」。def answer(question): intent detect_intent(question) slots extract_entities(question) cypher, params build_query(intent, slots) if not cypher: return 这个问题我还没学会换个问法试试 records graph.run(cypher, **params).data() if not records: return 图谱里暂时没有找到相关诗词。 return format_answer(intent, records)参数说明graph.run(...).data()返回的是字典列表字段名和 RETURN 里的别名一致。detect_intent可以用关键词命中实现命中多个意图时按优先级取第一个优先级建议是「查关系 查作者 查作品 查意象 查情感」。4.2 答案模板与多结果截断返回多条诗时不要一股脑全倒出来。我一般限制最多返回 5 条超出部分提示「还有 N 条可继续追问」。答案模板按意图区分查作品返回「诗人 共找到 N 首 诗题列表」查作者返回「《诗题》的作者是 XXX」查意象返回「包含『明月』的诗有……」并附上名句。def format_answer(intent, records): if intent 查作品: titles [r[title] for r in records[:5]] return f共找到 {len(records)} 首包括 、.join(titles) if intent 查作者: return f这首诗的作者是 {records[0][author]}。 if intent 查意象: lines [f《{r[title]}》{r[content][:20]}… for r in records[:5]] return \n.join(lines) return str(records[:5])逻辑说明截断用切片[:5]同时把总数len(records)告诉用户兼顾信息量和可读性。参数上content[:20]截取前 20 个字符做摘要避免整首诗刷屏。如果要做成 Web 接口把answer函数包一层 Flask 路由即可返回 JSON。4.3 用 Flask 暴露一个最小问答接口from flask import Flask, request, jsonify app Flask(__name__) app.route(/qa, methods[POST]) def qa(): question request.json.get(question, ) return jsonify({answer: answer(question)}) if __name__ __main__: app.run(port5000)逻辑说明POST 接收 JSON字段名question返回answer。参数上端口用 5000和前端联调时注意跨域开发阶段可以加flask-cors。这个接口就是毕设演示的入口前端页面或 Postman 都能调。5. 避坑与排查古诗词问答系统最容易翻车的 5 个地方5.1 现象Neo4j 连不上报ServiceUnavailable原因通常是三个Neo4j 服务没启动、bolt 端口被防火墙拦了、密码不对。解决顺序是先neo4j status看服务再telnet localhost 7687看端口最后确认auth里的密码是不是初始化时改过的那个。本科毕设常见错误是照抄教程里的默认密码neo4j但实际第一次登录已经强制改密了。5.2 现象节点重复查「李白」出来好几条原因是merge时唯一性字段没对上或者 CSV 里同一诗人名字有空格差异。解决方法是先建唯一性约束CREATE CONSTRAINT FOR (p:Poet) REQUIRE p.name IS UNIQUE再重新导入。导入前用strip()清洗字段全角和半角空格都要处理。5.3 现象问「思乡的诗」返回空但图谱里明明有原因是情感节点没挂上或者情感词和用户输入不一致。比如图谱里存的是「思乡」用户输入「想家」词典没覆盖。解决办法是维护同义词表把「想家」「思念故乡」都映射到「思乡」。排查时直接跑MATCH (e:Emotion) RETURN e.name看图谱里到底有哪些情感标签。5.4 现象分词把诗人名切碎实体抽不出来原因是 jieba 默认词典不含古诗人名。解决办法是jieba.add_word(李白)把诗人名和意象词都加进自定义词典或者用jieba.load_userdict(dict.txt)批量加载。参数上自定义词典每行「词 词频 词性」三段词频给高一点保证优先切出。5.5 现象Cypher 查询慢几秒才返回原因是没建索引或者查询写了笛卡尔积。解决办法是给常用查询字段建索引CREATE INDEX FOR (p:Poet) ON (p.name)诗题、意象名同理。另外检查 Cypher 里有没有漏写关系方向导致全图扫描EXPLAIN一下执行计划看到AllNodesScan就要警惕。6. 把问答准确率再抬一截同义词扩展与查询回退的实操技巧系统能跑通之后真正拉开差距的是准确率。我踩过的最大坑是「用户说法千变万化词典永远不够」。后来我加了一层同义词扩展效果立竿见影。做法是维护一个synonyms.json把情感、意象、意图触发词都做同义映射查询前先归一化。import json with open(synonyms.json, encodingutf-8) as f: SYNONYMS json.load(f) def normalize(text): for standard, variants in SYNONYMS.items(): for v in variants: if v in text: text text.replace(v, standard) return textsynonyms.json的结构是{思乡: [想家, 思念故乡, 乡愁], 明月: [月, 月亮, 月光]}。参数说明标准词必须和知识图谱里的节点名完全一致否则归一化后还是查不到。变体词按长度倒序替换避免「月」把「月亮」拆坏。第二层是查询回退。当精确查询返回空时不要直接说「没找到」而是降级先去掉情感槽位只按诗人查再不行只按意象查最后给一个「你是不是想问……」的猜测。这个回退链能把很多「差一点就命中」的问题救回来。回退层级查询条件适用场景第一层诗人 情感完整槽位第二层仅诗人情感词没匹配上第三层仅意象诗人名抽错第四层模糊匹配诗题用户直接报诗名验证方法很简单准备 50 条标注好答案的测试问句跑一遍统计命中率每次改词典或模板都重跑看数字是涨是跌。我一般会把测试集和结果存成 CSV方便对比不同版本。这套东西做完本科毕设的答辩基本稳了因为你能说清楚每一层为什么这么设计、边界在哪。最后一个习惯每次改完图谱或词典先跑一遍那 50 条测试问句别凭感觉觉得「应该没问题」。我吃过这个亏改了一个意象词结果把另一个情感查询带崩了答辩前一天才发现。希望帮到你。本文还有配套的精品资源点击获取