基于知识图谱的古诗词问答系统:Neo4j建模与Cypher查询实战
简介面向知识图谱课程大作业与自然语言处理入门学习者基于Neo4j的古诗词问答系统源码包完整覆盖从数据采集、知识抽取到图谱构建与问答实现的开发流程。资源共43个文件含11个Python脚本负责爬虫、数据清洗、实体识别、图谱写入等、11个CSV数据文件训练与语料数据、13个TXT文本及JSON模型文件分类模型与词表压缩包仅828KB结构紧凑便于快速部署。目前已有298人学习下载。通过该包可掌握Neo4j图数据库建模、基于模板的古诗词知识问答、机器学习文本分类等关键技能附带训练好的模型与词典下载后修改路径即可运行适合作为课程设计参考或深入理解知识图谱落地的实践样例。1. 基于知识图谱的古诗词问答系统为什么用 Neo4j 而不是全文检索当用户问出“李白写过哪些带‘剑’的诗句”时传统系统要在作者表、作品表、诗句表之间多次关联再用 LIKE 去扫“剑”字勉强查到结果却回答不了下一个问题“他当时是在哪里写的”。基于知识图谱的古诗词问答系统把诗人、作品、意象、典故、地名都建模成节点与关系多跳问题变成一条 CypherNeo4j 正是这类图查询最趁手的存储。这篇笔记从古诗词本体设计讲到 Cypher 建库再到把用户问题翻译成图查询的 Python 管道最后梳理这类项目里最容易翻车的地方。适合做 NLP 课程设计、知识图谱入门实践以及想把手头诗词数据做成 AI 问答 Demo 的开发者。2. 先把诗词变成图古诗词本体建模与关系设计2.1 六个节点类型一首诗不再是一行宽表图建模的第一步不是写 Cypher而是决定哪些东西是节点。一次常见的失误是把诗句、诗作、作者揉进一张大宽表等用户问到“哪句诗用了哪个典故”时SQL 查询会迅速变得不可维护。基础做法是拆出六个核心标签标签关键属性示例数据Poetname, aliases, era, birth_year李白太白、青莲居士唐Poempid, title, dynasty, genre, content《将进酒》Versevid, text, order_index, is_famous君不见黄河之水天上来Imageryname, category, description剑兵器、明月自然Placename, modern_city, note庐山今九江Allusionname, source_text, note庄生晓梦Poem 与 Verse 分开是最容易被忽略的一步。只保留 Poem 的情况下“带‘剑’的诗句”只能对整首做子串搜索无法回答“这句诗出自哪首诗”也无法统计某个意象在一首诗里出现几次。把每一联或每一句拆成 Verse 节点诗句层面的问答才有可靠抓手。Place 和 Allusion 属于加分项。数据量小、急着跑通问答时先只建 Poet、Poem、Verse、Imagery 四个标签地名和典故后续再补数据量允许的话建议一次建全六个因为补一个标签意味着把整张图重新遍历一遍后期迁移成本不低。另一个纠结是朝代做成属性还是节点。我的判断标准很简单如果只做“查出唐诗”这种过滤朝代属性就够如果要做“初唐到盛唐有哪些有影响力的诗人”这类跨朝代统计朝代提成节点更合适。课程设计阶段从属性做起不丢人。2.2 关系设计关系名就是你要回答的那类问题关系建模时我会反向检验每定义一个关系都要想清楚它能让哪条问答成立。以四条核心关系为例关系起点→终点语义建议属性WROTEPoet→Poem作者写了这首诗confidence多作者争议时用CONTAINSPoem→Verse诗句从属于诗order_indexHAS_IMAGERYVerse→Imagery诗句出现某个意象evidence完整原句MENTIONSPoem→Place诗中出现某地名frequency这里有一个容易想歪的点意象关系挂在 Verse 还是 Poem 上。“这首诗借酒抒情”是整首诗层面的判断可以把 HAS_IMAGERY 挂在 Poem但“这句诗里有剑”是句子层面的判断必须挂在 Verse。两者混用会导致用户搜“带剑的诗句”时返回一堆“整首提到剑”的诗精度明显下降。我个人的取舍是严格区分证据粒度。Verse 上的证据精确到联句Poem 上的证据只是整首的主题标签。问答系统优先回答证据粒度更细的结果粒度细的查不到才退回 Poem 的粗粒度关系。图模型比关系模型强的地方也在这里加一跳就是一次新查询不需要改表结构。比如从“李白写了哪些诗”到“这些诗里哪些提到过酒”只在中间加一个 Imagery 节点就能实现。2.3 属性取舍哪些进字段、哪些进关系属性分配上没有万能公式但我习惯遵守三条原则。第一只有“经常被拿来过滤或排序”的字段才作为独立属性。比如 genre、dynasty 值得作为 Poem 属性因为用户常问“有哪些五言绝句”而作者的详细生平小传属于低频展示字段放进 bio 字符串即可不需要为它单独建索引。第二长文本和检索文本要分开。poem.content 保留原文排版用于展示同时生成一个 verse_text 字段去掉全角空格和标点只负责匹配。清洁字段在导入时算好不要在查询时再用函数现场清洗否则每次查询都白付一遍 CPU。第三关系到哪层、证据到哪层就在那层放属性。WROTE 关系的 confidence 只在多作者存疑时出现不要在 Poem 节点上也存一个“作者可信度”。属性重复放在两个地方早晚会不一致这是图谱项目里很典型的“玄学 bug”。别名同理如果别名只是别名放进 Poet 的 aliases 数组如果别名本身还有生平或关系数据才考虑提成独立 Alias 节点。提示关系唯一性在 Neo4j 里没有内置约束脚本幂等性要靠导入方式保证这一点第 3 章会专门展开。3. Neo4j 建库实战约束、批量导入与索引对齐3.1 先建唯一约束给实体去重兜底拿到 neo4j.zip 这类项目包时我一般先找初始化脚本而不是直接跑 Python 端。把项目打包分发给别人时初始化顺序决定了对方能不能五分钟跑通。我的组织方式固定为约束脚本在前、CSV 数据其次、问答代码最后。Neo4j 的节点去重没有天然主键MERGE 依赖你指定唯一的属性。所以建库第一条语句应该是约束而不是 LOAD CSV。约束能同时带来性能收益WHERE name $name 的查询会直接走索引。CREATE CONSTRAINT poet_name IF NOT EXISTS FOR (p:Poet) REQUIRE p.name IS UNIQUE; CREATE CONSTRAINT poem_pid IF NOT EXISTS FOR (poem:Poem) REQUIRE poem.pid IS UNIQUE; CREATE CONSTRAINT verse_vid IF NOT EXISTS FOR (v:Verse) REQUIRE v.vid IS UNIQUE; CREATE TEXT INDEX poem_title_index IF NOT EXISTS FOR (poem:Poem) ON (poem.title); CREATE TEXT INDEX verse_text_index IF NOT EXISTS FOR (v:Verse) ON (v.text);注意 REQUIRE 是 Neo4j 5.x 的语法如果用 4.x要换成ASSERT p.name IS UNIQUE。IF NOT EXISTS让脚本可以重复执行适合放到项目初始化的文件里。TEXT INDEX 是给 poem.title 和 verse.text 这类支持子串匹配的字段用的普通 B-tree 索引对CONTAINS帮不上忙。3.2 LOAD CSV 两段式导入先节点后关系导入最大的坑在于关系必须引用已存在的节点。常见做法是分两遍跑第一遍只建节点第二遍建关系。诗人的 CSV 大致长这样name,aliases,courtesy_name,birth_year 李白,太白|青莲居士,,701 杜甫,子美|少陵野老,字子美,712节点导入语句用 MERGE 去重再用 ON CREATE 写初始值LOAD CSV WITH HEADERS FROM file:///poets.csv AS row MERGE (p:Poet {name: trim(row.name)}) ON CREATE SET p.aliases split(coalesce(row.aliases, ), |), p.birth_year toIntegerOrNull(row.birth_year);split 把“太白|青莲居士”拆成数组后续别名匹配可以直接用coalesce 处理空值避免 null 字段进图。诗作数据类似pid 尽量保持稳定就算作者名改成繁体pid 不变约束就不会制造重复诗作。pid,title,poet,dynasty,genre,content p0001,静夜思,李白,唐,五绝,床前明月光 疑是地上霜... p0002,登高,杜甫,唐,七律,风急天高猿啸哀...关系导入一进来就要面对重复执行的问题LOAD CSV WITH HEADERS FROM file:///poems.csv AS row MATCH (p:Poet {name: trim(row.poet)}) MERGE (poem:Poem {pid: row.pid}) ON CREATE SET poem.title trim(row.title) MERGE (p)-[:WROTE]-(poem);最后一行是完整路径的 MERGE不是先 MATCH 再 CREATE。如果写成MATCH (p), (poem) CREATE (p)-[:WROTE]-(poem)脚本跑第二遍时 WROTE 关系就会直接翻倍而 MERGE 路径不会。数据量大时可以在 LOAD CSV 前面加USING PERIODIC COMMIT 500控制事务大小避免一次事务写太多内存爆炸。3.3 索引对齐查询模式不是每个字段都值得建索引索引不是多多益善。古诗词问答里重复度高的查询模式只有几类我按查询模式决定建什么索引查询模式应该用的索引MATCH (p:Poet {name: 李白})唯一约束自动建索引WHERE poem.title CONTAINS 静夜TEXT INDEXpoem.titleWHERE v.text CONTAINS 明月TEXT INDEXverse.textMATCH (poem:Poem {genre: 五绝})B-tree Indexgenre如果用户经常问“某朝代的诗”可以把 dynasty 加进 Poem 的 B-tree 复合索引但如果只是偶尔过滤复合索引带来的写入开销就不划算。导入完成后用EXPLAIN看一条查询是否命中索引EXPLAIN MATCH (v:Verse) WHERE v.text CONTAINS 明月 RETURN v.vid。执行计划里出现 NodeIndexSeek 或 NodeIndexScan 说明索引生效出现 NodeByLabelScan 则说明在扫全量节点需要检查字段名和索引名是否对齐。中小型 Demo 保持上述三四个索引即可中文场景里 TEXT INDEX 对子串查找的帮助有限数据量到几十万行以后我一般把诗句全文挪进检索系统或向量索引让 Neo4j 专心做关系多跳。4. 问答核心用 Python 把用户问题翻译成 Cypher4.1 实体识别词典优先于分词模型问答的第一步是把用户问题里的“李白”“明月”抓出来。很多人一上来就上 BERT 序列标注其实古诗词人名、意象是封闭集合词典匹配更快、更可控也更容易解释给其他人听。import re POET_NAMES [李白, 杜甫, 苏轼, 李清照, 辛弃疾] IMAGERY_WORDS [明月, 剑, 酒, 雪, 梅花, 孤帆] ALIAS {太白: 李白, 子美: 杜甫, 东坡: 苏轼, 稼轩: 辛弃疾} def recognize_entities(text): text re.sub(r[。、\s], , text) found {poet: None, imagery: None} for alias, name in ALIAS.items(): if alias in text: found[poet] name for name in POET_NAMES: if name in text: found[poet] name for word in IMAGERY_WORDS: if word in text: found[imagery] word return found这段代码刻意没用 jieba原因在于“床前明月光”这类句子很容易被切成“床前”“明月”“光”而“明月”作为意象词必须整体命中直接子串匹配在小规模场景反而更鲁棒。如果你是做课程设计可以把 POET_NAMES 改成从数据库动态加载启动时查一次全量诗人名内存里维护一份名单这样新增诗人就不用改代码。4.2 意图识别几组关键词就能覆盖七成问题实体识别解决“提到谁”意图识别解决“要问它什么”。基于规则的意图分类只要关键词设计得清楚在小规模问答里比模型更实用def classify_intent(question, entities): if entities[poet] and any(k in question for k in (诗, 作品, 写)): return poet_poems if entities[imagery] and any(k in question for k in (诗, 句, 含, 提到)): return poem_by_imagery if entities[poet] and any(k in question for k in (介绍, 生平, 谁)): return poet_info return fallback注意意图判断的顺序先判断带实体的意图把“李白写过哪些诗”和“哪些诗含明月”分开拿不准的走 fallback明确告诉用户答不上来而不是硬套一个模板返回空结果。这套方法能覆盖课程设计和 Demo 的七成常见问法剩下的交给后续同义词扩展和模板扩充。4.3 模板映射到 Cypher用参数而不是拼字符串意图确定以后把槽位填进预写好的 Cypher 模板。这里最关键的安全习惯是使用参数$poet、$imagery不要直接把用户输入拼进查询串TEMPLATES { poet_poems: ( MATCH (p:Poet) WHERE p.name $poet MATCH (p)-[:WROTE]-(poem:Poem) RETURN poem.title AS title ORDER BY poem.pid LIMIT $limit ), poem_by_imagery: ( MATCH (v:Verse)-[:HAS_IMAGERY]-(i:Imagery) WHERE i.name $imagery MATCH (v)-[:CONTAINS]-(poem:Poem) RETURN poem.title AS title, v.text AS line LIMIT $limit ), poet_info: ( MATCH (p:Poet) WHERE p.name $poet RETURN p.name AS name, p.era AS era, p.birth_year AS birth_year ), }LIMIT $limit 是防手滑的重要手段。图查询如果忘写 LIMIT遇上“李清照写了多少首词”这类大结果集会把几百行一次性拉进内存。默认限制 10 条足够展示时再提示总数。模板里只写 MATCH 和 RETURN天然不会产生写操作这是基于模板方案对比大模型生成方案的一大优势。4.4 问答主流程与答案格式化把上面的函数串起来就是一个最小可运行的问答类from neo4j import GraphDatabase class PoemQA: def __init__(self, uri, user, password, databaseneo4j): self.driver GraphDatabase.driver(uri, auth(user, password)) self.database database def answer(self, question, limit10): entities recognize_entities(question) intent classify_intent(question, entities) if intent fallback: return 没听懂试试问我「李白写过哪些诗」或「带明月意象的诗句」 cypher TEMPLATES[intent] with self.driver.session(databaseself.database) as session: records session.run(cypher, **entities, limitlimit).data() if not records: return 没有查到换个实体再试试 return self._format(intent, records) def _format(self, intent, records): if intent poet_poems: return 找到 %d 首%s % (len(records), .join(r[title] for r in records)) if intent poem_by_imagery: return 例如「%s」出自《%s》 % (records[0][line], records[0][title]) if intent poet_info: r records[0] return %s%s生年约 %s % (r[name], r[era], r[birth_year])session.run 的后续参数会把同名变量传给 $poet、$imagery、$limit天然避免拼接注入。服务退出时记得调用 self.driver.close()否则容器环境里连接数会缓慢堆积。如果你之后接入大模型生成 Cypher这条只读白名单和参数化习惯依然适用只是把模板换成动态生成后再做一遍校验。5. 避坑古诗词问答系统最容易翻车的 5 个环节5.1 字号没进图问“子美的诗”直接落空现象用户输入“子美写了哪些诗”实体识别把“子美”映射成杜甫但数据库里只有 name杜甫WHERE p.name $poet 查不到。原因建模时只给 Poet 设置了 name字、号、别称全部漏掉。古诗词领域特别明显读者习惯用“太白”“东坡”“幼安”称呼诗人而数据源里往往只给了本名。解决给 Poet 增加 aliases 数组属性导入时把字、号、别称全部写入识别阶段先查别名字典再查正名识别成功后统一转成规范名。后续再遇到新别称只需要扩充词典不需要改图结构。这里还有一个隐藏细节用户说“李白字太白给我讲讲他”子串会同时命中“李白”和“太白”我的做法是别名映射只做一次后续统一用规范名不允许一个字段里同时出现两个候选值。5.2 导入脚本跑两遍WROTE 关系翻倍现象初始化脚本执行第二次诗作节点数量不变WROTE 关系数量变成两倍。这种问题肉眼很难发现因为查询结果看起来是一样的只有统计关系数时才会吓一跳。原因节点用了 MERGE 去重关系却用了 CREATE 或者先 MATCH 再 CREATE。解决关系必须用完整路径 MERGE例如MERGE (p)-[:WROTE]-(poem)并确保两端已经通过唯一属性匹配。想把当前数据彻底重置就显式删除关系再重导MATCH (:Poet)-[r:WROTE]-(:Poem) DELETE r。我建议在每次导入后跑一条固定检查语句MATCH (:Poet)-[r:WROTE]-(:Poem) RETURN count(r)然后和 CSV 行数比对把这句话写进测试脚本关系翻倍的问题会在第一次跑数据时就暴露。5.3 “床前明月光”把“床”识别成意象现象用户问“哪些诗写到床”系统返回《静夜思》还把“床”当成核心意象。单看结果好像没错但问“床”明显不是用户真正关心的诗歌主题。原因意象词典把常用字“床”收进去了而“床”在这句里更多是生活物件并非稳定情感意象子串匹配又让它命中。解决把“床”这类歧义高频词移出意象词典加入停用词表对意象至少要求两个字单字词除非人工确认否则不进词典。更稳妥的是维护一份负例清单每次问答抽测时把误报记进去迭代几轮后准确率会明显上升。不要指望自动扩展的意象词典能一步到位古诗词意象“酒、月、柳、雁”看着简单落到具体句子时边界非常模糊。5.4 版本异文导致诗句匹配不上现象用户背出“床前明月光”库里的古本却写作“床前看月光”CONTAINS 匹配失败同样的情况还有“唯见长江天际流”和“惟见长江天际流”。原因同一首诗流传过程中存在异文教材通行本和古籍版本不一致用户背的版本往往和入库版本不同。解决入库时额外生成一个 search_text 字段统一做繁体转简体、去标点、异体字映射并把常见异文归一到同一个规范形式查询时也使用同样的规范化函数。字段在导入阶段算好查询阶段不再做清洗性能更可控。展示字段保留原文检索字段负责匹配两者各司其职就不会出现“为了兼容异文把界面也改成错别字”的尴尬。5.5 生成式 Cypher 不受控白名单和只读权限现象接入大模型自动写 Cypher 后某次返回了 DELETE 语句差点清空整张图。原因让模型生成的 Cypher 直接跑在管理员账号上模型“幻觉”出了非查询语句。解决为问答服务单独建账号并在应用层加白名单校验。如果用的是 Neo4j Enterprise可以建只读角色CREATE ROLE qa_reader; GRANT TRAVERSE ON GRAPH * TO qa_reader; GRANT READ {name} ON GRAPH * TO qa_reader; CREATE USER qa_robot SET PASSWORD only-read-password SET PASSWORD CHANGE NOT REQUIRED; GRANT ROLE qa_reader TO qa_robot;注意角色管理在 Neo4j Community 版不可用社区版主要靠应用层白名单和参数化兜底。同时应用层拦截第一关键词import re def assert_readonly(cypher): if not re.match(r^(MATCH|CALL)\b, cypher.strip().upper()): raise ValueError(only read query is allowed)参数化是第一道防线白名单是第二道只读角色是第三道。三层都上不要嫌多。6. 进阶让系统经得起追问而不是只答预设问法6.1 问法归一化把“诗”和“作品”看成同一个意图规则问答最常见的瓶颈是同义替换。可以把近义词在入口处归一化SYNONYMS {诗: 作品, 诗句: 句子, 诗篇: 作品, 提到: 含} def normalize_question(question): for src, dst in SYNONYMS.items(): question question.replace(src, dst) return question在 recognize_entities 之前调用 normalize_question原本“杜甫的诗篇有哪些”会转成“杜甫的作品有哪些”直接命中已有意图。每加一个同义词就等于扩展一批问题覆盖比训练意图分类模型更快见效。6.2 用 PROFILE 复查每次新增查询新增一个查询模板后不要只跑一遍看结果对不对还要看执行计划。比如PROFILE MATCH (v:Verse)-[:HAS_IMAGERY]-(i:Imagery {name:明月}) RETURN v.text LIMIT 10;如果 db.hits 接近图谱里 Verse 节点总数说明索引导航没有生效查询在扫全量节点。此时检查 Imagery 的 name 上有没有唯一约束或索引以及 Cypher 里是否写成了WHERE i.name $name而不是WHERE i.name CONTAINS $name。前者才走索引后者只能子串匹配。这个习惯能帮你发现很多“结果对但性能差”的隐患。6.3 把词库和导入脚本纳入版本管理最后给一个建议诗人的别名表、意象词典、停用词表、CSV 转 Cypher 的脚本全部放进 Git。新数据进来先跑一遍LOAD CSV ... RETURN count看行数是否符合预期再跑一遍导入脚本新增意图时在同一批回归问题上跑一遍确认“诗”和“诗句”不会答混。我现在的做法是每次扩充前先把 CSV 里的全角空格和空行洗掉再跑约束脚本生成式 Cypher 只允许在带只读账号的测试环境里试。这套流程是从几次翻车里换来的血泪经验希望帮到你。本文还有配套的精品资源点击获取