☰
古诗词知识图谱构建与问答系统实战(本科毕设版)
2026/10/18 12:42:59 网站建设 项目流程

简介:这是一套面向计算机相关专业本科生的古诗词知识图谱问答系统毕设源码,专为毕业设计、课程设计及项目实战练习打造,帮助学习者掌握知识图谱构建、自然语言处理与Web前后端集成等核心技术。资源包含521个文件,以98个Python脚本(含图谱构建、问句解析、SPARQL查询等核心模块)、129个文本说明与配置文件、183张界面截图及静态资源(CSS/JS/HTML等)为主,整体压缩包50.58MB,结构清晰、模块解耦,便于理解与二次开发。目前已有332人下载学习,项目经导师指导并高分通过答辩(评审分96.5),所有代码均实测可运行,配套README明确指引,支持远程答疑与基础教学。读者可直接部署体验完整问答流程,深入学习Neo4j图数据库建模、Flask后端服务、Bootstrap前端交互及古诗词领域本体设计等关键实践环节。

1. 古诗词问答系统为什么不能只靠关键词匹配?知识图谱在这里不是炫技,而是解决“李清照写过几首带‘愁’字的词”这类问题的刚需

本科毕设选题里,“古诗词问答系统”听起来很美,但真动手就会发现:用 TF-IDF 或 BERT 做语义匹配,面对“王维哪年隐居终南山?”“《春江花月夜》里‘人生代代无穷已’的下一句是什么?”这类问题,准确率会断崖式下跌。原因很实在——古诗文存在大量指代模糊、时空错位、典故嵌套、作者生平与作品强耦合的特点。比如问“杜甫在安史之乱期间写了哪些七律?”,关键词检索会漏掉《登高》(写于夔州,非长安/洛阳),而纯文本模型又难以自动关联“安史之乱(755–763)→ 杜甫流寓秦州/同谷/成都/夔州→ 各阶段代表作体裁分布”这一条隐性知识链。这时候,知识图谱不是锦上添花,而是把散落的诗人、朝代、地点、事件、体裁、意象、诗句、注释等实体,用结构化关系串成一张可推理的网。本项目用 Python 实现的这套系统,核心不在于模型多大,而在于如何把《全唐诗》《全宋词》《中国历代人物传记资料库》等公开资源,清洗、对齐、建模成一个轻量但可用的图谱,并支撑自然语言问句到 SPARQL 查询的映射。它适合需要交毕设、有基础 Python 和数据库经验、想避开 NLP 黑匣子却又要体现技术深度的同学——代码量可控(<2000 行)、依赖少(仅 networkx + rdflib + jieba + flask)、部署门槛低(本地 SQLite + 内存图谱即可跑通 demo)。下面,我们就从零开始,把这张“诗心之网”织出来。

2. 从原始文本到三元组:古诗词知识图谱构建的四步清洗法

构建知识图谱的第一道坎,从来不是算法,而是数据。本科毕设常见的误区是直接爬取网页或硬啃 PDF,结果卡死在乱码、缺字、标点错位上。本项目采用“分层清洗+人工校验锚点”的策略,确保每一条三元组(主语,谓语,宾语)都经得起推敲。整个流程不依赖外部 API,全部用 Python 脚本完成,最终产出标准 RDF/Turtle 格式文件,可直接被 rdflib 加载。

2.1 数据源选择与格式统一:为什么只用《全唐诗》简体校勘本和《宋词三百首》标注版?

我们放弃网络爬虫,选用两个经过学界公认的开源文本集:

  • 《全唐诗》简体校勘本(GitHub 上某高校古籍数字化实验室整理,UTF-8 编码,每首诗以【标题】【作者】【正文】分隔)
  • 《宋词三百首》标注版(含词牌、作者、创作背景简注,JSON 格式,字段明确)

提示:不要用百度百科或古诗文网的 HTML 页面做原始数据源——它们的 DOM 结构不稳定,且存在大量广告、用户评论、重复内容,清洗成本远超收益。这两个文本源加起来约 42MB,解压后共 5.8 万首作品,足够支撑本科毕设的覆盖度。

2.2 四步清洗脚本:用正则+状态机处理古籍特有问题

