☰
Spring AI + RAG:医疗知识库智能问答系统搭建全流程
2026/10/22 21:13:34 网站建设 项目流程

前阵子收到了一个小需求:做一套医疗知识库问答的 Demo。背景很典型——团队里有一批常见病症科普、用药注意事项等资料散落在文档里,想让用户用自然语言提问,系统直接给出有依据的回答。我第一反应是微调一个垂直模型,但算了算数据准备、训练和迭代成本,果断放弃,转向了 Spring AI + RAG 这条路。

用 Spring AI 搭 RAG,本质上就是把“开卷考试”这个思路工程化:先根据用户问题检索知识库里的相关片段,再把这些片段拼进提示词交给大模型生成回答。这样做的好处很直接——回答有据可循、知识可以随时更新、数据不用全部喂给模型训练。这篇文章就把我从零构建医疗智能问答系统的完整过程拆开讲,包括技术选型、环境配置、核心代码实现、参数调优,以及几个不太好查的坑。

1. 整体设计与技术选型思路:为什么是 Spring AI + RAG

1.1 医疗问答场景的三个核心约束

医疗领域问答和普通闲聊最大的区别在于:它同时要求“事实准确”“知识可更新”“数据不出域”。

先说事实准确。大模型在开放域对话里偶尔编个名字、编个出处可能不太明显,但在医疗场景里,一句话说错就可能造成误导。直接拿通用模型裸答,写出来的内容往往看起来头头是道,实际却经不起推敲。RAG 的价值就在于把回答范围强行限定在检索到的资料内,模型只能根据知识片段生成答案,偏离空间被大大压缩。

再说知识更新。医疗知识是持续变化的,指南、药品说明书、科室介绍都会更新。如果用微调方案,每一次知识变化都要重新标注、重新训练,成本太高。RAG 不需要动模型,只需要替换或增量更新向量库里的文档,知识就“更新”了。

最后是数据隐私。医疗文档通常不出内网,尤其涉及患者信息和内部资料。如果调用公有云大模型接口,把用户问题和知识片段传出去,很多场景根本过不了合规评审。所以我在这个项目里直接用本地模型,整个链路完全闭环在内网环境。

1.2 系统模块拆解与数据链路规划

我的做法是把它拆成五个模块,按一条流水线串起来:

模块职责实现选择
知识库接入层读取各类格式文档(txt、md、pdf)Spring AI 的 TikaDocumentReader
文本切分层把长文档切成适合检索的片段TokenTextSplitter
向量化与存储层把文本片段转成向量并入库OllamaEmbeddingModel + SimpleVectorStore
检索增强问答层检索相关片段并拼接到提示词QuestionAnswerAdvisor
接口暴露层提供 HTTP 问答接口Spring MVC Controller

数据流非常简单:文档进来先被切分成若干小段,每一段通过嵌入模型转成向量,存入向量库。用户提问时,问题也被转成向量,在库里做相似度检索,找出最相关的 topK 个片段,然后连同问题一起交给大模型,生成答案。

1.3 为什么直接用 Spring AI,而不是自己封装

如果不用 Spring AI,这个链路需要手动做的事情很多:管理模型调用的 HTTP 客户端、处理请求响应格式、自己实现文本相似度计算、自己拼提示词、自己处理上下文窗口。最烦的是如果以后要换模型供应商,几乎要把链路重写一遍。

Spring AI 把这层抽象得很好。它提供了统一的 ChatModel、EmbeddingModel、VectorStore 接口,还内置了 QuestionAnswerAdvisor 这种现成的 RAG 装配器。我只需要声明一些 Bean,调用几个方法,就能把检索增强链路搭起来。对于本来就是 Java 技术栈的团队来说,这是最省事的一条路。

2. 环境准备与依赖配置:先把本地模型链路跑通

2.1 环境清单与版本搭配

这个项目我用的是 Spring Boot 3.x + Spring AI 0.8.x + Ollama 本地模型。版本组合如下:

组件版本说明
JDK17Spring Boot 3.x 的基础要求
Spring Boot3.2.x稳定版本即可
Spring AI0.8.1以当时稳定版为例,新版本 API 略有调整
Ollama0.1.x本地模型运行工具

选 JDK 17 没什么可说的,Spring Boot 3 从根上就不再支持 JDK 8,直接用 17 是当前最稳妥的选择。Spring AI 版本建议锁定 BOM,避免依赖冲突。

2.2 依赖引入与本地模型选择

