ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于Spring AI和RAG的医疗智能问答系统实践与落地

基于Spring AI和RAG的医疗智能问答系统实践与落地

这两年做 AI 应用,被问得最多的一句话就是:能不能让 AI 直接回答患者的问题?说实话,通用大模型聊天确实顺,但放到医疗场景里,马上就会撞上两个硬骨头。第一个是幻觉,它能一本正经地把错误答案讲得头头是道;第二个是没依据,患者追问一句“你这说法从哪来的”,它就哑火了。RAG(检索增强生成)恰好就是为这类问题准备的一套工程化解法。这段时间我用 Spring AI 从零搭了一套医疗智能问答系统,把公开的诊疗指南、药品说明书和院内科普材料整理成可检索的知识库,再让大模型只基于检索到的资料作答。这篇文章会把整体设计、核心代码、参数选择和踩坑记录完整写下来,适合有 Spring Boot 基础、想在业务里真正落地 RAG 的开发者。

1. 项目背景与整体方案选型

1.1 医疗问答为什么是“最难做的问答系统”之一

先别急着讨论用哪个模型,医疗问答和普通的商品客服问答有着本质区别。普通问答答错了,用户顶多骂两句重来;医疗问答答错了,是有实际风险的。患者拿着答案去吃药、去处理症状,一旦信息错误,后果完全不一样。所以最初我和团队拉需求时,产品侧提的第一条要求不是“答得全”,而是“错得少、错得有边界”。

医疗问答的特殊性主要体现在四层:

第一层是语言壁垒。用户提问用的是大白话,比如“脑袋后面老是疼是咋回事”,但知识库里的指南和论文全是“枕部疼痛”“颅内压增高”这样的专业术语。让大模型在口语和专业术语之间做匹配,本身就不是一件靠直觉能解决的事。

第二层是知识更新快。临床指南每隔几年就会更新,旧的用药建议可能被新研究推翻。如果知识是“焊死”在模型参数里的,更新一次就意味着重新训练一次,成本高得让人不想碰。

第三层是答案必须可追溯。患者可能不只是想要一个结论,他还会问“为什么”“有什么依据”。更现实的是,医疗从业者也需要看到信息来源,才能判断这个答案是否可以采用。没有出处的 AI 回复,在医疗场景里基本等于没用。

第四层是安全兜底。系统必须知道什么时候“不应该回答”。比如患者描述的是急性胸痛伴随大汗,系统应该优先给出尽快就医的提示,而不是真的去分析心绞痛和主动脉夹层的区别。这个“什么时候该闭嘴”的判断,纯靠模型自由发挥是不可靠的。

把这些难点摆出来以后,方案方向其实就清晰了:我们需要一个外挂的知识来源,让模型在回答前先查资料,再基于资料生成答案,并且把资料出处一并返回。这就是 RAG。

1.2 为什么是 RAG,而不是直接微调一个医疗大模型

项目评审的时候,团队里也有同事提出过微调方案:买一批医疗问答语料,直接在开源模型上做监督微调,效果不是更自然吗?这个思路放在通用聊天场景里没问题,但放在医疗场景里,我是不太推荐把它当首选方案的。

微调的本质是让模型记住训练数据里的规律,但它没法帮你区分“记住了”和“理解了”。假如训练语料里有一条过时的用药建议,模型会把它当作正确答案学进去,而且在未来每一次相关提问中稳定输出。等到指南更新,你又得重新整理语料、重新训练,等于把知识维护变成了一个持续燃烧的成本黑洞。

更麻烦的是可追溯性。微调后的模型回答问题时,你很难说清楚它的某个结论到底来自哪份资料。这在医疗场景是很棘手的,医生想复核都找不到源头。

