ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

知识图谱驱动的古诗词智能问答:Java与Neo4j实战

知识图谱驱动的古诗词智能问答:Java与Neo4j实战 简介基于知识图谱的古诗词智能问答Java实现适合具备一定Java基础、想深入理解知识图谱问答系统搭建流程的开发者或NLP学习者。项目采用JavaSSMSpringBootMavenReactMySQL前端接收问句并通过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, ListString images) { MapString, 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 SetString ENTITY_TYPES new HashSet(Arrays.asList(nr, nz)); public ListString extractEntities(String question) { ListTerm terms HanLP.segment(question); ListString 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) { // 简单规则如果问句包含“思乡、送别、边塞、怀古”等词就作为主题返回 ListString 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, MapString, Object params) { ListMapString, 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 飙升到 5sNeo4j 日志报连接超时。原因每个查询都LIMIT 100图遍历路径过长同时 session 没复用。解决一是在 Cypher 模板里强制加LIMIT 10控制返回规模二是 Spring Boot 中配置连接池最大连接数我一般设 50 到 100并开启 evict 空闲连接三是对频繁查询的节点属性诗人 name、诗作 title建索引CREATE INDEX FOR (p:诗人) ON (p.name)这类索引能显著加速实体定位。6. 让问答“更像人”排序、追问与评估的进阶技巧原型跑通之后我会把精力放在两件事上候选答案排序和查询失败时的追问。排序上不要只依赖图谱内部关系数量可以引入用户行为反馈比如点开过的答案次数但这需要日志系统。轻量做法是维护一个得分表诗作被引用次数、诗人知名度、诗句字数与问句关键词重叠度三者加权求和。另一个很实用的技巧是“追问式消歧”当问句“李白的诗”命中几百首时不要让用户翻页而是返回“李白写过许多诗你是想看思乡主题的、还是含‘月’意象的”这背后只需要再跑一次主题分布聚合用户在原型里会觉得系统真的有理解力。评估方法我建议每开发一个功能版本就准备 100 条标准问句人工标注期望答案计算精确率和召回率。我自己的教训是如果精确率低于 70%多半是实体识别问题高于 90% 后瓶颈通常在主题同义词覆盖上。提高这两个指标没有捷径就是不断补词典和模板。整个方案做到这一步已经能够支撑一个中小型的古诗词问答演示系统。希望帮到你。本文还有配套的精品资源点击获取
返回列表