简介:一份面向Java开发者和知识图谱初学者的古诗词智能问答完整工程,基于SpringBoot+SSM+Maven+React+MySQL与Neo4j图数据库实现。项目覆盖从React前端接收问句、词性标注与用户词典、句子抽象化与词向量生成、问题模板匹配,到图数据库查询并返回答案的完整链路,适合毕业设计、课程项目或知识图谱问答入门实战参考。资源包为zip压缩格式,共57个文件,包含12个Java后端源码、7个SQL脚本、7个JS前端页面逻辑、多个XML配置文件及Markdown说明文档,整体仅543KB,结构精简、便于快速部署与二次开发。目前已吸引2146人学习下载,资源内提供前端工程、Java后端、数据库初始化脚本及关键字典与模板数据,可帮助读者对照理解分词、模板匹配和图查询的核心思路,减少从零搭建的重复踩坑。
1. 古诗词智能问答,为什么值得用 JAVA 加知识图谱做
很多人看到“知识图谱 + 智能问答”,第一反应是 Python 的活,毕竟 NLP 生态都在那边。但真实业务里,你手头的会员系统、内容后台、管理端全是 Java 写的,问句进来之前要先过权限、限流、用户体系,问完还要落日志、进报表。与其再起一套 Python 服务做图谱查询,不如直接在主链路里用 Java 接图谱数据库,让问答变成一个普通的后端接口。这就是“JAVA 实现基于知识图谱的古诗词智能问答”最朴素的落地动机:不是炫技,是少一套运维、少一次跳转。
这套方案解决的是古诗词内容产品里的高频需求——用户问“李白的诗有哪些”“含明月的诗句”“静夜思是在什么背景下写的”,传统的关键词搜索只能给你一串列表,给不出关系。知识图谱把诗人、诗作、意象、地名、典故拆成节点和边,问答接口负责把自然语言翻译成图查询,答案天然带着上下文。适合三类人:想给诗词 App 加问答功能的 Java 工程师,做面试项目被死磕“你为什么不用 Python”的后端开发,以及想绕开大模型成本、用规则模板做垂直问答的团队。下面这套做法,不依赖 GPU 和在线大模型 API,只用 Neo4j 加 Spring Boot 就能跑起来。
2. 先定模型再写代码:图谱本体、存储选型与 JAVA 接入层
2.1 本体设计:用四类实体、五组关系盖住九成问答场景
知识图谱第一步不是连数据库,是画本体。诗词问答能被问到的无非几类:按作者找诗、按意象找诗、问创作背景、问地理位置、问典故化用。我把本体收敛成四类实体和五组关系。
实体方面,Poem 存标题、正文、译文、赏析、背景;Poet 存姓名、字号、朝代、籍贯;Place 存地名、现址;Image 意象存词条、释义、典型出处。关系上,Poet 与 Poem 是 WRITE_OF,Poem 与 Place 是 WRITTEN_AT,Poem 与 Image 是 MENTION,Poem 与 Poem 是 ALLUDE_TO(化用),Poet 与 Place 是 BORN_IN。加上这五组关系,作者问、意象问、地缘问、典故问全部能在两跳之内拿到答案。
设计时有个容易忽略的点:同名意象和同名地名的冲突。比如“明月”既可能是意象,也可能出现在地名里;“洛阳”既是古都也是诗里的常见意象。我的做法是给 Image 和 Place 都加一个 type 字段,查询时显式带上类型标签,而不是只靠 name 匹配。宁可数据冗余,也别让问答在语义上“二义性翻车”。
2.2 存储选型:Neo4j、RDF 三元组和 MySQL 的取舍
不少人会问,有现成的 RDF 三元组库(比如 Apache Jena),为什么还要 Neo4j?我的判断标准只有一条:查询的表达成本。RDF 那套 SPARQL 表达能力确实强,工业知识图谱项目里常用,但团队如果全是 Java 背景,SPARQL 的学习曲线比 Cypher 陡得多。Cypher 的MATCH (p:Poet {name:'李白'})-[:WRITE_OF]->(poem:Poem)这种写法,后端同事看一眼就会,维护成本低一大截。
MySQL 能不能做?能,但只适合“实体表 + 关系表”的扁平模式。用 MyBatis-Plus 根据 Java 实体类生成建表 SQL 那套,建两张表没问题;一旦出现“写诗地点所属的现代省份”这种多跳查询,SQL 的 JOIN 层数会让人怀疑人生,图数据库的遍历优势就体现出来了。我的建议是:数据量在十万节点以内、查询深度不超过三跳,Neo4j Community 完全够;别上来就搭集群,单机先跑通。
2.3 Spring Boot 接入 Neo4j:驱动配置与最小查询代码
接入层我不推荐一上来就上 Spring Data Neo4j(SDN)。SDN 的实体映射很爽,但它在事务边界、类型映射上藏了不少“黑匣子”,排障时反而耽误时间。先用官方 Java Driver,手写配置和查询,等跑通了再考虑要不要封装。
@Configuration public class Neo4jConfig { @Bean(destroyMethod = "close") public Driver neo4jDriver( @Value("${neo4j.uri}") String uri, @Value("${neo4j.user}") String user, @Value("${neo4j.password}") String password) { return GraphDatabase.driver(uri, AuthTokens.basic(user, password)); } } @Service public class PoemQueryService { private final Driver driver; public PoemQueryService(Driver driver) { this.driver = driver; } public List<Map<String, Object>> findPoemsByPoet(String poetName, int limit) { String cypher = """ MATCH (p:Poet {name: $name})-[:WRITE_OF]->(poem:Poem) RETURN poem.title AS title, poem.content AS content LIMIT $limit """; try (Session session = driver.session()) { Result result = session.run(cypher, Map.of("name", poetName, "limit", limit)); return result.list(r -> r.asMap()); } } }这段代码有两个细节值得说。第一,Cypher 全部用参数化写法,$name和$limit是参数占位符,不是字符串拼接。一方面防注入,另一方面让 Neo4j 能缓存执行计划,性能和安全性都占。第二,Java 15 以上的 text block 语法写多行 Cypher 很干净,如果你的项目还在 Java 8,就老实拼字符串,记得换行和缩进。连接参数里,uri 用bolt://localhost:7687,http 端口 7474 只用于浏览器管理界面,别搞混。
3. 图谱构建流水线:从原始语料到批量入库的完整通路
3.1 语料清洗:先解决诗文分段、标题提取、作者归属三个脏点
问答的准确率上限由图谱数据决定,不是由问答算法决定。古诗文语料从公开渠道拿回来后,第一关是清洗。我一般按“文件名 + 正文”的格式组织语料:文件名是“作者-标题.txt”,正文里保留原文、注释、译文、赏析几个段落。清洗代码要处理三个脏点:Windows 记事本带来的 BOM 头、全角空格混排、以及标题里的书名号残留。
public PoemItem parsePoemFile(Path filePath) { String fileName = filePath.getFileName().toString().replace(".txt", ""); String[] parts = fileName.split("-"); String poetName = parts[0].trim(); String poemTitle = parts[1].trim(); String content = Files.readString(filePath, StandardCharsets.UTF_8); // 去掉 BOM 头与全角空格 content = content.replace("\uFEFF", "") .replace('\u3000', ' ') .replaceAll("[ \\t]+", " ") .trim(); // 从正文首行提取标题,与文件名交叉校验 String firstLine = content.lines().findFirst().orElse(""); String titleInText = firstLine.replaceAll("[《》\\s]", ""); if (!poemTitle.equals(titleInText)) { // 文件名与正文不一致时,以正文为准并记录日志 poemTitle = titleInText; } String finalTitle = poemTitle; return new PoemItem(poetName, finalTitle, content); }这里有个“后悔药”设计:文件名和正文标题不一致时,以正文为准,而不是直接报错。因为公开语料的文件名经常是后人从数据库导出时生成的,说不准谁对,正文反而是更可信的来源。编码统一在读取时指定StandardCharsets.UTF_8,别依赖系统默认编码,不然在 Windows 服务器上跑一遍,所有中文到 Neo4j 里全是乱码。
3.2 实体识别与关系抽取:规则先行,模型兜底
实体识别这块,诗词领域最大的优势是“词表相对封闭”。诗人名字不过几百个,地名、常见意象也有现成的词典。所以我优先用词典规则抽实体,而不是一上来就训 BERT。步骤是:先把诗人名录、地名表、意象词典各建一份 CSV,加载到内存;然后遍历诗歌正文,凡是命中的词条就为这首诗挂上对应实体节点;命中不了的,先不进图谱,统一落到“待确认词表”,人工看了再补。
public void extractEntitiesAndRelations(String poemContent, String poemId, Map<String, List<String>> dict) { for (String image : dict.get("images")) { if (poemContent.contains(image)) { relationService.createMentionRelation(poemId, image); } } // 地名抽取同理,注意和意象词典去重 for (String place : dict.get("places")) { if (poemContent.contains(place)) { relationService.createWrittenAtRelation(poemId, place); } } }规则抽取的准确率取决于词典质量。我的经验是“宁可慢,不要快”:每首诗的正文只有几十到几百字,字符串匹配性能完全不是瓶颈;瓶颈在于词典里同一个词归属多个类型。比如“洛阳”同时出现在意象词典和地名词典里,我现在的做法是让两个类型都能挂关系,但查询阶段通过 Cypher 的 type 条件区分。等规则跑完一轮,用人工标注的几百条数据评估召回率,低于 80% 再考虑引入分词或模型方案,这能省下大量无效的模型训练时间。
3.3 批量入库:用 Java Driver 执行 MERGE,控制批次与幂等
数据清洗和实体识别做完,进入批量入库。这一节最容易翻车:直接循环 CREATE,跑两遍数据就翻倍;或者一个大事务写一万条,内存先爆掉。我的常规做法是分片提交、使用 MERGE、提前建唯一约束,三步齐活。
public void importPoems(List<PoemItem> poems, Driver driver) { // 建唯一约束:靠 id 保证同一首诗不会被重复创建 try (Session session = driver.session()) { session.run("CREATE CONSTRAINT poem_id IF NOT EXISTS " + "FOR (p:Poem) REQUIRE p.id IS UNIQUE"); } // 每 500 条一个事务批次 List<List<PoemItem>> batches = Lists.partition(poems, 500); for (List<PoemItem> batch : batches) { try (Session session = driver.session(SessionConfig.builder() .withDefaultAccessMode(AccessMode.WRITE) .build())) { session.executeWrite(tx -> { for (PoemItem item : batch) { String poemId = item.getPoetName() + "_" + item.getTitle(); tx.run(""" MERGE (poem:Poem {id: $id}) ON CREATE SET poem.title = $title, poem.content = $content MERGE (poet:Poet {name: $poetName}) MERGE (poet)-[:WRITE_OF]->(poem) """, Map.of( "id", poemId, "title", item.getTitle(), "content", item.getContent(), "poetName", item.getPoetName())); } return null; }); } catch (Exception e) { // 大批次失败时,拆成 100 条的小批次重试一次 log.error("batch import failed, batchSize={}", batch.size(), e); retryWithSmallerBatch(batch, driver); } } }注意这里的约束语法是 Neo4j 5.x 的写法;如果你用的是 4.x,要改成CREATE CONSTRAINT ON (p:Poem) ASSERT p.id IS UNIQUE,否则启动导入就会报语法错误。批次大小我一般设 500,写入失败后拆成 100 的小批次重试,因为大批次失败通常是大事务导致堆内存压力,拆小后基本能过。MERGE 的语义是“存在则跳过、不存在则创建”,配合 id 字段天然幂等,跑两遍、跑三遍结果都一样,这才是导入脚本该有的“后悔药”。
4. 智能问答实现:从问句到 Cypher 的模板路由
4.1 问句分类设计:把自然语言切到四类模板上
问答模块本质上是“问句翻译器”:把用户的自然语言翻译成一条 Cypher。在这个垂直领域,用户问法其实非常收敛,翻来覆去就那几类。我在实现里把问句分成四类:作者作品检索、意象检索、创作背景检索、模糊全文检索。前三种都有明确模板,第四种是兜底。
| 问句类型 | 用户问法样例 | 期望生成的 Cypher骨架 |
|---|---|---|
| 作者作品 | 李白的诗有哪些 | (Poet {name:$name})-[:WRITE_OF]->(Poem) |
| 意象检索 | 含明月的诗句 | (Poem)-[:MENTION]->(Image {name:$name}) |
| 背景检索 | 静夜思的创作背景 | (Poem {title:$title}).background |
| 模糊兜底 | 关于思乡的诗 | Poem.content CONTAINS $keyword |
如果用户问题落不进前三类,不要硬套模板,直接走全文检索或模糊匹配,至少不能让用户拿到空结果。第 6 章我会说怎么做效果验证,这里先把路由实现讲清楚。
4.2 规则模板转换:正则提取实体,参数化拼 Cypher
路由核心代码是一个QuestionRouter,输入原始问句,输出“Cypher 语句 + 参数 + 类型”。我在这里刻意写了几个正则模板,没有引入 NLP 库。原因很直接:诗词问答请求量通常不大,模板能覆盖八成常见问法,剩下的交给兜底;给这个系统加一个 200MB 的分词依赖,反而增加部署和排障成本。
public class QuestionRouter { public record QueryPlan(String cypher, Map<String, Object> params, String type) {} public QueryPlan route(String rawQuestion) { String q = rawQuestion.trim() .replaceAll("[“”\"',。??!!\\s]", ""); // 模板1:作者作品检索,如“李白的诗” Matcher authorMatcher = Pattern .compile("(.{2,4})的(诗|作品|代表作|诗句|诗有哪些)") .matcher(q); if (authorMatcher.find()) { String poet = authorMatcher.group(1); return new QueryPlan(""" MATCH (p:Poet {name: $poet})-[:WRITE_OF]->(poem:Poem) RETURN poem.title AS title, poem.content AS content LIMIT 20 """, Map.of("poet", poet), "POEM_BY_POET"); } // 模板2:意象检索,如“含明月的诗句” Matcher imageMatcher = Pattern .compile("(含|带有|描写|关于)(.{2,4})(的)?(诗句|诗|意象)") .matcher(q); if (imageMatcher.find()) { String image = imageMatcher.group(2); return new QueryPlan(""" MATCH (poem:Poem)-[:MENTION]->(i:Image {name: $image}) RETURN poem.title AS title, poem.content AS content LIMIT 20 """, Map.of("image", image), "POEM_BY_IMAGE"); } // 模板3:创作背景,如“静夜思的创作背景” Matcher bgMatcher = Pattern .compile("《?([^》]{2,10})》?的(创作背景|背景|故事|由来)") .matcher(q); if (bgMatcher.find()) { String title = bgMatcher.group(1); return new QueryPlan(""" MATCH (poem:Poem {title: $title}) RETURN poem.title AS title, poem.background AS background """, Map.of("title", title), "POEM_BACKGROUND"); } // 兜底:全文模糊检索 return new QueryPlan(""" MATCH (poem:Poem) WHERE poem.content CONTAINS $keyword RETURN poem.title AS title, poem.content AS content LIMIT 5 """, Map.of("keyword", q), "FUZZY_SEARCH"); } }几个参数说明:正则里.{2,4}是实体名长度约束,太短容易误匹配“的的诗”,太长会吞掉后面的关键动词;模板顺序有讲究,作者模板必须在背景模板之前,因为“李白的诗”也可能被背景正则部分匹配。record 是 Java 16+ 语法,如果你项目还在 Java 8,改成一个普通 POJO 类即可。这里生成 Cypher 全部带$参数,杜绝用户输入直接拼进查询字符串。
4.3 答案兜底与结果排序:模板覆盖不到的句子怎么处理
模板路由总有漏网之鱼。用户问一句“有哪些写庐山瀑布的古诗”,上面三个正则一个都命中不了,这时候如果直接返回空结果,体验非常差。我的兜底方案分两层:第一层是全文检索,把问句去停用词后作为 keyword 传入CONTAINS查询,能捞回一部分相关诗作;第二层是“学习型兜底”,把没命中的问句和最终用户点击/反馈的答案记录下来,定期人工标注后补成新模板。
排序上要注意一点:全文检索的CONTAINS不保证相关性排序,返回的 5 条可能是任意顺序。我给模糊检索加一个简单的得分算法:命中标题的权重大于命中正文,正文中命中多次的权重大于一次,然后按权重排序。这个排序逻辑写在 Java 里,不走 Cypher 的复杂表达式,排查问题的时候一眼能看懂。
5. 避坑专章:JAVA 接入 Neo4j 时五个高频翻车点
5.1 同名实体冲突:意象“洛阳”和地名“洛阳”合并成一个节点
现象:用户问“含有洛阳的诗句”,返回结果里混进了一堆跟地点完全无关的意象诗,或者问“写洛阳的古诗”,答案数量突然少了一半。原因是导入阶段两个来源的“洛阳”都 MERGE 到同一个节点上,Poem 同时挂上了 MENTION 和 WRITTEN_AT 两条关系,语义被污染。解决:本体设计阶段给实体加 type 字段,MERGE 的唯一键改成(name, type)复合结构;查询时 Cypher 里永远写{name: $name, type: '意象'},不要只靠 name 匹配。
5.2 中文乱码:文件、连接、终端三个环节互相甩锅
现象:Windows 上导出的 CSV 通过 Java 读进 Neo4j,Browser 里看是正常的,但通过 HTTP API 返回时中文变成问号;或者在 Linux 服务器上跑,日志里全是乱码。原因是导入文件是 GBK 编码,而你读文件时没指定编码;或者是 Neo4j HTTP 接口返回时没设置Content-Type: application/json; charset=utf-8。解决:入口处统一“文件读入用 UTF-8、数据库连接串不带中文参数、Response 显式声明 charset”。我在代码里所有读取操作都写StandardCharsets.UTF_8,环境变量和启动参数里也单独配-Dfile.encoding=UTF-8,从源头消灭问题。
5.3 MERGE 与 CREATE 混用:跑两遍导入脚本节点翻倍
现象:第一次导入正常,为了补几个实体重新执行一次导入脚本,全库 Poem 节点数量变成两倍,关系更是三倍。原因很直白:CREATE 是无脑新建,MERGE 是查存在再建。我在 3.3 节给的代码里全用 MERGE 且带复合 id,但如果你在关系创建部分用了CREATE (poet)-[:WRITE_OF]->(poem),那么同一对节点跑两遍,关系一定重复。解决:关系创建也换成 MERGE;如果不放心,导入完成后跑一条统计 Cypher,对比MATCH (p:Poem) RETURN count(p)和源文件条数,不一致就立刻排查。
5.4 Cypher 参数类型不匹配:String 和 Integer 互相看不上
现象:接口传进来year=755,Java 端解析成 Integer,Cypher 里和节点的 string 属性比较,一行都查不到;或者故意传"755"反而能查到,非常玄学。原因是 Neo4j 是强类型存储,{year: 755}和{year: "755"}严格不等。解决:入口参数统一做类型转换。我的习惯是 Controller 层收到参数一律先按字符串处理,再根据查询目标显式转成对应类型;写 Cypher 时优先用toString()后的值去匹配字符串属性,避免不必要的隐式转换坑。
5.5 连接池耗尽与长事务:接口假死和启动失败
现象:并发一上来,Neo4j 抛出Unable to acquire a connection from the pool;或者某个大范围查询没加 LIMIT,把数据库跑僵,后续所有查询排队,接口表现就是“第一次调用就超时”。原因有两个:驱动默认连接池偏小,以及 Cypher 里没有限制返回行数。解决:在连接配置里显式调大maxConnectionPoolSize;所有面向问答的 Cypher 一律加LIMIT 20,把“查询结果太多”这个坑从语法层面堵死。如果接口还有缓存层,尽量先查缓存再查库,减少对图谱数据库的并发压力。遇到“启动失败”先别急着怀疑配置类,先用 Neo4j Browser 跑一遍同一条 Cypher,确认查询本身没问题,再回头查连接参数。
6. 从“能答”到“好用”:评测校验、缓存收尾与接口加固
6.1 用 60 道评测集量化问答质量,三类错误分开记
没有评测集的问答系统就是盲人摸象。我建评测集时会准备 60 道问句,覆盖作者、意象、背景、兜底四类,每类 15 道,每道都预先写好“期望返回的实体/诗名”。跑完一轮后,错误分成三类统计:实体抽取错误(名字没抽对)、Cypher 生成错误(抽对了但查错路径)、数据缺失(图谱里本来就缺这条关系)。这三类错误的修复手段完全不一样,混在一起统计等于没统计。
| 评测维度 | 样例问句 | 通过标准 |
|---|---|---|
| 作者检索 | 杜甫的诗有哪些 | 返回列表含《春望》《登高》,命中率≥90% |
| 意象检索 | 含明月的诗句 | 返回诗句正文确实含“明月”字样的比例≥95% |
| 背景问答 | 静夜思的创作背景 | 结果与语料库背景字段一致 |
| 模糊兜底 | 写思乡的诗 | 不返回空结果,且结果相关度人工评分≥3分(5分制) |
评测不是一次性工作。我每加一个新模板都重跑一遍这 60 道题,把“新增模板改善了 A 类错误但弄坏了 B 类”这种回归及时暴露出来。
6.2 热点问答缓存:用 Caffeine 缓存 Cypher 查询结果
诗词问答的请求分布高度倾斜,可能 80% 的流量都打在“李白的诗有哪些”这类热门问题上。热点问题没必要每次都去 Neo4j 走图遍历,加一层本地缓存能大幅降低数据库压力。我用 Caffeine 做缓存,Key 是规范化后的问句,Value 是答案列表,过期时间 30 分钟。
Cache<String, List<Map<String, Object>>> answerCache = Caffeine.newBuilder() .maximumSize(20_000) .expireAfterWrite(Duration.ofMinutes(30)) .build(); public List<Map<String, Object>> ask(String question) { String normalized = question.trim().replaceAll("\\s+", ""); String key = DigestUtils.md5DigestAsHex(normalized.getBytes(StandardCharsets.UTF_8)); return answerCache.get(key, k -> executeQuery(question)); }注意一个细节:get里的加载函数如果返回 null,Caffeine 不会缓存这个 key,极端情况下热点空结果会反复穿透到数据库。所以 executeQuery 方法无论如何要返回一个非 null 的列表,查不到数据就返回List.of()。缓存命中率可以通过 Actuator 的 Cache 指标观察,我一般要求热点问题命中率在 70% 以上,达不到就先查 Cypher 是不是写得太宽泛。
6.3 Controller 层收尾:参数校验、限流与防爬
问答接口本质是一个公开读接口,Controller 层要做三件事:校验问句非空且长度不超过 50 字;做简单的基于 IP 加用户维度的限流;日志里记录问句和命中的模板类型,方便后续分析。防爬方面,Controller 层防止爬虫的常规手段都适用,比如对高频异常请求返回验证码、对同一 IP 的 QPS 做阈值限制。我自己的一个血泪教训是:不要在一开始就给接口加太复杂的鉴权,先用日志跑两周,看真实问句分布再决定哪些模板要补、哪些请求要拦,把力气花在数据价值最高的地方。
我现在的习惯是每加一个新功能,先跑评测集,再压一轮接口,最后才更新文档。这套“JAVA + 知识图谱 + 古诗词问答”的方案,最难的部分从来不是写代码,而是把本体设计、语料清洗和模板迭代当作一个闭环持续养。希望帮到你。
本文还有配套的精品资源,点击获取