RAG 的逻辑则是把“知识存储”和“答案生成”彻底分开。知识放在外部向量数据库里,模型只负责阅读理解检索到的片段,然后组织语言。这样带来三个实打实的好处:

  • 知识更新只需要替换文档,清洗切片再灌进向量库,分钟级生效。
  • 每条答案都可以追溯到具体的资料片段,前端页面能直接展示“参考来源”。
  • 业务方可以精确控制知识边界,资料库里没有的内容,模型会被提示词约束为“不知道”或“建议就医”。

当然,微调不是一无是处。它更适合用来改变模型语气、输出格式、或者让它彻底学会某种专业风格。但作为知识注入手段,RAG 的性价比和风险控制能力明显更适合医疗这种高合规要求的场景。我们的结论是:主链路走 RAG,微调只在后续必要时作为补充手段。

1.3 为什么用 Spring AI 来搭这条链路

确定用 RAG 之后,下一个问题是技术选型。团队本来就是 Java 技术栈,核心系统全是 Spring Boot 服务,所以“用 Spring AI 还是自己封装 HTTP 调用”这个问题,几乎没怎么纠结。

自己封装大模型 API 看起来很轻,其实坑很深。你要自己处理 SSE 流式解析、超时重试、token 统计、上下文窗口裁剪、结构化输出校验……这些代码写起来不难,但维护起来非常烦。而且项目还要接向量库,每次换一个向量库都要重写一套客户端逻辑,纯属浪费时间。

Spring AI 的价值在于它把这层抽象做成了 Spring 风格的东西。模型调用有统一的ChatClient,向量检索有统一的VectorStore,上下文管理有统一的Advisor机制。外部依赖从模型 API 换到兼容协议的其它服务,只需要改配置;向量库从 PGVector 换到别的实现,也只需要替换 starter 和少量配置。这种“可替换性”在项目初期看起来不起眼,等系统跑起来以后就会觉得真香。

我们最终的技术栈定成这样:

  • 应用框架:Spring Boot 3.4 + JDK 17
  • AI 抽象层:Spring AI 1.0.0 GA,锁定版本
  • 模型服务:兼容 OpenAI 协议的大模型 API,chat 和 embedding 分开配置
  • 向量存储:PostgreSQL + PGVector,复用已有数据库,省掉独立向量库的运维
  • 知识来源:公开临床指南、药品说明书、医院授权的健康科普材料

整体链路不复杂:用户问题进来以后,先向量化,然后在知识库检索 TopK 个片段,把片段拼进提示词,交给大模型生成答案,最后把回答和引用片段一起返回。难点全在细节里,下面一个个说。

2. 知识库建设:决定问答上限的 60% 工程

2.1 数据准入规则:哪些内容有资格进知识库

很多文章讲 RAG 都是直接从“怎么切分文档”开始,但实际上最该认真对待的是第一步:数据准入。医疗场景尤其如此,乱放内容进知识库就是在给自己埋雷。

我们在项目里建立了一套简单的准入规则,分「可以进」和「不能进」两类。

可以进的知识源包括:

  • 公开的临床诊疗指南,最好是有明确发布机构、版本号和更新日期的。
  • 药品说明书,优先选厂家公开发布或权威数据库收录的版本。
  • 医院授权的健康科普文章、科室 FAQ,要求文章来源明确、有责任人审核。
  • 经过业务方审核确认的内部问答对,这类一般覆盖最常见的高频问题。

不能进的知识源,清单比可以进的长得多:病友群聊天记录、未经审核的个人科普贴、来源不明的偏方、带具体患者隐私信息的病例数据、旧版本的指南或已经被撤回的论文。这些东西就算内容本身看起来“有道理”,合规风险和信息准确性都过不了关,一律不碰。

数据来源确定后,清洗是个繁琐但必须做扎实的环节。用 PDF 直接读出来的文本,页眉页脚、目录页码、图表说明全部混在里面,如果不清洗就入库,检索的时候经常会匹配出一堆无用内容。我们当时的做法是:先转成纯文本,用脚本把页眉页脚、连续空白符、出版信息等噪音剔除,再人工抽检一部分确认格式。这一步没有捷径,属于“脏活累活”,但恰恰是它决定了后面所有环节的质量。

