做「安诊用药」这个老年健康主动管理AI助手项目以来,RAG这一块是我投入精力最多的部分。现在回过头看,从最初"向量库一搜、Prompt一拼就完事了"的天真想法,到真正理解检索增强生成的每一个环节都在影响最终效果,这条路上的每一个技术决策都是踩了坑之后才想明白的。这篇文章就把我们在文档切分和检索召回两个核心环节上的实践和思考整理出来。

一、不是所有PDF都能直接用正则解析

1.1 正则方案为什么不够用

项目里的医疗知识全部来自PDF文档,包含各种临床指南、药品说明书、诊疗规范。一开始我们用Apache PDFBox逐页提取文本,然后写了一套正则去匹配标题层级——"一、二、三"开头的是章节标题,"1.1""2.3"这种是子标题,其余的都归为正文段落。

这套方案在格式规整的文档上勉强能用,但问题很快暴露出来。页眉里重复出现的"某某医院临床诊疗指南"被当作正文切进了知识片段,页码"第12页"混在药效说明中间,某些文档的数字编号格式跟正则完全不匹配导致整篇都变成了一坨无结构的正文。更麻烦的是,被页眉页脚污染的知识片段在向量化之后,语义被严重稀释——检索"阿司匹林用法用量"的时候,召回的前几条里居然有两条的内容开头都是重复的页眉文字。这根本不是切分策略的问题,而是输入源头的脏数据就注定了下游的检索质量不会好。

1.2 MinerU带来的版面理解能力

这促使我们决定引入MinerU来做PDF的版面分析。MinerU是一个开源的PDF解析工具,底层用深度学习模型做版面识别和结构还原,能把页面里的文字块按照标题、正文、页眉、页脚、页码、表格、图片等类型精确地区分开。最关键的是,它不是靠启发式规则去猜某个文字块是什么类型,而是基于预训练的视觉模型去理解整个页面的布局,标题的字号大小、页脚的固定位置、页码在页面底部的重复模式,这些信息都被纳入了判断依据。

我们的接入方式是部署一个本地的MinerU FastAPI服务,后端通过HTTP multipart/form-data把PDF文件上传过去,拿到的是一个结构化的JSON响应,里面每个block都标注了类型、文本内容、标题层级、页码和坐标信息。客户端的核心逻辑是先过滤掉页眉、页脚、页码这三类无效block,然后按标题block进行内容聚合——同一个标题下的所有正文block合并为一个结构单元,无标题的连续文本则归为段落级别。标题的层级信息(一级、二级、三级)也被完整保留,这让后续检索时展示的引用来源不再是一个模糊的页码,而是"便秘外科诊治指南——诊断标准"这样有层次的信息。

1.3 优雅的降级设计

整个MinerU方案上还有一个非常重要的工程考量,就是降级机制。外部服务总会有不可用的时候,网络波动、服务重启、资源不足,任何一个原因都可能导致MinerU调用失败。如果因为MinerU挂了整个知识库入库就停摆,那这个系统太脆弱了。

我们设计了一个三级降级链路。第一级是MinerU正常工作的理想情况。第二级是MinerU服务不可用或返回空结果时自动回退到传统的正则分析器,虽然质量会差一些,但入库流程不会中断。第三级是正则分析器也解析失败时的兜底方案——直接按固定大小滑动窗口切分,700字符一个chunk,120字符重叠,这是最原始的切法但保证在任何情况下都能出结果。配置文件里还有一个总开关,mineru.enabled设为false就可以完全跳过MinerU,这在调试或者MinerU服务需要维护的时候非常实用。

二、孤立chunk是怎么毁掉召回质量的

2.1 一个被忽略的致命问题

文档切完之后,每个知识片段就是一个独立的向量,被存进Milvus里等待检索。但这里有一个非常隐蔽的问题,在RAG领域被称为"孤立chunk"——一个片段被从原文中切出来之后,丢失了它在整篇文档中的上下文定位信息。

举个例子,一段"推荐剂量为每次5毫克,每日一次"这样的文本,单独看它是一条完整的用药说明,但检索的时候Embedding模型只能看到这十几个字本身。它不知道这是哪个药的剂量、在什么条件下适用、有没有禁忌人群。如果用户问的是"高血压患者用什么降压药",这条片段和用户问题之间的语义关联就非常微弱,几乎不可能被召回,但它的确是一条相关性很高的知识。这个问题在医疗文档中格外严重,因为医学文本的上下文依赖极强,单独拎出来的一个用法说明、一段禁忌描述,脱离了它所属的章节标题和前置病因叙述,语义就丧失了大半。

2.2 Contextual Retrieval的解决思路

解决这个问题的思路来自Anthropic在2024年提出的Contextual Retrieval策略。核心想法很简单也很优雅:在每个chunk入库之前,用大模型为它生成一段简短的上下文摘要,然后把摘要前置到chunk内容的前面一起向量化。这样一来,Embedding在编码的时候就能看到这个片段讨论的是什么主题,向量里携带的语义信息不再是孤立的几个词,而是包含了整个片段在文档中的角色和定位。

