ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

简介:基于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> <groupId>org.springframework.boot</groupId> <artifactId>spring-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 List<SymptomEntity> 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 接口只需要继承Neo4jRepository:

public interface DiseaseRepository extends Neo4jRepository<DiseaseEntity, Long> { Optional<DiseaseEntity> findByName(String name); @Query("MATCH (d:Disease)-[:HAS_SYMPTOM]->(s:Symptom) " + "WHERE d.name = $name " + "RETURN d, collect(s) AS symptoms") Optional<DiseaseEntity> 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") List<SymptomSummary> findSymptomsByName(@Param("name") String name);

注意RETURN后面的别名必须和投影接口的 getter 名对应,AS name对应getName(),AS level对应getLevel()。这种投影方式比返回Map<String,Object>更干净,而且前端拿到的 JSON 结构完全可控。如果你用的是Neo4jTemplate,也可以直接用query()方法拿到Map列表,但维护性不如投影接口。

4. 从自然语言问句到 Cypher 查询:问答接口的设计与实现

4.1 没有 NLP 模型也能做问答:词典最大匹配 + 模板分类

毕设项目的问答不需要从零训练模型,按实体词典匹配加规则模板就足够撑起演示效果。先把图里的疾病、症状、药品名称全部加载到内存,按名称长度倒序排列,然后对用户问句做包含匹配,这叫做“词典最大匹配”。注意一定要先匹配长的实体,比如“高血压病”和“高血压”,先匹配短的会把长实体拆断。

核心逻辑可以写成一个独立的 Service:

@Component public class EntityExtractor { private final List<EntityDictItem> dict = new ArrayList<>(); public void rebuild(List<DiseaseEntity> diseases, List<MedicineEntity> medicines, List<SymptomEntity> 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 List<EntityDictItem> extract(String question) { List<EntityDictItem> 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_SYMPTOM
XX挂什么科高血压挂什么科Disease 查 DEPARTMENT
XX不能吃什么药高血压不能吃什么药Disease 反向查 CONTRAINDICATE 的 Medicine
XX和什么药相互作用硝苯地平能和阿司匹林一起吃吗Medicine 无方向查 INTERACT
XX需要做什么检查高血压需要做什么检查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) { List<EntityDictItem> 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"; Map<String, Object> params = Map.of("name", target.name); List<Map<String, 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,接口里却是getName,Spring Data Neo4j 无法自动映射。
解决:把所有RETURN别名和接口 getter 严格对齐。更稳的做法是直接用Map<String,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_AUTH=neo4j/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 医疗知识图谱问答才算真正稳了,希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进