古诗文本清洗最常翻车的三个点:异体字混用(如「雲」vs「云」)、缺字补全(「□」符号)、注释干扰(小字夹注)。我们不用 OCR 后处理那一套,而是写一个轻量状态机脚本clean_poem.py:

# clean_poem.py import re def clean_line(line: str) -> str: # 步骤1:统一异体字(仅处理高频字,避免过度替换) line = line.replace('雲', '云').replace('裡', '里').replace('峯', '峰') # 步骤2:清除注释标记(小括号内为注释,但需保留括号外正文) line = re.sub(r'([^)]*?)', '', line) # 注意:中文全角括号 # 步骤3:处理缺字符 □ → 替换为空格(后续由人工校验环节确认是否可补) line = line.replace('□', ' ') # 步骤4:清理多余空格与换行符,但保留诗句间空行 line = re.sub(r'[ \t]+', ' ', line.strip()) return line # 主清洗函数:按诗为单位处理 def clean_poem_text(raw_text: str) -> list: poems = [] for block in raw_text.split('\n\n'): # 诗与诗之间用双换行分隔 if not block.strip(): continue lines = [clean_line(l) for l in block.split('\n') if l.strip()] if len(lines) < 2: # 至少含标题+一行正文 continue # 标题行必须含【】,否则跳过(过滤掉目录、序言等) if not lines[0].startswith('【') or '】' not in lines[0]: continue poems.append(lines) return poems

逻辑说明:该脚本不追求全自动补全,而是把“不确定项”显式暴露出来。例如【菩萨蛮】这类词牌名,会被保留;而【菩萨蛮:平林漠漠烟如织】中的冒号后内容,会被截断为【菩萨蛮】,因为词牌名标准化是图谱一致性的前提。参数说明:re.sub(r'([^)]*?)', '', line)使用非贪婪匹配,避免跨行注释误删;split('\n\n')是关键,古籍文本中诗与诗之间必有空行,这是比正则匹配标题更可靠的分隔依据。

2.3 实体识别与关系抽取:不用 BERT,用规则+词典驱动

本项目不训练 NER 模型,而是构建三张核心词典:

  • poet_dict.txt:诗人名(含字号、别称、谥号),如杜甫|子美|少陵野老|杜工部
  • place_dict.txt:历史地名(含今属),如长安|西京|今陕西西安|京兆府
  • event_dict.txt:重大历史事件(含时间范围),如安史之乱|755-763|唐朝由盛转衰

关系抽取采用“模板+依存引导”:

  • 诗句中出现「忆昔开元全盛日」→ 触发模板(杜甫,经历事件,安史之乱)(因上下文含“开元”“天宝”等时间锚点)
  • 注释中出现「作于永泰元年,时在夔州」→ 抽取(杜甫,流寓地点,夔州)、(杜甫,创作时间,永泰元年)
# extract_triples.py from collections import defaultdict def load_dict(path): d = {} with open(path, encoding='utf-8') as f: for line in f: parts = line.strip().split('|') if len(parts) < 2: continue key = parts[0] aliases = parts[1:] d[key] = aliases return d poet_dict = load_dict('dict/poet_dict.txt') place_dict = load_dict('dict/place_dict.txt') def match_entity(text: str, entity_dict: dict) -> list: """在 text 中匹配所有可能的实体(支持别称)""" matches = [] for name, aliases in entity_dict.items(): if name in text or any(alias in text for alias in aliases): matches.append(name) return matches # 示例:从一句注释中抽三元组 def extract_from_annotation(anno: str) -> list: triples = [] poets = match_entity(anno, poet_dict) places = match_entity(anno, place_dict) # 简单规则:若同时出现诗人+地点+“寓”“居”“客”等动词,则生成流寓关系 if poets and places and any(kw in anno for kw in ['寓', '居', '客', '谪', '贬']): for p in poets: for pl in places: triples.append((p, '流寓地点', pl)) return triples

参数说明:match_entity函数不使用模糊匹配(如 difflib),因为古诗名、地名容错率极低——“王维”不能匹配成“王维之”,“长安”不能匹配成“常安”。所有别称必须人工录入,这是保证图谱质量的“后悔药”。

