资讯动态

Spring Boot + Neo4j 医疗知识图谱问答系统构建

发布时间:2026/10/18 20:12:24 来源:尧图企业网站定制
简介基于Spring Boot与Neo4j构建的医疗知识图谱问答系统毕业设计项目面向Java后端开发者、知识图谱方向学习者以及需要完成类似课题的学生。项目完整展示了如何借助Spring Boot简化Web应用搭建利用Neo4j图数据库存储疾病、症状、药物、科室等医疗实体及其关联关系并结合自然语言处理技术完成问题解析、实体识别与关系查询最终返回准确答案。压缩包共210个文件主要包含Java源码、class字节码、txt文本说明、XML/Properties配置以及bin格式模型文件其中源码覆盖启动类、控制器、服务层、DAO层和Spring Data Neo4j交互代码配置文件定义了运行环境与参数json数据可供导入图数据库。资源包大小约71.69MB已有527人浏览学习。通过学习该项目可掌握知识图谱问答的整体架构、Cypher查询写法及项目分层思想也可借鉴其异常处理与模块组织方式是医疗领域图应用开发的一份实用参考资料。1. Spring Boot Neo4j 医疗知识图谱问答做的是什么以及答辩会被问什么答辩现场评委问一句“你这套系统到底是搜索引擎还是问答系统”很多毕设就垮了。用 Spring Boot 做接口、Neo4j 做存储再把医疗领域的疾病、症状、药品、科室建成图谱最后用问句反查 Cypher这件事的本质不是“查数据库”而是把“高血压有什么症状”这类问题变成一段可解释的图查询。这个标题值得做的核心点在两个地方一是知识图谱的数据模型是否真的符合医疗问答场景二是问句到 Cypher 的转换是否足够稳定。适合的人是在校做毕设、需要演示医疗问答或知识图谱方向项目的开发者不适合只想堆 CRUD 的同学因为这一套要跑通至少得吃透节点建模、关系方向和查询模板三块东西。2. 先把医疗知识图谱的数据模型立住节点、关系与查询规范2.1 为什么医疗问答优先选图数据库关系型表 join 不到三跳之外的禁忌医疗问答里最常被问到的一类问题是“高血压患者能吃布洛芬吗”。这个问题的答案不是一张表能直接给出的它至少牵扯到高血压、布洛芬、药品禁忌症三个实体之间的关系。如果用关系型数据库需要疾病表、药品表、疾病-药品关系表再加上症状表、科室表查询时要多次 join每增加一种关系就要改表结构而图数据库里这就是从节点出发走一条边或者两条边的事。我一般会这样向导师解释选型图数据库的表达方式和医生写病历的思维方式更接近实体是节点关系是边而不是把关系拆成外键和中间表。Neo4j 的 Cypher 查询天然支持变长路径比如查“和高血压有共现症状的其他疾病”在关系型数据库里是一个烦琐的集合运算在 Cypher 里是MATCH (d1:Disease)-[:HAS_SYMPTOM]-(s:Symptom)-[:HAS_SYMPTOM]-(d2:Disease)一行的事。毕设做问答重点不是把数据存起来而是让“问”和“答”之间有可解释的路径这条路恰好是图数据库最擅长的。2.2 最小图谱长什么样疾病、症状、药物、科室四个节点动手建图之前先定义好实体边界。医疗领域实体很多但毕设问答不需要全收录把四类核心实体建立起来就够覆盖大部分高频问题疾病、症状、药品、科室。我建议在这个基础上再加一个检查项节点这样“高血压需要做什么检查”这类问题也能接住。先用一段最小的 Cypher 把节点和关系建起来验证环境是否正常CREATE CONSTRAINT FOR (n:Disease) REQUIRE n.name IS UNIQUE; CREATE CONSTRAINT FOR (n:Symptom) REQUIRE n.name IS UNIQUE; CREATE CONSTRAINT FOR (n:Medicine) REQUIRE n.name IS UNIQUE; CREATE CONSTRAINT FOR (n:Department) REQUIRE n.name IS UNIQUE; MERGE (d:Disease {name: 高血压}) MERGE (s:Symptom {name: 头晕}) MERGE (m:Medicine {name: 硝苯地平}) MERGE (dept:Department {name: 心血管内科}) MERGE (d)-[:HAS_SYMPTOM {level: 常见}]-(s) MERGE (d)-[:RECOMMEND]-(m) MERGE (d)-[:DEPARTMENT]-(dept)这里有几个参数和写法需要解释。CREATE CONSTRAINT FOR (n:Disease) REQUIRE n.name IS UNIQUE是 Neo4j 5.x 的语法如果用的是 Neo4j 3.x要换成CREATE CONSTRAINT ON (n:Disease) ASSERT n.name IS UNIQUE否则会语法报错。用MERGE而不是CREATE是为了让脚本可以重复执行反复运行不会产生重复节点这一点在毕设调试阶段非常重要很多同学用CREATE导入两遍数据问答结果直接翻倍。关系HAS_SYMPTOM上带了一个level属性用来区分常见症状和罕见症状。这说明关系本身可以带属性后面做答案排序时可以直接用ORDER BY r.level这种写法。2.3 关系方向、属性与命名让后面问题模板少改三次节点定义好之后最难统一的是关系方向和命名。我给这套医疗问答系统定的规范如下新建关系时沿着表格里的方向走查询时不要随意反着写起点节点关系类型终点节点含义方向约定DiseaseHAS_SYMPTOMSymptom疾病表现出的症状疾病 - 症状DiseaseRECOMMENDMedicine疾病推荐用药疾病 - 药物MedicineCONTRAINDICATEDisease药物禁忌症药物 - 疾病MedicineINTERACTMedicine药物相互作用无方向DiseaseDEPARTMENTDepartment就诊科室疾病 - 科室DiseaseNEED_CHECKCheckItem需要做的检查疾病 - 检查项命名统一用大写下划线实体用驼峰属性统一小写。为什么强调方向因为 Cypher 查询一旦写错方向结果就是空。比如查药物禁忌症正确写法是MATCH (m:Medicine {name:$name})-[:CONTRAINDICATE]-(d:Disease)如果你在导入时把方向建成疾病到药品查出来就是空的而数据本身看起来没问题。药物相互作用是特殊情况它天然是双向的。常见做法是每一对药品只建一条边查询时不指定方向MATCH (a:Medicine {name:$name})-[i:INTERACT]-(b:Medicine) RETURN b.name。这样既避免重复数据又能在问答时返回对手药品。这一步构建的命名和方向规范直接决定后续问答模板好不好写如果方向混乱每加一个问句都要重新查数据判断方向非常消耗时间。3. 在 Spring Boot 里把 Neo4j 变成数据访问层依赖、实体映射与查询封装3.1 接入 Spring Data Neo4j依赖选择和连接参数后端部分最常见的做法是使用 Spring Data Neo4j它把 Cypher 查询封装成 Repository 方法代码里基本上看不到 Driver 的样板代码。项目里需要引入一个 starter 依赖Maven 中对应的坐标是dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-neo4j/artifactId /dependency这里不写具体版本号是因为 Spring Boot 父工程已经帮你锁版本了。要注意的是 Spring Boot 2.x 和 3.x 的连接配置前缀不一样Spring Boot 2.x 一般是spring.neo4j.*Spring Boot 3.x 是spring.data.neo4j.*。我在本地常用的配置是这样spring: data: neo4j: uri: bolt://localhost:7687 username: neo4j password: demo123456 pool: max-connection-pool-size: 50 connection-acquisition-timeout: 60000如果按 Spring Boot 2.x 启动把data这层去掉即可。max-connection-pool-size我建议至少给到 50因为知识图谱问答接口里通常会连续执行实体查询和答案查询两个 Cypher并发上来后连接池太小会直接报PoolTimeoutException。connection-acquisition-timeout给到 60 秒是为了避免高并发时接口迟迟不返回。密码不要写明文在配置文件里演示项目可以这样做但至少把application.yml加到.gitignore里。3.2 实体类和 Repository把图节点映射成 Java 对象Spring Data Neo4j 的核心思想是让图节点变成 Java 对象。以疾病节点为例写一个实体类Node(Disease) public class DiseaseEntity { Id GeneratedValue private Long id; Property(name) private String name; private String icdCode; Relationship(type HAS_SYMPTOM, direction Relationship.Direction.OUTGOING) private ListSymptomEntity symptoms; public DiseaseEntity() { } public DiseaseEntity(String name) { this.name name; } // getter / setter 省略 }这里有几个关键点。Node(Disease)声明这个类对应图里的Disease标签Id和GeneratedValue让 Neo4j 自动生成内部 ID如果你的业务中已经有唯一业务键比如疾病名称也可以把Id放到名称字段上但那样后续更新节点会比较麻烦。Relationship用来声明实体之间的关联关系type必须和图里的关系类型完全一致direction声明方向OUTGOING表示疾病节点指向症状节点。对应的 Repository 接口只需要继承Neo4jRepositorypublic interface DiseaseRepository extends Neo4jRepositoryDiseaseEntity, Long { OptionalDiseaseEntity findByName(String name); Query(MATCH (d:Disease)-[:HAS_SYMPTOM]-(s:Symptom) WHERE d.name $name RETURN d, collect(s) AS symptoms) OptionalDiseaseEntity findDiseaseWithSymptoms(Param(name) String name); }findByName是 Spring Data 根据方法名自动推导出来的查询适用于简单节点查询。复杂查询需要自己写Query上面的 Cypher 里用collect(s)把多个症状聚合成一个列表这样返回的DiseaseEntity.symptoms不会被多次查询填充。还有一个容易翻车的点如果DiseaseEntity里没有无参构造函数Spring Data Neo4j 做映射时直接抛异常查出来的数据全变空。3.3 想要结构化答案用自定义 DTO 接收 Cypher 查询结果问答接口最终返回的不是实体对象而是“症状列表”、“科室名称”这种轻量结构。如果每次都返回实体类会把不必要的图关系也带出来性能差不说返回给前端的数据也很冗余。我一般会定义一个接口投影来接收查询结果public interface SymptomSummary { String getName(); String getLevel(); }然后 Repository 方法改成Query(MATCH (d:Disease {name: $name})-[r:HAS_SYMPTOM]-(s:Symptom) RETURN s.name AS name, r.level AS level ORDER BY CASE r.level WHEN 常见 THEN 0 ELSE 1 END) ListSymptomSummary findSymptomsByName(Param(name) String name);注意RETURN后面的别名必须和投影接口的 getter 名对应AS name对应getName()AS level对应getLevel()。这种投影方式比返回MapString,Object更干净而且前端拿到的 JSON 结构完全可控。如果你用的是Neo4jTemplate也可以直接用query()方法拿到Map列表但维护性不如投影接口。4. 从自然语言问句到 Cypher 查询问答接口的设计与实现4.1 没有 NLP 模型也能做问答词典最大匹配 模板分类毕设项目的问答不需要从零训练模型按实体词典匹配加规则模板就足够撑起演示效果。先把图里的疾病、症状、药品名称全部加载到内存按名称长度倒序排列然后对用户问句做包含匹配这叫做“词典最大匹配”。注意一定要先匹配长的实体比如“高血压病”和“高血压”先匹配短的会把长实体拆断。核心逻辑可以写成一个独立的 ServiceComponent public class EntityExtractor { private final ListEntityDictItem dict new ArrayList(); public void rebuild(ListDiseaseEntity diseases, ListMedicineEntity medicines, ListSymptomEntity symptoms) { dict.clear(); for (DiseaseEntity d : diseases) { dict.add(new EntityDictItem(d.getName(), Disease)); } for (MedicineEntity m : medicines) { dict.add(new EntityDictItem(m.getName(), Medicine)); } // 按名称长度降序保证“高血压病”优先于“高血压” dict.sort(Comparator.comparingInt((EntityDictItem o) - o.name.length()).reversed()); } public ListEntityDictItem extract(String question) { ListEntityDictItem hits new ArrayList(); for (EntityDictItem item : dict) { if (question.contains(item.name)) { hits.add(item); } } return hits; } }这段代码里的rebuild需要在系统启动后执行一次可以从 Repository 里拿全量实体名称。extract面对的是小规模词典毕设数据量一般不超过一万实体遍历一次不会有明显延迟如果以后接入真实医院数据需要换成 Trie 树或 Aho-Corasick 自动机否则每次问答都遍历全量词典会越来越慢。4.2 高频问句模板把“问什么”映射成“查什么”词典只能抽出实体还得知道用户问的是症状、科室还是用药建议。我习惯把问句分成固定模板示例模板如下问句模板示例生成的 Cypher 逻辑XX有什么症状高血压有什么症状Disease 查 HAS_SYMPTOMXX挂什么科高血压挂什么科Disease 查 DEPARTMENTXX不能吃什么药高血压不能吃什么药Disease 反向查 CONTRAINDICATE 的 MedicineXX和什么药相互作用硝苯地平能和阿司匹林一起吃吗Medicine 无方向查 INTERACTXX需要做什么检查高血压需要做什么检查Disease 查 NEED_CHECK以“XX有什么症状”为例接口里生成 Cypher 后交给 Neo4jTemplate 执行RestController RequestMapping(/api/qa) public class QaController { private final EntityExtractor extractor; private final Neo4jTemplate neo4jTemplate; public QaController(EntityExtractor extractor, Neo4jTemplate neo4jTemplate) { this.extractor extractor; this.neo4jTemplate neo4jTemplate; } GetMapping(/chat) public QaResponse chat(RequestParam String question) { ListEntityDictItem entities extractor.extract(question); if (entities.isEmpty()) { return QaResponse.unknown(未识别到医学实体请换个问法); } EntityDictItem target entities.get(0); if (question.contains(有什么症状) || question.contains(症状有哪些)) { String cypher MATCH (d:Disease {name: $name})-[r:HAS_SYMPTOM]-(s:Symptom) RETURN s.name AS symptom, r.level AS level; MapString, Object params Map.of(name, target.name); ListMapString, Object rows neo4jTemplate.query(cypher, params) .stream().map(it - it.asMap()).toList(); return QaResponse.success(question, cypher, rows); } // 其他模板按同样方式扩展 return QaResponse.unknown(暂时只能回答症状、科室、药品问题); } }这个接口把cypher原样返回给前端答辩时可以直接展示“我是怎么从问题生成查询语句的”这是知识图谱问答项目最有说服力的部分。neo4jTemplate.query()返回的是QueryResult每条记录通过asMap()转成Map再用一个通用响应包装返回给前端。4.3 接口返回结构把图路径转成对话卡片问答接口的最终返回结构我建议统一为 JSON 对象而不是一个字符串。前端拿到数据结构化渲染后端也方便扩展卡片和 FAQ。参考结构如下{ code: 200, question: 高血压有什么症状, answer: 高血压可能伴有的症状有头晕、头痛、心悸。, entities: [高血压], cypher: MATCH (d:Disease {name: 高血压})-[r:HAS_SYMPTOM]-(s:Symptom) RETURN s.name AS symptom, rows: [ {symptom: 头晕, level: 常见}, {symptom: 头痛, level: 常见}, {symptom: 心悸, level: 少见} ] }answer字段由后端把rows拼成自然语言这一步不要交给前端做否则前端代码会非常臃肿。如果查询结果为空不要返回空列表而是把answer设置成“未找到相关医学证据建议线下就诊”这样演示时不会显得系统像坏掉一样。接口参数建议加上RequestParam(defaultValue )做空值保护避免null进入实体提取器导致报错。5. Spring Boot Neo4j 医疗问答避坑手册从数据清洗到并发超时的五个翻车点5.1 实体明明存在问答却回答“未找到”现象在 Neo4j Browser 里执行MATCH (d:Disease {name:高血压}) RETURN d有数据但问答接口回答“未找到”。原因数据源里疾病名称不唯一某一条数据叫“高血压”另一条叫“高血压病”而词典提取时优先匹配了长名称“高血压病”进入 Cypher 查询时用高血压病去查只存了高血压的节点自然查不到。解决实体名称规范化。建图时增加别名节点或者在提问进入模板前先做一次归一化映射。我习惯用一张同义词映射表把“高血压病”、“原发性高血压”、“高血压”全部映射到标准名再查图谱。另一个办法是 Cypher 里使用不区分大小写的正则匹配但中文场景下同义词问题不是大小写能解决的最终还是得做别名表。5.2 Cypher 查到数据但接口返回空数组或字段丢失现象直接用 Cypher 查询能返回s.name和r.level但接口返回的rows是空的或者只有部分字段。原因RETURN s.name AS symptom, r.level AS level返回的是字符串和普通类型但投影接口的 getter 名称和别名不一致比如别名写成symptom接口里却是getNameSpring Data Neo4j 无法自动映射。解决把所有RETURN别名和接口 getter 严格对齐。更稳的做法是直接用MapString,Object接收再手动从 map 里取字段字段少时这样更直接字段多了再换投影接口。我自己踩过一次后就养成习惯Cypher 里一律写清楚AS xxx不依赖大小写匹配。5.3 本地查询很快一压测就报连接池超时现象接口单次调用 200 毫秒内返回用压测工具并发 100 次后后台报Failed to obtain connection within...。原因Neo4j 连接池默认值太小且问答接口里可能同时调用了实体加载和 Cypher 查询两个连接操作连接数被快速耗尽。解决把spring.data.neo4j.pool.max-connection-pool-size调到 50同时确认方法上是否加了Transactional(readOnly true)。只读查询加事务后连接释放会更快如果加了Transactional但还是超时检查是不是把整个列表循环查询写进了同一个事务里长时间占着连接不放。正确的做法是图谱查询一次 Cypher 拿全量结果不要在事务里逐条查。5.4 数据清洗时用 CREATE导入两遍后图谱数据翻倍现象导入脚本执行两遍HAS_SYMPTOM关系数量直接翻倍连带着答案里出现同一个症状重复三次。原因脚本里用的是CREATE每次执行都创建新节点新关系没有幂等性。解决所有导入脚本统一使用MERGE并且给核心节点加上唯一约束。唯一约束存在时MERGE的行为是先查再建重复执行只会命中已有节点。这里有个细节约束创建失败往往是因为已有重复节点所以导入前先清理历史垃圾数据或者把约束创建放在首次建图之后。给毕设项目写一个init-data.sh每次重跑前先执行MATCH (n) DETACH DELETE n再执行导入脚本这样演示环境不会越跑越乱。5.5 中文问句在服务器上变成乱码怎么都查不到现象本地中文问答正常部署到 Linux 服务器后日志里看到的问题字符串全变成了???或编码错误Neo4j 查询结果变空。原因Spring Boot 默认请求编码和服务器文件编码不一致最常见的是导入的 CSV 或实体名称文件用 GBK 编码保存程序读取后进入 Neo4j 的中文全部乱码。解决在application.yml里显式设置 Spring MVC 编码为 UTF-8并把所有资源文件和 CSV 统一转成 UTF-8。具体配置如下server: servlet: encoding: charset: UTF-8 enabled: true force: true导入知识图谱数据时不要用 Excel 直接另存为 CSV那样会默认存成 GBK建议把数据文件统一通过文本编辑器转成 UTF-8 with BOM 或 UTF-8 without BOM。Neo4j 本身对 Unicode 支持很好绝大多数中文查不到问题都出在编码转换链路而不是图数据库自身。6. 把毕设做成可演示的系统一条命令重启 Neo4j 和 Spring Boot答辩前最后一个下午最怕的是什么现场改数据改完忘了清缓存演示时答案还是旧数据。我的习惯是把整套环境固化成一条启动命令让答辩现场可以一键拉起来。#!/usr/bin/env bash # 启动 Neo4j 容器5.x 社区版开启 apoc 扩展可选 docker run -d --name neo4j-demo \ -p 7474:7474 -p 7687:7687 \ -e NEO4J_AUTHneo4j/demo123456 \ -e NEO4J_PLUGINS[apoc] \ neo4j:5-community sleep 30 # 导入图谱数据脚本内部用 MERGE可重复执行 ./scripts/import-data.sh # 启动 Spring Boot ./mvnw spring-boot:run这里的要点是容器停止后不要删掉重跑docker start neo4j-demo数据还在如果要彻底初始化再执行docker rm -f neo4j-demo后重新docker run。导入脚本每次执行前都清掉旧上下文保证幂等。Spring Boot 打包时记得把application.yml里的 Neo4j 地址写成环境变量比如${NEO4J_URI:bolt://localhost:7687}这样换机器演示不需要改代码。最后一个忠告不要在答辩现场现查现改 Cypher把常见的五类问答预先写成演示脚本每次只改疾病名词成功率会高很多。我当年就是因为现场改脚本漏了一个引号答案变成空列表那十分钟非常难熬。后来我给自己定了个规矩所有演示环境必须能在三分钟内从零重建所有问答模板必须在部署前用自动化脚本跑一遍。做到这两点这套 Spring Boot Neo4j 医疗知识图谱问答才算真正稳了希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