简介:基于知识图谱的古诗词智能问答Java实现,适合具备一定Java基础、想深入理解知识图谱问答系统搭建流程的开发者或NLP学习者。项目采用Java+SSM+SpringBoot+Maven+React+MySQL,前端接收问句并通过Ajax与后端交互,后端依次完成分词、词性标注、句子抽象化、词向量生成、问题模板匹配与还原,最终结合服务层逻辑访问Neo4j图数据库返回答案,完整演示了从自然语言问句到图谱查询的闭环。资源压缩包共57个文件,包含12个Java源文件、7个SQL脚本、7个JavaScript文件,以及xml、properties、json、css等配置与前端资源,整体大小约543KB,代码结构清晰,覆盖后端业务逻辑、前端页面、数据库脚本及项目配置文件。目前已有2146人学习使用。对于学习者而言,这是一份可直接运行的完整问答项目,可对照源码理解用户字典、HashMap词映射、问题模板训练等关键技术细节,也可基于此扩展新词库与问答模板,快速搭建属于自己的知识图谱问答应用。
1. 知识图谱+古诗词问答,到底比“关键词搜索”强在哪?
做过中文搜索的人都知道,面对“床前明月光”这种原句查询,Elasticsearch 一个 match 查询就搞定了。但用户换成“李白写过哪些思乡诗句”“含有‘明月’的悼亡诗有哪些”“苏轼和辛弃疾的词风有什么交集”,传统检索就吃力了:要么拆词后在结果里做二次过滤,要么在关系型数据库里写一长串 join,查询意图稍微绕一点就翻车。JAVA 实现基于知识图谱的古诗词智能问答,正是把“诗人、诗作、意象、朝代、诗风”建模成实体和关系,让这些问题变成一次图查询,而不是靠关键词猜。这套方案对 java 工程师入门知识图谱构建、研究生做毕设、或者中小型知识问答系统选型都有直接的参考价值,落地成本也控制在单机可运行的范围。接下来我从建模、入库、问句解析一路讲到踩坑记录,尽量给你一套照着能跑通的思路。
2. 先把图谱“建模”做对:古诗词问答的实体、关系与基础设计
2.1 为什么古诗词问答要用知识图谱:从两类查询说起
古诗词领域的问题天然分成两类。第一类是“原句精确查询”,比如“静夜思全文”,这类需求做倒排索引就够了,不需要图谱。第二类是“关系推理查询”,比如“李白的思乡诗”“写月的边塞诗”“杜甫诗中与‘酒’相关的意象”,这类问题的答案藏在多个实体之间的路径里,SQL 也能查,但要从诗句文本里反复解析,查询条件和业务逻辑耦合得很重。知识图谱把“李白—创作—静夜思”“静夜思—表达意象—思乡”“思乡—属于—情感主题”变成显式的三元组,问句就不再是一条 where 条件,而是一次路径检索。
另一个容易被忽略的点是,古诗词语料非常稀疏。一首五言绝句可能只有二十个字,传统文本召回靠字面匹配,遇到“床前明月光”与“举头望明月”之间的意象呼应就失效了。图谱通过“明月”这个意象节点把两首诗关联起来,语义上的近邻关系可以直接用图遍历拿到。这就是为什么我不建议一上来就上向量数据库:古诗词的语义关系大多是有明确结构依据的(作者、时代、诗体、意象),结构化三元组表达更可控,也更容易向业务方解释结果是怎么来的。
2.2 实体与关系的取舍:别把每首诗都建成一个节点
我第一次做这个方向的时候,把所有字段塞进一个“诗”节点,结果查询“李白写过哪些诗”倒还好,但“李白哪些诗表达思乡”就不知道挂在哪了。后来调整为按查询粒度建模。常见的实体类型我建议这样定:
- 诗人:姓名、字、号、朝代、生平简介
- 诗作:标题、正文、译文、创作时间(能考据则写,否则留空)
- 意象:明月、流水、杨柳、酒、孤舟等,只有高频意象才值得抽出来
- 朝代:唐、宋、元、明、清
- 诗体:五言绝句、七言律诗、词牌名等
- 主题/情感:思乡、送别、边塞、怀古、爱情
关系的设计遵循“查询驱动”原则。例如“李白→创作→静夜思”“静夜思→属于诗体→五言绝句”“静夜思→表达主题→思乡”“静夜思→包含意象→明月”。这里的关键是不要贪多。像“李白→出生于→碎叶城”这种史实关系如果和你的问答需求无关,宁可不建,因为每多一种关系,问句解析就要多维护一条模板,数据质量也难保证。我一般先列 20 个高频问句,把问句需要的路径抽出来,再反推实体和关系,而不是先把所有史料都塞进图谱。
2.3 属性与标签:把节点设计成可扩展的键值结构
Neo4j 的节点和关系都可以带属性,但设计上要区分“查询定位属性”和“展示属性”。诗人节点里,name是用于实体识别的关键属性,必须单独建索引;intro只是展示用,不参与查询。诗作节点里,title和first_line要建全文索引,因为用户会直接输入“床前明月光”来反问是哪首诗。我习惯给每个节点加一个kg_id作为稳定标识,用来做去重和实体链接,避免用中文名直接当主键——同一个诗人可能存在“李白”“李太白”“青莲居士”多个别名,后面合并的时候kg_id就派上用场了。
关系的方向也要统一。我的做法是永远从“主体”指向“客体”:诗人→诗作,诗作→意象,诗作→主题。这样在 Cypher 里写(:诗人 {name:'李白'})-[:创作]->(:诗作)逻辑一致,不会出现同一层关系一半从诗人到诗、一半从诗到诗人。如果后面要支持“哪些意象被李白写过”,可以走反向遍历,Neo4j 对无方向匹配也友好,但写入时必须明确方向。
3. 从清洗到入库:用 JAVA + Neo4j 构建古诗词知识图谱
3.1 数据来源与清洗策略:公共语料加工的三道关
古诗词公开语料很多,常见的形式是 JSON 或 SQL 文件,字段一般有标题、作者、正文、注释。清洗工作我按三条线做:第一是去重,同一首诗在多个数据集里标题和正文略有差异,我以“标题+首句”为键做 MD5,重复的只保留完整度最高的那一条;第二是拆分,把“作者”字段里“李白(701—762)”这种杂合信息拆成姓名和生卒年,正则切分即可;第三是意象抽取,这一步不能完全交给程序,我的做法是先维护一个高频意象表(约 200 个),再用分词工具在诗文中匹配,匹配到就给诗作与意象之间建一条“包含意象”关系。
清洗这一步最耗时间的不是写代码,而是标注数据。如果你只是做原型验证,可以只抽“明月、酒、剑、孤舟、杨柳、长亭”这些出现频率最高的意象,先把链路跑通,后续再扩充。数据量不是越大越好,我见过有人一次导入两万首诗,结果实体链接没做好,同一位诗人有了十几个不同节点,问答准确率反而更低。
3.2 写一个可复用的图谱导入器:Spring Boot + Neo4j Driver
我常用 Spring Boot 2.x 配合官方 Neo4j Java Driver 4.4 来实现批量导入。这里不推荐 Spring Data Neo4j 的 Repository 方式做初始化导入,因为批量场景下 Session 事务控制更直接。下面是一段可复用的导入器核心代码:
import org.neo4j.driver.*; import java.util.*; public class GraphImporter implements AutoCloseable { private final Driver driver; public GraphImporter(String uri, String user, String password) { this.driver = GraphDatabase.driver(uri, AuthTokens.basic(user, password)); } public void importPoetAndPoem(String poetName, String dynasty, String title, String content, List<String> images) { Map<String, Object> params = new HashMap<>(); params.put("poetName", poetName); params.put("dynasty", dynasty); params.put("title", title); params.put("content", content); params.put("images", images); String cypher = "MERGE (p:诗人 {kg_id:$poetName}) " + "SET p.name=$poetName, p.dynasty=$dynasty " + "MERGE (d:朝代 {name:$dynasty}) " + "MERGE (p)-[:属于朝代]->(d) " + "MERGE (s:诗作 {kg_id:$title}) " + "SET s.title=$title, s.content=$content " + "MERGE (p)-[:创作]->(s) " + "WITH s, $images AS images " + "UNWIND images AS img " + "MERGE (i:意象 {name:img}) " + "MERGE (s)-[:包含意象]->(i)"; try (Session session = driver.session()) { session.executeWrite(tx -> tx.run(cypher, params)); } } @Override public void close() { driver.close(); } }这段代码有三个关键设计。第一,全用MERGE而不是CREATE,这样重复导入同一位诗人、同一首诗不会建出重复节点,而是复用已有节点继续建关系。第二,用kg_id充当稳定主键,诗人节点以姓名为 id、诗作节点以标题为 id,这个设计在数据源干净时能用,如果存在同名诗人或同名诗作,就要改成“诗人名+生卒年”“标题+首句”作为 kg_id。第三,意象通过UNWIND批量建关系,避免在 Java 代码里循环写库,千万条数据时性能差距明显。导入时注意控制事务大小,我一般每 200 首诗提交一次事务,否则事务日志太大会拖垮写入速度。
3.3 实体链接与去重:同名诗人与一诗多收的坑
“同名诗人”在古诗词数据集里很常见。“李煜”和“李白”还好区分,“王维”和“王绩”字面相似但不同人。我的做法是在引用数据时强制带上朝代字段,诗人实体用kg_id设计为“姓名_朝代”,例如王维_唐。这样即使两个数据集里“王维”写法完全一致,只要朝代不同也能区分。诗作同理,遇到不同诗集里同一首诗标题相同但内容略有差异,我以“标题_首句八字符”作为合并键,首句前八个字在近体诗中足够稳定。
去重之后还要处理别名。诗人姓名“李白”,字号“太白”“青莲居士”,在问句中都可能出现。我给诗人节点维护一个alias属性,类型是字符串数组。在导入阶段可以从百科数据里把别名拉进来,也可以在问答解析阶段遇到查不到实体时,用 Levenshtein 距离做一次模糊匹配。后者适合作为兜底方案,因为别名表不全时,至少能把“李太白”匹配到“李白”。
4. 智能问答流水线:怎么把“李白的思乡诗”变成 Cypher 查询
4.1 问句解析:分词、词性标注与候选实体识别
问答系统的第一步是把自然语言问句转成结构化查询。我采用的方案是:先做中文分词和词性标注,再从词序列里识别出实体提及(Entity Mention)。Java 生态里我常用 HanLP,它的标准分词对古诗名词的识别效果尚可,但要注意把“诗”“词”“句”等后缀词加入自定义词典,否则“思乡诗”会被切成“思乡/诗”两个词,影响后续意图判断。
下面给出一个最小可跑的实体识别逻辑:
import com.hankcs.hanlp.HanLP; import com.hankcs.hanlp.seg.common.Term; import java.util.*; public class QueryParser { private static final Set<String> ENTITY_TYPES = new HashSet<>(Arrays.asList("nr", "nz")); public List<String> extractEntities(String question) { List<Term> terms = HanLP.segment(question); List<String> entities = new ArrayList<>(); for (Term term : terms) { // nr 代表人名,nz 代表其他专名,这里先粗粒度召回 if (ENTITY_TYPES.contains(term.nature.toString())) { entities.add(term.word); } } return entities; } public String extractTheme(String question) { // 简单规则:如果问句包含“思乡、送别、边塞、怀古”等词,就作为主题返回 List<String> themes = Arrays.asList("思乡", "送别", "边塞", "怀古", "爱情"); for (String theme : themes) { if (question.contains(theme)) { return theme; } } return null; } }这段代码有两个明显的简化。第一,HanLP 的模型对“李白”这类历史人物通常会标为nr,但“静夜思”作为诗名可能被标为nz,所以召回时我用nr和nz兜底。第二,主题提取用的是字符串包含,这在原型阶段够用,但“表达思乡之情的诗”这种句式会漏掉,需要补充同义词表“思乡→想家、思念故乡”。你必须清楚这是一条规则为主的分支,不要把准确率预期调得太高,它的价值是让你先把流水线跑通,后续要提升再引入序列标注模型也不晚。
4.2 意图模板与 Cypher 生成:模板匹配为主,规则兜底
识别出实体和主题后,下一步是确定问句意图。我把古诗词问答的意图收敛成五个高频类别:按作者查诗、按主题查诗、按意象查诗、查某首诗的作者、查诗人介绍。所有意图都用一个QuestionPattern类表示,然后映射到 Cypher 模板。
public class QueryGenerator { public String generateQuery(String poet, String theme, String image) { // 按作者+主题查诗 if (poet != null && theme != null) { return "MATCH (p:诗人 {name:$poet})-[:创作]->(s:诗作)-[:表达主题]->(t:主题 {name:$theme}) " + "RETURN s.title, s.content LIMIT 10"; } // 按意象查诗 if (image != null) { return "MATCH (s:诗作)-[:包含意象]->(i:意象 {name:$image}) " + "RETURN s.title, s.content LIMIT 10"; } // 查某首诗的作者:先按标题匹配 if (poet == null && theme == null && image == null) { return "MATCH (s:诗作 {title:$title})-[:创作]->(p:诗人) RETURN p.name"; } return null; } }参数通过查询参数传递,而不是拼接字符串。这一点非常重要,我在实际项目里吃过亏:用户问“李白写的‘静夜思’是不是表现了‘思乡’”,问句中带引号,如果直接拼进 Cypher 就成了语法错误,甚至可能被注入。用$poet这类参数绑定,Neo4j 驱动会做转义,既安全又避免特殊字符带来的解析问题。
这里还有一个被很多人忽略的细节:主题不能直接作为节点属性匹配。比如“思乡”在问句里是主题,但在图里可能同时存在“思乡”“思念故乡”“怀乡”多个等价节点。我在建模阶段会做一层主题归一化,把所有同义主题合并到同一个节点,比如“思乡”作为主节点,“怀乡”作为 alias 属性。否则同样的查询意图,两次问法不同,结果就不一致。
4.3 答案生成与置信度判断:空结果和歧义结果都要兜住
Cypher 查出来的结果不能直接当答案返回。诗人节点可能同名、诗作标题可能重复,所以在生成回答之前,要先做一轮置信度判断。我的策略是:结果数为 0 时,降级为全文搜索;结果数大于 1 时,优先返回被不同关系路径命中次数最多的节点,比如“明月”意象关联了十首诗,那就按诗作节点入度排序,入度最高的说明它被引用最多,作为展示答案更有代表性。
public String buildAnswer(String question, String cypher, Map<String, Object> params) { List<Map<String, Object>> result = query(cypher, params); if (result.isEmpty()) { return "暂时没有找到符合条件的内容,试试换个说法吧"; } if (result.size() == 1) { return result.get(0).get("title") + ":" + result.get(0).get("content"); } // 多结果时按入度降序,取前三条 result.sort((a, b) -> (int) b.getOrDefault("hitCount", 0) - (int) a.getOrDefault("hitCount", 0)); return "为你找到多首相关作品:" + result.subList(0, Math.min(3, result.size())) .stream().map(r -> r.get("title").toString()).reduce((x, y) -> x + "、" + y); }这里的hitCount可以从图里带出来,也可以在当前结果里计数。如果发现结果仍然不合理,我建议在问答接口加一个debug参数,返回实际执行的 Cypher 和参数,排查时能看到具体是哪一步出了问题。生产环境千万不要把这个接口直接暴露给外部用户,否则等于把图谱结构免费送人。
5. 避坑与排查:从“查询不到”到“答非所问”的修复记录
5.1 中文分词把诗名切得七零八落
现象:用户输入“静夜思的作者是谁”,实体识别把“静夜思”拆成“静夜”和“思”,查不到节点。原因:HanLP 的分词模型按现代汉语语料训练,对古诗文标题切分不稳定。解决:把常见诗名加入自定义词典,并在分词前先做一次“高频诗名表”的贪心匹配——在问句里优先找出命中的完整诗名,如果找到了,就把它从待分词字符串里摘除,剩余部分再走标准分词。这个小改动能把诗名识别准确率提升一大截。
5.2 Cypher 里中文引号导致查询报错
现象:用户问题里带了中文引号或书名号,生成的 Cypher 字符串拼接后语法错误。原因:业务问句里的“《静夜思》”或者“‘明月’”,直接进入查询语句时破坏了 Cypher 的字符串边界。解决:所有用户输入一律通过查询参数绑定,不使用+拼接。同时在建实体时把“静夜思”和“《静夜思》”统一去掉书名号存储,实体链接阶段也做一次符号剥离。
5.3 诗人同名但朝代不同,查询结果张冠李戴
现象:问“王维的诗”,返回了“王绩”的作品。原因:图谱里两个诗人节点因为kg_id只用了名字,被MERGE合并到了一起。解决:kg_id必须带朝代后缀。如果数据源没有朝代字段,可以从“诗人-属于朝代”关系中反推。做完这步之后,每次导入前先用 Cypher 查一下同名节点数量,能提前发现合并异常。
5.4 关系方向写反导致查询结果为空
现象:Cypher 写(:诗作)-[:创作]->(:诗人)查某首诗的作者,一直返回空。原因:导入时我用的是(诗人)-[:创作]->(诗作),方向反了自然查不到。解决:在导入器所有的MERGE关系写法里统一方向约定,并加一个测试用例:每导入一批数据,就执行MATCH p=()-[r:创作]->() RETURN count(p)统计数量,反向关系数量应该为 0。这个检查应加入 CI,避免以后改代码时方向漂移。
5.5 图谱数据量变大后,重查询拖垮 Neo4j 连接池
现象:并发访问一上来,查询响应时间从 50ms 飙升到 5s,Neo4j 日志报连接超时。原因:每个查询都LIMIT 100,图遍历路径过长,同时 session 没复用。解决:一是在 Cypher 模板里强制加LIMIT 10,控制返回规模;二是 Spring Boot 中配置连接池最大连接数,我一般设 50 到 100,并开启 evict 空闲连接;三是对频繁查询的节点属性(诗人 name、诗作 title)建索引,CREATE INDEX FOR (p:诗人) ON (p.name)这类索引能显著加速实体定位。
6. 让问答“更像人”:排序、追问与评估的进阶技巧
原型跑通之后,我会把精力放在两件事上:候选答案排序和查询失败时的追问。排序上,不要只依赖图谱内部关系数量,可以引入用户行为反馈,比如点开过的答案次数,但这需要日志系统。轻量做法是维护一个得分表:诗作被引用次数、诗人知名度、诗句字数与问句关键词重叠度,三者加权求和。另一个很实用的技巧是“追问式消歧”,当问句“李白的诗”命中几百首时,不要让用户翻页,而是返回“李白写过许多诗,你是想看思乡主题的、还是含‘月’意象的?”这背后只需要再跑一次主题分布聚合,用户在原型里会觉得系统真的有理解力。评估方法我建议每开发一个功能版本,就准备 100 条标准问句,人工标注期望答案,计算精确率和召回率。我自己的教训是:如果精确率低于 70%,多半是实体识别问题;高于 90% 后,瓶颈通常在主题同义词覆盖上。提高这两个指标没有捷径,就是不断补词典和模板。整个方案做到这一步,已经能够支撑一个中小型的古诗词问答演示系统。希望帮到你。
本文还有配套的精品资源,点击获取