在我们的实现中,ChunkContextEnhancer组件负责这个上下文增强的工作。每个切分好的知识片段会先截取前400个字符作为预览,发给大模型让它用一到两句话概括这段文字讨论的是什么。Prompt的设计刻意保持简洁——"你是一个医学文档处理助手,请为下面的医学文档片段生成1-2句简洁的上下文摘要,说明这段文字讨论的主题是什么,只输出摘要,不要其他内容,摘要控制在50字以内"。约束输出格式和长度很重要,因为摘要太长了会稀释原文的语义信号,太短了又起不到上下文补充的作用。

生成出来的摘要会以"[本文讨论:...]"的前缀形式插入到片段内容的最前面。比如原来内容是"推荐剂量为每次5毫克,每日一次",增强之后变成"[本文讨论:硝苯地平缓释片用于高血压治疗的常规用法用量] 推荐剂量为每次5毫克,每日一次"。这个增强后的文本再拿去向量化,检索时就能准确地和"高血压降压药"这类查询匹配上了。上下文增强是批量处理的,每批10条,避免了对大模型API的并发冲击。整个环节可以通过配置文件的knowledge.context-enhance参数关闭,但我们实测下来,开启之后在医疗类查询上的召回率有非常明显的提升,尤其是片段较短或者文档跨章节引用频繁的场景。

三、单路召回的天花板在哪里

文档切得好、上下文补得全,确实能把每个chunk的质量做到很高,但这还只解决了"库里有什么"的问题。真正决定检索效果的,是怎么找到最相关的那几条。

我们最初的方案就是标准的单路向量召回:用户问题用阿里云text-embedding-v4编码成1024维向量,然后拿到Milvus里做余弦相似度搜索,返回Top-K条最相似的片段。这个方案在大部分场景下表现不错,但它在医学场景下暴露出一个致命的问题——对专有名词和精确术语不敏感。

向量检索的本质是语义相近,而不是词汇匹配。当用户问"硝苯地平的副作用"时,向量召回确实能把讨论硝苯地平不良反应的片段排到前面。但当用户问"拜新同是什么药"时,向量检索就会完全陷入困境。"拜新同"是硝苯地平的商品名,知识库里可能通篇都在说硝苯地平但从未出现"拜新同"这三个字。Embedding模型既没有学过这个药品的商品名映射,也没法靠语义推断出"拜新同"和"硝苯地平"是同一个东西——因为商品名本身就是一个没有语义记忆的外来词汇。

这就是单路向量召回的天花板。语义覆盖和精确匹配是两种完全不同的能力,向量的优势在于"理解意思",而对于专有名词、药品名称、术语缩写这种需要精确命中的场景,传统的关键词检索反而更可靠。单靠其中任何一种,都解决不了对方擅长的那类查询。

四、多路召回与RRF融合

4.1 双路并行的架构

解决思路是让向量召回和关键词召回两条路并行,各取所长。向量召回负责语义级的相似性匹配,BM25关键词召回负责精确的术语命中。两条路独立执行,各取Top-N条结果,然后在融合层做合并和重排序。

BM25这一路我们用的是基于MySQL全文索引的轻量实现。加了全文索引后用自然语言模式的MATCH AGAINST做关键词检索,这样做的好处是不需要额外的Elasticsearch或者BGE-M3这类独立组件,维护成本和部署复杂度大幅降低。虽然MySQL的全文检索在分词精度上不如Elasticsearch,但在中文医疗术语的匹配上,命中率已经足够支撑我们需要的召回精度。

4.2 RRF——零参数的融合艺术

两路召回各自拿到结果之后,面对的合并问题远比想象中复杂。最简单的方案是加权求和,给向量召回的结果乘一个权重、BM25的结果乘另一个权重,然后按总分排。但权重怎么定?0.6和0.4还是0.7和0.3?每一组权重都需要一套完整的评估来验证,而且不同Query类型对权重的敏感度完全不同,调好了一种查询可能毁了另一种。

我们最终选择的是RRF(Reciprocal Rank Fusion)算法。它的核心理念非常优雅——不依赖任何超参数,直接基于每条结果在各自召回列表中的排名来计算融合分数。一条结果在向量召回里排第2、在BM25里排第5,那它的RRF分数就是1/(60+2) + 1/(60+5)。其中60是一个固定的平滑常数,用来避免对第一名过度加权,让算法对排名波动不那么敏感。

RRF最大的优势在于工程上的零调参负担。不需要知道每条结果的原始相似度分数是多少——不同召回路的分数分布差异极大,直接比较毫无意义——只需要排名就够了。这让融合层的代码极其简洁,而且行为完全可预测。60这个平滑常数是学术界广泛验证过的推荐值,我们拿来直接用,没有做任何调整,效果就已经超过了我们花了两周时间反复试出来的加权融合方案。

五、Query改写:先想清楚用户到底在问什么

多路召回解决了"不同性质的匹配"的问题,但还有一个上游问题没有被处理——用户输入的原始查询,本身可能就不够清晰。

