做过医疗垂直场景 RAG 的朋友应该都有体会:同样的框架跑百科类知识库效果还不错,一旦换到医学文档,各种问题就冒出来了。术语被切碎、检索召回的片段对不上、答案看起来顺滑但找不到出处……我在做医疗问答系统的过程中,踩过的坑差不多能写一本手册。这篇文章梳理的是项目 v21.7 迭代中从知识分段、混合检索到重排溯源的全链路调优实践,核心成果是把答案溯源覆盖率从 68.3% 提到了 90.0%,也就是标题里“21.7”的由来——21.7 个百分点。整个过程涉及文本分段策略、BM25+向量混合检索、重排模型选型、引用溯源链路,适合正在做医疗 AI、企业知识库或 RAG 应用的同学参考。
1. 医疗垂直场景 RAG 的“坑”从哪来
1.1 通用 RAG 在医疗场景翻车的原因
很多人第一次把通用 RAG 框架迁移到医疗场景时会很困惑:embedding 模型没换、向量库没换、Prompt 模板也没换,为什么效果断崖式下跌?我的经验是,问题几乎都出在“文档特性”和“检索目标”的错位上。
医疗文档有几个显著特点。第一是术语密集且实体很长,比如“急性ST段抬高型心肌梗死”这种实体,如果按固定 512 字符切块,很容易从中间切断,导致语义碎掉,embedding 出来的向量既不完整也不稳定。第二是文档权威性分层极其明显:临床诊疗指南、专家共识、药品说明书、医学科普文章,可信度完全不是一个量级,检索系统如果一视同仁,低质量内容会把高质量内容挤下去。第三是断言的严谨性要求:医疗答案里每一句话都应当有据可查,模型不能像闲聊机器人那样说“可能”“大概”就完事。第四是时效性,医学指南会定期更新,同一主题的旧版本文档如果没被过滤,检索时很可能命中过时信息。
这四点叠加起来,通用 RAG 的“检索一段相似文本 + 拼进 Prompt 让模型生成”的思路就显得过于简单了:它只解决了“有没有相关内容”的问题,没解决“内容是否可信、是否完整、是否最新、能不能引用”的问题。
1.2 医疗 RAG 必须满足的三个硬性要求
在做这个项目之前,我和团队花了很长时间梳理需求,最后沉淀出三个硬性要求,后面所有的技术选型都是围绕它们展开的。
| 硬性要求 | 说明 | 对技术链路的影响 |
|---|---|---|
| 可溯源 | 答案中的每个关键断言都能对应到知识库中的原始片段 | 切块时必须保留稳定的文档 ID、章节 ID;生成时要输出引用标记 |
| 可拒答 | 知识库没有足够依据时,系统要明确说“不知道”,不能硬答 | 检索和重排之后需要置信度判断,低分片段直接拦截 |
| 可审核 | 医生的审核流程中必须能方便地跳转到原文,核对引用是否被曲解 | 前端展示引用原文;生成后的引用要做一致性校验 |
顺带说一句合规问题:这类系统在真实落地时定位通常是“辅助信息检索与证据整理”,不输出诊断结论,也不替代医生决策。系统设计上要留出“医生审核”这一道人工环节,提示语里也要把边界说清楚。这不是保守,是医疗场景的基本要求。
2. 知识分段:医疗文档切块不是“按字数切”那么简单
2.1 医学文档的结构特点与切块原则
我开始做 v1 版本时采用了最省事的固定字符切块,结果被医疗文档狠狠教育了一课。临床指南里最关键的“推荐意见”往往是一个带编号的短段落,后面跟着证据等级和参考文献;药品说明书里“用法用量”可能是一张表格;教材里一个疾病诊断标准会跨好几个段落。固定长度切块的本质问题是:切出来的块不是“语义完整的最小单元”,而是“字符数量恰好相等的连续文本”,两者在检索场景下的表现天差地别。
后来我们定下的切块原则只有一条:切出来的每个块,必须能独立回答某个类型的问题。听起来简单,落地时要对文档做结构化解析。对 PDF 格式的指南,先用解析工具把版面转成带标题层级的 Markdown;对 docx 格式的教材,直接读取标题样式。然后以标题层级为锚点:一级标题、二级标题作为块的边界,块内如果太长,再按句号边界切分。切出来的子块如果太短,比如只有一两句话,就向上合并到父级块,避免产生大量碎片信息。
2.2 实操中的切块策略与参数选择
这套策略在实现时的大概流程是这样:
- 先做文档结构解析,把 PDF、Word 转成 Markdown,保留标题层级。
- 以“二级标题”为默认切块边界,二级标题下的内容为一个候选块。
- 候选块长度判断:少于 150 字,向上并入一级标题块;超过 2000 字,在句号、问号处再次分割。
- 切块时保留 128 字符的重叠窗口,避免跨段的语义承接被切断。
- 每个块生成并存储元数据:doc_id、chapter_id、标题、版本号、发布时间、来源类型(指南/说明书/教材/科普)。
这里重点说下重叠窗口的作用。医学描述里经常出现“该病患者应避免剧烈运动,尤其是在急性期之后”这种句子,前半句和后半句关联很强,如果刚好被窗口边界切开,后面单独检索到“尤其是在急性期之后”时,语义是不完整的。重叠窗口并不能让切块完全避免这种问题,但能显著降低边界切错的概率。128 字符是我实测下来成本和收益比较平衡的值,重叠太少保护不足,重叠太多会引入大量重复块,检索时同一内容反复出现,白白占向量库容量。
2.3 实测对比与避坑
在内部 1000 条医疗问答测试集上,我们对比过几种切块策略的召回效果,口径是“检索到能支撑答案的片段”的命中率:
| 切块策略 | 检索命中率 | 备注 |
|---|---|---|
| 固定 512 字符无重叠 | 52.6% | 基线,术语切断问题严重 |
| 固定 1024 字符 + 128 重叠 | 58.3% | 有所提升,但表格和剂量信息仍会被切断 |
| 结构感知切块 + 128 重叠 | 67.1% | 标题层级锚点,完整保留语义单元 |
| 结构感知 + 表格单独抽取 | 68.8% | 表格作为独立块,命中率进一步提升 |
表格单独抽取这一点容易被忽视。药品说明书里的“用法用量”表、指南里的“危险分层”表,一旦被转成线性文本再切块,行列对应关系全乱,检索到也读不懂。我们把表格区域单独识别出来,按“表头+行内容”转换为结构化文本后作为一个独立块存储,效果立竿见影。
还有一个很隐蔽的坑是引用编号。学术性医疗文档里大量出现“根据相关研究[3]”,如果切块时把“相关研究”和“[3]”拆到两个块里,溯源时就找不到证据来源。所以切块后我们会单独做一步后处理:把段落末尾的引用编号列表连接到对应文本块的元数据里,而不是让它散落在文本中间。
我后来总结了这样一句话:分段是检索的上限,如果切块阶段就丢了信息,后面接什么检索模型、重排模型都救不回来。这也是为什么我坚持把“知识分段”放在全链路优化的最前面。
3. 混合检索与多路召回:为什么 BM25 和向量检索必须一起上
3.1 单一检索方式在医疗场景的失效案例
当我们把切块优化到一定程度后,瓶颈自然转移到了检索环节。最开始我们以为向量检索已经够用,结果遇到两类典型问题。
第一类是术语变体问题。用户问“心梗后多久可以恢复正常工作”,文档里写的是“心肌梗死患者出院后建议休息时间”,query 里的“心梗”和文档里的“心肌梗死”在字面上不匹配,embedding 虽然能把语义拉近,但 BM25 这种稀疏检索在这里就无能为力。反过来,用户问“阿莫西林一次吃几片”这种带有精确剂量和药名的问题时,向量检索对数值的敏感度不够,经常把“0.5g”“每日两次”这种关键信息模糊掉,而 BM25 能精确命中这些字面信息。
第二类是缩略语问题。医疗场景有大量英文缩写:ACS、STEMI、NSTEMI、COPD……用户可能用中文提问,也可能直接丢缩写,还可能把缩写和全称混在一起。单一检索方式很难同时处理精确匹配和语义改写这两类需求。这也是“混合检索”在医疗场景几乎成为必选项的原因。
3.2 混合检索与多路召回的实现方案
我们的方案是“BM25 稀疏检索 + 向量稠密检索”双路召回,再加一路基于规则的查询改写,具体流程如下:
- 查询预处理:先做分词,对医疗缩写做同义词扩展。比如“STEMI”扩展为“ST段抬高型心肌梗死”,“心梗”扩展为“心肌梗死”,扩展后的多个 query 分别送检索。
- 稀疏检索:BM25 在倒排索引上精确召回,主要解决药名、剂量、检查指标这类对字面匹配敏感的问题。
- 稠密检索:用中文医疗语料微调过的 embedding 模型对 query 和切块向量做余弦相似度召回,主要解决同义改写、口语化表述的问题。
- 多路结果合并:对 BM25 结果和向量结果分别取 top100,然后用 RRF(Reciprocal Rank Fusion)融合。RRF 的公式很简单:每个文档打分为所有检索通路中 1/(k + rank) 的累加,k 通常取 60。
关于 RRF 我想多说一句,很多人一开始会倾向做分数归一化然后加权求和,但实测下来不同检索通路的分数分布差异很大,权重很难调,而 RRF 只用排名信息,天然规避了分数不可比的问题,鲁棒性更好。我们在医疗数据上对比过,RRF 的稳定性明显优于手工加权求和的方案。
在工程集成的层面,向量库我们用的是 Milvus,它原生支持稠密向量和稀疏向量的混合检索,可以比较自然地把 BM25 和向量召回统一到一套 API 里。如果团队技术栈是 Java,langchain4j 和 spring-ai 都对 Milvus 有比较成熟的集成,langchain4j 里可以通过 EmbeddingStore 接口对接 Milvus,检索链路里串联 DocumentSplitter、QueryTransformer 这些组件实现同样的“多路召回+重排”流程。我们部分服务用的是 Java,所以这套链路在两种技术栈下都跑通过。
3.3 粗召回数量与参数实测
这里给出几个我们实测后觉得比较稳的参数值,可以作为起步参考:
- 粗召回量:BM25 top100 + 向量 top100,融合后取 top50 送重排。
- RRF 参数:k=60,效果稳定;k 太小会让排名靠前的文档获得过大权重,k 太大则各路召回的区分度被稀释。
- 查询扩展数量:每个 query 扩展出 2~3 个变体即可,盲目扩太多会把检索质量拉低。
采用混合检索后,内部测试集的检索命中率从 68.8% 提升到了 73.6%,增量主要来自“术语变体”和“缩写查询”这两类 case。这个结果让我们确信:在医疗垂直场景,混合检索不是锦上添花,而是刚需。
4. 重排:从粗召回 top50 到精排 top5
4.1 为什么必须有一层重排
粗召回阶段无论是 BM25 还是向量检索,用的都是“快速但粗粒度”的匹配方式。向量模型为了效率,把文档压缩成一个向量,天然会损失细节;BM25 则是纯字面匹配。两者召回的结果里,往往混杂着“主题相关但并不是答案直接依据”的片段。如果直接把 top50 全塞进大模型上下文,不仅浪费 token,还会因为干扰信息太多拉低答案质量。
所以我们在粗召回和生成之间加了重排环节。重排模型采用的是交叉编码器(cross-encoder)结构,把 query 和一个候选片段拼接起来,输入模型做深度语义匹配打分。相比向量检索阶段的双编码器(bi-encoder),交叉编码器能看到 query 和片段之间更细粒度的交互,精度高不少,代价是速度慢。所以正确的用法是:用快速检索做粗召回保证不漏,用交叉编码器做精排保证头部结果准。
4.2 重排模型选型与实测:关于 2b 和 4b 的差距
这部分是不少读者私信问得最多的:医疗场景重排模型该选多大参数?通义 2b 重排模型和 4b 重排模型差距大吗?我的回答是:差距确实存在,但要看你的优化目标是什么。
我们同时测试了 2b 和 4b 两个规模的重排模型,也在业务测试集上做过对比。结论可以分成三点:
第一,效果上,4b 在 nDCG@10 指标上普遍比 2b 高出 3~5 个百分点,尤其是涉及多条件约束的查询,比如“糖尿病患者合并肾功能不全时,降压药如何选择”,这类需要同时考虑多个医学条件的问题,4b 的理解能力明显更强。
第二,资源消耗上,4b 的显存占用和推理延迟差不多是 2b 的两倍。我们线上服务的响应时间预算有限,2b 重排+生成全链路能压在 3 秒以内,换成 4b 之后延迟涨了约 40%。
第三,端到端影响上,在我们的医疗问答集上,4b 比 2b 给溯源覆盖率带来的提升大约是 1.2 个百分点。换句话说,重排模型加大参数带来的收益是真实存在的,但没有检索命中率提升那么显著。
基于这些测试,我们最终采用的是“线上 2b、离线 4b”的组合:线上服务用 2b 保证响应速度,关键数据集评测和效果回归时用 4b 得出更准确的上限结论。如果你的业务对响应时间不敏感,且 GPU 资源充裕,直接上 4b 是完全合理的选择。
另外在榜单之外,我还建议大家关注重排模型的输入长度限制。医疗文档的切块经常超过 512 token,重排时直接截断会丢掉尾部关键信息。我们的做法是:把候选片段截断到模型支持的最大长度以内,同时在元数据里保留完整文本;如果重排发现某个片段的高分信息部分在尾部,再由后面生成的溯源校验环节兜底验证。
4.3 重排后的截断与置信度判断
重排完成后,还有个容易被忽视的步骤:不要把 top20 全塞给大模型。我们目前只取 top3~5 进入最终上下文。原因有两点:一是医疗问题的答案通常集中在少数几个片段里,取更多片段会增加信息噪音;二是大模型的注意力是有限的,上下文越长,模型越难聚焦到真正的依据上。
同时在重排分数上设定一个动态阈值:如果 top1 片段的分数过低,说明知识库里可能确实没有能支撑答案的内容,这时候系统会走“拒答”分支,告诉用户“当前知识库中没有找到足够依据,建议咨询专业医生”,而不是强行生成一个看起来像模像样的回答。这个“会拒绝”的能力在医疗场景比“答得多”更重要。
5. 溯源与可信输出:让每个答案都有出处
5.1 医疗场景为什么溯源是硬需求
做医疗问答系统久了,我越来越觉得溯源不是后置的“装饰功能”,而是决定医生和患者是否愿意信任整个系统的关键一环。对医生来说,一个答案如果拿不出出处,基本等同于没有价值;遇到复杂病例,医生需要快速判断这条信息来自哪版指南、哪个章节、原文怎么说。对责任界定来说,一个可回溯到原文的答案,即使最终被证明参考了过时资料,也能通过溯源链条定位问题出在知识库更新环节而不是模型杜撰。
这些要求直接改变了我们对“检索结果”的定义:检索返回的不再是一段纯文本,而是一份带有完整证据链的“材料包”。每个材料包里包含了原文片段、doc_id、章节路径、版本号、来源类型。所有下游的生成、展示、审核都基于这个材料包展开。
5.2 溯源链路的实现细节
溯源链路在工程实现上从切块阶段就已经开始了。我们对每个知识块生成一个稳定的 block_id,由 doc_id、章节 ID、文本哈希共同计算得出,保证同一个位置的文本无论切多少次,得到的 ID 都是一致的。这为后面的回溯打下了基础。
检索和重排阶段,所有候选片段都要携带自己的元数据,包括原始标题、发布机构、版本时间、来源类型。到了生成阶段,Prompt 里会强制模型按照“依据 + 回答 + 引用”的结构输出,要求答案中的关键断言必须引用具体的片段编号,并且禁止输出记忆中的医学知识。
Prompt 里大致会这样约束:
- 严格按照“[依据片段] 回答内容”的格式组织输出。
- 每个答案句如果对应某个片段,必须在句末标记引用编号。
- 如果片段内容不足以回答问题,只能回答“当前知识库中没有足够依据”。
- 禁止总结、推理片段中不存在的医学结论。
生成完成之后还有一道“溯源校验”关卡。我们会用一个轻量级的蕴含判断模型,检查答案里的每个断言和它引用的片段之间是否存在逻辑蕴含关系。如果答案句子的含义明显超出片段范围,或者引用编号对不上,就触发后处理:要么删除该句,要么把该句标记为“需人工核查”。这一步是为了防止模型“强行引用”的情况——生成模型可以为了满足 Prompt 格式而把不相关的片段编号挂在句尾,这是我在实际测试里反复遇到过的现象。
最后在应用前端,用户点击答案中的引用角标,可以直接显示对应的知识块原文、来源文档标题、发布章节和版本时间。整个“点击引用 → 跳转原文”的过程,就是医生审核体验里最关键的那一步。
5.3 21.7 个百分点的提升是怎么拆解的
前面提到溯源覆盖率从 68.3% 提升到 90.0%,这里给出评测口径和归因拆解,方便大家对照复现。
评测集是我们内部构建的 800 条医疗问答,覆盖术语解释、诊疗流程、用药剂量、禁忌事项四类问题。指标定义是:溯源覆盖率 = 答案中可溯源到知识库依据的断言句数 / 总断言句数。
| 优化阶段 | 溯源覆盖率 | 增量 |
|---|---|---|
| v20.1 基线:固定切块+纯向量检索+top5直出 | 68.3% | - |
| +结构感知知识分段 | 71.5% | +3.2% |
| +BM25+向量混合检索 | 76.3% | +4.8% |
| +交叉编码器重排 | 82.4% | +6.1% |
| +生成后溯源校验与引用约束 | 90.0% | +7.6% |
21.7 个百分点就是这四步增量的累加。需要说明的是,这些数字是业务场景特定的,不同数据集的效果增幅肯定不一样,但整体的优化优先级是共通的:先保证知识库里能检索到完整依据(分段),再提高关键依据的召回率(混合检索),然后把最相关的依据排到最前面(重排),最后确保生成内容严格依附于依据(溯源校验)。每一层都是下一层的地基。
在实际部署后我还有一个体会:最能提升医生信任度的,往往不是把模型从 7B 换到 72B,而是把溯源链路做扎实。当一个答案点击引用后能精确跳转到指南的对应章节,医生对系统的态度会明显不一样。这个项目目前还在继续迭代,后续打算把知识库版本自动更新、多模态检查报告文本解析、以及基于 agent 的多轮追问能力逐步加进来,但底层“分段 → 混合检索 → 重排 → 溯源”这条主线不会变。回到开头那个问题,医疗 RAG 的难度不在于某一个环节有多复杂,而在于每个环节都得为“可信”这个目标服务,少一环都会漏风。