拓冰建站拓冰建站
首页 / 资讯中心 / 正文

古诗知识图谱问答(KBQA)实战:从问句解析到Neo4j查询优化

简介面向唐诗领域的知识图谱问答系统poemKBQA完整项目资源适合NLP、知识图谱初学者和相关课程设计参考也可作为领域问答原型搭建的起点。包内共26个文件以6个Python脚本为核心覆盖问题解析、实体链接、图谱查询等关键环节另有ttl/nt/owl格式的知识图谱数据文件、SQL建库脚本和说明文档压缩包仅860KB结构清晰、易于本地部署。当前已有277人学习下载。项目完整实现了从自然语言问题到SPARQL查询、再到答案返回的KBQA流程可直接运行体验“诗句查询—诗人信息检索—诗歌背景问答”等典型场景代码模块划分明确便于替换数据扩展至其他领域。对初学者而言是串联词法分析、句法分析、知识匹配与答案生成等知识点的高质量实战样例。无论是毕业设计还是个人学习都能借此快速上手。1. poemKBQA 不是一次简单的 nl2sql古诗问句必须先过知识图谱这一关把一首诗的标题、作者、名句和主题装进表格再让用户用自然语言去查这看起来只是一次常规的问答系统开发。但真正动手做过古诗领域的 KBQAKnowledge Based Question Answering知识图谱问答之后你会发现瓶颈不在存储也不在召回而在「问句里的实体和关系根本对不上库里的 schema」。用户问「李白的诗里哪句写月亮」你的库里存的是「作者」「作品」「诗句」三个节点但「写月亮」是一个主题标签还是从诗句内容推断出来的关系这决定了你走图谱查询还是走文本检索。poemKBQA 这个标题把范围卡得很准古诗poem加知识图谱KG加问答QA。它的本质任务是「古诗领域的自然语言问句到图查询的转换」中间的差异点在于古诗词表达的高度省略和隐喻导致实体识别和关系映射远比通用百科问答困难。这篇文章按「数据建模 → 问句解析 → 存储查询 → 评测迭代」的顺序把一套可落地的古诗 KBQA 方案拆开讲覆盖从 schema 设计到 badcase 回归的完整链路。2. 古诗词知识图谱的 schema 设计与实体关系抽取 —— poemKBQA 的数据底座2.1 四类核心实体诗人、作品、诗句、典故的边界怎么定刚接触古诗 KBQA 的人最容易犯的错是把「诗句」既当实体又当属性。比如「床前明月光」这句诗它既是《静夜思》的一个属性值又是用户问句里的一个查询对象。如果一个实体同时承担两种角色图查询时要么多一次类型判断要么直接在关系上产生歧义。我在 poemKBQA 里习惯把实体分成四类诗人Poet、作品Work、诗句Verse、典故Allusion。其中「诗句」单独建实体和「作品」用belongs_to关系连接和「诗人」用written_by关系连接这样问「这句诗谁写的」和「这首诗里有什么名句」都能用统一的关系路径表达。典故单独成类是后期才补的因为「庄周梦蝶」「折柳送别」这类文化符号在问句里出现频率极高而且不像诗人名那样能靠词典覆盖。四类实体之外还需要属性。属性不建节点直接挂在对应节点上诗人的生卒年、字号、朝代作品的体裁、创作年份诗句的平仄、押韵典故的出处原文。属性尽量扁平化不要为「朝代」「体裁」单独建节点除非你需要按朝代聚合统计否则查询时多一跳关系性能就多一分损耗。2.2 从原诗文本到三元组的抽取流水线图谱的原始数据来源通常是《全唐诗》或《全宋词》的纯文本格式大概是「卷号 作者 诗题 正文」。第一步先把文本按记录切分这一步不需要模型正则加人工抽检就够了。第二步是给每首诗切出诗句列表注意区分五言、七言和杂言按标点切分后过滤空串即可。第三步才是抽取的核心把「作者 → 作品 → 诗句」这条链建起来顺便从诗的注解和编者按里识别典故。我一般用词边界加词典匹配来做实体识别不直接上 BERT 序列标注因为古诗词领域标注数据太少预训练模型在通用领域的实体边界习惯和这里差异很大——比如「李白乘舟将欲行」里的「李白」是人名但「李花白」里的「李白」是字面组合上下文完全不同。词典匹配虽然粗暴但胜在可控、可解释、好回滚。# poem_entity_extract.py import re import json poet_dict {李白: {dynasty: 唐, alias: [青莲居士, 谪仙人]}, 杜甫: {dynasty: 唐, alias: [少陵野老]}} work_pattern re.compile(r《(.?)》) verse_delimiters 。 def extract_poem_record(raw_text): records [] for block in raw_text.strip().split(\n): # block 形如: 唐·李白·静夜思·床前明月光疑是地上霜。 parts block.split(·) if len(parts) 4: continue poet_name, work_title, verses_raw parts[1], parts[2], parts[3] verses [v for v in re.split(verse_delimiters, verses_raw) if v] records.append({ poet: poet_name, work: work_title, verses: verses, poet_alias: poet_dict.get(poet_name, {}).get(alias, []) }) return records records extract_poem_record(open(poems.txt, encodingutf-8).read()) with open(poem_triples.json, w, encodingutf-8) as f: json.dump(records, f, ensure_asciiFalse, indent2)这段代码做了三层事情按·分隔符切分原始文本再用标点把诗句从正文里拆出来最后把诗人的别名一起带进结构化结果里。参数poet_dict是你人工维护的诗人词典work_pattern这行在演示代码里没有用到但在真实抽取场景中它是从诗文正文里反查作品名引用的工具比如「《静夜思》中的思乡之情」这里就需要把书名的引用和诗句实体区分开。切分诗句时用verse_delimiters作为正则字符集注意。和都必须保留因为一句五言诗可能以逗号在中间断开。2.3 关系的最小集不要按用户问法设计关系关系设计遵循一个原则能推断的关系不建能遍历的关系不冗余。用户会问「李白的诗里哪首最豪放」「豪放」是风格标签如果你把「豪放」建成了诗和诗人之间的关系那「婉约」「雄奇」「悲壮」也要建关系会爆炸。正确做法是给作品加一个style属性查询时按属性过滤。poemKBQA 的关系集控制在 12 条以内written_by作品→诗人、belongs_to诗句→作品、contains作品→诗句、references作品→典故、mentioned_in典故→作品、born_in、died_in诗人→地点、friend_of、teacher_of诗人→诗人以及predecessor_of这类关系。表 1 是常见问句和对应关系路径的映射。用户问句示例关系路径查询类型杜甫写过哪些诗(诗:Work)-[written_by]-(人:Poet)实体到集合这句诗出自哪首诗(句:Verse)-[belongs_to]-(诗:Work)实体到实体李白和杜甫是什么关系(人:Poet)-[friend_of]-(人:Poet)实体关系到实体哪些诗引用了庄周梦蝶(诗:Work)-[references]-(典:Allusion)集合反向查询这张表同时也决定了问句解析层的意图类型。关系设计固定之后问句解析的槽位slot就跟着固定将来加新关系只需要扩展映射表不需要改解析框架。3. 问句解析与 SPARQL 生成poemKBQA 问答系统的主链路3.1 意图分类和槽位填充先分四类再谈复杂模型自然语言问句进系统之后我第一件事不是做实体链接而是先做意图分类。古诗领域的问句表面形式千变万化但落到查询模式上只有四大类查实体属性「李白字什么」、查集合「杜甫的诗有哪些」、查关系「李白和杜甫见过吗」、查条件过滤「写月的诗有哪些」。先分类的好处是能缩小实体识别的范围。拿「床前明月光」这句来说如果意图是「查实体属性」那「床前明月光」应该被识别为诗句实体如果意图是「查写月亮的诗」那「明月光」就变成了主题词而不是实体。同一个字符串在不同意图下要进不同的处理分支这个矛盾用规则或者模板比用模型更好控制。# intent_and_slots.py INTENT_RULES [ # (正则, 意图类型, 槽位名) (r(.?)写的?(?:诗|作品|词), query_works, poet), (r(.?)和(.?)是?(什么关系|什么交情), query_relation, poet,poet), (r谁写的, query_poet_of_verse, verse), (r(?:写|描|咏)(.?)的?(?:诗|诗句|词), query_by_theme, theme), ] def parse_question(question): for pattern, intent, slots in INTENT_RULES: m re.search(pattern, question) if m: values m.groups() return {intent: intent, slots: list(zip(slots.split(,), values))} return {intent: unknown, slots: []}INTENT_RULES里的正则顺序就是匹配优先级我在实际使用中会把「谁写的」这类强信号规则放在前面因为它几乎没有歧义。注意query_by_theme这条规则里主题词是从「写月」「咏梅」这种动宾结构里提取的它不是实体而是之后要传给检索层的关键词。如果你把主题词误送进实体识别就会去图谱里找一个叫「月」的节点结果当然是空。槽位填充之后实体链接再针对poet槽做别名归一化比如把「青莲居士」「李太白」统一映射到「李白」。这里还是查词典不需要向量召回因为诗人数量级只有几千规则覆盖的复杂度远低于语义模型。3.2 模板驱动的 SPARQL 生成和它的边界意图和槽位确定之后SPARQL 生成就是简单的模板拼接。这里要澄清一个常见误解KBQA 不等于一定要做语义解析成完整 SPARQL。对于固定 schema 的领域图谱生成一个带参数的 SPARQL 模板比让模型从头生成查询语句稳定得多尤其是古诗领域的谓词数量极少模板化几乎不会损失表达能力。# query_works_by_poet.sparql PREFIX poem: http://poemkbqa.org/ontology# SELECT ?work WHERE { ?poet poem:name 李白 ; poem:written_by ?work . } LIMIT 100这个模板对应「李白的作品」这类意图poem:name是诗人节点的属性谓词poem:written_by是关系谓词。注意?poet后面用分号连接表示?poet poem:name 李白和?poet poem:written_by ?work是同一个主语的两个三元组这种写法写出来更紧凑执行效率与展开写法没有差别。LIMIT 100是必须的否则「杜甫的诗」能返回上千条对后续渲染和网络传输都是压力。模板化方案的边界在于复杂问句比如「王维的诗里有没有写秋天的」这类问题需要同时处理「诗」和「秋天」两个条件。我一般把这种问题拆成两步先用模板查作品集合再用主题关键词做二次过滤而不是试图写一条大而全的 SPARQL。SPARQL 模板只做「结构化约束」文本相关性判断交给检索层。3.3 大模型辅助的定位改写问句不生成查询最近基于大模型做知识图谱问答的讨论很多deepseek 这类模型在古诗理解和文风改写上确实比规则强。但我建议把大模型的角色限定在「问句改写」和「意图兜底」不要让它直接输出 SPARQL 或 Cypher。原因有两层一来生成式模型输出的查询语句没法保证谓词和 schema 对齐出错时还难排查二来模板查询的延迟在几十毫秒级别大模型单次推理耗时按秒计把它放在主链路里整个系统就没法上线。实际用法是规则匹配不到意图时拿问句去大模型做一次改写比如用户说「那个写静夜思的人还写了啥」模型改写成「李白还有哪些作品」再走一遍规则和模板。如果改写后还是命中不了模板直接返回「暂不支持该问题」比硬生成一条错查询对用户更友好。4. 图数据库选型与查询执行Neo4j 索引、缓存与 overall 性能调优4.1 Neo4j 还是 RDF 三元组库存储选型取决于你的 SPARQL 占比前面用 SPARQL 举例但存储层不一定选 RDF 三元组库。poemKBQA 这类场景的 schema 固定、关系深度浅最多三层用属性图更顺手原因有两个属性图允许在查询时直接带出属性值不必像 RDF 那样每个属性都写成一条三元组属性图的路径查询表达更接近人的直觉排查时看图更快。我这边用 Neo4j 做主要存储。如果你已经有现成的 Fuseki 或 Virtuoso 三元组库也不必迁移只要在应用层把 SPARQL 查询结果映射成统一的 JSON 结构即可。下图是我自己整理的选型表供你参考。对比项Neo4j属性图Apache Jena FusekiRDF数据建模节点 关系 属性三元组查询语言CypherSPARQL适合场景固定 schema、深关系遍历开放 schema、本体推理全文本检索需要额外接 ES 或 Lucene依赖外部索引运维成本单机版部署简单内存配置敏感古诗 KBQA 的查询大多数是「指定一个实体找相邻实体」这类查询在属性图上就是一次MATCH加WHERE在 RDF 上要写多条三元组模式后者可读性和调试成本明显更高。但如果你的下游需要做本体推理比如「凡是唐朝诗人都是古人」这类传递推理RDF 三元组库的推理机是现成优势这一点要想清楚再选。4.2 给实体和关系建立索引Cypher 里的实战配置Neo4j 建索引的原则和关系型数据库一样查询里高频WHERE的字段必须加索引否则全库扫描会让查询延迟从毫秒级变成秒级。古诗图谱的数据量不大全唐诗约五万首建好索引之后大多数查询应该压到 50 毫秒以内。CREATE CONSTRAINT poet_name IF NOT EXISTS FOR (p:Poet) REQUIRE p.name IS UNIQUE; CREATE INDEX work_title_index IF NOT EXISTS FOR (w:Work) ON (w.title); CREATE INDEX verse_contains_text_index IF NOT EXISTS FOR (v:Verse) ON (v.text); CREATE INDEX allusion_name_index IF NOT EXISTS FOR (a:Allusion) ON (a.name);第一条是约束不是索引它对Poet节点的name属性加了唯一性约束同时会自动创建对应的索引目的是防止图谱里出现两个「李白」。后面三条是针对Work、Verse、Allusion的属性索引分别对应作品标题查询、诗句原文查询和典故名称查询。注意Verse的索引建在text属性上这个属性存的是整句诗精确匹配场景居多如果要做模糊匹配LIKE 或 CONTAINSNeo4j 的 B-tree 索引帮不上忙需要另接全文索引。查询层还要做一层缓存常用问题比如「静夜思的作者是谁」这类热门查询key 用规范化之后的问句去停用词 实体别名归一化value 存结果 JSONTTL 设 24 小时。实际实践中缓存命中率能到 30% 左右热门前 100 条问句的响应时间可以稳定压在 20 毫秒以下。4.3 从问句到 Cypher 的适配层和超时保护在第 3 章的 SPARQL 模板之外Neo4j 场景要写一套对应的 Cypher 模板。适配层是一个字典映射意图类型到 Cypher 模板函数函数接收槽位填充后的参数返回带参 Cypher 语句。下面这条是「查某位诗人的作品」的 Cypher 版本。MATCH (poet:Poet {name: $poet_name})-[:written_by]-(work:Work) RETURN work.title AS title, work.style AS style ORDER BY work.create_year DESC LIMIT $limit这里用$poet_name和$limit做参数绑定不要用字符串拼接嵌入查询原因不只是防止注入更重要的是 Neo4j 的参数化查询可以利用已编译的执行计划性能比拼接语句高一截。ORDER BY work.create_year是可选的排序条件「杜甫的诗里最晚写的」这类问句会命中这个排序模板先按年份排序再取第一条即可。另一个必须处理的细节是超时和空结果。古诗领域用户问句的随意性很高「李白写过什么」和「李白生平有几个孩子」都是合法问题但后者如果图谱里没有数据查询返回空结果。处理空结果的策略是兜底话术模板比如「图谱中暂无相关信息换个说法试试」不要让前端渲染一个空页面。Cypher 查询统一设置timeout为 1000 毫秒超过就返回超时错误码由上层走意图改写流程不要无限等待。5. 评测集与 badcase 驱动迭代让 poemKBQA 的准确率从 60% 到 85%5.1 构造 200 条评测问句的几个原则没有评测集的问答系统就是盲人摸象poemKBQA 至少需要一份覆盖实体替换、省略表达、指代消解三种类型的评测集。实体替换是把「李白」换成「青莲居士」「谪仙人」省略表达是把「杜甫写的诗」说成「杜甫的诗」甚至「杜甫作品」指代消解是「这首诗的作者还写过什么」中的「这首诗」要回到上一轮对话里去解析。我建议评测集最少 200 条每类问句按 5:3:2 分配。评分标准不要用模糊的「语义等价」直接定义三档完全命中图谱返回的实体集合与标注答案一致、部分命中答案集合多或少但包含正确答案、未命中。统计指标只要两个Precision1 和 Recall10前者看第一条结果对不对后者看前十条结果里有没有正确答案。5.2 badcase 的三个来源和对应修法实体边界错误是最常见的问题。「月落乌啼霜满天」里的「月落」不是实体「明月」才是但词典里如果只有「月」没有「明月」识别结果就会把「月」当实体去图谱里匹配。这种 badcase 的修法是扩充词典词条和别名表每次发现一个就补一条不要尝试用模型一次性解决所有边界问题。第二个来源是关系谓词选错。「李白的师父是谁」图谱里根本没有teacher_of这个关系只有friend_of系统直接返回空。修法有两个方向在规则层把「师父」「老师」「师从」映射到allusion或mentioned_in关系上或者干脆在问句层提示用户「此信息不在图谱中」。我倾向后者因为强行绑定会让错误答案比空结果更糟糕。第三个来源是条件过滤的排序。「写月亮的诗」系统返回了一堆诗但用户预期是「咏月」主题的诗排在最前。这里要把主题相关度从图谱查询中拆出来用全文检索的评分排序图谱只负责候选集的生成。5.3 一个实用技巧把「新问句 → 改写结果 → 真实 SQL/Cypher」沉淀成回归集每次遇到 badcase不要改完规则就完事。把原始问句、改写后的标准问句、命中的 Cypher 模板、期望答案这四样东西写进回归集。下一次改动实体词典或关系映射之后跑一遍全量回归集看有没有把之前修好的问题重新弄坏。回归集跑完之后输出一份失败问句列表按错误类型分组统计。我一般会追踪一个指标badcase 回归通过率目标是每次迭代之后不降低。如果连续两个迭代周期同一类错误没减少说明规则路线遇到了天花板这时候才考虑引入模型比如用大模型做意图兜底或实体链接重排。这套「评测集 badcase 回归 分类统计」的循环是让 poemKBQA 从 demo 到可用的核心路径比盲目堆功能有效得多。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门