简介:这是一套面向计算机、通信、人工智能等专业学生与开发者的医疗领域知识图谱问答项目源码,基于SpringBoot与Neo4j构建,可作为毕业设计、课程大作业或期末课设的完整参考方案,也适合希望入门知识图谱与图数据库应用的小白进阶学习。压缩包共210个文件,约71.71MB,以61个java源文件、62个class编译文件、66个txt说明文档为主,另含xml配置、json数据、properties参数文件及md项目说明,覆盖实体建模、数据生成、问句分类与匹配等核心模块。目前已有268人学习下载。项目经调试测试可稳定运行,答辩评审分达98分,读者可据此掌握医疗知识图谱的构建流程、Neo4j图数据库操作与问答匹配逻辑,并在此基础上修改调整,实现个性化功能扩展。
1. 医疗知识图谱问答系统:从 SpringBoot 到 Neo4j 的完整落地路径
医疗领域的数据有个特点:实体多、关系杂、术语还特别绕。一个「高血压」可能关联到几十种药物、并发症、检查指标,用传统关系型数据库做多跳查询,SQL 写到后面自己都看不懂。这个基于 SpringBoot + Neo4j 的医疗知识图谱问答项目,解决的正是这个问题——把疾病、症状、药品、科室等实体建成图结构,用户用自然语言提问,系统解析意图后在图谱里检索答案。它适合正在做 Java 毕业设计的学生,也适合想了解知识图谱问答系统怎么从零搭起来的开发者。源码包里有完整的后端代码、图谱构建脚本和项目说明,拿到手能跑、能改、能扩展。
2. 技术选型拆解:为什么是 SpringBoot + Neo4j 而不是 MySQL
2.1 图数据库在医疗场景下的不可替代性
医疗问答的核心操作是「多跳关系查询」。比如用户问「糖尿病患者不能吃哪些药」,系统需要先找到「糖尿病」节点,再沿着「禁忌药物」关系找到所有关联药品节点。在 MySQL 里,这至少涉及三张表 JOIN,如果关系层级再深一点,查询性能会断崖式下跌。Neo4j 作为原生图数据库,节点和关系都是物理存储,遍历关系的时间复杂度是 O(1),跟图谱规模无关。
这个项目里,医疗实体大致分为几类:疾病(Disease)、症状(Symptom)、药品(Drug)、科室(Department)、检查项目(Check)。实体之间的关系包括「疾病-症状」「疾病-药品」「疾病-科室」「药品-禁忌」等。用 Cypher 查询语言表达这些关系,比 SQL 直观得多:
// 查询高血压关联的所有症状和药品 MATCH (d:Disease {name: '高血压'})-[:HAS_SYMPTOM]->(s:Symptom) OPTIONAL MATCH (d)-[:TREATED_BY]->(drug:Drug) RETURN d.name, collect(s.name) AS symptoms, collect(drug.name) AS drugs这段 Cypher 的逻辑很清晰:先匹配疾病节点,再分别沿着「有症状」和「被治疗」两条关系边扩展。OPTIONAL MATCH保证即使某种关系不存在,疾病节点本身也会返回。参数{name: '高血压'}是节点属性过滤,实际项目中会从用户输入里抽取实体后动态传入。
2.2 SpringBoot 在问答系统中的角色分工
SpringBoot 在这个项目里承担的是「编排层」的职责。它不直接处理图谱查询,而是负责:接收前端请求、调用 NLP 模块做意图识别和实体抽取、把抽取结果转成 Cypher 查询、调用 Neo4j 驱动执行、把结果组装成自然语言返回。
项目常见的分层结构是这样的:
| 层级 | 职责 | 关键类/包 |
|---|---|---|
| Controller | 接收 HTTP 请求,参数校验 | QaController |
| Service | 意图识别、实体抽取、查询编排 | QaService、NlpService |
| Repository | Neo4j 数据访问 | DiseaseRepository等 |
| Config | Neo4j 连接、CORS、拦截器 | Neo4jConfig |
这种分层的好处是,NLP 模块可以独立替换。比如你一开始用 HanLP 做分词和实体识别,后面想换成别的方案,只要接口不变,Service 层不用大改。
2.3 环境搭建:Neo4j 安装与 SpringBoot 项目初始化
Neo4j 社区版就够用。下载解压后,进入bin目录执行启动命令:
# Linux/Mac ./neo4j start # Windows neo4j.bat start启动后访问http://localhost:7474,默认用户名和密码都是neo4j,首次登录会强制改密码。这里有个坑:社区版默认只监听本地,如果 SpringBoot 和 Neo4j 不在同一台机器上,需要改conf/neo4j.conf里的dbms.default_listen_address=0.0.0.0。
SpringBoot 项目初始化时,在pom.xml里加 Neo4j 驱动依赖:
<dependency> <groupId>org.neo4j.driver</groupId> <artifactId>neo4j-java-driver</artifactId> <version>5.x.x</version> <!-- 版本号以项目实际为准 --> </dependency>如果用 Spring Data Neo4j,还需要加spring-boot-starter-data-neo4j。两者的区别是:原生驱动更灵活,适合手写 Cypher;Spring Data Neo4j 提供了 Repository 抽象,简单查询写起来快,但复杂查询还是得用@Query注解写 Cypher。
配置文件application.yml里至少要配这些:
spring: neo4j: uri: bolt://localhost:7687 authentication: username: neo4j password: 你的密码bolt://是 Neo4j 的二进制协议端口,比 HTTP 端口(7474)性能好,生产环境都用这个。
3. 图谱构建与问答流程:从 CSV 导入到意图识别
3.1 医疗实体数据建模与 CSV 批量导入
项目源码里通常会带一份医疗数据的 CSV 文件,格式大概是这样的:
disease,symptom,drug,department 高血压,头痛,硝苯地平,心内科 高血压,头晕,氯沙坦,心内科 糖尿病,多饮,二甲双胍,内分泌科导入 Neo4j 有两种常见做法。一种是写 Cypher 的LOAD CSV:
// 导入疾病-症状关系 LOAD CSV WITH HEADERS FROM 'file:///medical_data.csv' AS row MERGE (d:Disease {name: row.disease}) MERGE (s:Symptom {name: row.symptom}) MERGE (d)-[:HAS_SYMPTOM]->(s);MERGE而不是CREATE是关键——MERGE会先检查节点是否存在,避免重复导入时产生重复节点。file:///指向 Neo4j 安装目录下的import文件夹,CSV 必须放在那里才能被读到。
另一种做法是在 SpringBoot 启动时用 Java 代码批量导入,适合数据量不大或者需要做数据清洗的场景:
// 伪代码示意,实际以项目源码为准 try (Session session = driver.session()) { for (MedicalRecord record : records) { session.run( "MERGE (d:Disease {name: $disease}) " + "MERGE (s:Symptom {name: $symptom}) " + "MERGE (d)-[:HAS_SYMPTOM]->(s)", Map.of("disease", record.getDisease(), "symptom", record.getSymptom()) ); } }参数用$disease这种占位符传入,不要用字符串拼接,否则会有 Cypher 注入风险。批量导入时建议每 500 条提交一次事务,太大容易内存溢出,太小则性能差。
3.2 意图识别与实体抽取的工程实现
用户输入「高血压吃什么药」,系统需要做两件事:识别意图是「查询治疗药物」,抽取实体是「高血压」。项目里常见的做法是用 HanLP 做分词和命名实体识别,再配合关键词匹配判断意图。
// 简化的意图识别逻辑 public QaIntent recognizeIntent(String question) { // 用 HanLP 分词 List<Term> terms = HanLP.segment(question); // 提取医疗实体 List<String> entities = terms.stream() .filter(t -> t.nature == Nature.nz || t.nature == Nature.n) .map(Term::word) .collect(Collectors.toList()); // 关键词匹配意图 if (question.contains("吃什么药") || question.contains("用什么药")) { return new QaIntent("QUERY_DRUG", entities); } if (question.contains("什么症状") || question.contains("有哪些表现")) { return new QaIntent("QUERY_SYMPTOM", entities); } return new QaIntent("UNKNOWN", entities); }这段代码的逻辑是:先分词,再根据词性筛选可能的医疗实体(nz是其他专名,n是名词),最后用关键词匹配确定意图。实际项目中,实体识别会更精细,比如维护一个医疗实体词典,用词典匹配 + 词性过滤双重校验。
意图识别完成后,Service 层根据意图类型选择对应的 Cypher 模板:
public String answer(String question) { QaIntent intent = nlpService.recognizeIntent(question); String cypher; switch (intent.getType()) { case "QUERY_DRUG": cypher = "MATCH (d:Disease {name: $name})-[:TREATED_BY]->(drug:Drug) " + "RETURN drug.name AS answer"; break; case "QUERY_SYMPTOM": cypher = "MATCH (d:Disease {name: $name})-[:HAS_SYMPTOM]->(s:Symptom) " + "RETURN s.name AS answer"; break; default: return "抱歉,我暂时无法理解这个问题"; } // 执行查询并组装答案 return executeCypher(cypher, intent.getEntities().get(0)); }这种「意图 → Cypher 模板 → 参数填充」的模式,是这个项目最核心的工程思路。它的好处是可扩展:新增一种问法,只需要加一个意图类型和一个 Cypher 模板。
3.3 多跳查询与答案组装
有些问题需要多跳查询。比如「高血压患者不能吃哪些药」,路径是:疾病 → 禁忌药品 → 药品名称。Cypher 写起来是这样的:
MATCH (d:Disease {name: $name})-[:CONTRAINDICATED_DRUG]->(drug:Drug) RETURN drug.name AS drugName, drug.description AS description如果关系方向不确定,可以用-[r]-不指定方向,或者用shortestPath找最短路径:
MATCH (d:Disease {name: $name}), (target:Drug) WHERE target.name = $drugName MATCH path = shortestPath((d)-[*..5]-(target)) RETURN path[*..5]表示最多 5 跳,防止查询在大图上无限扩展。这个参数要根据实际图谱的深度来调,太小查不到,太大性能差。
答案组装时,通常会把查询结果拼成一段自然语言:
public String formatAnswer(String intentType, List<String> results) { if (results.isEmpty()) { return "未找到相关信息"; } switch (intentType) { case "QUERY_DRUG": return "治疗该疾病的常用药物包括:" + String.join("、", results); case "QUERY_SYMPTOM": return "该疾病的常见症状有:" + String.join("、", results); default: return String.join("、", results); } }4. 避坑与排查:那些让我加班到凌晨的问题
4.1 Neo4j 连接超时或认证失败
现象:SpringBoot 启动时报Unable to connect to localhost:7687或Authentication failed。
原因:三种可能——Neo4j 没启动、端口不对、密码没改对。社区版首次登录必须改密码,如果跳过这步,后续所有连接都会失败。
解决:先确认 Neo4j 进程在跑(neo4j status),再检查application.yml里的 URI 是bolt://不是http://,最后确认密码和 Neo4j 浏览器里设置的一致。如果忘了密码,可以删掉data/dbms/auth文件重置。
4.2 Cypher 查询返回空结果但数据明明存在
现象:在 Neo4j 浏览器里能查到的数据,通过 Java 驱动查就是空的。
原因:最常见的是属性名大小写不一致。Neo4j 的属性名是区分大小写的,{name: '高血压'}和{Name: '高血压'}是两个不同的属性。另一个原因是中文编码问题,CSV 导入时如果没指定 UTF-8,中文会变成乱码。
解决:统一属性命名规范,建议全小写。CSV 文件保存时确认编码是 UTF-8,LOAD CSV时可以加FIELDTERMINATOR和ENCODING参数。
4.3 意图识别准确率低,答非所问
现象:用户问「糖尿病不能吃什么」,系统返回了「糖尿病的症状」。
原因:关键词匹配太粗糙,「不能吃什么」和「吃什么」被归到了同一类意图。另外,实体抽取时如果没识别出「糖尿病」,后续查询直接失败。
解决:意图匹配的关键词要覆盖否定句式,比如「不能」「禁忌」「避免」单独归为一类。实体抽取可以加一个医疗词典做兜底,HanLP 的通用模型对医疗术语识别率有限,自定义词典能明显提升效果。
4.4 批量导入时内存溢出
现象:导入几千条数据时 Java 进程 OOM,或者 Neo4j 响应变慢。
原因:一次性把所有数据加载到内存,或者每条数据单独提交事务,导致事务开销过大。
解决:分批处理,每 500 条提交一次。用session.run()时不要每次新建 Session,复用同一个 Session 对象。如果数据量特别大,考虑用 Neo4j 的neo4j-admin import工具做离线导入,速度比 Cypher 快一个数量级。
4.5 前端跨域请求被拦截
现象:前端调接口时报CORS policy错误。
原因:SpringBoot 默认不允许跨域请求,而前端开发服务器通常跑在另一个端口。
解决:加一个全局 CORS 配置类,或者在 Controller 上加@CrossOrigin注解。生产环境建议用 Nginx 做反向代理,把前后端放在同一个域名下,从根上避免跨域问题。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOrigins("*") .allowedMethods("GET", "POST") .allowedHeaders("*"); } }5. 进阶技巧:让问答系统更懂医疗场景
5.1 用同义词扩展提升召回率
医疗领域的同义词特别多:「心梗」和「心肌梗死」、「高血压」和「血压高」、「糖尿病」和「消渴症」。如果图谱里只存了标准术语,用户用口语提问就查不到。常见的做法是建一张同义词表,在实体抽取后做一次归一化:
private static final Map<String, String> SYNONYM_MAP = Map.of( "心梗", "心肌梗死", "血压高", "高血压", "消渴症", "糖尿病" ); public String normalizeEntity(String entity) { return SYNONYM_MAP.getOrDefault(entity, entity); }这张表可以硬编码在代码里,也可以存到 Neo4j 里做成Synonym节点,通过关系关联到标准实体。后者的好处是维护方便,不用改代码重新部署。
5.2 查询结果排序与置信度
当查询返回多个结果时,需要决定哪个排在前面。一个简单的策略是按关系权重排序:在图谱里给每条关系加一个weight属性,查询时按权重降序返回。
MATCH (d:Disease {name: $name})-[r:TREATED_BY]->(drug:Drug) RETURN drug.name AS name, r.weight AS weight ORDER BY r.weight DESC LIMIT 10weight可以手动标注,也可以根据数据来源的可信度自动生成。比如来自临床指南的数据权重设为 1.0,来自百科的设为 0.6。这样返回的结果更符合医疗场景的严谨性要求。
5.3 对话上下文保持
单轮问答只能处理简单问题,用户问完「高血压吃什么药」之后接着问「那有什么副作用」,系统需要知道「那」指的是前面提到的药物。实现方式是在 Session 里存一个上下文对象:
public class DialogContext { private String lastEntity; private String lastIntent; // getter/setter 省略 }每次请求进来先检查上下文,如果当前问题里没有实体,就用上下文里的lastEntity补上。这个机制不复杂,但能明显提升多轮对话的体验。
5.4 图谱可视化与调试
开发阶段建议把 Neo4j 浏览器一直开着,写完 Cypher 先在浏览器里跑一遍,确认结果对了再写到代码里。Neo4j 浏览器会自动把查询结果渲染成图,节点和关系一目了然。如果查询结果不对,可以逐步拆解:先查节点是否存在,再查关系是否存在,最后查完整路径。
我自己的习惯是,每次改完 Cypher 模板,先在浏览器里用真实数据跑三遍:一遍正常输入,一遍边界输入(比如不存在的疾病名),一遍空输入。三遍都过了再集成到代码里。这个习惯帮我省了很多返工的时间。
希望帮到你。
本文还有配套的精品资源,点击获取