简介这是一份基于Spring Boot与Neo4j实现的医疗系统知识图谱问答毕设项目面向Java全栈开发者、知识图谱初学者以及毕业设计选题学生。资源完整呈现了从数据建模、图存储到自然语言问答的落地流程适合用来学习Spring Data Neo4j集成、Cypher查询和NLP实体识别等关键技术。压缩包共210个文件包含61个Java源码、62个class编译文件、66个txt文本、5个xml配置、4个properties配置及json、bin、iml等辅助文件整体大小约71.69MB结构清晰便于按模块研读和二次开发。项目覆盖启动类、控制器、服务层、DAO层及Neo4j交互代码并涉及疾病、症状、药物等实体关系的图谱构建通过实际问答场景展示如何解析问题并返回答案。资源包已吸引527人学习浏览对于想深入理解图数据库落地应用或快速搭建知识图谱问答原型的开发者是一份具备参考价值的实战素材。1. 基于Spring Boot Neo4j的医疗系统知识图谱问答先搞懂它到底能答什么如果你的毕设方向锁定在 Spring Boot 后端又想和普通的管理系统拉开差距基于 Spring Boot Neo4j 的医疗系统知识图谱问答值得认真评估。它解决的痛点不是能不能查而是能不能顺着关系答把疾病、症状、药品、科室、检查项抽成实体和关系装进图库用户用自然语言问感冒了吃什么药返回的不只是一条记录而是带关系链的答案和可视化图谱。适合两类人Spring Boot 基础已过关、想补一课图数据库建模和中文分词实操的学生以及需要评估 Neo4j 在垂直领域落地成本的开发者。要提前想清楚边界它擅长事实型问答和关系查询不擅长复杂病案推理别当成诊断决策系统。2. 系统架构与数据流一次问答从Controller到Neo4j要过哪几层2.1 模块边界把NLP和图查询拆开后面才改得动这套工程的可贵之处在于它把问答链路拆成了职责清晰的四段而不是把全部逻辑堆在一个 Service 里。我在本地把工程跑起来之后先抓了一次完整请求的调用链顺序是这样的Controller 层接收问句只做参数校验和结果包装Service 层编排流程依次调用分词、实体识别、意图分类意图模块根据规则产出意图名和实体名交给 Cypher 模板生成器执行器把参数化 Cypher 提交给 Neo4j拿回结果后组装成统一答案对象返回前端。这个顺序本身没有惊喜但边界划得清楚才有价值。实体识别和意图分类是独立类Cypher 生成独立成类意味着想加一个忌口问答模板时只需要新增一条意图规则和一条 Cypher 模板主流程代码一行都不用动。我见过不少毕设把分词、拼 Cypher、执行查询全部写在一个方法里答辩现场演示时想临时加功能改一处崩三处那就是典型的模块边界没划好。值得注意的一点这套资源里 Controller 返回的答案对象不是只有文字还有一个关系列表前端拿到以后可以直接用关系图谱组件渲染成图。这个设计让知识图谱的呈现变得非常直观也是答辩时的加分点。如果你要自己改造建议保留这个结构不要为了省事只返回纯文本。2.2 工程接入pom依赖、配置文件与连接池参数先把依赖看清楚。pom.xml 里和本选题强相关的依赖就三个Web、spring-boot-starter-data-neo4j以及一个中文分词库。spring-boot-starter-data-neo4j 会帮你自动装配 Driver、Template 和 Repository 机制省掉大量样板代码。!-- pom.xml只列和本选题直接相关的依赖 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-neo4j/artifactId /dependency dependency groupIdcom.hankcs/groupId artifactIdhanlp/artifactId !-- 版本号以你本地 Maven 仓库能解析到的为准 -- /dependency这里的逻辑是Web 提供接口能力data-neo4j 提供图数据库驱动hanlp 负责中文分词和实体识别。starter 自动装配完成后你只需要在 application.yml 里告诉驱动图库在哪里、用什么账号连。最常见的配置长这样spring: neo4j: uri: bolt://localhost:7687 authentication: username: neo4j password: 改成你自己的密码参数说明uri 是 Bolt 协议地址7687 是 Neo4j 默认端口authentication 下的账号密码要与 Neo4j 实例保持一致。这里有一个版本差异要提醒你Spring Boot 2.x 用的是 spring.data.neo4j 前缀Spring Boot 3.x 换成了 spring.neo4j你要是从网上复制老配置启动时会发现属性不生效甚至直接报错。判断办法很简单看 IDE 里配置项有没有飘红或者启动日志里有没有 Unknown property 提示。如果后续要做压测连接池参数值得显式配出来。我一般会这样加spring: neo4j: pool: max-connection-pool-size: 50 connection-acquisition-timeout: 60s参数含义max-connection-pool-size 控制最多同时持有的物理连接数问答场景 QPS 不高时 50 足够connection-acquisition-timeout 是向连接池申请连接的最长等待时间冷启动的第一请求容易撞上这个超时给它留足余量能少踩一个坑。具体属性名会随 Spring Boot 版本微调以 IDE 提示为准。2.3 三种查询方式Template、Repository、手写Session怎么选spring-boot-starter-data-neo4j 提供了三套查询入口Neo4jTemplate 适合对单节点做 CRUDQuery 注解的 Repository 适合固定 Cypher 模板直接拿 Driver 开 Session 适合问答这种动态拼 Cypher 的场景。这套工程最核心的查询方式是第三种因为用户问句是无限变化的模板无法穷举只能由意图模块动态生成 Cypher 字符串再执行。工程里的执行器大致长这样// 动态 Cypher 执行器负责把模板生成的语句提交给 Neo4j Service public class Neo4jExecutor { private final Driver driver; public Neo4jExecutor(Driver driver) { this.driver driver; } public ListMapString, Object query(String cypher, MapString, Object params) { try (Session session driver.session()) { return session.run(cypher, params) .list(row - row.asMap()); } } }逻辑说明try-with-resources 保证每个 Session 用完后归还连接池避免连接泄漏session.run(cypher, params) 把语句和参数分开提交参数会被驱动安全地 bind 成 Cypher 参数而不是拼进字符串。返回的 List里每个 Map 是一行结果键名就是 Cypher 里的 AS 别名。有一点要特别说明如果你本地 Neo4j 开了多数据库比如默认的 neo4j 库之外还建了别的库最好在 driver.session() 里加 SessionConfig 指定库名否则驱动默认连系统库查不到任何业务数据。单库部署就无所谓。3. 知识图谱建模与数据导入实体、关系、索引缺一个都会翻车3.1 实体和关系模型六类节点、六类关系怎么定知识图谱问答的地基是图模型。这套资源采用的模型在医疗知识图谱实践里比较通行六类实体节点六类关系。实体类型和关键属性如下。实体类型关键属性示例疾病 Diseasename、department、infectivity感冒、高血压症状 Symptomname、description咳嗽、发热药品 Drugname、usage、dosage布洛芬食物 Foodname、nature梨、辣椒检查项 CheckItemname血常规科室 Departmentname呼吸内科关系类型全部按主语→宾语的方向定义避免后续模板方向混乱关系方向语义HAS_SYMPTOM疾病→症状该疾病有哪些症状TREAT_WITH疾病→药品用什么药治疗CHECK_WITH疾病→检查项需要做什么检查BELONGS_TO疾病→科室挂哪个科室EAT_GOOD疾病→食物宜吃什么EAT_BAD疾病→食物忌吃什么这个模型有两个细节值得学习。第一个是食物关系拆成了 EAT_GOOD 和 EAT_BAD 两条而不是用一条 EAT 关系加一个正负属性。原因很简单问答模板里吃什么好和不能吃什么是两个完全不同的意图查询时按关系类型过滤比按属性过滤直观得多Cypher 写起来也短。第二个细节是 BELONGS_TO 直接挂在疾病上而不是挂在症状上这样感冒挂什么科这类问句可以用一条边回答不需要跳两步。实际导入的时候数据规模大概在几万节点量级来自公开医疗百科的结构化整理。这个体量下不需要分布式图计算单机 Neo4j 完全扛得住这也正是图库在这个场景下的优势——数据量不大但关系查询的演示效果极好。3.2 CSV数据导入LOAD CSV与MERGE的正确姿势知识图谱的数据导入通常分两步先建实体再建关系。工程里数据以 CSV 形式放在 Neo4j 的 import 目录下典型的导入语句是这样// 导入疾病实体用 MERGE 而不是 CREATE重复跑不会产生重复节点 LOAD CSV WITH HEADERS FROM file:///disease.csv AS line MERGE (d:Disease {name: line.name}) ON CREATE SET d.department line.department, d.infectivity line.infectivity;逻辑说明MERGE 会先按 name 查一遍没有才创建所以脚本可以安全地重复执行ON CREATE SET 只在节点第一次创建时才补齐属性后续再导入不会把已有属性覆盖成空值。这里要注意 name 必须唯一否则 MERGE 会创建出多个同名节点后面的关系匹配会全部错乱。关系导入的写法是先把两个端点 MATCH 出来再 MERGE 关系// 导入 疾病-症状 关系 LOAD CSV WITH HEADERS FROM file:///disease_symptom.csv AS line MATCH (d:Disease {name: line.disease}) MATCH (s:Symptom {name: line.symptom}) MERGE (d)-[:HAS_SYMPTOM]-(s);这段的逻辑是每一行 CSV 代表一条关系先按疾病名找到疾病节点按症状名找到症状节点再在两者之间建一条边。性能上的坑在于如果数据有十万条关系每行都要做两次按名 MATCH没索引时会非常慢。所以导入前一定要先建好索引见 3.3否则一次全量导入跑一两个小时都不知道卡在哪。文件放置上工程里一般会先执行一个复制命令把数据文件拷进 Neo4j 的 import 目录# 把清洗好的 CSV 放进 Neo4j 的 import 目录路径按你的安装位置改 cp data/disease.csv data/disease_symptom.csv /opt/neo4j/import/注意 file:/// 路径永远相对 Neo4j 的 import 文件夹不是相对你项目路径。很多人第一次跑 LOAD CSV 报 File not found就是这个原因。Windows 上 Neo4j 安装目录下的 import 文件夹一样处理。3.3 索引与约束让MATCH不再全表扫描图数据库真正的性能法宝是索引和唯一约束。导入前先执行这两条// Neo4j 5.x 写法4.x 请用 CREATE CONSTRAINT ON (d:Disease) ASSERT d.name IS UNIQUE CREATE CONSTRAINT disease_name_unique IF NOT EXISTS FOR (d:Disease) REQUIRE d.name IS UNIQUE; CREATE INDEX symptom_name_index IF NOT EXISTS FOR (s:Symptom) ON (s.name);参数说明约束保证了疾病名的唯一性从源头阻止重复节点索引则为按名 MATCH 建立查询加速原理和关系型数据库的索引类似。工程里每个实体类型的 name 字段都建议建上顺序是先建索引再导入数据导入速度和查询速度都会明显改善。实际体感几万节点规模下有索引的按名 MATCH 是毫秒级没索引可能到几百毫秒甚至秒级问答场景里这个差距直接决定用户体验。4. 问答链路实现从问句到Cypher的三段式转换4.1 分词与自定义词典医疗实体不能被通用分词拆碎问答链路的第一段是让机器看懂问句。中文没有空格分词通用分词器面对布洛芬缓释胶囊过敏性鼻炎这类医疗长词时大概率会拆成碎词导致后续实体识别失败。工程里的做法是先把图库里所有实体名批量灌进分词器的自定义词典让分词结果往实体名上靠。核心代码// 把所有实体名加入自定义词典防止医疗长词被误切 public class DictionaryLoader { public void load(ListString entityNames) { for (String name : entityNames) { hanlp.CustomDictionary.add(name); } } }逻辑说明CustomDictionary.add 把词条加入分词器的全局词典后续 HanLP 分词时会优先按完整词条切分。这里的 entityNames 从哪来一般是从图库直接扫一遍所有节点的 name 属性也可以加载数据源 CSV 里的去重实体列表。两个来源我都试过直接扫库更省事数据一变下次重建词典就行。参数上有一个细节实体名里有不少别名比如高血压又叫高血压病。词典里只加标准名的话用户问高血压病去哪看会匹配不到。工程里通常会把 CSV 里的 alias 字段也一并加入词典并在实体识别时做别名到标准名的映射。我建议你改造时保留这个别名表它是实体识别召回率的关键。4.2 意图规则与模板映射演示级问答用正则模板就够分词之后是意图识别。很多同学一上来就想用深度学习模型做意图分类实际在毕设这种规模和演示场景下一套有序的正则规则比模型更好用、更好讲。工程里的意图规则大致长这样// 意图规则表正则在前命中即返回顺序敏感 public class IntentParser { private static final ListRule RULES Arrays.asList( new Rule(query_drug, .*吃什么药|.*用什么药|.*怎么治疗), new Rule(query_symptom, .*什么症状|.*有哪些表现), new Rule(query_department,.*挂什么科|.*去哪个科室), new Rule(query_not_eat, .*不能吃|.*忌口), new Rule(query_eat_good, .*宜吃|.*吃什么好) ); public String parse(String question) { for (Rule rule : RULES) { if (Pattern.matches(rule.pattern, question)) { return rule.intent; } } return unknown; } }逻辑说明规则按具体优先排列比如不能吃什么必须在吃什么好前面判断否则两条正则都命中的时候会返回错误意图。Pattern.matches 要求全串匹配所以每条正则前后都加了 .*。这个设计的好处是加意图只需要加一行规则坏处是遇到说法不在规则里的问句就会落入 unknown需要兜底逻辑接住。实际问答时还有一个关键步骤实体识别要在意图判断前完成。一般做法是先遍历问题分词结果看哪些词命中实体词典找到疾病实体后再交给意图规则。这样感冒了吃什么药先抽到实体感冒再命中 query_drug 规则后面的 Cypher 生成就有据可依。工程里这两个类是分开的顺序是实体优先、意图其次别反了。4.3 参数化Cypher与兜底回答模板生成和失败降级意图和实体都有了最后一步是把它翻译成 Cypher。工程里用模板方法生成语句且强制参数化// Cypher 模板生成器按意图返回对应查询语句参数统一用 $占位 public class CypherBuilder { public String build(String intent) { if (query_drug.equals(intent)) { return MATCH (d:Disease {name:$disease})-[:TREAT_WITH]-(drug:Drug) RETURN drug.name AS name, drug.usage AS usage; } if (query_symptom.equals(intent)) { return MATCH (d:Disease {name:$disease})-[:HAS_SYMPTOM]-(s:Symptom) RETURN s.name AS name; } // 其他意图同理…… return null; } }参数说明$disease 是占位符执行时由参数 Map 传入真实实体名驱动会把值 bind 成参数而不是拼进字符串。这是防注入的底线也是让 Neo4j 复用查询计划的关键。命名规范上模板里所有参数都用实体名加前缀比如 $disease、$symptom一看就知道该传什么。光有正常路径不够兜底逻辑决定用户体验的底线。工程里一般有三层兜底第一层实体识别为空时返回没有识别到您关心的疾病或症状请换种说法再试第二层意图规则未命中时返回该实体的基础信息比如疾病的简介而不是直接报错第三层Cypher 查询结果为空时用模糊查询再试一次-- 兜底查询精确匹配失败后用包含匹配注意 CONTAINS 不走索引只适合小数据量 MATCH (d:Disease) WHERE d.name CONTAINS $keyword RETURN d.name AS name LIMIT 5;这段的逻辑是让系统答不出来时至少给个相近答案。注意 CONTAINS 在 Neo4j 里一般走不了索引数据量大时会慢但在几万节点的演示数据上完全可接受。把三层兜底讲清楚答辩时比只讲主流程更能体现工程考虑。5. 常见问题与避坑开发中反复踩的五个坑5.1 冷启动第一次查询必超时现象服务启动后发的第一条问答请求经常卡住十几秒然后抛 ConnectionPoolTimeoutException点第二次又正常了。很多同学第一反应是代码写错了其实不是。原因Neo4j 驱动是懒加载的第一条真实查询到来时才建立物理连接握手、认证、路由表同步都在这个时间点发生冷连接耗时远高于正常查询。连接池的获取超时如果配得太小默认有时只有几秒冷启动就直接超时失败。解决两个办法一起用最靠谱。一是在 application.yml 把 connection-acquisition-timeout 调到 60s给冷连接留足余量二是在项目启动后做一个预热查询比如 ApplicationRunner 里执行一次最简单的 MATCH (n) RETURN n LIMIT 1把连接池暖起来。从那以后我做任何 Neo4j 项目都会加预热这个坑基本绝迹。5.2 医疗实体被分词器切碎实体识别全线失败现象界面输入布洛芬缓释胶囊副作用返回没有识别到相关实体但图库里明明有布洛芬缓释胶囊这个节点。单独调试分词器发现它被切成布洛芬/缓释/胶囊三段。原因通用分词词典没有覆盖医疗专名长词命不中完整词条被最大匹配算法拆开。实体词典没加载后续识别当然全空。解决把图库所有实体名包括别名批量加入自定义词典并且在实体匹配时按词长降序排列优先匹配长词避免感冒匹配到感冒灵颗粒里造成实体错位。这里强调一遍词典加载必须发生在任何问答请求之前最好做成启动时加载而不是首次请求时懒加载否则第一波用户问题大概率全挂。5.3 手拼Cypher导致语法错乱甚至清库现象用户输入感冒) DETACH DELETE n //这种带引号和注释的字符串时查询直接报语法错误换成更极端的输入图库节点被成批删除。这不是段子是真实发生过的风险。原因代码里用了字符串拼接把用户输入直接塞进 Cypher比如 MATCH (d:Disease {name: name })引号没转义就语法错误注释符 // 后面的内容全部变成注释DETACH DELETE 就真的执行了。解决全程参数化查询模板里写 $disease执行时传参数 Map驱动会自动处理转义恶意输入变成纯数据。同时 Controller 层对问句长度和字符集做白名单校验超过 50 个字符或包含非法符号直接拒绝。这两个措施叠加安全边界才算完整。5.4 CSV导入中文乱码或关系数量明显偏少现象LOAD CSV 后中文全部变成乱码或者 disease_symptom.csv 明明有 3 万行导入后关系只有不到 1 万条。原因乱码是文件编码问题Windows 记事本默认 ANSI 保存Neo4j 按 UTF-8 读取就全乱。关系数量偏少最常见的原因是 CSV 里实体名前后有空格或者整列用了全角逗号导致 MATCH 匹配不到端点那一行被静默跳过。解决统一用编辑工具另存为 UTF-8 without BOM分隔符只用半角逗号导入前先写一条 LOAD CSV 语句只 RETURN 前 10 行人工校验列名和内容导入后再跑一段检查脚本统计每个关系类型的数量和源数据行数对比对不上的就能定位到脏数据。5.5 关系方向画反精确问句返回空现象前端图谱上一律箭头反指问感冒吃什么药返回空但 Neo4j Browser 里手工执行MATCH (d:Disease {name:感冒})-[:TREAT_WITH]-(:Drug)也没有结果。原因建模文档里定义的是一套方向导入脚本里写的却是反的或者模板里查的方向和实际导入方向不一致。问答模板、导入脚本、前端渲染三处各写各的方向约定没有统一。解决先别急着改代码在 Neo4j Browser 里跑一条探索查询确认数据真实方向-- 确认关系方向返回任意一条疾病到药品的路径 MATCH p(d:Disease)-[r]-(drug:Drug) RETURN p LIMIT 5;确认实际方向后把导入脚本、Cypher 模板、前端关系渲染的方向全部对齐到主语→宾语约定并在实体关系文档里把方向表写死。血泪经验是方向问题靠人眼排查最靠谱别靠感觉猜。6. 交付前验证问答准确率抽测与查询性能压测6.1 准确率抽测一张表把所有意图类型跑一遍准备 50 到 80 条测试问句覆盖六个意图类型每条标注期望答案类型然后逐个调接口把结果记进表格统计每个意图的答对率。表格可以做成下面这样。意图类型测试条数答对答对率疾病→药物151493%疾病→症状151387%疾病→科室1010100%统计完别只看总量要看每个意图的短板。答对率最低的意图类型基本就是兜底逻辑没接住或规则没覆盖的问法优先补那里。批量请求用一段简单的脚本就能跑# 每一行是问句|期望答案逐条请求并打印人工比对 while IFS| read -r q expect; do resp$(curl -s http://localhost:8080/qa?question$q) echo 问句: $q echo 期望: $expect echo 返回: $resp echo --- done test_questions.txt6.2 压测与缓存让演示现场不卡顿演示现场最怕的是页面转圈。先用 apache bench 打一轮看平均响应时间和最慢请求# 200 个请求、20 并发观察平均响应时间 ab -n 200 -c 20 http://localhost:8080/qa?question感冒吃什么药重点看两个指标平均响应时间和最慢一档的请求耗时。如果最慢请求超过一两秒大概率是触发了 CONTAINS 兜底或者冷连接。此时给高频问法加一层本地缓存收益非常直观// 用 Caffeine 做热点问答缓存命中后直接返回不再走图库 Configuration public class CacheConfig { Bean public CacheString, AnswerResult qaCache() { return Caffeine.newBuilder() .maximumSize(2000) .expireAfterWrite(Duration.ofMinutes(30)) .build(); } }参数说明maximumSize 控制缓存条目上限2000 条对演示规模足够expireAfterWrite 设 30 分钟保证知识数据更新后最多半小时内生效。缓存的 key 用意图名实体名拼接而不是原始问句这样感冒吃什么药和感冒用什么药能命中同一条缓存。从那以后我每交付一个问答模板都会强制走一遍确认关系方向、跑测试问句、打一轮压测这三步答辩现场再也没翻过车。希望帮到你。本文还有配套的精品资源点击获取