清洗后还要给每篇文档打元数据。我们的元数据设计得比较简单,但很关键:

字段说明示例
source资料原始出处科室文档库 / 某指南文件名
title文档标题高血压基层诊疗指南
category所属科室或知识分类心内科
update_time资料更新时间,方便后续清理2024-06

元数据不是给用户看的,它的核心作用有两个:一是检索后可以按来源过滤,二是把引用信息回传给页面展示。

2.2 文档切分的参数课:块大小、重叠与语义完整性

知识库建设里,切分策略是最容易被低估的环节。很多人上来就把整篇文档丢进去,然后发现检索效果稀烂。原因不复杂:Embedding 模型对长文本的语义表征会“摊薄”,一整篇上万字的指南,向量化之后可能只保留了大概的主题方向,细节信息全丢了。而且检索返回的粒度太粗,你拿到的是一个完整文档,塞给大模型也没法精确引用某个段落。

切分的核心矛盾在于:块太小,语义不完整,检索匹配度低;块太大,语义被稀释,上下文还塞不下。我们最终的经验值是:中文场景下,单块控制在 500 token 左右,重叠 50 token。这里有个实际换算问题,中文一个字对应 1 个 token 左右,但实际要看分词器,大致按 300 到 600 个汉字一个块来理解就行。

500 token 这个数字不是随便定的。它大约能覆盖两到三个自然段,足够表达一个相对完整的医学知识点,又不会因为内容过多导致 embedding 方向被带偏。重叠 50 token 是为了防止“一个完整知识点恰好被从中间切断”的情况,让前后块的边界部分都有机会被检索到。

在实际切分时,我们还做了一步容易被忽略的预处理:按文档原有标题层级切分,而不是简单按字符数硬切。比如一篇指南有“流行病学”“诊断标准”“治疗原则”等章节,先按二级标题把文档切成大段,再把大段按 token 数切成小块。这样切出来的每一块,内容主题更聚焦,检索时也更不容易跨主题混淆。

切分完以后一定记得过滤垃圾块。清洗再干净,也会出现一些只有几个字的残留片段,比如“见图 1”“如表 3 所示”。这类片段没有实际语义,塞进向量库只会成为检索噪音。我们的策略是长度低于 50 个字符的块直接丢弃,不参与向量化。

2.3 向量化与入库:一个小错误能让检索全废

切分完成后进入向量化环节。这一步最常踩的坑是:写入时用一个 embedding 模型,检索时用了另一个 embedding 模型,结果向量空间根本不一致,检索结果自然一塌糊涂。这个错误低级,但特别容易发生,因为模型 API 升级、配置改动、环境变量切换都可能悄悄改变模型名。我们在项目里把模型名写进了配置文件的固定位置,并且加了启动时校验,确保写库和查询用的总是同一个模型。

向量化本身的实现没有太多神秘感,就是调 embedding 接口把文本转成向量列表,然后写入向量库。但有两个细节值得注意。

第一个是批量写入的大小。有些模型服务的 embedding 接口有单次请求的文本数量限制,批量塞太多会报错。我们按 32 条一批来写,既稳定也不会太慢。第二个是向量维度的兼容性。PGVector 表如果已经用某个维度的向量建好了,后续换了一个输出维度不同的 embedding 模型,写入会直接失败。所以建表时最好先确认维度,后续换模型要特别注意这一点。

存储方案我们选了 PostgreSQL + PGVector,没有额外引入独立的向量数据库。原因很简单:项目规模还没到需要独立向量库的程度,数据量在几十万条以内,PGVector 配合 HNSW 索引完全够用。复用 PostgreSQL 还能继续用熟悉的备份、权限、事务体系,运维成本低很多。如果未来数据量真涨到千万级,再考虑迁移也不迟。

入库之后,建议做一次最简单的“自检”:随便拿几个高频问题,去向量库走一遍检索,人工看看返回的前几块到底相不相关。这一步在项目前期特别重要,因为它能最快暴露出清洗和切分的问题。

