☰
古诗词知识图谱问答系统:从本体设计到Cypher生成实战
2026/10/3 8:52:40 网站建设 项目流程

简介:面向自然语言处理与知识图谱初学者及开发者,这份以唐诗为应用场景的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", encoding="utf-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", encoding="utf-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, subj=subj, pred=pred, obj=obj) 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(key=lambda 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 查询模板里默认不直接展开热节点的全部关系,而是等用户明确要求“统计”时才走聚合查询。

做减法这步看起来和数据规模矛盾,但实际上问答体验会明显改善。用户问的是某几首诗的场景,不需要一次性把上千首诗堆在结果页里。我在项目里对“月”“风”“酒”这类高频节点做了同样的处理,响应时间降了一半以上。图谱不是越大越好,能回答问题的图谱才是好图谱。这些坑都是一个个踩过来的,希望帮到你。

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

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

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

立即咨询