项目是 Maven 工程,核心依赖就三个:Web、Ollama 适配器、Tika 文档读取器。

<dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-bom</artifactId> <version>0.8.1</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-ollama-spring-boot-starter</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-tika-document-reader</artifactId> </dependency> </dependencies>

模型方面,问答生成我用的是 llama3.1:8b 这种通用开源模型,嵌入向量用 nomic-embed-text。第一次要先把模型拉到本地:

ollama pull llama3.1:8b ollama pull nomic-embed-text ollama list

如果你机器配置一般,也可以换更小的模型,但要确认模型名跟配置完全一致。本地链路有个好处:文档内容和用户问题只会出现在自己的机器上,不会出内网,对医疗类演示项目来说这一点非常关键。

2.3 配置文件与关键参数

在 application.yml 里配 Ollama 的地址、模型和生成参数:

spring: ai: ollama: base-url: http://localhost:11434 embedding: options: model: nomic-embed-text chat: options: model: llama3.1:8b temperature: 0.2

这里最需要解释的是 temperature。temperature 控制模型输出的随机性,值越大回答越发散。医疗场景要求严谨稳定,我直接调到 0.2,让模型尽可能少“发挥”。

另外,嵌入模型和对话模型是分离的。嵌入模型只负责把文本转成向量,本身不生成回答;对话模型才负责最终的答案生成。很多同学一开始会混为一谈,以为一个模型干完所有事,其实在 RAG 链路里这两个模型各管一段。

3. 核心代码实现:文档解析、向量入库与问答接口一次跑通

3.1 知识库准备与文档读取入口

我先准备了一份演示知识库,放在 resources 目录下,文件名是knowledge/medical-qa.md,内容是用公开科普资料整理的若干条常见慢性病问答,所有内容都做了脱敏处理,不涉及具体患者信息。这套系统的定位是“导诊科普辅助”,不是诊疗工具,不能替代医生建议。

读取文档用 Spring AI 内置的 TikaDocumentReader,它支持 md、txt、pdf 等多种格式,一处写代码,以后换文件类型不用改逻辑。

Resource resource = new ClassPathResource("knowledge/medical-qa.md"); var reader = new TikaDocumentReader(resource); List<Document> documents = reader.read();

得到的 Document 对象已经带上了source等元数据,后续在回答里追溯来源时可以直接用。

3.2 文本切分:让每段资料“小而准”

文档读取出来是最原始的长文本,不能直接塞进向量库。一个几百行的 Markdown 文件整体转成一个向量,检索时粒度太粗,用户问细节问题很难命中。我在这里用 TokenTextSplitter 做切分:

TokenTextSplitter splitter = TokenTextSplitter.builder() .withDefaultTokenLimit(500) .withDefaultOverlap(80) .build(); List<Document> chunks = splitter.apply(documents);

chunkSize 设置为 500 token,overlap 设置为 80 token。为什么这样设?在医疗知识库里,一个完整的知识点通常在三五百字左右,切成 500 token 可以保证片段信息完整。而 overlap 的作用是让相邻片段有少量重叠内容,避免一个完整句子被拦腰截断后,语义信息丢失。

我之前试过把 chunk 调到 1000,结果一个问题经常命中一大段无关信息,回答里混进了冗余内容;调到 200 以下又太碎,经常只检索到半个知识点。500 是一个比较平衡的起点。

3.3 向量化与写入向量库

向量库我选择 SimpleVectorStore,它是 Spring AI 内置的内存向量库,本地演示不需要额外部署任何中间件,几分钟就能跑通。生产环境可以平滑切换到 PgVector、Milvus 这类持久化方案,这也是我强调“先本地跑通、再上生产”的典型路径。

先声明 VectorStore Bean:

@Bean public VectorStore vectorStore(EmbeddingModel embeddingModel) { return SimpleVectorStore.builder(embeddingModel).build(); }

然后在启动阶段把切分好的文档向量化并写入:

@Component public class KnowledgeBaseInitializer implements ApplicationRunner { private final VectorStore vectorStore; public KnowledgeBaseInitializer(VectorStore vectorStore) { this.vectorStore = vectorStore; } @Override public void run(ApplicationArguments args) { Resource resource = new ClassPathResource("knowledge/medical-qa.md"); var reader = new TikaDocumentReader(resource); List<Document> documents = reader.read(); TokenTextSplitter splitter = TokenTextSplitter.builder() .withDefaultTokenLimit(500) .withDefaultOverlap(80) .build(); List<Document> chunks = splitter.apply(documents); vectorStore.add(chunks); System.out.println("知识库初始化完成,共写入 " + chunks.size() + " 个片段"); } }