3. 用 Spring AI 把 RAG 链路真正跑起来

3.1 依赖与配置:Spring AI 1.0 的落地姿势

Spring AI 的版本节奏比较快,不同版本的 API 有一些差异。项目里我直接锁定了 1.0.0 GA,避免文档和实际代码对不上。依赖只需要关注三个核心 starter:

<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency>

第一个 starter 负责模型调用,第二个负责向量库自动配置,第三个是 PGVector 访问数据库需要的 JDBC 基础。

配置文件的重点也拆开写一下:

spring: ai: openai: api-key: ${MODEL_API_KEY} base-url: ${MODEL_BASE_URL} chat: options: model: ${MODEL_CHAT_NAME} temperature: 0.2 embedding: options: model: ${MODEL_EMBED_NAME} vectorstore: pgvector: table-name: medical_knowledge initialize-schema: true datasource: url: jdbc:postgresql://localhost:5432/medical_qa username: postgres password: ${DB_PASSWORD}

这里有几个配置项值得单独说明。temperature我调到了 0.2,医疗问答需要的是稳定和克制,不需要创意。initialize-schema设为 true 可以省去手动建表,但生产环境建议关掉,由 DBA 检查后再自己建。base-url留了环境变量,这样后续换模型服务商只改环境变量,代码一行不用动。

除了配置,还有一个依赖管理的细节。Spring AI 的正式版要配合它的 BOM 来管理版本,不然依赖之间版本可能打架。建议在父 pom 的dependencyManagement里引入 BOM,子模块只写 artifactId,不写版本号。

3.2 知识写入流程:从原始文档到 PGVector 的最后一公里

把文档变成向量数据,我封装了一个独立的KnowledgeIngestService,核心流程是读文件、切分、补元数据、写向量库四步。

