这两年做 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 17AI 抽象层Spring AI 1.0.0 GA锁定版本模型服务兼容 OpenAI 协议的大模型 APIchat 和 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避免文档和实际代码对不上。依赖只需要关注三个核心 starterdependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai-spring-boot-starter/artifactId version1.0.0/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-pgvector-store-spring-boot-starter/artifactId version1.0.0/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-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 )); ListDocument chunks textSplitter.split(doc); // 过滤无效短块 ListDocument 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接口很干净核心就是一个方法ListDocument references vectorStore.similaritySearch(SearchRequest.builder() .query(userQuestion) .topK(5) .similarityThreshold(0.2) .build());topK5的意思是返回最相似的 5 个块。一开始我用 3效果不够好偶尔会有正确的块排在第四名但没被取回来。调到 5 以后覆盖率明显提升。代价是塞给模型的内容变多了所以要靠下一步的提示词来做约束。similarityThreshold0.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 FluxString answerStream(RequestParam String question) { ListDocument 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和配套的AdvisorChatMemory 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 关键词检索和向量检索的混合召回再把查询改写做得更精细一些。这些方向听起来不如换新模型那么刺激但对问答质量的提升远比换个更强的模型来得实在。