古诗知识图谱问答(KBQA)实战:从问句解析到Neo4j查询优化
2026/9/14 14:33:36 网站建设 项目流程

简介:面向唐诗领域的知识图谱问答系统poemKBQA完整项目资源,适合NLP、知识图谱初学者和相关课程设计参考,也可作为领域问答原型搭建的起点。包内共26个文件,以6个Python脚本为核心,覆盖问题解析、实体链接、图谱查询等关键环节;另有ttl/nt/owl格式的知识图谱数据文件、SQL建库脚本和说明文档,压缩包仅860KB,结构清晰、易于本地部署。当前已有277人学习下载。项目完整实现了从自然语言问题到SPARQL查询、再到答案返回的KBQA流程,可直接运行体验“诗句查询—诗人信息检索—诗歌背景问答”等典型场景;代码模块划分明确,便于替换数据扩展至其他领域。对初学者而言,是串联词法分析、句法分析、知识匹配与答案生成等知识点的高质量实战样例。无论是毕业设计还是个人学习,都能借此快速上手。

1. poemKBQA 不是一次简单的 nl2sql:古诗问句必须先过知识图谱这一关

把一首诗的标题、作者、名句和主题装进表格,再让用户用自然语言去查,这看起来只是一次常规的问答系统开发。但真正动手做过古诗领域的 KBQA(Knowledge 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", encoding="utf-8").read()) with open("poem_triples.json", "w", encoding="utf-8") as f: json.dump(records, f, ensure_ascii=False, indent=2)

这段代码做了三层事情:按·分隔符切分原始文本,再用标点把诗句从正文里拆出来,最后把诗人的别名一起带进结构化结果里。参数poet_dict是你人工维护的诗人词典,work_pattern这行在演示代码里没有用到,但在真实抽取场景中,它是从诗文正文里反查作品名引用的工具,比如「《静夜思》中的思乡之情」,这里就需要把书名的引用和诗句实体区分开。切分诗句时用verse_delimiters作为正则字符集,注意都必须保留,因为一句五言诗可能以逗号在中间断开。

2.3 关系的最小集:不要按用户问法设计关系

关系设计遵循一个原则:能推断的关系不建,能遍历的关系不冗余。用户会问「李白的诗里哪首最豪放」,「豪放」是风格标签,如果你把「豪放」建成了诗和诗人之间的关系,那「婉约」「雄奇」「悲壮」也要建,关系会爆炸。正确做法是给作品加一个style属性,查询时按属性过滤。

poemKBQA 的关系集控制在 12 条以内:written_by(作品→诗人)、belongs_to(诗句→作品)、contains(作品→诗句)、references(作品→典故)、mentioned_in(典故→作品)、born_indied_in(诗人→地点)、friend_ofteacher_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 Fuseki(RDF)
数据建模节点 + 关系 + 属性三元组
查询语言CypherSPARQL
适合场景固定 schema、深关系遍历开放 schema、本体推理
全文本检索需要额外接 ES 或 Lucene依赖外部索引
运维成本单机版部署简单内存配置敏感

古诗 KBQA 的查询大多数是「指定一个实体找相邻实体」,这类查询在属性图上就是一次MATCHWHERE,在 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属性加了唯一性约束,同时会自动创建对应的索引,目的是防止图谱里出现两个「李白」。后面三条是针对WorkVerseAllusion的属性索引,分别对应作品标题查询、诗句原文查询和典故名称查询。注意Verse的索引建在text属性上,这个属性存的是整句诗,精确匹配场景居多,如果要做模糊匹配(LIKE 或 CONTAINS),Neo4j 的 B-tree 索引帮不上忙,需要另接全文索引。

查询层还要做一层缓存,常用问题比如「静夜思的作者是谁」这类热门查询,key 用规范化之后的问句(去停用词 + 实体别名归一化),value 存结果 JSON,TTL 设 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 分配。评分标准不要用模糊的「语义等价」,直接定义三档:完全命中(图谱返回的实体集合与标注答案一致)、部分命中(答案集合多或少但包含正确答案)、未命中。统计指标只要两个,Precision@1 和 Recall@10,前者看第一条结果对不对,后者看前十条结果里有没有正确答案。

5.2 badcase 的三个来源和对应修法

实体边界错误是最常见的问题。「月落乌啼霜满天」里的「月落」不是实体,「明月」才是,但词典里如果只有「月」没有「明月」,识别结果就会把「月」当实体去图谱里匹配。这种 badcase 的修法是扩充词典词条和别名表,每次发现一个就补一条,不要尝试用模型一次性解决所有边界问题。

第二个来源是关系谓词选错。「李白的师父是谁」图谱里根本没有teacher_of这个关系,只有friend_of,系统直接返回空。修法有两个方向:在规则层把「师父」「老师」「师从」映射到allusionmentioned_in关系上,或者干脆在问句层提示用户「此信息不在图谱中」。我倾向后者,因为强行绑定会让错误答案比空结果更糟糕。

第三个来源是条件过滤的排序。「写月亮的诗」系统返回了一堆诗,但用户预期是「咏月」主题的诗排在最前。这里要把主题相关度从图谱查询中拆出来,用全文检索的评分排序,图谱只负责候选集的生成。

5.3 一个实用技巧:把「新问句 → 改写结果 → 真实 SQL/Cypher」沉淀成回归集

每次遇到 badcase,不要改完规则就完事。把原始问句、改写后的标准问句、命中的 Cypher 模板、期望答案这四样东西写进回归集。下一次改动实体词典或关系映射之后,跑一遍全量回归集,看有没有把之前修好的问题重新弄坏。

回归集跑完之后,输出一份失败问句列表,按错误类型分组统计。我一般会追踪一个指标:badcase 回归通过率,目标是每次迭代之后不降低。如果连续两个迭代周期同一类错误没减少,说明规则路线遇到了天花板,这时候才考虑引入模型,比如用大模型做意图兜底或实体链接重排。这套「评测集 + badcase 回归 + 分类统计」的循环,是让 poemKBQA 从 demo 到可用的核心路径,比盲目堆功能有效得多。

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

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

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

立即咨询