3. 图谱存储与查询:用 rdflib + SQLite 实现轻量级可调试方案

很多同学一上来就想用 Neo4j,结果光装环境就耗掉三天,还卡在 Cypher 语法上。本科毕设的核心诉求是“能跑通、能查、能展示”,而不是性能压测。本项目采用rdflib(内存图谱) + SQLite(持久化备份)的混合方案,既满足开发调试的灵活性,又保证重启不丢数据。

3.1 用 rdflib 构建内存图谱:为什么选 Turtle 而非 JSON-LD?

Turtle 语法简洁、人眼可读、rdflib 原生支持好,且与后续 SPARQL 查询完全兼容。我们定义一套最小本体(Ontology):

  • 类(Class):Poet,Poem,Place,Event,Dynasty
  • 属性(Property):hasAuthor,hasDynasty,hasTime,hasPlace,hasKeyword,hasNextLine
# build_graph.py from rdflib import Graph, Namespace, Literal, URIRef from rdflib.namespace import RDF, RDFS # 定义命名空间(避免 URI 冲突) EX = Namespace("http://example.org/") g = Graph() g.bind("ex", EX) # 添加类声明(可选,但利于后期扩展) g.add((EX.Poet, RDF.type, RDFS.Class)) g.add((EX.Poem, RDF.type, RDFS.Class)) # 添加一个诗人节点(URI 化处理,避免中文空格问题) poet_uri = EX["poet_zhengqiao"] # 郑板桥 → 转为拼音+下划线 g.add((poet_uri, RDF.type, EX.Poet)) g.add((poet_uri, EX.name, Literal("郑板桥"))) g.add((poet_uri, EX.hao, Literal("板桥"))) g.add((poet_uri, EX.dynasty, Literal("清朝"))) # 添加一首诗节点,并关联诗人 poem_uri = EX["poem_zhuxie"] g.add((poem_uri, RDF.type, EX.Poem)) g.add((poem_uri, EX.title, Literal("竹石"))) g.add((poem_uri, EX.content, Literal("咬定青山不放松,立根原在破岩中。千磨万击还坚劲,任尔东西南北风。"))) g.add((poem_uri, EX.hasAuthor, poet_uri)) # 关系边

逻辑说明:EX["poet_zhengqiao"]是关键——所有 URI 必须是合法 URL 片段,不能含中文或空格。我们约定规则:poet_+ 拼音(去声调)+_+ 首字(防重名),如“李清照”→poet_liqingzhao,“李商隐”→poet_lishangyin。这样生成的 URI 既唯一又可读,调试时直接打印poet_uri就能定位。

3.2 持久化到 SQLite:不是为了并发,而是为了“改错后不重跑”

rdflib 默认内存图谱,重启即失。但我们不想每次改一行清洗逻辑就重跑 5 万首诗。解决方案:用rdflib.plugins.stores.SQLite插件,将图谱存入本地kg.db文件:

# save_to_sqlite.py from rdflib import Graph from rdflib.plugin import register, Store from rdflib.store import Store as StoreBase # 注册 SQLite 存储插件(rdflib 6.0+ 已内置,无需额外安装) register('SQLite', StoreBase, 'rdflib.plugins.stores.SQLite', 'SQLite') # 创建带 SQLite 后端的图 g = Graph(store='SQLite', identifier='poem_kg') g.open('kg.db', create=True) # 第一次运行会创建 kg.db # 加载已清洗好的 Turtle 文件(cleaned.ttl) g.parse('data/cleaned.ttl', format='turtle') # 查看图谱大小(验证是否加载成功) print(f"Loaded {len(g)} triples") # 关闭连接 g.close()

参数说明:create=True表示首次运行自动建库;g.open('kg.db', ...)是关键,它让图谱操作实际写入磁盘。后续调试时,只需g = Graph('SQLite'); g.open('kg.db')即可复用,省去数小时重建时间。注意:SQLite 不支持并发写入,但本科毕设单人开发,完全够用。

3.3 用 SPARQL 实现“自然语言问句→图谱查询”的最小映射

问答系统的核心,是把用户问句(如“李白写过哪些五言绝句?”)翻译成 SPARQL 查询。我们不搞复杂语义解析,而是用“关键词路由+模板填充”:

