古诗词知识图谱问答系统:从本体设计到Cypher生成实战

发布时间:2026/10/3 8:52:42
古诗词知识图谱问答系统:从本体设计到Cypher生成实战 简介面向自然语言处理与知识图谱初学者及开发者这份以唐诗为应用场景的KBQA问答系统示例完整演示了从问题解析、知识匹配到答案生成的核心流程。压缩包共26个文件约860KB包含6个Python脚本问题分析、查询构造、语料处理等、图谱数据文件.nt/.ttl/.owl、SQL建表脚本及项目说明文档目录结构清晰便于按功能模块研读。已有279人学习下载。借助这套代码与图谱数据读者可以直观理解实体识别、关系映射、SPARQL查询等关键环节系统以诗人、诗名、诗句与创作背景等典型实体构建唐诗知识体系既适合课程设计与毕业设计参考也可将问答思路迁移至历史、科学等其他领域。资源附带的README和映射文件能帮助快速上手并可直接替换数据与规则用于搭建其他垂直领域的问答原型。1. poemKBQA 是做什么的从“查一首诗”到“问懂一首诗”手头有十几万首古诗词最常见的交互还是输入关键词返回列表。poemKBQA 这种面向古诗词领域的知识图谱问答方案解决的是用户用自然语言发问比如“李白在秋天写的五言绝句有哪些”系统从图谱里把答案取回来。难点不在语言学也不在检索排序而在实体、关系、查询模板这三层“对齐”工作上。适合想做诗词教育知识库、语文教辅智能问答或者想搭一套最小可用 KBQA 练手的人。我常跟同事说先别急着上模型先把数据层的地基打牢这条路才走得通。2. 古诗词知识图谱怎么搭本体设计决定问答的天花板2.1 先把 Schema 定出来诗、作者、地名、意象四类实体知识图谱构建最容易被忽视的一步是本体设计。很多团队拿到诗库就开始导 Neo4j结果问答阶段发现“问作者查不到”“问地名全是歧义”回头改数据模型代价比想象中大得多。poemKBQA 的第一步应该落在实体类型定义上而不是写代码。我一般会把古诗词领域的最小实体集控制在四类诗、诗人、地点、意象。再加一个时间节点实体做辅助也可以但早期不建议上因为朝代和年份的对齐问题会很早暴露出来影响主流程推进。实体类型代码命名核心属性备注诗poemtitle, dynasty, genre, text一首诗一个节点诗人poetname, birth_year, death_year, alias一人一个节点地点placename, modern_name, admin_region古今地名对照意象imagename, alias, category月、柳、酒、舟等这四类实体的选择不是拍脑袋。问答系统的高频问题集中在“谁写的”“在哪写的”“写了什么意象”“什么体裁”四类实体加五类关系基本能覆盖六成以上的提问。实体类少对齐成本低实体类太多别名表和消歧规则会拖垮整个项目。2.2 关系要能回答“为什么”不只存“作者写了诗”知识图谱和关系数据库最大的区别在于边能承载语义。poemKBQA 里最常见的关系是 poem 到 poet 的“作者”关系但这个粒度远远不够。用户会问“李白为什么总写月”这个问题在图谱上要先走“诗→意象月→出现次数统计”再关联到作者。我的最小关系集是这样设计的关系起点终点示例问答用途创作poetpoem李白→静夜思作者提问包含意象poemimage静夜思→月意象提问发生于poemplace黄鹤楼送孟浩然之广陵→扬州地点提问属于体裁poemgenre绝句、五言律诗体裁提问写作时间poemdynasty/year盛唐时间过滤关系命名要用动词短语不要用 has_a 这类弱语义命名。开发时图数据库里看起来差不多但到了 Cypher 生成阶段语义清晰的关系名能少写很多条件分支。2.3 用规则加词典把原始诗库切成三元组数据清洗阶段从哪来常见的做法是从公开的古诗文语料库拿到 JSON 行数据每条包含标题、作者、正文、注释。这个格式离三元组还差一层转换。我一般会写一个抽取脚本先用词典做实体匹配再用正则在诗题里抓体裁信息。import json import re # data/poems.jsonl 每行一条诗数据 with open(data/poems.jsonl, encodingutf-8) as f: poems [json.loads(line) for line in f if line.strip()] # 意象词典可继续扩充 image_dict [月, 柳, 酒, 舟, 笛, 雪, 江, 云, 风] genre_alias {五绝: 五言绝句, 七绝: 七言绝句, 五律: 五言律诗, 七律: 七言律诗} def extract_triples(poem): 从一条诗记录中抽取三元组返回 (subject, predicate, object) 列表 triples [] title poem.get(title, ) poet poem.get(author, ) text poem.get(content, ) # 作者与诗的创作关系 if poet and title: triples.append((poet, 创作, title)) # 意象匹配宁可少配不能错配 for img in image_dict: if img in text: triples.append((title, 包含意象, img)) # 从标题末尾抓体裁如《黄鹤楼送孟浩然之广陵》是七绝 match re.search(r(七绝|五绝|七律|五律), title) if match: raw_genre match.group(1) triples.append((title, 属于体裁, genre_alias.get(raw_genre, raw_genre))) return triples # 执行抽取 all_triples [] for p in poems[:5000]: # 先跑 5000 条验证效果 all_triples.extend(extract_triples(p)) print(抽取三元组数量:, len(all_triples)) print(样例:, all_triples[:5])这段代码有几个设计点需要说明。image_dict目前是纯字符串匹配没有做分词目的是保证高准确率、低召回率。古诗词问答宁可答不上来不能答错答不上来还有兜底策略答错了用户直接放弃。体裁抽取放在标题上做而不是正文因为七言绝句和七言律诗的格式差异在正文里要靠标点判断太容易出错。normalization 也是这个阶段该做的事。繁体转简体、全角转半角、作者别名表李白也叫“李太白”“谪仙人”不在这里做后面实体对齐阶段统一处理。现在只管抽三元组把图谱的原始边集铺出来。2.4 图谱自检在建库前先检查数据分布实体和关系抽完之后不要急着导入图数据库。先做一次数据分布检查确认图谱不是一边倒的结构。常见做法是统计每个节点的度数比如某个诗人的诗数量是否为 0某个地名关联的诗是否过多。# 统计每个实体的出现次数检查数据倾斜 python3 - EOF import json from collections import Counter with open(triples.jsonl, encodingutf-8) as f: triples [json.loads(line) for line in f if line.strip()] head_count Counter(t[0] for t in triples) print(出现次数最多的10个头实体:, head_count.most_common(10)) EOF这一步不需要写成正式脚本一个临时统计就够。重点是看两个指标第一有没有空头实体也就是文本里有关系但被漏抽的第二有没有超大连通子图比如“月”这个意象挂了上万首诗这种分布会在后续问答路径搜索时造成性能毛刺。出现这些情况不用慌图谱本来就是幂律分布但要心里有数。3. 图谱的落地存储从三元组到 Neo4j 的写入和索引3.1 为什么选图数据库而不是关系表这个选择经常被挑战。三元组数据放进 MySQL 三列表格也不是不能查但问答场景的典型查询是“李白写的五言绝句有哪些”对应 SQL 是两次自连接写起来勉强能忍如果再问“李白写的包含月字的五言绝句”就变成三次连接SQL 复杂度直线上升。知识图谱设计的初衷就是避免这种连接风暴把图结构查询变成路径遍历。我用 Neo4j 而不是更轻量的 RDF 三元组存储原因很简单工业场景下的知识图谱设计大多数选型落在 Neo4j生态成熟、驱动完善、可视化工具现成。RDF 和 SPARQL 更适合学术推理对问答系统的工程化不友好。poemKBQA 这种规模的数据量在百万三元组以内Neo4j Community 版完全撑得住。3.2 写库的四个细节MERGE、约束、索引、中文字段Cypher 写入代码看着简单真正落地时要同时处理幂等、约束、索引三个问题。我第一次写的时候直接一个 CREATE 怼进去跑两遍脚本就多了一倍节点后来换成 MERGE 才解决。MERGE 相当于 Cypher 里的 upsert按 key 匹配已有节点不存在才创建。from neo4j import GraphDatabase URI bolt://localhost:7687 USER neo4j PASSWORD your-password class PoemGraphWriter: def __init__(self): self.driver GraphDatabase.driver(URI, auth(USER, PASSWORD)) def write_triples(self, triples): 将三元组写入 Neo4j重复执行不会产生重复节点 with self.driver.session() as session: for subj, pred, obj in triples: session.execute_write(self._merge_triple, subj, pred, obj) staticmethod def _merge_triple(tx, subj, pred, obj): # lit 结尾表示字面量属性类型不同强行统一成 string query ( MERGE (s:Entity {name: $subj}) MERGE (o:Entity {name: $obj}) MERGE (s)-[r:REL {type: $pred}]-(o) RETURN r ) result tx.run(query, subjsubj, predpred, objobj) return result.single() writer PoemGraphWriter() writer.write_triples(all_triples)这段代码的问题也很明显所有实体都用Entity一个 label没有区分诗、诗人、地点、意象。这是初始化时图省事的选择但后面提问“李白写了哪些诗”就没法按 label 过滤了。所以建库时建议把实体类型一起写进去比如用MERGE (s:Poem {title: $subj})这类写法。上面代码保留了个粗糙版本实际项目里不要照抄这个 label 设计。MERGE 不是银弹。如果三元组里同一个实体名在不同上下文里代指不同事物MERGE 就会错误合并。比如“长安”既是地名又是古称如果只按 name 合并就会把两套关系堆在一个节点上。所以实体对齐应该在 MERGE 前完成而不是靠 Cypher 去消歧。3.3 写库前先建 Index 和约束数据量大时这是救命稻草Neo4j 在数据量小的时候无所谓索引但图谱过万节点后MERGE 的性能下降非常明显。每次 MERGE 都要做一次 name 全表扫描。我通常在建库前先跑一段初始化脚本建索引和唯一约束顺序不能反。CREATE CONSTRAINT poem_title_unique IF NOT EXISTS FOR (p:Poem) REQUIRE p.title IS UNIQUE; CREATE CONSTRAINT poet_name_unique IF NOT EXISTS FOR (p:Poet) REQUIRE p.name IS UNIQUE; CREATE INDEX place_name_index IF NOT EXISTS FOR (p:Place) ON (p.name); CREATE INDEX image_name_index IF NOT EXISTS FOR (i:Image) ON (i.name);约束和索引的区别要理解清楚。约束保证重名节点不会出现写库时直接报错而不是静默覆盖索引加速查询。标题和诗人名字必须上唯一约束因为问答里这两个实体的对齐精度要求最高。地点和意象用普通索引就够了它们天然会大量重复。3.4 快速校验数据是否进库几个必须掌握的 Cypher 查询写完库后先别急着写问答接口做一次图谱完整性校验。我用三个查询检查节点数量、孤立节点、关系分布。// 1. 各类节点数量分布 MATCH (n) RETURN labels(n) AS label, count(*) AS num ORDER BY num DESC LIMIT 20; // 2. 孤立点检查没有关系连接的实体 MATCH (n) WHERE NOT (n)--() RETURN count(n) AS isolated_count; // 3. 关系类型分布 MATCH ()-[r]-() RETURN type(r) AS rel_type, count(*) AS num ORDER BY num DESC;孤立节点是抽取脚本的 bug 重灾区。比如某条诗记录缺少作者字段但脚本又强行把“Unknow”作为作者写进去产生了“Unknown”节点。这类脏数据越早发现越好等问答阶段查出来排查成本会翻好几倍。我在项目里做这步时发现过一个隐蔽问题意象抽取时把“床前明月光”里的“前”也误匹配成方位词导致“前”成了一个意象节点。这种错误靠 Cypher 查询很难发现只能靠人工抽查三元组样例。4. 问答主链路实体识别、意图分类和 Cypher 生成4.1 实体识别先试词典匹配不要让模型背锅问答系统的入口是自然语言问句。用户说“李白写的含‘月’的诗有哪些”第一步要把“李白”识别成诗人实体“月”识别成意象实体。实体识别这块我走过弯路一开始直接上 BERT 序列标注效果时好时坏后来发现古诗词领域的实体边界特别清晰词典和规则足以解决 80% 以上的情况。import re # 实体词典从知识图谱的反向查询得到 POET_DICT [李白, 杜甫, 白居易, 王维, 苏轼] IMAGE_DICT [月, 柳, 酒, 舟, 笛, 雪, 江] PLACE_DICT [长安, 扬州, 洛阳, 金陵, 长安城] def recognize_entities(question): 基于词典的实体识别返回 {实体类型: 实体名} 列表 entities [] for poet in POET_DICT: if poet in question: entities.append((poet, poet)) for img in IMAGE_DICT: if img in question: entities.append((image, img)) for place in PLACE_DICT: if place in question: entities.append((place, place)) # 同一个位置可能出现多个实体按长度排序让最长实体优先 entities.sort(keylambda x: -len(x[1])) return entities q 李白在长安写的含月的诗有哪些 print(recognize_entities(q)) # 输出示例: [(poet, 李白), (place, 长安), (image, 月)]这段代码的问题在于顺序匹配导致的长实体被短实体拆分。比如“长安城”出现在词典里时如果“长安”先匹配结果是两个重叠实体。所以在匹配后要做一次 overlap 清理保留最长匹配。另外实体识别典型的失败场景是“月”字在问句里出现但不是意象比如“六月”的月是时间不是意象。这类歧义要靠意图模板下沉不能只靠实体识别层解决。4.2 意图归一把自然语言问句变成查询意图实体识别出来只成功了一半。还要判断用户想问的是“作者”“意象”“地点”还是“体裁”。这个意图分类我用的是规则加优先级表没有用模型。因为在限定领域里用户提问模式非常固定规则覆盖率高而且可解释性强。意图优先级表我一般按“查询主体”来定而不是按动词。比如“李白写了哪些诗”和“哪些诗是李白写的”前者主语是李白但查询目标是诗后者主语是诗。这两句话都能映射到同一个意图按作者查诗。def classify_intent(question, entities): 基于实体类型和疑问词做意图归一 if 哪些 in question or 什么 in question: has_poet any(e[0] poet for e in entities) has_image any(e[0] image for e in entities) has_place any(e[0] place for e in entities) if has_poet and has_image: return POET_AND_IMAGE_TO_POEM if has_poet: return POET_TO_POEM if has_image: return IMAGE_TO_POEM if has_place: return PLACE_TO_POEM if 作者 in question: return POEM_TO_POET if 多少首 in question: return COUNT_POEM return UNKNOWN意图和实体的组合数不能太多。我控制在七个以内按作者查诗、按意象查诗、按地点查诗、按体裁查诗、查诗的意象、查诗的作者、统计数量。超过七个规则表就开始维护困难每加一种问法都可能影响已有规则的匹配。4.3 用模板映射到 Cypher 查询先固定骨架再放宽边界意图分类完成后的最后一步是生成 Cypher。这一步最忌动态拼接原因不只是注入风险还有可维护性。poemKBQA 场景的 Cypher 模板可以提前定好每个意图对应一个模板函数参数从实体识别结果里填进去。def intent_to_cypher(intent, entities): 将意图与实体映射为 Cypher 查询实体值通过参数传入 if intent POET_AND_IMAGE_TO_POEM: return ( MATCH (p:Poem)-[:CONTAINS]-(i:Image) WITH p, i MATCH (p)-[:CREATED]-(a:Poet) WHERE a.name $poet AND i.name $image RETURN p.title ) if intent PLACE_TO_POEM: return ( MATCH (p:Poem)-[:LOCATED_AT]-(pl:Place) WHERE pl.name $place RETURN p.title ) if intent COUNT_POEM: return ( MATCH (a:Poet)-[:CREATED]-(p:Poem) WHERE a.name $poet RETURN count(p) ) raise ValueError(f不支持意图类型: {intent}) params {poet: 李白, image: 月} cypher intent_to_cypher(POET_AND_IMAGE_TO_POEM, {poet: 李白, image: 月}) print(cypher)Cypher 参数要不要直接用$name这种变量绑定取决于 Neo4j 驱动版本。旧版本对参数化支持不好但现在的驱动都支持得很完善建议全部参数化。模板的返回字段也要固定方便后续统一处理答案格式。4.4 查不到答案时的兜底策略模糊检索和路径推荐问答系统一定会遇到图谱里没有的问题。实体识别出来了Cypher 也执行了结果为空。这个时候直接返回“暂无答案”会让用户流失。我加了一个两级兜底。第一级是模糊检索把实体名换成语义近似的名字再查一次比如“长安”换成“京兆”。第二级是路径推荐返回与问题中实体直接相关的几个实体提示用户换一种问法。def fallback_search(question_entities, driver): 第一级兜底把实体替换为别名再查一次 alias_map {长安: 京兆, 月亮: 月, 太白: 李白} replaced_entities [] for etype, ename in question_entities: new_name alias_map.get(ename, ename) replaced_entities.append((etype, new_name)) # 替换后重新执行意图到 Cypher 的映射 return search_by_entities(replaced_entities, driver)兜底不能无限递归。替换一次没结果就该停最多做两轮。另一个要注意的点是兜底结果要标记为“近似答案”不要把“长安”的“京兆”同名异地混为一谈。古诗词的地名古今对照是重灾区兜底逻辑里宁可降级展示也不能让用户以为京兆就是今天的西安府。5. 落地避坑实体对齐、模板泛化和图查询的五个常见问题5.1 实体对齐问题同一个人有三种写法怎么办实体对齐是知识图谱构建里最脏的活也是 poemKBQA 系统里最影响问答准确率的环节。李白、李太白、青莲居士、谪仙人在诗库里可能分别以不同作者字段出现。我在实际清洗时发现同一个诗人出现四种写法不合并的话“李白写了哪些诗”查出来只有一半结果。解决思路分为两步。第一步是建别名表维护一个规范名到别名的映射。第二步是写归一化逻辑在实体识别和写入图谱入口处统一替换。这里要特别注意一个问题李白的《月下独酌》和《月下独酌四首》是“同一诗题还是不同诗题”不同版本的诗库处理不一样。我建议以诗题全文为唯一键不做截断匹配。5.2 意图模板覆盖问题问法一变就查不到模板规则最怕用户问法漂移。前期只定七个意图模板跑一轮真实用户问答后就发现用户不会按开发者的句式问。他们问“李白写月亮的诗里有没有写酒的”这个问法同时带了两个意象不是单意图能覆盖的。这时需要模板支持多实体组合而不只是多意图。我的做法是不要把意图定义成互斥的而是定义成“过滤条件”的组合。先找到主查询目标再把其余实体全部当作 WHERE 条件拼进去。这样从七个意图变成四个查询目标每个目标挂若干过滤器维护成本反而降低。5.3 图查询性能问题路径一深响应时间就崩问答系统对响应时间的要求比离线分析高。图数据库在一个深度内的查询非常快但一旦路径超过两层比如“李白写的五言绝句中包含月亮的”它要经历诗→作者、诗→体裁、诗→意象三次跳转。在没有索引和查询计划约束的情况下Neo4j 会把候选集先撑大再过滤导致响应时间飙升。我在 Cypher 里用了一个简单手段先用MATCH (a:Poet {name: $poet})把起点收紧再做扩展查询而不是一次写完整个模式。profile指令可以看具体哪一步消耗大建议在调优时每一条模板查询都跑一遍。5.4 别名与同名异义问题长安不是长安地名里的同名异义比人名更隐蔽。“长安”既是唐代都城也是一个普通地名。诗库里“长安”出现几百次但有些诗里的“长安”可能指代遥远的帝都并非实际地理坐标。如果问答系统问“长安在哪里”就答“西安市”对一部分诗来说是对的对另一部分诗就是过度解读。古诗词问答系统通常只做现象级问答不做地理还原验证。我的处理是在知识图谱构建的时间维度上保持克制只保留“古称—今称”一个映射关系不引入地理位置计算。如果用户问到“在哪里”系统展示古今地名对照而不是直接给出城市坐标。这样可以绕开大部分争议。5.5 中文分词造成的踩坑记录中文分词在古诗词场景和现代汉语完全不一样。现代分词工具会把“床前明月光”切得很碎而古诗词里的格式本身就是断句单位。有一次我把诗题“黄鹤楼送孟浩然之广陵”丢给 HanLP分词结果是“黄鹤楼/送/孟浩然/之/广陵”“之”被当成单独词导致实体识别时误以为这是一个人名。解决方法是做一层分词兜底对诗题和正文先按标点和空格切出短句再做实体词典匹配。在用 HanLP 之前先跑一遍自定义词典分词保证“孟浩然”这类人名不会被拆开。分词这块宁可多保留候选不要过早截断。6. 让问答系统真正可用压测集、召回率和图谱瘦身技巧6.1 建一份可以反复跑的问答验证集没有验证集就谈不上迭代。我建议从真实用户问题里累积 100 到 200 条问答对覆盖七类意图每类意图至少 10 条。格式很简单就用 JSONL。{question: 李白写的五言绝句有哪些, expected: [静夜思, 秋浦歌]}验证集的预期答案不要只写一个。因为知识图谱里的答案是全集而用户期望的是经典名篇。所以我会在验证集里额外标注“期望包含”和“允许漏掉”两个字段分别计算召回率和过度召回。问答评估有两套标准一套严格的一套宽容的前期看宽容标准后期逐步收紧。6.2 指标怎么算不要只看准确率问答系统评估最常用的准确率在古诗词场景里会失真。比如系统返回十首诗其中八首符合条件准确率 80%但用户想要的是最著名的几首这里没有考虑相关性和排序。建议加一个覆盖度指标期望结果集与实际结果集的交集占期望结果集的比例相当于归一化的召回率。def evaluate_system(qa_pairs, system_predict): 按意图维度计算召回率平均值 intent_buckets {} for item in qa_pairs: intent item[intent] expected set(item[expected]) predicted set(system_predict(item[question])) recall len(expected predicted) / len(expected) if expected else 0.0 intent_buckets.setdefault(intent, []).append(recall) return {intent: sum(scores) / len(scores) for intent, scores in intent_buckets.items()}我第一次跑这个验证集时发现整体准确率不错但“按意象查诗”的召回率特别低原因是意象词典太薄“笛声”“羌笛”都没收进去。后来把意象词典扩充到一百多个词召回率从不到五成涨到接近八成。指标拆开看才能发现问题在哪一层。6.3 最后一个小技巧对图谱做减法问答系统上线前的最后一个优化不是加数据而是减数据。我把图谱里度数超过某个阈值的节点标记为“热节点”比如“月”这个意象关联了上千首诗它在路径选择时会严重影响性能。做法是给这类节点增加一个freq_level属性Cypher 查询模板里默认不直接展开热节点的全部关系而是等用户明确要求“统计”时才走聚合查询。做减法这步看起来和数据规模矛盾但实际上问答体验会明显改善。用户问的是某几首诗的场景不需要一次性把上千首诗堆在结果页里。我在项目里对“月”“风”“酒”这类高频节点做了同样的处理响应时间降了一半以上。图谱不是越大越好能回答问题的图谱才是好图谱。这些坑都是一个个踩过来的希望帮到你。本文还有配套的精品资源点击获取