前阵子收到了一个小需求:做一套医疗知识库问答的 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 本地模型。版本组合如下:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 17 | Spring Boot 3.x 的基础要求 |
| Spring Boot | 3.2.x | 稳定版本即可 |
| Spring AI | 0.8.1 | 以当时稳定版为例,新版本 API 略有调整 |
| Ollama | 0.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 效果好不好,第一层就看检索准不准。我整理了一组实测对照:
| 参数 | 测试值 | 效果 | 我的建议 |
|---|---|---|---|
| topK | 3 | 漏检较多,部分问题命中不到关键资料 | 5 起步 |
| topK | 5 | 覆盖度较好,噪声可控 | 默认推荐 |
| topK | 8 | 覆盖度提高,但无关片段增多,回答开始“跑偏” | 高质量知识库可尝试 |
| similarityThreshold | 0.7 | 容易空召回,系统经常回答“不知道” | 不建议过高 |
| similarityThreshold | 0.4 | 召回宽松,但无关内容混入明显 | 低于 0.4 基本不可用 |
| chunkSize | 300 | 命中精准,但上下文信息偏少 | 适合条款式资料 |
| chunkSize | 500 | 平衡,大多数知识片段完整 | 通用起点 |
| chunkSize | 800 | 片段完整,但一个片段涵盖多个主题,检索精度下降 | 不推荐 |
我最终稳定在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 的难点从来不在代码,而在数据和参数。同样的代码,换一份清洗得更干净、切分更合理的知识库,效果能差出一个量级。后面如果要继续做,我打算把知识库更新做成定时任务,文档一变就增量重新切分入库,再给接口加上用户反馈按钮,用真实提问反哺知识库的筛选。这套从零开始搭起来的路子虽然朴素,但把它沉淀下来,以后做任何垂直领域的知识库问答,都是同一套打法。