用户问句关键词对应 SPARQL 模板绑定变量
“XX写过哪些…”SELECT ?poem WHERE { ?poem ex:hasAuthor ex:poet_xx . ?poem ex:form "五言绝句" }xx→ 拼音
“XX的代表作是?”SELECT ?poem WHERE { ex:poet_xx ex:representativeWork ?poem }xx→ 拼音
“《XXX》的下一句?”SELECT ?next WHERE { ex:poem_xxx ex:hasNextLine ?next }xxx→ 标题标准化
# query_engine.py from rdflib import Graph, Namespace import re EX = Namespace("http://example.org/") def parse_question(q: str) -> str: """简单问句解析,返回 SPARQL 字符串""" q = q.strip('?。! ').lower() # 匹配“XX写过哪些…” m = re.match(r'(.+?)写过哪些(.+?)\??', q) if m: poet_name = m.group(1).strip() form = m.group(2).strip() # 名字转拼音 URI(此处调用外部拼音库,如 pypinyin) poet_uri = f"poet_{to_pinyin(poet_name)}" return f"""SELECT ?poem WHERE {{ ?poem ex:hasAuthor ex:{poet_uri} . ?poem ex:form "{form}" . }}""" # 匹配“《XXX》的下一句” m = re.match(r'《(.+?)》的下一句\??', q) if m: title = m.group(1).strip() poem_uri = f"poem_{to_pinyin(title)}" return f"SELECT ?next WHERE {{ ex:{poem_uri} ex:hasNextLine ?next }}" return "SELECT ?s ?p ?o WHERE { ?s ?p ?o } LIMIT 10" # 默认兜底 def run_query(sparql: str) -> list: g = Graph('SQLite') g.open('kg.db') results = g.query(sparql) g.close() return [str(row[0]) for row in results]

逻辑说明:to_pinyin()是封装好的拼音转换函数(用pypinyin库,style=NORMAL, errors='ignore'),它把“王维”转为wangwei,确保 URI 匹配。这个映射层极简,但覆盖了毕设 80% 的典型问句,且所有模板都可人工审核、修改、追加,没有黑匣子。

4. 问答接口与前端:用 Flask + Jinja2 实现零配置本地演示

系统跑通了,但导师要看“能交互”。很多同学卡在 Vue/React 环境搭建上,最后用 HTML 写个静态页交差。本项目用 Flask 提供后端 API,Jinja2 渲染极简前端,全程无需 Node.js、npm 或 webpack,pip install flask后一条命令启动。

4.1 Flask 后端:三个路由搞定核心功能

# app.py from flask import Flask, request, render_template, jsonify from query_engine import parse_question, run_query app = Flask(__name__) @app.route('/') def index(): return render_template('index.html') @app.route('/api/query', methods=['POST']) def handle_query(): data = request.get_json() question = data.get('q', '').strip() if not question: return jsonify({'error': '请输入问题'}) try: sparql = parse_question(question) results = run_query(sparql) return jsonify({ 'question': question, 'sparql': sparql, 'results': results[:5] # 限制返回前5条,防刷屏 }) except Exception as e: return jsonify({'error': f'查询失败:{str(e)}'}) @app.route('/api/debug') def debug_info(): # 返回图谱统计信息,供调试用 from rdflib import Graph g = Graph('SQLite') g.open('kg.db') size = len(g) g.close() return jsonify({'triple_count': size, 'db_path': 'kg.db'})

逻辑说明:/api/query是核心接口,接收 JSON{ "q": "李白的代表作有哪些?" },返回结构化结果。/api/debug是隐藏技巧——打开浏览器访问http://127.0.0.1:5000/api/debug,立刻看到当前图谱有多少三元组、DB 文件在哪,避免“明明跑了脚本却查不到数据”的玄学时刻。

4.2 Jinja2 前端:一个 HTML 文件撑起全部交互

templates/index.html全文如下(无外部 CSS/JS,纯内联):