@Component public class KnowledgeIngestService { private final VectorStore vectorStore; private final TextSplitter textSplitter; public KnowledgeIngestService(VectorStore vectorStore) { this.vectorStore = vectorStore; // 块大小 500,重叠 50,按需要调整 this.textSplitter = new TokenTextSplitter(500, 50); } public void ingest(String content, String title, String source, String category) { Document doc = new Document(content, Map.of( "title", title, "source", source, "category", category )); List<Document> chunks = textSplitter.split(doc); // 过滤无效短块 List<Document> validChunks = chunks.stream() .filter(c -> c.getText().length() > 50) .toList(); vectorStore.add(validChunks); log.info("入库完成:{},共 {} 块", title, validChunks.size()); } }

TokenTextSplitter的构造参数在不同的微小版本里可能略有差异,项目里以实际引入版本的 API 为准。核心逻辑不复杂:先拿到一套完整文本,拆成一个一个Document,每个Document带上一组元数据,然后交给vectorStore。

这里有一个很容易被忽略的点:元数据一定要在切分之前就挂到父文档上。因为切分器切出来的子块会继承父文档的元数据,如果等切完再手动给每个子块补元数据,又慢又容易漏。继承机制用好了,后面检索结果自动就带上了来源、标题这些信息,省很多事。

入库是离线流程,不需要很高的并发,跑一个批量脚本就行。我在项目里直接提供/internal/ingest接口,权限控制在内网,负责运维的同学上传文件、调用接口、看日志三步完成入库。

3.3 检索与提示词:把“边界感”写进系统提示词

检索和生成是 RAG 链路里最需要磨合的部分。先看检索。Spring AI 的VectorStore接口很干净,核心就是一个方法:

List<Document> references = vectorStore.similaritySearch(SearchRequest.builder() .query(userQuestion) .topK(5) .similarityThreshold(0.2) .build());

topK=5的意思是返回最相似的 5 个块。一开始我用 3,效果不够好,偶尔会有正确的块排在第四名但没被取回来。调到 5 以后,覆盖率明显提升。代价是塞给模型的内容变多了,所以要靠下一步的提示词来做约束。similarityThreshold=0.2是相似度下限,防止低质量片段混进来。这个值不是通用的,它跟 embedding 模型和文本类型都有关系,建议跑一批真实问题调一下。

检索拿到的是“资料”,但模型不会自动知道它只能在这个圈子里作答。所以必须把边界感写进系统提示词。我们的模板长这样:

String systemPrompt = """ 你是一名面向普通患者的医疗科普助手。 你的回答必须严格基于下面提供的「参考资料」,不得编造任何数据、剂量或疾病判断。 如果参考资料不足以回答用户问题,请直接说明「资料不足,建议线下就诊」。 回答时请标注引用的资料序号,例如 [1]、[2]。 禁止给出具体处方剂量,禁止下诊断结论。 如果用户描述的是急性胸痛、呼吸困难、严重外伤等紧急场景,请直接建议尽快前往急诊就诊。 """;

这一段提示词是整个系统的安全护栏。看起来只是几句“不要”,但对模型的约束作用非常明显。如果不写,模型就会用自己记忆里的知识自由发挥;写了以后,它遇到资料里没有的信息时,会倾向于承认不知道,而不是硬编一个答案。

用户消息组装时,把检索到的片段按顺序排列,加上编号:

String formattedContext = IntStream.range(0, references.size()) .mapToObj(i -> "[%d] 来源:%s\n%s".formatted( i + 1, references.get(i).getMetadata().get("source"), references.get(i).getText())) .collect(Collectors.joining("\n\n")); String userMessage = """ 【参考资料】 %s 【用户问题】 %s """.formatted(formattedContext, userQuestion);

组装好消息,调用就很简单了:

ChatClient chatClient = ChatClient.builder(chatModel).build(); String answer = chatClient.prompt() .system(systemPrompt) .user(userMessage) .call() .content();

这里要注意一个常见理解偏差:很多教程会把参考资料直接拼在系统提示词里,我习惯把它们放到用户消息里,因为后面做日志审计时,用户消息是完整留痕的,参考资料作为上下文的一部分被记录下来,排查问题会更方便。

3.4 流式输出与引用回传的工程细节

生产环境不能等模型生成完整答案后再返回给用户,几十秒的空白体验太差了,所以要做流式输出。Spring AI 的ChatClient对流式的支持很直接:

@GetMapping(value = "/answer/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> answerStream(@RequestParam String question) { List<Document> references = retrieve(question); String userMessage = buildUserMessage(question, references); return chatClient.prompt() .system(systemPrompt) .user(userMessage) .stream() .content(); }

前端接一个 SSE 流,边生成边显示,体感会好很多。但流式有一个天然的麻烦:引用信息怎么传回去。答案是一段一段吐出来的,引用资料列表却是一个整体,不能等流结束才返回。

我们的做法是:先检索,把参考资料列表作为一个独立的数据结构返回给前端,再另开一个流式接口推送答案文本。前端先展示“正在生成”,等流式内容到达后,根据答案里的 [1] [2] 标记,把对应的参考来源折叠展示在答案下方。这样用户在看答案的同时,就能直接点开出处核对。

4. 实测效果、踩坑记录与上线兜底

4.1 多轮对话的上下文管理

单轮问答跑通以后,下一个立刻会碰到的问题是:用户问完“高血压患者有什么饮食注意事项”,接着问“那运动方面呢?”这里“那运动方面”指代的还是高血压,如果直接把第二句拿去检索,检索效果一定很差,因为缺少了主题信息。

解决这类问题有两个层面的手段。第一层是在检索前做指代消解,把对话历史交给模型做一次精简改写,得到独立的查询语句。第二层是在生成答案时把最近几轮对话作为上下文传给模型,让它理解指代。两层可以分开做,也可以结合。项目初期我们没有单独做改写,主要靠第二层。

Spring AI 提供了ChatMemory和配套的Advisor:

ChatMemory chatMemory = MessageWindowChatMemory.builder() .maxMessages(20) .build(); ChatClient chatClient = ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor(chatMemory)) .build();

maxMessages(20)大约能覆盖最近十轮对话,足够普通咨询使用。但要注意,历史消息不能无限塞,模型上下文窗口有限,塞得越多,检索片段能占的空间就越少,反而影响主问题回答质量。临床项目里我们还限制过单轮问答的内容总长,超出就提示用户重新描述问题。

4.2 高频问题排查速查表

项目跑起来以后,我们会陆续遇到一些典型问题。整理成一张速查表,每一行都是实际踩过的坑:

现象常见原因处理办法
检索返回的片段和问题毫无关系切片太碎或太整;用户口语和文档术语差异过大调整块大小;增加 topK;做查询关键词改写
答案明明带了资料,却答得不对模型没有严格按资料作答,被自身记忆带偏强化“禁止在资料外补充”的表述;降低 temperature
引用标记 [1] 对不上内容检索片段与答案解析没有做绑定保持片段顺序,在答案中标号后按顺序回传
中文场景检索相似度普遍偏低当前 embedding 模型对中文支持不够换中文优化过的 embedding 模型,并重新入库
流式输出经常中断网络超时、上下文超长、API 限流调大超时时间;压缩历史消息;增加重试
同一个问题换一种问法,结果差别很大没有做查询改写;口语和书面语鸿沟增加一个改写层,把口语问题转成知识库风格再检索

这里面的“查询改写”是个很实用的技巧。做法很简单:把用户原始问题交给模型,让它改写成一个更适合检索的、包含完整主题信息的查询语句,然后再去向量库检索。这个步骤会多消耗一次模型调用,但对检索命中率的提升非常明显,尤其是国内用户的口语表达差异特别大,同一件事能有十几种说法。

4.3 效果评测与上线前必须做的兜底

最后一块,也是医疗项目无论如何不能省的部分:评测和安全兜底。

评测方面,我们建了一个 100 条左右的人工评测集,覆盖高频问题、边缘问题和危险场景。每条问题记录三个指标:检索是否命中正确资料、答案是否基于资料、答案是否包含安全兜底提示。实测下来,加了 RAG 之后,“检索命中正确资料”这一项从最初不足 60% 提升到 85% 以上,生成答案基本稳定,安全性则靠提示词和规则双重保障。需要提醒的是,这个数字和知识库质量强相关,换一套脏数据源,效果可能立刻掉到 40%,所以不要盲目对比别人的评测数字。

上线前的安全措施做了四件事:

一是输入端脱敏。系统不收集患者姓名、证件号、联系方式,只在日志里记录脱敏后的问题内容,用于后续质检。

二是输出端兜底。除了提示词约束,还加了一层规则校验,命中紧急关键词时直接返回固定话术:“您描述的情况可能比较紧急,请尽快前往最近医院的急诊科就诊。”这条规则不依赖模型,保证即使在模型异常时也能兜住最危险的部分。

三是免责声明。前端页面和流式响应尾部都会带上一句“以上内容仅供参考,不构成医疗建议,请结合线下医生诊断”的固定文案,这句不能省,是产品底线。

四是可观测性。用 Spring Boot Actuator 把每次问答的耗时、检索命中数、token 消耗量记录成指标,配合日志里的完整问答链路,方便出问题时快速回溯。

做完这套系统,我最深的体会是:RAG 项目的天花板并不取决于大模型,而取决于知识库整理的精细度。模型 API 只是一个会阅读、会翻译、会归纳的助手,真正决定“答得准不准”的,还是那堆清洗干净的文档、切分合理的片段和认真设计的提示词。如果后续继续做扩展,我会优先考虑加一层 BM25 关键词检索和向量检索的混合召回,再把查询改写做得更精细一些。这些方向听起来不如换新模型那么刺激,但对问答质量的提升,远比换个更强的模型来得实在。

RELATED READING

延伸阅读

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