需要提醒的是,SimpleVectorStore 是内存实现,进程重启后数据会消失,所以每次启动都要重新初始化。演示没问题,但如果要长期运行,建议换成持久化向量库。

3.4 问答接口:用 QuestionAnswerAdvisor 组装 RAG

问答接口的核心是把“检索”和“生成”串起来。Spring AI 的 QuestionAnswerAdvisor 正好干这件事:它会在用户提问后,先从 VectorStore 里检索相关文档片段,再把片段内容拼接到系统提示词里,最后调用大模型生成回答。

@RestController @RequestMapping("/api") public class MedicalQaController { private final ChatClient chatClient; private final VectorStore vectorStore; public MedicalQaController(OllamaChatModel chatModel, VectorStore vectorStore) { this.vectorStore = vectorStore; SearchRequest searchRequest = SearchRequest.builder() .topK(5) .similarityThreshold(0.5) .build(); this.chatClient = ChatClient.builder(chatModel) .defaultSystem("你是医疗知识库问答助手。请只根据提供的参考资料回答用户问题,如果资料中没有答案,明确回答不知道,不要编造。") .defaultAdvisors(new QuestionAnswerAdvisor(vectorStore, searchRequest)) .build(); } @PostMapping("/ask") public Map<String, Object> ask(@RequestBody Map<String, String> body) { String question = body.get("question"); String answer = chatClient.prompt() .user(question) .call() .content(); return Map.of("answer", answer); } }

ChatClient 是 Spring AI 的链式 API,非常直观。defaultSystem设置了系统提示词,defaultAdvisors挂载了 RAG 检索增强顾问。

接口返回的 answer 是大模型最终生成的答案。但如果只有答案没有出处,用户会怀疑这是不是模型随口编的。所以我单独加了一次检索,把命中的文档标题一并返回:

@PostMapping("/ask") public Map<String, Object> ask(@RequestBody Map<String, String> body) { String question = body.get("question"); String answer = chatClient.prompt() .user(question) .call() .content(); List<String> sources = vectorStore.similaritySearch( SearchRequest.builder().query(question).topK(3).build() ).stream() .map(doc -> doc.getMetadata().getOrDefault("source", "unknown")) .distinct() .toList(); return Map.of("answer", answer, "sources", sources); }

这样回答就有了“锚点”,用户可以看到信息来自哪份文档。虽然医疗演示场景距离真正的生产系统还有很大距离,但至少“可溯源”这一步是成立的。

3.5 启动验证与示例响应

全部配置好之后启动项目,用 curl 做一个简单验证:

curl -X POST http://localhost:8080/api/ask \ -H "Content-Type: application/json" \ -d '{"question":"高血压患者日常饮食需要注意什么?"}'

我当时的响应大致是这样的:

{ "answer": "根据已有资料,高血压患者的饮食应注意控制钠盐摄入,减少腌制食品摄入,适当多吃新鲜蔬菜水果,同时避免过量饮酒。", "sources": ["medical-qa.md"] }

到这里,一个最小可用的“Spring AI + RAG 医疗问答系统”已经跑通了。从代码量来看,核心代码并不复杂,真正的难点和价值都在后面要说的调优环节。

4. 调优实测记录:检索参数与生成质量怎么找平衡

4.1 检索参数对照:topK、阈值和切分长度

系统跑通之后,我开始调参数。RAG 效果好不好,第一层就看检索准不准。我整理了一组实测对照:

参数测试值效果我的建议
topK3漏检较多,部分问题命中不到关键资料5 起步
topK5覆盖度较好,噪声可控默认推荐
topK8覆盖度提高,但无关片段增多,回答开始“跑偏”高质量知识库可尝试
similarityThreshold0.7容易空召回,系统经常回答“不知道”不建议过高
similarityThreshold0.4召回宽松,但无关内容混入明显低于 0.4 基本不可用
chunkSize300命中精准,但上下文信息偏少适合条款式资料
chunkSize500平衡,大多数知识片段完整通用起点
chunkSize800片段完整,但一个片段涵盖多个主题,检索精度下降不推荐

我最终稳定在topK=5、similarityThreshold=0.5、chunkSize=500。这个组合在演示知识库上的表现是:大多数问题都能命中正确片段,回答质量也稳定。

4.2 生成质量与提示词约束