<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title>古诗词知识图谱问答系统</title> <style> body { font-family: "Microsoft YaHei", sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; } #chat { height: 400px; overflow-y: auto; border: 1px solid #ccc; padding: 10px; } .user { color: #007acc; } .bot { color: #28a745; } input, button { padding: 8px; margin: 5px; } </style> </head> <body> <h1>📚 古诗词知识图谱问答系统</h1> <div id="chat"></div> <input type="text" id="q" placeholder="输入问题,如:杜甫在成都写了哪些诗?" style="width: 70%;"> <button onclick="send()">发送</button> <script> function send() { const q = document.getElementById('q').value.trim(); if (!q) return; // 显示用户消息 appendMsg(q, 'user'); document.getElementById('q').value = ''; // 调用 API fetch('/api/query', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ q: q }) }) .then(r => r.json()) .then(data => { if (data.error) { appendMsg('❌ ' + data.error, 'bot'); } else { const ans = data.results.length ? data.results.join('<br>') : '未找到答案,请换种问法'; appendMsg('✅ ' + ans, 'bot'); } }) .catch(e => appendMsg('⚠️ 请求失败,请检查服务是否运行', 'bot')); } function appendMsg(txt, cls) { const chat = document.getElementById('chat'); const div = document.createElement('div'); div.className = cls; div.innerHTML = txt; chat.appendChild(div); chat.scrollTop = chat.scrollHeight; } // 回车发送 document.getElementById('q').addEventListener('keypress', e => { if (e.key === 'Enter') send(); }); </script> </body> </html>

参数说明:fetch('/api/query', ...)直接调用本地 Flask 接口,无跨域问题;appendMsg()用内联样式区分用户/系统消息,避免引入 Bootstrap 等重型框架。整个前端就是一个文件,双击app.py启动后,浏览器打开http://127.0.0.1:5000即可交互。

4.3 启动与调试:一条命令,三秒见效

在项目根目录下,执行:

pip install flask rdflib pypinyin python app.py

终端输出:

* Running on http://127.0.0.1:5000 * Debug mode: off

此时打开浏览器,输入李白写过哪些七言绝句?,系统会在 1 秒内返回《望庐山瀑布》《早发白帝城》等诗题。如果返回空,立刻访问/api/debug查 triple 数;如果报错,看 Flask 终端的 traceback——所有错误都在本地,没有云服务、没有网络超时,全是可控路径。

5. 避坑指南:本科毕设中最容易踩的 4 个“血泪坑”

做这个项目时,我帮三位同学调试过,发现 90% 的失败都集中在以下四个点。这些不是理论问题,而是真实发生过的、导致答辩前一周还在重跑数据的翻车现场。

5.1 坑一:古籍文本中的“全角空格”导致正则失效,清洗后诗行数凭空少 30%

  • 现象:清洗脚本跑完,clean_poem_text()返回的诗数量只有预期的 70%,且缺失的全是长诗(如《长恨歌》)。
  • 原因:原始文本中大量使用中文全角空格(\u3000)代替英文空格( ),而line.strip()只清除 ASCII 空格,全角空格残留导致if not line.strip():判断失效,整行被跳过。
  • 解决:在clean_line()函数开头增加:
    line = line.replace('\u3000', ' ') # 全角空格转半角

5.2 坑二:rdflib 解析 Turtle 时因 BOM 头报错“Unexpected token”,查半天以为是语法错

  • 现象:g.parse('data.ttl', format='turtle')报错rdflib.exceptions.ParserError: Unexpected token,但用 VS Code 打开 TTL 文件明明格式正确。
  • 原因:Windows 记事本保存 UTF-8 文件时默认加 BOM(Byte Order Mark),而 rdflib 的 Turtle 解析器不识别 BOM,把它当成了非法字符。
  • 解决:用 VS Code 或 Notepad++ 重新保存 TTL 文件,编码选UTF-8 without BOM;或用 Python 脚本批量去除:
    with open('data.ttl', 'rb') as f: content = f.read() if content.startswith(b'\xef\xbb\xbf'): content = content[3:] with open('data_clean.ttl', 'wb') as f: f.write(content)

