基于知识图谱的古诗词问答系统:本科毕设从图谱构建到问答源码全流程 简介这是一套面向计算机相关专业本科生与项目实战学习者的古诗词问答系统源码以知识图谱为核心技术路线可作为毕业设计、课程设计或期末大作业的完整参考方案。项目围绕古诗词实体与关系构建图谱并实现自然语言问答交互适合人工智能、软件工程等方向的学生理解知识图谱落地流程。压缩包共521个文件约50.58MB其中98个py文件承载图谱构建与问答逻辑24个json与129个txt用于数据存储与语料组织另有html、css、js及图片字体等前端与静态资源整体结构完整、层次清晰。目前已有331人学习下载。代码经测试可运行答辩评审平均分96.5分读者可据此掌握图谱建模、问答匹配与前后端联调思路并在此基础上修改扩展功能用于毕设、课设或项目立项演示。1. 古诗词问答系统从知识图谱到可运行源码本科毕设怎么落地做古诗词问答的本科毕设最容易翻车的地方不是模型选得不够深而是数据散、关系乱、问答链路断。你手里如果只有一个基于知识图谱的古诗词问答系统python源码本科毕设.zip真正要搞清楚的是三件事图谱怎么建、问句怎么解析、答案怎么查出来。这套方案适合计算机相关专业的本科生也适合想快速搭一个垂直领域问答 demo 的开发者。它不追求大模型端到端生成而是用结构化图谱保证答案可控、可解释、可复现。下面按我实际搭过一遍的顺序把每个环节拆开讲。2. 知识图谱构建从诗词文本到三元组的完整链路2.1 为什么古诗词问答必须用图谱而不是纯文本检索古诗词问答的典型问题不是“这首诗的作者是谁”这种单跳查询而是“李白写过哪些送别诗”“杜甫诗中提到月亮的句子有哪些”“苏轼和黄庭坚的交往诗有哪些”。这类问题涉及实体识别、关系推理和多跳查询纯文本检索只能做关键词匹配遇到“送别”这种主题词诗题里不一定出现正文里也不一定出现但图谱可以通过“题材”这个关系节点把它挂上去。知识图谱的核心优势在于把隐式关系显式化。一首诗可以同时挂到“作者”“朝代”“体裁”“题材”“意象”“名句”等多个维度上每个维度都是一个可查询的入口。本科毕设的体量不需要做全唐诗全宋词选 200 到 500 首代表性诗词把关系抽干净问答效果就足够撑起答辩。常见做法是先用爬虫或公开数据集拿到诗词文本再按“作者—朝代—诗题—正文—注释—题材—意象”这几个字段做结构化。我一般会先把原始数据存成 CSV每行一首诗字段固定后面所有处理都基于这个中间层。2.2 用 Python 把诗词数据转成 Neo4j 可导入的三元组假设你已经有了一个poems.csv字段是title, author, dynasty, content, genre, theme, imagery。下面这段代码把每行拆成节点和关系输出成 Neo4j 的LOAD CSV能直接吃的格式。import csv import re # 输入文件poems.csv字段title,author,dynasty,content,genre,theme,imagery # 输出nodes.csv实体节点和 relations.csv关系边 nodes {} # 用字典去重key 是 (label, name) relations [] def add_node(label, name): if not name or not name.strip(): return key (label, name.strip()) if key not in nodes: nodes[key] {label: label, name: name.strip()} def add_relation(head_label, head_name, rel_type, tail_label, tail_name): if not head_name or not tail_name: return relations.append({ head_label: head_label, head_name: head_name.strip(), rel_type: rel_type, tail_label: tail_label, tail_name: tail_name.strip() }) with open(poems.csv, r, encodingutf-8) as f: reader csv.DictReader(f) for row in reader: title row[title].strip() author row[author].strip() dynasty row[dynasty].strip() genre row[genre].strip() theme row[theme].strip() imagery row[imagery].strip() # 诗节点 add_node(Poem, title) # 作者节点 add_node(Author, author) # 朝代节点 add_node(Dynasty, dynasty) # 体裁节点 add_node(Genre, genre) # 题材节点 add_node(Theme, theme) # 关系作者写诗 add_relation(Author, author, WROTE, Poem, title) # 关系诗属于朝代 add_relation(Poem, title, BELONGS_TO, Dynasty, dynasty) # 关系诗属于体裁 add_relation(Poem, title, HAS_GENRE, Genre, genre) # 关系诗属于题材 add_relation(Poem, title, HAS_THEME, Theme, theme) # 意象可能有多个用顿号或逗号分隔 for img in re.split(r[、,], imagery): img img.strip() if img: add_node(Imagery, img) add_relation(Poem, title, HAS_IMAGERY, Imagery, img) # 写节点文件 with open(nodes.csv, w, encodingutf-8, newline) as f: writer csv.DictWriter(f, fieldnames[label, name]) writer.writeheader() for node in nodes.values(): writer.writerow(node) # 写关系文件 with open(relations.csv, w, encodingutf-8, newline) as f: writer csv.DictWriter(f, fieldnames[head_label, head_name, rel_type, tail_label, tail_name]) writer.writeheader() for rel in relations: writer.writerow(rel) print(f节点数{len(nodes)}关系数{len(relations)})这段代码的逻辑很直白先把每首诗的各个字段拆成独立节点再用关系边把它们连起来。add_node用字典去重避免同一个作者或同一个意象重复建节点。add_relation只负责记录边不关心方向以外的逻辑。意象字段用正则按顿号、逗号切分是因为诗词数据里意象标注格式不统一有人用“明月、故乡”有人用“明月故乡”统一切完再入库最稳。参数上唯一需要调的是re.split里的分隔符集合。如果你的数据里还有空格分隔或分号分隔把对应符号加进字符组即可。输出文件nodes.csv和relations.csv的列名要和后面 Neo4j 的LOAD CSV语句对齐否则导入时会报字段找不到。2.3 Neo4j 导入与索引建立让查询从秒级降到毫秒级有了两个 CSV接下来在 Neo4j Browser 里执行导入。先建约束再导数据顺序不能反。// 先给每种节点的 name 字段建唯一约束同时自动建索引 CREATE CONSTRAINT IF NOT EXISTS FOR (a:Author) REQUIRE a.name IS UNIQUE; CREATE CONSTRAINT IF NOT EXISTS FOR (p:Poem) REQUIRE p.name IS UNIQUE; CREATE CONSTRAINT IF NOT EXISTS FOR (d:Dynasty) REQUIRE d.name IS UNIQUE; CREATE CONSTRAINT IF NOT EXISTS FOR (g:Genre) REQUIRE g.name IS UNIQUE; CREATE CONSTRAINT IF NOT EXISTS FOR (t:Theme) REQUIRE t.name IS UNIQUE; CREATE CONSTRAINT IF NOT EXISTS FOR (i:Imagery) REQUIRE i.name IS UNIQUE; // 导入节点 LOAD CSV WITH HEADERS FROM file:///nodes.csv AS row CALL apoc.create.node([row.label], {name: row.name}) YIELD node RETURN count(node); // 导入关系 LOAD CSV WITH HEADERS FROM file:///relations.csv AS row MATCH (h {name: row.head_name}) MATCH (t {name: row.tail_name}) CALL apoc.create.relationship(h, row.rel_type, {}, t) YIELD rel RETURN count(rel);这里用了 APOC 库的apoc.create.node和apoc.create.relationship因为节点标签和关系类型是动态的原生 Cypher 不支持直接传变量。如果你的 Neo4j 没装 APOC要么先装要么把每种标签和关系类型写成静态语句但那样代码会膨胀很多。导入完成后跑一句验证MATCH (a:Author)-[:WROTE]-(p:Poem)-[:HAS_THEME]-(t:Theme {name: 送别}) RETURN a.name, p.name LIMIT 20;如果这句能在毫秒级返回说明索引生效了。如果超过一秒检查约束是否真的建上去了用SHOW CONSTRAINTS确认。提示Neo4j 的LOAD CSV默认从import目录读文件CSV 要放在数据库安装目录的import文件夹下否则会报找不到文件。3. 问句解析与意图识别把“李白送别诗”翻译成 Cypher3.1 古诗词问句的三种类型与对应解析策略古诗词问答的问句大致分三类。第一类是实体属性查询比如“静夜思的作者是谁”这种直接抽实体和属性就能拼出 Cypher。第二类是关系查询比如“李白写过哪些诗”需要识别作者实体和关系类型。第三类是带约束的复合查询比如“杜甫在成都写的诗”“苏轼关于月亮的词”涉及多个实体和关系叠加。本科毕设不需要上 BERT 做意图分类用规则加词典就能覆盖八成以上的问句。核心思路是先做实体识别把问句里出现的作者名、诗题、朝代、题材、意象都标出来再根据疑问词和句式判断查询类型最后按模板拼 Cypher。我一般会维护几个词典文件authors.txt、titles.txt、dynasties.txt、themes.txt、imagery.txt。实体识别用最大正向匹配从问句里把词典词捞出来。疑问词表用来判断意图比如“是谁”“作者”对应属性查询“哪些”“有什么”对应列表查询“关于”“提到”对应意象或题材查询。3.2 基于模板的 Cypher 生成代码与参数说明下面这段代码接收一个自然语言问句输出可执行的 Cypher 语句。它依赖前面提到的词典文件。import re # 加载词典 def load_dict(path): with open(path, r, encodingutf-8) as f: return [line.strip() for line in f if line.strip()] authors load_dict(authors.txt) titles load_dict(titles.txt) dynasties load_dict(dynasties.txt) themes load_dict(themes.txt) imagery load_dict(imagery.txt) def extract_entities(question): 最大正向匹配抽取实体 found {Author: [], Poem: [], Dynasty: [], Theme: [], Imagery: []} dict_map { Author: authors, Poem: titles, Dynasty: dynasties, Theme: themes, Imagery: imagery } for label, words in dict_map.items(): for w in words: if w in question: found[label].append(w) return found def build_cypher(question): ents extract_entities(question) author ents[Author][0] if ents[Author] else None theme ents[Theme][0] if ents[Theme] else None imagery_word ents[Imagery][0] if ents[Imagery] else None dynasty ents[Dynasty][0] if ents[Dynasty] else None # 意图 1问作者 if (作者 in question or 是谁 in question) and ents[Poem]: title ents[Poem][0] return fMATCH (a:Author)-[:WROTE]-(p:Poem {{name: {title}}}) RETURN a.name AS answer # 意图 2问某作者某题材的诗 if author and theme: return (fMATCH (a:Author {{name: {author}}})-[:WROTE]-(p:Poem) f-[:HAS_THEME]-(t:Theme {{name: {theme}}}) fRETURN p.name AS answer) # 意图 3问某作者写的诗 if author and (哪些 in question or 有什么 in question or 写过 in question): return (fMATCH (a:Author {{name: {author}}})-[:WROTE]-(p:Poem) fRETURN p.name AS answer LIMIT 50) # 意图 4问某意象相关的诗 if imagery_word: return (fMATCH (p:Poem)-[:HAS_IMAGERY]-(i:Imagery {{name: {imagery_word}}}) fRETURN p.name AS answer LIMIT 50) # 意图 5问某朝代的诗 if dynasty: return (fMATCH (p:Poem)-[:BELONGS_TO]-(d:Dynasty {{name: {dynasty}}}) fRETURN p.name AS answer LIMIT 50) return None # 测试 questions [ 静夜思的作者是谁, 李白写过哪些送别诗, 杜甫写过哪些诗, 关于月亮的诗有哪些, 唐代的诗有哪些 ] for q in questions: cypher build_cypher(q) print(f问句{q}) print(fCypher{cypher}\n)这段代码的核心是extract_entities和build_cypher两个函数。前者用最大正向匹配从问句里捞实体后者按意图模板拼查询。意图判断用的是关键词组合比如同时出现作者和题材就走“作者题材”模板出现“作者”或“是谁”且句中有诗题就走属性查询模板。参数上最需要调的是词典覆盖度。如果问句里的作者名没在authors.txt里实体识别就抓不到后面所有模板都失效。我一般会先把图谱里所有 Author 节点的 name 导出来当词典保证词典和图谱一致。题材和意象词典同理直接从图谱里MATCH (t:Theme) RETURN t.name导出即可。注意模板法对问句的句式变化容忍度有限。如果用户问“谁写了静夜思”你的模板里没有“谁写”这个触发词就会漏掉。解决办法是把触发词表做成可配置的每遇到一个漏掉的句式就补一条迭代几轮后覆盖率会明显上升。3.3 实体识别漏词与歧义两个必须处理的边界第一个边界是作者名和诗题重名。比如“李白”既是作者名也可能出现在某首诗题里。最大正向匹配如果先匹配到诗题就会把作者漏掉。解决办法是优先匹配长词同时给作者词典更高优先级匹配到作者后从问句里把该片段移除再匹配其他类型。第二个边界是单字意象。比如“月”这个意象如果词典里有“月”那么“月亮”“明月”“月色”都会被误匹配成“月”。解决办法是意象词典里只放双字及以上的词单字意象要么合并到双字词里要么单独建一个单字匹配规则但匹配后要做上下文校验。# 优先匹配作者匹配到后从问句中移除避免诗题干扰 def extract_entities_v2(question): found {Author: [], Poem: [], Dynasty: [], Theme: [], Imagery: []} remaining question # 作者优先 for w in sorted(authors, keylen, reverseTrue): if w in remaining: found[Author].append(w) remaining remaining.replace(w, , 1) # 诗题其次 for w in sorted(titles, keylen, reverseTrue): if w in remaining: found[Poem].append(w) remaining remaining.replace(w, , 1) # 其余类型 for label, words in [(Dynasty, dynasties), (Theme, themes), (Imagery, imagery)]: for w in sorted(words, keylen, reverseTrue): if w in remaining: found[label].append(w) remaining remaining.replace(w, , 1) return found这段改进版的核心变化是按类型优先级依次匹配每匹配到一个词就从剩余问句里删掉避免同一个片段被重复识别成不同类型。sorted(words, keylen, reverseTrue)保证长词优先比如“明月”不会被“月”抢先匹配。4. 问答接口与前端联调从 Cypher 结果到用户看到的答案4.1 Flask 接口设计三个路由撑起整个问答链路后端用 Flask 就够了不需要上 FastAPI 或 Django。三个路由/ask接收问句返回答案/graph返回子图数据给前端可视化/stats返回图谱规模统计。from flask import Flask, request, jsonify from neo4j import GraphDatabase app Flask(__name__) driver GraphDatabase.driver(bolt://localhost:7687, auth(neo4j, your_password)) def run_cypher(query): with driver.session() as session: result session.run(query) return [record.data() for record in result] app.route(/ask, methods[POST]) def ask(): data request.get_json() question data.get(question, ).strip() if not question: return jsonify({error: empty question}), 400 cypher build_cypher(question) if not cypher: return jsonify({answer: [], cypher: None, msg: 未识别的问题类型}) try: records run_cypher(cypher) answers [list(r.values())[0] for r in records if r] return jsonify({answer: answers, cypher: cypher}) except Exception as e: return jsonify({error: str(e), cypher: cypher}), 500 app.route(/graph, methods[GET]) def graph(): # 返回前 100 个节点和关系供前端力导向图渲染 nodes run_cypher(MATCH (n) RETURN n.name AS name, labels(n)[0] AS label LIMIT 100) rels run_cypher(MATCH (a)-[r]-(b) RETURN a.name AS source, b.name AS target, type(r) AS rel LIMIT 200) return jsonify({nodes: nodes, links: rels}) app.route(/stats, methods[GET]) def stats(): node_count run_cypher(MATCH (n) RETURN count(n) AS c)[0][c] rel_count run_cypher(MATCH ()-[r]-() RETURN count(r) AS c)[0][c] return jsonify({nodes: node_count, relations: rel_count}) if __name__ __main__: app.run(debugTrue, port5000)/ask路由的逻辑是收问句、调build_cypher、执行、把结果里第一个字段的值抽出来当答案。/graph路由返回节点和边前端用 ECharts 或 D3 渲染力导向图。/stats路由给答辩时展示图谱规模用。参数上要注意 Neo4j 连接串的认证信息默认用户是neo4j密码是你第一次启动时设的。如果连不上先确认 Neo4j 服务在跑再用neo4j status看端口。4.2 前端最小实现一个输入框加一个结果区前端不需要框架一个 HTML 文件加 fetch 就能跑。!DOCTYPE html html head meta charsetutf-8 title古诗词问答系统/title /head body h2古诗词知识图谱问答/h2 input idq typetext placeholder例如李白写过哪些送别诗 stylewidth:400px button onclickask()提问/button div idresult stylemargin-top:20px/div script async function ask() { const question document.getElementById(q).value.trim(); if (!question) return; const res await fetch(/ask, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({question}) }); const data await res.json(); const box document.getElementById(result); if (data.answer data.answer.length 0) { box.innerHTML b答案/bbr data.answer.join(br); } else { box.innerHTML b未找到答案/bbr (data.msg || ); } // 把生成的 Cypher 也显示出来方便调试 if (data.cypher) { box.innerHTML brbrb生成的 Cypher/bbrcode data.cypher /code; } } /script /body /html这个页面只做两件事把问句发给/ask把返回的答案列表渲染出来。把生成的 Cypher 也显示在页面上是为了答辩时能直观展示“问句到查询”的映射过程评委一看就懂。4.3 联调时最容易卡住的三个点第一个是跨域。如果前端用file://打开fetch 请求会因为跨域被浏览器拦掉。解决办法是把 HTML 文件放到 Flask 的static目录下通过http://localhost:5000/static/index.html访问同源就没问题。第二个是 Neo4j 驱动版本。neo4jPython 包 5.x 和 4.x 的 API 有差异5.x 里session.run返回的 record 取值方式变了。如果你装的是 5.x用record.data()拿字典如果是 4.x用dict(record)。装之前先pip show neo4j确认版本。第三个是中文编码。CSV 文件必须存成 UTF-8Neo4j 导入时如果报乱码检查LOAD CSV语句里有没有加WITH HEADERS和文件本身的编码。Flask 返回 JSON 时默认ensure_asciiTrue中文会变成\uXXXX在jsonify前加app.config[JSON_AS_ASCII] False就能正常显示中文。5. 避坑与排查本科毕设里最容易翻车的五件事5.1 图谱导入后查询返回空结果现象Cypher 语句在 Neo4j Browser 里执行不报错但返回 0 条记录。原因最常见的是节点 name 字段带了首尾空格导入时没 strip查询时用精确匹配就对不上。其次是关系方向建反了比如WROTE应该是 Author 指向 Poem结果建成了 Poem 指向 Author。解决导入前在 Python 里对所有字段做.strip()导入后用MATCH (n) RETURN n.name LIMIT 10肉眼检查有没有空格。关系方向用MATCH (a)-[r]-(b) RETURN a.name, type(r), b.name LIMIT 10确认。5.2 问句里作者名被诗题“吃掉”现象问“李白静夜思写了什么”实体识别只抓到“静夜思”作者“李白”丢了。原因最大正向匹配按词典顺序匹配如果诗题词典先匹配到“静夜思”作者匹配时问句里已经没有“李白”这个片段了。解决按类型优先级匹配作者优先于诗题匹配到后从问句里移除该片段。参考 3.3 节的extract_entities_v2。5.3 Cypher 拼接导致注入或语法错误现象问句里带单引号比如“静夜思的作者是谁”拼出来的 Cypher 变成name: 静夜思的作者是谁单引号不配对执行报语法错误。原因模板法直接字符串拼接没有对实体值做转义。解决在拼接前把实体值里的单引号替换成\或者改用参数化查询。参数化查询更稳但模板法动态拼关系类型时参数化不好写折中方案是做一层转义函数。def escape_cypher(s): return s.replace(\\, \\\\).replace(, \\)5.4 Neo4j 内存不够导致导入中断现象导入几千个节点后 Neo4j 报OutOfMemoryError服务自动重启。原因默认堆内存太小批量导入时事务太大。解决改neo4j.conf里的dbms.memory.heap.max_size本科毕设的数据量设 1G 到 2G 就够。另外导入时用CALL {} IN TRANSACTIONS OF 500 ROWS分批提交避免单事务过大。5.5 前端答案显示为乱码或问号现象页面上答案全是????或\u674e\u767d。原因Flask 的jsonify默认把非 ASCII 字符转义或者 HTML 文件没声明 UTF-8。解决Flask 端加app.config[JSON_AS_ASCII] FalseHTML 端加meta charsetutf-8。两个都加上基本不会再出乱码。6. 进阶技巧用子图匹配和路径查询把问答做深前面讲的模板法能覆盖大部分单跳和双跳查询但遇到“苏轼和苏辙互相写过哪些诗”这种需要双向关系的问题模板就不够用了。这时候可以用 Cypher 的变长路径查询把两个作者之间的所有路径捞出来。// 查两个作者之间通过诗关联的所有路径最多 4 跳 MATCH path (a:Author {name: 苏轼})-[*1..4]-(b:Author {name: 苏辙}) RETURN path LIMIT 10;这条查询会返回苏轼和苏辙之间所有通过诗、题材、意象等节点连起来的路径。如果只想看直接互赠的诗可以限定关系类型MATCH (a:Author {name: 苏轼})-[:WROTE]-(p1:Poem)-[:WROTE]-(b:Author {name: 苏辙}) RETURN p1.name;这个模式的意思是苏轼写了一首诗苏辙也写了同一首诗——在古诗词里这通常表示两人有唱和关系。如果你的数据里唱和关系是单独建的边那就更简单直接MATCH (a)-[:CHANGHE]-(b)就行。另一个进阶方向是把模板法和向量检索结合。模板法负责精确查询向量检索负责模糊语义匹配。比如用户问“表达思念家乡的诗”模板法匹配不到“思念”这个题材词但可以用句向量在诗题和正文里做相似度检索把 top-k 结果返回。本科毕设如果能把这两条路都跑通答辩时的工作量展示会充实很多。验证方法上我一般会准备 30 到 50 条测试问句覆盖单跳、双跳、属性查询、列表查询、模糊查询五种类型跑一遍看准确率和召回率。准确率低于 70% 就回去补词典和模板召回率低就检查图谱里关系是不是建少了。提示测试问句不要自己编从实际用户可能问的角度出发比如“这首诗是谁写的”“某某诗人有哪些代表作”“哪些诗提到了某个意象”这样测出来的结果才有参考价值。我自己踩过最深的坑是图谱建完就以为万事大吉结果问句解析的词典和图谱数据不一致图谱里有“王维”词典里写的是“王维唐”匹配永远对不上。后来养成习惯图谱导入完成后第一件事就是从图谱里导出所有实体名直接生成词典文件保证两边永远同步。这个习惯帮我省掉了至少三次返工。希望帮到你。本文还有配套的精品资源点击获取