1. 医疗问答为什么直接问大模型不行:RAG解决的核心痛点
先讲一个我实际遇到过的场景。某次我给一个通用大模型提问:"某某感冒药能不能和布洛芬一起吃?"模型回答得头头是道,从药理作用到代谢途径讲得清清楚楚,最后给出一个看似严谨的结论。但我拿这个结论去对比药品说明书原文时发现,它在关键剂量和禁忌人群上完全说错了。这种"看着专业、实际荒谬"的回答,就是业内常说的幻觉。
医疗场景恰恰是幻觉容忍度最低的领域之一。用户问的每一个问题都可能影响用药判断,如果系统给出错误信息,后果不是"回答不准确"这么简单。所以医疗智能问答系统的核心矛盾是:大模型泛化能力强、通用知识丰富,但它的知识是训练时固化的,无法保证准确性、时效性,更无法提供可追溯的来源。
RAG(Retrieval-Augmented Generation,检索增强生成)就是为解决这个矛盾设计的。它的思路很直接:大模型不需要记住你的私有知识,你先把知识库按规则切成片段、转成向量存起来,用户提问时先从库中检索出最相关的文档片段,然后把"问题+检索到的原文"一起交给大模型生成回答。模型不是凭记忆瞎编,而是看着参考资料作答——就像开卷考试一样。
相比直接微调大模型,RAG有几点对医疗场景特别重要。第一,知识可以随时更新,新增一份药品说明书、最新临床指南,只要重新入库就能生效,不需要重新训练。第二,回答可以溯源,模型说"某药每日剂量不超过X毫克"时,可以定位到是哪份资料支撑的。第三,实施成本低得多,不需要GPU做训练。这也是我在做医疗问答项目时最终选择RAG而不是微调路线的原因。
这篇实战记录会按照我实际搭建这个系统的顺序来写,从架构设计、技术选型,到知识库预处理、检索链路、生成链路,再到离线评估和调优,全程以Java技术栈为主,核心是Spring AI这套框架。如果你所在团队是Java技术栈,又想把大模型能力安全地接入业务系统,这篇应该能让你少走不少弯路。
2. 整体架构与关键技术选型:Java团队为什么选Spring AI
当时我评估过两条路线:一条是直接调用大模型厂商的SDK,自己写检索、拼Prompt、管上下文;另一条是引入Spring AI,把嵌入、向量检索、Prompt模板、对话记忆这些能力统一纳管。最后选了Spring AI,不是因为它是Spring官方产品所以无脑站队,而是它在工程化封装上切中了几个实际问题。
2.1 系统架构拆解:从文档入库到问答响应的完整链路
我搭出来的系统分两条链路。第一条是知识入库链路:原始医疗文档(药品说明书、诊疗指南、健康科普)经过清洗、切片、向量化,写入向量数据库,同时记录每一段的来源文档、标题、页码等元数据。第二条是问答响应链路:用户提问后,系统先做意图识别和问题改写,再用向量检索加上关键词检索从库里召回相关片段,经过重排后把TopN拼接成上下文,连同Prompt模板交给大模型生成回答,最后流式返回给前端。
这两条链路看起来不复杂,但每一步都有很多细节。比如切片策略直接决定检索质量,重排是否引入决定回答精准度,Prompt怎么写决定模型是否老实引用资料而非自由发挥。后面几章我会逐个展开。
2.2 Spring AI相比直接调SDK的优势
先说结论:如果你只是写个Demo,直接调SDK更快;如果要做成一个可持续迭代的内部系统,Spring AI的抽象层能帮你省掉很多脏活。
一个很实际的点是,Spring AI把"嵌入模型、向量库、大模型"都抽象成了可替换的接口。你最开始可以先用某个开源嵌入模型跑通,后面换效果更好的另一个模型,只需要改配置和引入对应依赖,业务代码不用推倒重来。向量库同理,从本地测试用的简易实现切换到企业级的向量数据库,变更成本也被封装住了。
另一个点是PromptTemplate。Spring AI设计了一套类似于Spring MVC里视图解析的机制,Prompt模板可以外置成文件,参数用占位符绑定。医疗场景的Prompt往往很长,而且不同问题类型要拼接不同的指令片段,这套模板机制在调试时可太有用了——改Prompt不用改代码、不用重新部署。
2.3 模型、向量库与知识源的选型取舍
模型方面,生成模型和嵌入模型是两回事,需要分开选。生成模型我选了在中文医疗问答上效果比较稳的一个通用对话模型,API接口与主流标准兼容;嵌入模型选了常用的开源中文向量模型,维度适中,检索效果能满足需求,最关键的是它可以本地部署,不涉及数据出域的问题——这在医疗场景里是个重要考量。
向量库的选择我对比过几个方案。早期为了快速验证直接用地道的内存向量存储,数据量一上去就露馅了。后来迁移到开源向量数据库,支持多种索引类型,社区也活跃。如果你所在团队已经用了支持的中间件,比如某内存数据库自带的向量能力,也可以直接用,少维护一个组件。
知识源我整理了三类:药品说明书(结构化程度高,但格式混乱)、临床诊疗指南(半结构化,段落长)、健康科普文章(纯文本,口语化)。三类数据的特点决定了它们必须走不同的清洗和切片路线,这也就是下一章要说的内容。
3. 医疗知识库的清洗与切片:影响检索质量的第一个关口
很多人做RAG一上来就急着往向量库里灌数据,结果问答效果差,怀疑是模型不行、嵌入不行,其实最可能的问题是文档切片切得稀烂。文档切得好不好,直接决定了检索能不能把最相关的那一段捞出来。
3.1 数据来源与清洗:表格、层级标题、乱码都要处理
医疗文档有个特点:大量使用表格、条目化列表,以及"【适应症】【用法用量】【不良反应】"这类固定栏目。直接从Word或PDF里提取出来之后,原始文本往往是带着层级标记、页码、页眉页脚的脏数据。我的清洗思路是三步。
第一步是格式归一化。把所有来源统一转换成纯文本,去掉页眉页脚、重复的分页符,修正全角半角字符和异常编码。第二步是结构化增强。药品说明书的用法用量里往往有"一次X粒,一日Y次,饭后服用"这类描述,我会把表格转换成自然语言句子,否则表格内容提取出来后顺序错乱,检索到也是残次品。第三步是栏目映射,把说明书中常见的栏目名映射成统一字段,建索引的时候用得上——比如用户问"副作用",可以直接按元数据过滤"不良反应"栏目。
一个容易踩的坑是,医疗文档里大量出现药品小名、商品名和通用名的混用,以及英文缩写。清洗时我维护了一份别名映射表,在入库前把常见别名统一改写为通用名,这比在检索环节做同义词扩展省事得多。
3.2 切片策略:固定长度切还是按语义单元切
切片有两种极端做法。一种是纯按固定字符长度切,比如每500字一段、带100字重叠,但这样极易拦腰截断语义单元;另一种是按文档原有结构整段整段切,但这会导致大段落向量化后语义发散,检索时召回一堆宽泛内容。
我的实际策略是混合式。以语义单元为主:先按文档本身的一级、二级标题拆分,把每个栏目下的最小条目作为一个候选片段。比如"用法用量"这一节里有"成人用法""儿童用法""肾功能不全者用法"三个并列段落,我会尽量把每个独立段落单独做成一个切片,因为它们针对不同人群、解决方案完全不同。然后再对每个语义切片做长度检查,超过上限(我设的是800字)再细分,低于下限(不足80字)就和相邻片段合并。
这里最关键的判断是,医疗问答用户往往问的是非常具体的问题,比如"孕妇能不能吃某药""和某药同服有什么风险",这些问题对应的答案往往就藏在一两句关键描述里。如果切片粒度太粗,模型会读到大量无关信息;如果太细,又会丢失上下文。所以切完之后我会抽样检查切片质量,标准是:单看这个切片,能否回答"这个片段在讲什么主题"。
3.3 元数据设计:你以为存的是文本,其实存的是检索条件
切片文本只是向量库记录的一部分,真正帮你做精准召回的还有元数据。我每一条入库记录都带了这么几个字段:文档ID和文档标题、来源类型(说明书/指南/科普)、栏目名(适应症/用法用量/不良反应等)、实体标签(关联药品或疾病名)、更新日期。
这套元数据的价值在检索阶段才会体现。比如用户问"某药怎么吃",检索时我可以直接限定元数据中的栏目名包含"用法用量";用户问某个疫苗的禁忌人群,可以限定实体标签等于对应疾病名称。相比纯语义检索,加元数据过滤像是给检索装上了精准的定向条件,能省掉大量无效召回。这也是我在检索链路里最终采用混合策略的基础。
4. 嵌入与检索链路实现:让系统真的"找到"相关文档
知识库准备妥当之后,接下来的核心就是把"用户问题"和"库里片段"做语义匹配。这一步实现得好不好,直接决定了模型拿到的上下文是"对口资料"还是"凑数内容"。
4.1 向量化入库流程:Spring AI里一次批量任务
文本嵌入这块用Spring AI封装得很好,核心就是调用嵌入模型把切片转成向量,再写入向量库。我的入库服务用Spring Batch做驱动,每次都只处理增量文档,流程大致是:
@Service public class DocumentIngestService { private final EmbeddingModel embeddingModel; private final VectorStore vectorStore; public void ingest(List<MedicalDocument> documents) { List<Document> springDocs = documents.stream() .map(doc -> { var metadata = Map.of( "source", doc.getSourceType(), "title", doc.getTitle(), "section", doc.getSectionName(), "entity", String.join(",", doc.getEntityTags()) ); return new Document(doc.getChunkText(), metadata); }) .toList(); // Spring AI 会自动完成文本向量化并写入向量库 vectorStore.add(springDocs); } }这里有个细节值得强调:Document对象除了正文之外,必须把元数据一起带进去。如果你在入库阶段偷懒不写元数据,检索阶段就无法做过滤,就只能全靠语义相似度硬扛,效果差一大截。
4.2 查询侧召回:向量检索加关键词检索的混合策略
经验告诉我,纯向量检索在医疗问答上不够稳。原因有两点:一是嵌入模型对药品名、疾病名这类专有名词的语义理解往往一般,拼写相近的两个药名可能被混为一谈;二是当问题里有明确的术语时,关键词精确匹配比语义匹配更可靠。
所以我做了双路召回,合流之后再做重排。具体来说,向量检索负责语义召回,用向量库的相似度搜索接口:
var searchRequest = SearchRequest.builder() .query(question) .topK(10) .build(); List<Document> semanticResults = vectorStore.similaritySearch(searchRequest);关键词检索负责术语配对,用倒排索引匹配用户问题里的专业词条,比如药品名、成分名、疾病名。这一步我直接用文本检索引擎的持久化索引来做,把标题、栏目名、实体标签这些字段都建了索引。
两路结果怎么合并?我一开始用的是简单的"去重后拼接",效果不稳定。后来改成加权合并:向量检索结果按相似度打分,关键词检索命中越多、字段越关键就加分,最终按综合分排序取TopK。这个综合排序公式不需要多复杂,但比单一来源稳定得多。
4.3 相关性阈值与重排:得让垃圾片段过不了关
很多RAG项目有一个通病——只要结果里多少沾点相关就把TopK的上下文扔给模型。但实际跑起来你会发现,召回的片段里总有百分之三四十是只有一点点关联的噪音内容,把这些内容塞进上下文会让模型回答变得松散甚至跑偏。
我的处理分两层。第一层是相关性硬门槛,不是所有相似度分数都值得进入上下文。我用一批典型问题做了一次分数分布统计,找到切分点设成阈值,低于阈值直接丢弃。阈值这个参数不能拍脑袋设,不同嵌入模型的分数分布差异很大,务必基于自己的数据统计。
第二层是精排重排。用重排模型对召回的候选片段重新打分,它比向量相似度更精细——它可以同时看到用户问题片段和文档片段两者,依赖大模型的能力判断是否真正相关。实践中,重排后Top5里真正可用的比例明显提升。代价是多了一次模型推理,在数据量不大、并发不高的内部系统里完全可接受。
这一步做完,检索引擎交付给生成引擎的上下文,基本是真正跟问题强相关的知识片段,而不是一堆"看着相关"的模糊内容。
5. 增强生成:Prompt设计、引用来源与合规边界
检索做得再好,如果Prompt设计得一塌糊涂,大模型照样能把天聊飞。医疗场景的生成环节有它自己的一套约束,核心是三个字:别瞎编。
5.1 Prompt模板设计:把"开卷考试"的规则说清楚
我给系统设计的Prompt模板核心是这样一段:
你是一名医疗信息整理助手。请严格依据以下资料回答用户的问题。 可用资料: {context} 用户问题: {question} 要求: 1. 如果资料中没有答案,请直接回答"未在现有知识库中找到相关信息",不要自行编造。 2. 回答必须基于资料原文,逐条列明关键信息,不能添加资料中没有的内容。 3. 每条信息后标注来源编号,编号对应可用资料中的[1][2][3]。 4. 如果问题涉及具体用药、治疗方案,请在回答末尾补充一句:"本回答由AI依据公开资料整理,仅供参考,不能替代专业医生诊断。"这份模板看起来没有什么花哨技巧,但每一句都是针对实际踩过的坑写的。第1句是为了堵住幻觉口子,第2句是为了压制发散,第3句是为了实现引用溯源,第4句是医疗场景必须有的边界声明。写Prompt的时候要记住,大模型不是一个"什么都知道的医生",它是一个"阅读理解能力很强的助手"——你给它的规则越多、越明确,它的表现就越可控。
5.2 引用溯源实现:让模型输出[1][2]之后怎么变成可点击来源
在Spring AI里做引用溯源其实比想象中简单。核心思路是:把检索回来的文档按顺序编号拼进context参数,同时保留编号到原文档标题、来源类型的映射关系。模型生成带有[1][2]标记的文本后,后端解析这些标记,把编号换算成实际来源信息返回给前端。
public QaResult answer(String question) { List<Document> docs = retrieve(question); String context = buildContext(docs); Prompt prompt = buildPrompt(question, context); String content = chatClient.call(prompt); Map<Integer, SourceInfo> sourceMap = buildSourceMap(docs); List<SourceInfo> citedSources = extractCitations(content, sourceMap); return new QaResult(content, citedSources); }这个功能对用户信任感的提升是立竿见影的。用户在阅读回答时可以直接点开来源链接去核对原文,发现系统确实是依据某份权威说明书的某一段来回答的,比任何承诺都管用。做医疗问答如果没有引用溯源,我是绝对不敢放出去给人用的。
5.3 流式输出与超时控制:用户体验的最后一公里
很多人做RAG系统会把注意力全放在检索和Prompt上,忽略了一个体验层面的细节:大模型生成完整答案可能需要几秒甚至更长,如果不做流式输出,用户看到的界面就是长时间空白。Spring AI的ChatClient提供了流式接口,配合前端SSE(Server-Sent Events),可以做到逐字输出。
流式的同时还得做超时控制。医疗问题的上下文会拼很长,模型推理时间不稳定,我设置了两个级别的防护:第一是单次调用超时时间,超过就返回兜底话术;第二是整体链路熔断,检索环节如果向量库发生异常,同样不给用户一个空白的等待页面,而是返回"系统繁忙,请稍后再试"。这看起来是小事,但对一个要给真实用户使用的系统来说,比很多花哨功能都重要。
还有一点实操心得:如果使用流式输出,引用来源的呈现方式需要动态处理。因为逐字输出的过程中你不确定后续会不会出现[3][4],我听到的方案是前端先渲染纯文本,等流结束后再解析来源标记统一渲染来源卡片。这样不会造成文本跳动,实现也简单。
6. 效果评估与迭代方向:光能跑通还不够
系统能在本地跑通只是起点。医疗问答这类系统最需要回答的问题是:凭什么相信你的效果达标了?我用的是"离线评测集加人工复核轮次"的组合方法。
6.1 离线评测集构建:不量化就无法优化
我建了一个评测集,包含100条左右的问题,覆盖日常用药咨询、禁忌查询、剂量确认、相互作用、特殊人群(孕妇、老人、儿童)等几大类。每条问题都配有参考答案和预期关联文档ID。批量跑完一轮后,算三个指标:
| 指标 | 含义 | 我的评价方式 |
|---|---|---|
| 检索召回率 | 预期关联文档是否出现在召回TopK中 | 越高越好,低于80%需调切片或检索策略 |
| 忠实度 | 回答是否完全基于上下文,有无臆造 | 逐条人工复核,出现幻觉即视为不通过 |
| 完整度 | 答案是否覆盖了参考要点的全部信息 | 缺少关键要点则扣分 |
这三个指标各有侧重。检索召回率查的是"系统有没有找到该找的资料",忠实度查的是"模型有没有乱说",完整度查的是"回答有没有漏掉关键提醒"。我见过不少项目只盯着忠实度,忽略了召回率低导致检索根本没把关键文档送进去,模型无米下锅,只能泛泛而谈。
6.2 检索与生成的联合调优:一次迭代的完整过程
给大家分享一次我实际做过的调优周期。第一轮评测跑完,发现"特殊人群用药"这一类问题的检索召回率只有65%,大量问题在向量库里根本捞不到正确的说明书片段。
排查发现原因在切片。特殊人群的用法通常混杂在大段的"用法用量"里,我的切片策略按栏目切,一个成年人用法和儿童用法被切进同一段,向量化后语义互相干扰。后来我把这类文档单独走一套切片流程,通过关键词识别出"成人""儿童""老年人""妊娠期"等分节词,强制按人群拆分。重新入库后,这一类问题的召回率从65%升到了88%。
然后看生成环节的问题。忠实度整体不错,但完整度有差距——有些回答虽然没错,但漏掉了剂量上限或禁忌提示。问题出在Prompt模板里没有强制要求回答必须覆盖关键字段。我在模板里补了一句话:"回答时需明确说明适应症、用法用量、禁忌人群和注意事项,如果资料中缺少某一项,请注明未获取到相关信息。"这一改,完整度明显提升。
每次调优我只动一个变量,跑评测集,对比差异,记录结论。这样复盘时才能准确归因,到底是切片变了影响检索,还是Prompt变了影响生成,不会混为一谈。
6.3 后续可扩展的方向:从单轮问答到真正可用的系统
单轮问答跑通之后,要向一个真正可用的方向演进,还有几件事我认为优先级比较高。第一是多轮对话记忆,用户第一轮说"我感冒了",第二轮问"这个药我能吃吗",系统如果记不住前面的上下文,就答不准。Spring AI提供了对话记忆组件,接入成本不高。
第二是知识更新与失效管理,医疗知识时效性很强,一份指南更新后,旧版本就不能再作为回答依据。我在元数据里加了生效日期和失效日期,检索时按当前时间过滤,这个优先级很高。第三是用户反馈闭环,在答案后面加"有帮助/没帮助"的按钮,把负面反馈埋点下来,定期回看,这是持续优化数据来源。
最后分享一个我自己的体会:医疗问答系统永远不要以"替代医生"为定位,而是以"辅助用户理解公开知识"为边界。技术上追求的是把资料找对、把话说准、把来源标清楚,产品上追求的则是让用户获得准确信息的同时意识到AI回答的局限性。把这两件事想清楚,这个系统的技术骨架才算真正立住了。