ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于知识图谱的古诗词问答系统:Python源码实现与Neo4j实战

基于知识图谱的古诗词问答系统:Python源码实现与Neo4j实战 简介这是一套面向计算机相关专业本科生与项目实战学习者的古诗词问答系统源码以知识图谱为核心技术路线可作为毕业设计、课程设计或期末大作业的完整参考方案。项目经导师指导并通过答辩评审平均分达96.5分代码均经过运行测试适合人工智能、计科、通信、自动化等方向的学生与教师下载学习。压缩包共521个文件约50.58MB其中98个py文件承载问答逻辑与图谱构建24个json与129个txt用于数据存储和语料组织另有html、css、js等前端页面资源及jpg图片素材整体结构完整、模块清晰。目前已有330人学习下载。读者可从中获得知识图谱构建、古诗词实体关系抽取、问答匹配与前端交互的完整实现思路也可在现有代码基础上修改扩展用于毕设、课设或项目初期立项演示具备较高的参考与复用价值。1. 古诗词问答系统为什么值得用知识图谱重做一遍把《全唐诗》五万多首塞进数据库用 LIKE %明月% 去查能跑但一被问「李白写过哪些带月亮的送别诗」就露馅——关键词匹配分不清「月亮」是意象还是诗题也串不起「李白—送别—孟浩然」这层关系。基于知识图谱的古诗词问答系统解决的正是这种多跳、带语义约束的查询把诗人、诗题、朝代、意象、情感、地点抽成实体把「创作」「送别」「包含意象」抽成关系存进图数据库再用自然语言问句去图里走路径。这套 Python 源码方向适合本科毕设也适合想入门知识图谱构建的开发者——它把本体建模、实体抽取、Neo4j 存储、问句解析、答案生成串成一条完整链路规模可控跑得起来答辩时讲得清。下面按「先立住原理、再动手复现、最后避坑」的顺序拆开讲。2. 古诗词知识图谱的本体设计与数据来源2.1 先定本体实体、关系、属性三张表知识图谱不是先把数据爬下来再想结构而是先定本体Ontology也就是这套图里允许出现哪些类型的节点、哪些类型的边。古诗词领域本体不复杂但定错了后面全要返工。我一般按三层来划核心实体层诗人、诗作、朝代、地点、语义层意象、情感、题材、典故、关联层流派、官职、友人。实体类型控制在 8 到 10 个关系类型控制在 10 到 15 个本科毕设这个量级刚好再多标注和抽取成本就压不住了。实体类型示例关键属性诗人 Poet李白、杜甫生卒年、字号、朝代诗作 Poem《静夜思》体裁、创作年份、全文朝代 Dynasty唐、宋起止年份地点 Place长安、黄鹤楼今属地意象 Image明月、杨柳、孤舟类别情感 Emotion思乡、离别、豪迈极性题材 Theme送别、边塞、咏物无关系类型里「诗人—创作—诗作」「诗作—包含意象—意象」「诗作—表达情感—情感」「诗作—属于题材—题材」「诗人—友人—诗人」这五条是问答命中率最高的优先保证抽取质量。属性别贪多生卒年和朝代是过滤条件全文是答案展示用其余能省则省。2.2 数据从哪来公开语料 结构化字段古诗词数据不像通用领域那么散公开语料质量其实不错。常见做法是拿一份带注释的诗词 JSON 或 CSV字段通常包含标题、作者、朝代、正文、注释、译文。我一般会先做一次字段盘点确认哪些能直接映射成实体属性哪些需要二次抽取。下面这段是把原始 JSON 规整成中间表的脚本跑之前先确认字段名和你手头语料一致。import json import csv # 原始语料每行一个 JSON 对象字段名按实际语料调整 def normalize_poems(raw_path, out_path): rows [] with open(raw_path, r, encodingutf-8) as f: for line in f: line line.strip() if not line: continue obj json.loads(line) rows.append({ title: obj.get(title, ).strip(), author: obj.get(author, ).strip(), dynasty: obj.get(dynasty, ).strip(), content: obj.get(content, ).replace(\n, ), tags: |.join(obj.get(tags, [])) # 意象/题材标签 }) # 去重同题同作者同正文视为重复 seen set() unique [] for r in rows: key (r[title], r[author], r[content][:20]) if key not in seen: seen.add(key) unique.append(r) with open(out_path, w, encodingutf-8, newline) as f: writer csv.DictWriter(f, fieldnames[title, author, dynasty, content, tags]) writer.writeheader() writer.writerows(unique) print(f原始 {len(rows)} 条去重后 {len(unique)} 条) normalize_poems(poems_raw.jsonl, poems_clean.csv)逻辑说明逐行读 JSONL 避免一次性载入大文件tags用竖线拼接方便后续按分隔符拆去重键取标题、作者加正文前 20 字是因为部分诗作正文有异文但前两句一致这样能压掉大部分重复。参数上content[:20]这个截断长度可以调语料异文多就取前 10 字语料干净就取前 30 字。跑完看打印的条数如果去重比例超过 30%说明语料本身有大量重复得回头查数据源。2.3 意象和情感怎么抽规则打底模型补漏意象、情感这类语义标签公开语料不一定标全。我的做法是规则打底先建一个意象词典明月、杨柳、孤舟、落花等 200 到 300 个高频词用字符串匹配把能命中的先标上剩下的用轻量分类模型补。本科毕设不建议上大模型用 TF-IDF 加朴素贝叶斯或 TextCNN 就够训练数据从已标注的诗词里切。规则匹配的代码很短但词典质量决定召回。IMAGE_DICT [明月, 杨柳, 孤舟, 落花, 流水, 青山, 白云, 秋风] def extract_images(content, image_dictIMAGE_DICT): hits [] for word in image_dict: if word in content: hits.append(word) return list(set(hits)) # 去重同一意象出现多次只记一次 # 批量处理 import pandas as pd df pd.read_csv(poems_clean.csv) df[images] df[content].apply(extract_images) print(df[df[images].map(len) 0].shape[0], 首命中意象)逻辑说明extract_images遍历词典做子串匹配返回去重后的意象列表。参数上词典要按语料实际用词维护比如「明月」和「月」要分开还是合并取决于你的问答粒度——问「带月的诗」就合并问「明月意象」就分开。跑完看命中率如果低于 40%说明词典覆盖不够得从语料里统计高频二字词补充。这一步的产出会直接进图数据库所以宁可少标也别错标。3. 用 Neo4j 把诗词数据落成可查询的图3.1 图模型落地节点、关系、约束数据规整完下一步是进 Neo4j。图模型要和 2.1 的本体对齐节点标签用英文Poet、Poem、Image 等关系类型也用英文WROTE、HAS_IMAGE 等属性名保持和 CSV 列一致减少映射错误。建图前先加唯一约束否则重复导入会生成重复节点后面查询结果翻倍这种坑我踩过不止一次。// 唯一约束诗人按姓名诗作按标题作者 CREATE CONSTRAINT poet_name IF NOT EXISTS FOR (p:Poet) REQUIRE p.name IS UNIQUE; CREATE CONSTRAINT poem_key IF NOT EXISTS FOR (po:Poem) REQUIRE (po.title, po.author) IS UNIQUE; CREATE CONSTRAINT image_name IF NOT EXISTS FOR (i:Image) REQUIRE i.name IS UNIQUE; // 建索引加速按朝代过滤 CREATE INDEX poet_dynasty IF NOT EXISTS FOR (p:Poet) ON (p.dynasty);逻辑说明REQUIRE ... IS UNIQUE在导入时自动去重重复的 MERGE 不会新建节点。复合唯一约束(po.title, po.author)是因为不同诗人可能写同题诗单靠标题会误合并。索引建在dynasty上是因为问答里「唐代诗人」这类过滤很频繁。执行完用SHOW CONSTRAINTS确认生效再开始导数据。3.2 批量导入从 CSV 到图Neo4j 导入 CSV 用LOAD CSV但要注意文件路径得放在 import 目录下或者用绝对路径且服务端允许。下面这段分三步先建诗人和朝代再建诗作并连关系最后挂意象。分步走比一条大语句好排查哪步出错看哪步的计数。// 第一步导入诗人 LOAD CSV WITH HEADERS FROM file:///poets.csv AS row MERGE (p:Poet {name: row.author}) SET p.dynasty row.dynasty MERGE (d:Dynasty {name: row.dynasty}) MERGE (p)-[:LIVED_IN]-(d); // 第二步导入诗作并连诗人 LOAD CSV WITH HEADERS FROM file:///poems_clean.csv AS row MERGE (po:Poem {title: row.title, author: row.author}) SET po.content row.content WITH po, row MATCH (p:Poet {name: row.author}) MERGE (p)-[:WROTE]-(po); // 第三步挂意象 LOAD CSV WITH HEADERS FROM file:///poems_with_images.csv AS row MATCH (po:Poem {title: row.title, author: row.author}) WITH po, split(row.images, |) AS imgs UNWIND imgs AS img MERGE (i:Image {name: img}) MERGE (po)-[:HAS_IMAGE]-(i);逻辑说明MERGE保证幂等重复跑不会产生重复节点。第二步里WITH po, row是为了把变量传下去否则MATCH拿不到 row。第三步split加UNWIND把竖线分隔的意象拆成多行每个意象建一个节点再连边。参数上CSV 里的空值要提前处理成空字符串否则MERGE会建出 name 为空的脏节点。导入完跑一句MATCH (n) RETURN count(n)看总数和预期对不上就查哪步计数异常。3.3 验证图是否可用三条查询先跑通图建完别急着写问答先用三条查询验证结构对不对。第一条查某诗人的所有作品第二条查含某意象的诗第三条走两跳查「某诗人送别诗里的意象」。这三条能跑通说明节点和关系都挂对了。// 1. 李白的所有诗 MATCH (p:Poet {name:李白})-[:WROTE]-(po:Poem) RETURN po.title LIMIT 10; // 2. 含「明月」意象的诗 MATCH (po:Poem)-[:HAS_IMAGE]-(i:Image {name:明月}) RETURN po.title, po.author LIMIT 10; // 3. 李白送别题材诗里的意象两跳 MATCH (p:Poet {name:李白})-[:WROTE]-(po:Poem)-[:HAS_IMAGE]-(i:Image) WHERE po.theme 送别 RETURN po.title, collect(i.name) AS images;逻辑说明第三条依赖po.theme属性如果导入时没设题材得先补一步。collect把同一首诗的多意象聚成列表方便答案展示。参数上LIMIT在调试时加上避免一次返回几千条卡住界面。三条都返回非空结果图就算立住了可以进问句解析。4. 问句解析与答案生成从自然语言到 Cypher4.1 问句分类先判断用户问的是哪类问题古诗词问答的问句类型其实有限常见就五六类问作者「《静夜思》是谁写的」、问作品「李白写过哪些诗」、问意象「哪些诗有明月」、问情感「表达思乡的诗」、问关系「李白和杜甫是什么关系」。先做意图分类再按类走不同的解析模板比直接端到端生成 Cypher 稳得多。分类器用朴素贝叶斯或 FastText 都行训练数据自己标 300 到 500 条就够。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.naive_bayes import MultinomialNB from sklearn.pipeline import Pipeline # 训练数据问句 意图标签 train_x [静夜思是谁写的, 李白写过哪些诗, 哪些诗有明月, 表达思乡的诗, 李白和杜甫什么关系] train_y [ask_author, ask_works, ask_image, ask_emotion, ask_relation] clf Pipeline([ (tfidf, TfidfVectorizer(analyzerchar, ngram_range(1, 2))), (nb, MultinomialNB()) ]) clf.fit(train_x, train_y) print(clf.predict([月亮的诗有哪些])) # 预期 ask_image逻辑说明中文短问句用字符级 n-gram 比词级稳因为分词误差会传导。ngram_range(1,2)兼顾单字和双字特征。参数上训练数据每类至少 50 条否则朴素贝叶斯容易偏。实际项目里我会把分类置信度低于 0.6 的问句转给兜底模板避免硬答。4.2 槽位抽取从问句里抠出实体和约束分类完要知道问的是谁、什么意象、什么朝代。槽位抽取用词典加正则最省事诗人名从诗人列表匹配意象从意象词典匹配朝代从固定枚举匹配。抽不到就追问或走模糊查询。POETS [李白, 杜甫, 白居易, 王维] DYNASTIES [唐, 宋, 元, 明, 清] def extract_slots(question): slots {} for p in POETS: if p in question: slots[poet] p break for d in DYNASTIES: if d in question: slots[dynasty] d break for img in IMAGE_DICT: if img in question: slots[image] img break return slots print(extract_slots(李白写的有明月的诗)) # {poet: 李白, image: 明月}逻辑说明按优先级依次匹配诗人名可能包含在更长词里所以先匹配长的。参数上词典要按实际数据更新比如诗人列表从 Neo4j 里查出来动态加载比硬编码好。抽不到槽位时返回空字典上层根据意图决定是追问还是给默认答案。4.3 模板生成 Cypher五类意图五套模板槽位有了按意图套 Cypher 模板。模板要参数化别拼字符串防注入也防引号问题。下面给三类最常用的模板其余照葫芦画瓢。def build_cypher(intent, slots): if intent ask_author: return MATCH (p:Poet)-[:WROTE]-(po:Poem {title:$title}) RETURN p.name, {title: slots.get(title,)} if intent ask_works: return MATCH (p:Poet {name:$poet})-[:WROTE]-(po:Poem) RETURN po.title LIMIT 20, {poet: slots.get(poet,)} if intent ask_image: cypher MATCH (po:Poem)-[:HAS_IMAGE]-(i:Image {name:$image}) params {image: slots.get(image,)} if slots.get(poet): cypher MATCH (p:Poet {name:$poet})-[:WROTE]-(po:Poem)-[:HAS_IMAGE]-(i:Image {name:$image}) params[poet] slots[poet] return cypher RETURN po.title, po.author LIMIT 20, params return None, {}逻辑说明$param是 Neo4j 参数占位符驱动层会做转义。ask_image里根据有没有诗人槽位切换查询路径有诗人就多一跳。参数上LIMIT 20是展示上限可按界面调整。返回的params字典键要和 Cypher 里的占位符名一致不一致会报参数缺失。4.4 答案组装把查询结果变成人话查出来的是节点属性要拼成自然语言。这一步别过度设计模板填空就够「李白写过《静夜思》《将进酒》等 20 首」。如果结果为空给「没有找到相关诗作换个说法试试」别直接抛异常。def format_answer(intent, records): if not records: return 没有找到相关结果换个说法试试 if intent ask_author: return f这首诗的作者是{records[0][p.name]} if intent ask_works: titles [r[po.title] for r in records] return f共找到 {len(titles)} 首 、.join(titles[:10]) if intent ask_image: return 、.join([f{r[po.title]}{r[po.author]} for r in records[:10]]) return 查询完成逻辑说明records是驱动返回的字典列表键名带标签前缀所以取r[po.title]。参数上展示条数控制在 10 条以内多了界面放不下。空结果分支必须有否则前端拿到 None 会崩。5. 避坑与排查这套系统最容易翻车的五个地方5.1 意象词典不全导致召回低现象问「带落花的诗」返回空但库里明明有。原因意象词典只收了「花」没收「落花」或者收了「落花」但语料里写的是「落红」。解决从语料里统计高频二字词人工过一遍补进词典同义意象建别名表查询时展开。我一般会跑一遍全量语料的词频取 top 500 二字词人工筛比拍脑袋建词典靠谱。5.2 重复导入生成重复节点现象查李白的诗返回两遍同样的结果。原因没建唯一约束就跑了两次导入或者约束建了但 CSV 里作者名有空格差异「李白」和「李白 」。解决导入前TRIM所有字符串字段建唯一约束导入用MERGE不用CREATE。已经脏了就用MATCH (n:Poet) WITH n.name AS name, collect(n) AS nodes WHERE size(nodes) 1找出来合并。5.3 问句分类置信度低还硬答现象用户问「杜甫的诗有什么特点」系统当成问作品返回一堆诗题。原因意图分类训练数据没覆盖「特点」这类问法模型硬分到最近的类。解决设置信度阈值低于阈值走兜底回复「这个问题我还没学会你可以问某诗人的作品或某意象的诗」。阈值我一般设 0.6低于就兜底宁可少答别答错。5.4 Cypher 参数名和字典键不一致现象查询报Expected parameter(s): poet。原因Cypher 里写$poet参数字典里键是author。解决模板和参数字典写在一起别分开维护加一层校验构建完 Cypher 后用正则抽出所有$xxx和字典键比对不一致直接抛错。这个校验函数十行代码能省很多调试时间。5.5 中文编码在导入时乱码现象Neo4j 里查出来的诗题是问号或乱码。原因CSV 存成了 GBK或者LOAD CSV没指定编码。解决CSV 统一存 UTF-8Neo4j 导入时确认dbms.import.csv.legacy_quote_escaping配置必要时在LOAD CSV前用 Python 转一遍编码。我习惯在规整脚本最后加一句df.to_csv(..., encodingutf-8-sig)带 BOM 能让部分工具正确识别。6. 让问答更准的两个进阶技巧第一个技巧是给意象加权重。同样是「明月」出现在诗题里和出现在正文里的重要性不一样出现在送别诗里和出现在咏物诗里语义也不同。我一般会在HAS_IMAGE关系上加一个weight属性诗题命中记 1.0正文首联命中记 0.8其余记 0.5查询时按权重排序。这样问「最典型的明月诗」时排前面的是真正以明月为核心的而不是随便提了一嘴的。加权重不用改图结构导入时多写一个属性就行MATCH (po:Poem)-[r:HAS_IMAGE]-(i:Image) WHERE po.title CONTAINS i.name SET r.weight 1.0;第二个技巧是给答案加出处。用户问「哪些诗表达思乡」只给诗题不够最好附上依据比如「《静夜思》——正文含『故乡』情感标注为思乡」。做法是在答案组装时把命中的关系路径也带出来查询时RETURN多带一个i.name或e.name格式化时拼进去。这招在答辩时特别加分评委一看就知道你的答案不是瞎猜的。验证这套系统好不好用我有个笨办法但很有效准备 50 条问句覆盖五类意图每条人工写好标准答案跑一遍算准确率。低于 70% 就回头查是分类错了还是槽位抽错了还是 Cypher 写错了逐层定位。这个测试集不用大50 条足够暴露主要问题。我自己做的时候第一版准确率只有 55%查下来一半是意象词典不全补完词典直接到 78%。所以别急着上模型先把词典和模板打磨好性价比最高。希望帮到你。本文还有配套的精品资源点击获取
返回列表