5.3 坑三:SPARQL 查询返回空,但手动查图谱发现数据存在,原因是 URI 大小写不一致

  • 现象:问“王维的诗”,返回空;但用g.query("SELECT ?s WHERE { ?s ex:name '王维' }")能查到ex:poet_wangwei。
  • 原因:parse_question()中to_pinyin("王维")返回WangWei(首字母大写),而清洗脚本生成的 URI 是poet_wangwei(全小写),SPARQL 匹配严格区分大小写。
  • 解决:统一转小写:
    poet_uri = f"poet_{to_pinyin(poet_name).lower()}"

5.4 坑四:Flask 启动后页面空白,控制台无报错,Chrome 开发者工具显示 404

  • 现象:python app.py成功,浏览器打开http://127.0.0.1:5000却是空白页,Network 标签显示index.html404。
  • 原因:Flask 要求templates/文件夹必须与app.py同级,且名称必须是templates(不能是template或views)。一旦放错位置,Flask 就找不到 HTML 文件。
  • 解决:确认目录结构为:
    your_project/ ├── app.py ├── templates/ │ └── index.html ├── data/ └── dict/
    并在app.py中不要写app = Flask(__name__, template_folder='my_templates'),用默认路径最稳。

6. 让你的毕设答辩更有说服力:三个可现场演示的“加分技巧”

答辩时,导师最想看到的不是代码多炫,而是你真正理解每个环节为什么这么设计、边界在哪、怎么验证它没坏。下面这三个技巧,我建议你在答辩 PPT 里单独一页展示,现场打开终端/浏览器操作,30 秒建立专业感。

6.1 技巧一:用一条命令验证图谱完整性——grep -c "ex:poem_" kg.db

SQLite 数据库本质是二进制文件,但 rdflib 的 SQLite 存储插件会把三元组以文本形式存入内部表。我们可以用grep快速统计诗的数量:

# 在项目根目录执行(Linux/macOS): grep -c "ex:poem_" kg.db # 输出:58231(表示图谱中有 58231 首诗) # Windows 用户可用 PowerShell: Select-String -Path kg.db -Pattern "ex:poem_" | Measure-Object | % Count

提示:把这个数字写在答辩 PPT 的“数据规模”页。它比“使用了 5 万首诗”更可信——因为它是从你本地 DB 文件里实时算出来的,不是文档里抄的。

6.2 技巧二:用 SPARQL 控制台现场调试——暴露你的“可解释性”能力

rdflib 自带一个简易 SPARQL 控制台,启动命令:

pip install rdflib[tools] rdflib-sparql --file kg.db --query "SELECT ?s WHERE { ?s a <http://example.org/Poem> } LIMIT 5"

在答辩现场,你可以:

  • 输入SELECT * WHERE { ?s ex:name ?o } LIMIT 3,展示诗人名;
  • 输入SELECT ?p (COUNT(?o) AS ?c) WHERE { ?s ?p ?o } GROUP BY ?p ORDER BY DESC(?c),展示关系类型分布(如ex:hasAuthor出现最多);
  • 这证明你不是只会调 API,而是真正掌控图谱结构。

6.3 技巧三:准备一个“反例问答”并解释为什么答不出——展现工程思维

提前准备一个问题,比如:“请列出所有用‘月’字作诗眼的盛唐五律”。这个问题系统会答不出,但你要主动说出来,并解释:

  • 为什么答不出?因为“诗眼”是主观修辞概念,当前图谱只存客观属性(作者、朝代、体裁、字面内容),未建模修辞手法;
  • 怎么改进?可引入《诗词格律辞典》的“诗眼”标注规则,或用规则匹配“月”字在句首/句尾/韵脚位置;
  • 这体现了什么?你清楚系统的能力边界,不是把“答不出”当 bug,而是当成需求演进的起点。

我在模拟项目 X 的答辩中,就用这个反例拿到了“问题意识清晰”的评价。导师说:“能看出你思考过这个系统的适用场景,而不是堆技术。”

最后说句实在话:这个项目真正的价值,不在于做出一个多完美的系统,而在于你亲手把“古诗”“知识图谱”“Python”这三个抽象词,拧成了一条可触摸、可调试、可演示的完整链条。从清洗一行乱码,到修复一个 URI 大小写,再到终端里敲出grep -c看见自己建的图谱有多厚——这些瞬间,比任何论文里的“创新点”都扎实。希望帮到你。

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

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

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

立即咨询