老年用户问问题的方式和年轻人不一样,他们更习惯用口语化的、带有个人叙述的表达。"我这两天便秘,吃不下饭,肚子胀,好几天了"——这句话里有便秘症状、食欲不振、腹胀三种情况,还有持续时间的描述,但核心意图其实是"便秘怎么办"。如果直接把整句话拿去做向量检索,Embedding模型会把三个问题点的语义混合在一起,产生一个语义模糊的混合向量,导致召回的片段三件事都想沾一点但哪件事都不精准。

我们在检索之前加了一层Query改写。改写的逻辑很简洁:把一个口语化的、可能包含多个子问题或冗余描述的用户输入,提炼成一条干净、聚焦核心意图的检索查询。这一步由大模型完成,Prompt的核心指令是"去掉无关的语气词和冗余的个人描述,保留最核心的医学问题",同时要求输出不超过30字,确保改写后的查询仍然紧凑。

改写后的查询用于向量召回,而BM25这一路用的仍然是原始查询——因为关键词匹配需要保留完整的术语和可能的变体表达,改写后的精炼版本可能反而丢掉了某些有助于精确命中的词。两路各取所需,融合之后互相补充,这是我们刻意保留的一个设计差异。

改写这一步的代价是一次额外的LLM调用,大约增加200到400毫秒的延迟。但这个延迟被后续的并行召回和流式生成完全覆盖掉了,用户感知不到。在我们的评估集上,开启Query改写之后HIT@5从67%提升到了73%,提升幅度非常可观,尤其是在多意图混合查询和口语化表达的典型老年交互场景下。

六、Rerank精排——最后一公里的质量守卫

经过两路召回和RRF融合之后,我们拿到了一个初步排序的候选列表,通常是15到20条候选结果。但RRF融合做的只是"排名的融合",它看不到每条结果的实际内容和用户查询之间的深层语义关系。两条结果在各自召回路里排名相同,RRF就会给它们一样的融合分数,但实际上一旦仔细比对内容,其中一条可能跟用户的问题完全不相关——只是碰巧在两个列表中排到了相似的位置。

这就是Rerank的用武之地。和第一阶段的双编码器向量召回不同,Rerank模型是一个专门训练来做"查询-文档"相关性判断的交叉编码器模型。它不是把文档和查询分别编码再算余弦距离,而是把查询和文档拼接成一个完整的输入序列,让模型逐字逐句地理解两者之间的关系,然后直接输出一个相关性分数。这种交叉注意力的方式比双编码器精确得多,代价是计算量大——必须对每对查询-文档都跑一次完整的模型推理。

我们用的是阿里云gte-rerank模型,输入是用户原始查询和每条候选片段的内容,输出是一个0到1之间的相关性分数,然后按这个分数重新排序取最终的Top-5。Rerank这一步的延迟大约是800毫秒到1.2秒,但它带来的精度提升是决定性的。在我们的评估集上,RRF融合之后HIT@5是76%,加上Rerank之后到了80%。而且这4个百分点的提升全部集中在最难的那些Case上——候选列表中的相关性高度接近、靠相似度分数根本分不出高下的场景,Rerank的深层语义判断起到了关键的分辨作用。

七、完整链路与评测结果

把上面的所有步骤串起来,最终检索链路由六个环节组成。用户输入先经过Query改写,把口语化表达提炼为精准的检索意图;改写后的查询送入向量召回通道,在Milvus中基于余弦相似度检索Top-10候选;同时原始查询送入BM25关键词召回通道,在MySQL全文索引中检索Top-10候选;两条路的结果经过RRF融合,按倒排分数合并去重后得到一个约20条的粗排候选集;这20条候选送入Rerank精排模型,按查询-文档相关性分数降序排列;最终取Top-5作为知识库上下文,和用户问题一起组成Prompt喂给大模型生成回答。

我们在一个包含120条医疗领域典型查询的测试集上做了评测。这个测试集覆盖了药物用法用量、副作用查询、疾病诊断标准、禁忌人群判断、联合用药注意事项这五类常见问题。对比最初的单路向量召回基线(HIT@5为62%),加入MinerU结构化和Contextual Retrieval后提升到67%,加上双路召回和RRF融合后到73%,Query改写单独贡献了约3个百分点到76%,最终Rerank精排拉到80%。整个链路的改进带来了18个百分点的累积提升,每一步的贡献都可以在评估数据上被清晰地验证。

写在最后

RAG这个领域,每一个单独的技术点拿出来都不复杂,但把它们组合成一个真正可用的工程系统,需要大量的细节打磨。MinerU解决了输入的质量问题,Contextual Retrieval补上了切片上下文断裂的最短板,双路召回让语义匹配和关键词命中两种能力协同工作,RRF融合用零参数的优雅设计替代了反复调参的痛苦,Query改写从源头上优化了检索的意图表达,Rerank在最后一公里做精细化纠偏。这六个环节,缺了任何一个,最终的效果都会有肉眼可见的下降。

在医疗这种对准确性要求极高的场景下,没有哪个单一技术能解决所有问题。好的RAG系统,本质上是在不同的信号源之间做平衡,让它们各司其职、互相补充,最终形成一个可靠的检索链路。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