检索到位不等于回答到位。同一个知识片段,不同提示词下生成的答案可以差很多。我在生成侧做了三个约束:

第一,把 temperature 压到 0.2,降低模型“自由发挥”的概率。第二,系统提示词里明确写“只根据资料回答,资料没有就回答不知道”。第三,在提示词里补充“回答要尽量简洁,不要展开无关内容”。

还有一个很容易忽略的点:如果知识库本身是中文,但嵌入模型对中文支持不好,检索边会严重下降。nomic-embed-text 在中文场景的表现并不算好,我后来实测发现它对中英文混合文本的区分度一般。如果你要处理的是纯中文知识库,强烈建议测试一下嵌入模型的检索命中率,必要时换一个对中文更友好的多语言嵌入模型。

4.3 本地模型性能与硬件资源

我用的 llama3.1:8b 量化模型,在普通桌面级 GPU 上单次问答大约 2 到 4 秒,如果只用 CPU,会拉到十几秒甚至更久。这个表现对 Demo 来说可以接受,但要做成在线实时服务,就得认真考虑性能优化了。

优化方向有三个:一是换更小的模型降低推理耗时;二是给用户提问加缓存,把高频问题的答案存起来;三是把请求放到异步任务里处理,前端先返回“处理中”,等推理完成再推送结果。RAG 链路本身检索部分耗时很小,瓶颈基本都集中在生成阶段。

5. 常见问题与排查技巧实录

5.1 检索结果为空或者明显不相关

我排查这类问题的顺序很固定。第一步看嵌入模型是否正常返回向量,直接用 Ollama 的接口测一下nomic-embed-text能不能给出结果。第二步看文档是否真的进了向量库,启动日志里会打印片段数量。第三步检查查询语句和知识库的语言是否一致——我用的是中文知识库配英文嵌入模型,初期偶尔会出现中文问题打不出高分的情况。

这个问题的核心在于:检索是“用向量找向量”,如果提问方式和文档表达方式差异太大,向量距离就会很远。后续我通过调整提问句式、适当降低 similarityThreshold、提高 topK,召回率明显改善。

5.2 上下文超长导致报错

当 chunkSize 设置过大、topK 设置过高,拼接出来的提示词可能超出模型的上下文窗口。我遇到过问答接口直接抛异常的情况,日志里写着上下文长度超限。

处理的思路很简单:减少片段数量,或者缩小片段长度。我把 chunkSize 从 800 调回 500,topK 从 8 降到 5,问题立刻消失。如果你确实需要容纳更多资料,可以显式调大模型上下文窗口,比如给 Ollama 配置 num-ctx。

spring: ai: ollama: chat: options: num-ctx: 8192

但要注意,调大上下文不等于无限调大,token 越多,单次请求的显存和耗时都会上升。

5.3 答案“一本正经地乱答”

这是 RAG 系统最容易碰到的问题。表面上看模型回答得很流畅,但内容跟检索片段无关,甚至完全在自说自话。

原因通常有两个。一是系统提示词约束不够,模型还是把自己当成通用助手在回答。二是检索结果本身质量差,模型拿不到有效资料,只能“硬编”。

我的处理方式是:把“只根据资料回答,资料没有就回答不知道”写进默认系统提示词,同时把 temperature 调低。如果这样还是乱答,就要回过去看检索结果,确认 topK 片段里到底有没有正确答案。

5.4 问题排查速查表

现象可能原因处理方式
回答为空或报“知识库无数据”嵌入模型未启动;文档未成功入库检查 Ollama 模型状态,看初始化日志
召回结果不相关embedding 模型与知识库语言不匹配换多语言嵌入模型,适度降阈值
答案混入无关内容topK 过高,噪声太多调低 topK,提高 similarityThreshold
输入太长导致异常chunkSize 或 topK 超出上下文窗口缩小 chunkSize,降低 topK,或调大 num-ctx
模型输出不稳定temperature 过高调到 0.2 以下测试对比

项目做完之后,我最大的体会是:RAG 的难点从来不在代码,而在数据和参数。同样的代码,换一份清洗得更干净、切分更合理的知识库,效果能差出一个量级。后面如果要继续做,我打算把知识库更新做成定时任务,文档一变就增量重新切分入库,再给接口加上用户反馈按钮,用真实提问反哺知识库的筛选。这套从零开始搭起来的路子虽然朴素,但把它沉淀下来,以后做任何垂直领域的知识库问答,都是同一套打法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询