安诊用药AI助手——RAG增强生成的探索实践与原理

这段时间在开发安诊用药AI助手这个项目时,RAG这一块花了我不少时间去理解和落地。现在回过头来看,从最初的"不就是接个向量库吗"到真正理解每一步的设计考量,中间踩了不少坑。这篇文章就把我在项目中的实践和一些底层原理的理解整理出来.

从功能需求出发——为什么需要RAG

做这个项目的初衷是想给老年人做一个用药AI助手,聊天式的,用什么药、怎么吃、有什么副作用、什么情况下要去看医生,这些都能问。但问题很快就来了。

大模型虽然能聊天,但它是通用训练的,它不知道我这个系统里有哪些医疗知识。如果我直接问"便秘了怎么办",它会基于训练数据给出一个泛泛的回答,但不会引用我精心整理的那些医学指南和用药说明。而且更麻烦的是,它可能会编造一些看起来很合理但其实不存在的药物或剂量——这在医疗场景里是绝对不能接受的。

所以核心需求就很清楚了:要让大模型基于我准备好的医疗知识来回答,不知道的就说不清楚,不要自己瞎编。这就是RAG要解决的问题——检索增强生成,先检索到相关的知识片段,再把它们和用户问题一起喂给大模型,让模型有的放矢。

接下来的几个部分,我就按照数据怎么入库、怎么查、怎么生成这个顺序,把整个流程串起来。

文档摄入——PDF是怎么变成知识库的

项目里的医疗知识主要来自PDF文档,放在data目录下。系统启动的时候会自动扫描这个目录,也可以随时上传新的PDF。

PDF解析这块用的是Apache PDFBox,逐页提取文字内容。解析出来的纯文本,一方面会全文保留存在MySQL的KnowledgeDoc表里,给后续的关键词检索兜底;另一方面会被切成小片段,向量化之后存进Milvus。

这里有一个文件去重的设计。每个PDF导入前都会算一个SHA-256哈希值,如果发现文件内容和上次一样、而且切片已经正常写在Milvus里了,就跳过。这样做的好处是启动扫描不会因为重复导入浪费时间和资源。

代码里相关的逻辑在KnowledgeService.ingestPdf方法中,大致流程是提取文本、检查哈希、切分、插入或更新文档记录、创建切片、向量化写入Milvus。

String fileHash = sha256(pdfPath);
KnowledgeDoc existing = knowledgeDocMapper.selectOne(
    new LambdaQueryWrapper<KnowledgeDoc>()
        .eq(KnowledgeDoc::getSource, source)
        .last("limit 1"));
if (existing != null
    && Objects.equals(existing.getFileHash(), fileHash)
    && isDocumentIndexed(existing.getId())) {
    return new IngestOutcome(false, source);
}

文档切分——Chunking策略的选择

RAG效果好不好,切分策略的影响非常大。这块我在学习过程中也走了不少弯路,后来看了不少资料才慢慢理清楚。

为什么不能把整篇PDF存成一个向量?原因有两个。一是Embedding模型有输入长度限制,一篇几千字的文档塞不进去。二是就算塞得进去,整篇文章压缩成一个向量之后,细节信息会被"平均掉"——你想找"药物的用法用量",但向量里还混着"储存条件""不良反应"等内容,检索出来的是一个笼统的文档,不是精确的那段话。

在这个项目里,我采用的是固定大小加重叠的切分方式。默认chunkSize是700个字符,overlap是120个字符。每个PDF页单独处理,一页内容按照固定大小切分,前一个chunk和后一个chunk之间有120个字符的重叠内容,保证跨边界的语义不被截断。

private List<PageChunk> splitIntoChunks(Path pdfPath) throws IOException {
    List<PageChunk> chunks = new ArrayList<>();
    try (PDDocument document = PDDocument.load(pdfPath.toFile())) {
        PDFTextStripper stripper = new PDFTextStripper();
        for (int page = 1; page <= document.getNumberOfPages(); page++) {
            stripper.setStartPage(page);
            stripper.setEndPage(page);
            String pageText = normalizeText(stripper.getText(document));
            if (!StringUtils.hasText(pageText)) continue;

            int start = 0;
            while (start < pageText.length()) {
                int end = Math.min(start + chunkSize, pageText.length());
                String content = pageText.substring(start, end).trim();
                if (StringUtils.hasText(content)) {
                    chunks.add(new PageChunk(page, content));
                }
                if (end >= pageText.length()) break;
                start = Math.max(0, end - chunkOverlap);
            }
        }
    }
    return chunks;
}

说实话,固定大小加重叠是最基础的一种切分方式。它简单可控,但最大的问题是可能在句子中间截断,语义不完整。更理想的策略应该是按语义边界切——段落、句子这些天然的断点,或者对于有标题结构的文档按标题层级切。不过考虑到我们的PDF文档排版比较规整、内容以药品说明为主,固定大小加重叠在当前阶段也够用了。

这里有一个值得注意的点是切分粒度的问题。chunk太小会丢失上下文,LLM拿到一个半句话看不懂;chunk太大会把太多语义压缩在一起,检索精度下降。700个字符这个值是我根据医疗文档的特点调的——一段药物说明通常在500到1000字左右,700字符大致能覆盖一个完整的知识点又不至于太冗长。

切片相关的实体设计是KnowledgeSlice,记录了docId、title、content、vectorId、页码、排序号这些字段。每条切片对应向量数据库里的一条记录,也是后续检索时返回的基本单元。

@TableName("knowledge_slice")
public class KnowledgeSlice extends BaseEntity {
    private Long docId;
    private String title;
    private String content;
    private String vectorId;
    private Integer orderNum;
    private Integer pageNum;
    private Float score;
}

向量化——Embedding的选型与实践

文档切好片之后,下一步就是把每一段文字转成向量,这样才能做语义检索。这个环节叫Embedding。

Embedding模型本质上做的是"语义压缩"——把一段自然语言文本映射成一个固定长度的浮点数向量。关键特性是:语义相近的文本,算出来的向量在数学空间里的距离也近。这就让语义检索成为可能,不是去匹配关键词,而是去匹配意思。

举个例子,"老年人便秘怎么处理"和"老年便秘的处置方法"这两句话,关键词几乎没有重复的,但意思几乎一样。Embedding模型能把它们映射到高维空间中很靠近的位置,做到"意思相近"的检索。

在这个项目里,Embedding模型的选择有几个考量。首先是中英文支持——医疗知识库主要是中文内容,所以中文效果一定要好。其次是部署方式——我选择通过API调用阿里云的text-embedding-v4模型,而不是本地部署,省去了模型运维的负担。LangChain4j的配置里可以看到,它通过OpenAI兼容接口来调用:

@Bean
public EmbeddingModel embeddingModel() {
    return OpenAiEmbeddingModel.builder()
            .apiKey(secretsConfig.getLangchain().getApiKey())
            .modelName(embeddingModelName)   // text-embedding-v4
            .baseUrl(baseUrl)
            .build();
}

项目里用的是384维的向量。维度越高精度越好,但存储空间和检索速度也会增加。对于几万到几十万条知识片段的规模,384维是够用的。

向量化的过程是分批进行的,默认每批10条,这是为了平衡API调用频率和内存占用。写入Milvus的时候也是一批一批地插入,而不是每条单独写。

int batchSize = Math.max(1, embeddingBatchSize);  // 默认10
for (int start = 0; start < slices.size(); start += batchSize) {
    // ...批量生成向量并插入Milvus
    List<TextSegment> segments = batch.stream()
            .map(slice -> TextSegment.from(slice.getContent()))
            .toList();
    List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
}

向量数据库——Milvus的落地

向量化之后的数据需要一个地方存,而且要能高效地检索。这里就需要向量数据库了。

很多人一开始会想,MySQL加个向量字段不就行了?但实际上这是完全不同的两件事。MySQL擅长精确匹配,WHERE id = 123这种,靠B-tree索引效率极高。但向量检索要找的是"最相近的",不是"等于的",高维向量的相似度搜索用B-tree基本上没有用——你不可能对一个1024维的向量建B-tree然后说"帮我找最接近的"。

向量数据库的核心能力是ANN(近似最近邻搜索),能在百万甚至亿级的向量里毫秒级找到最相似的几条。它靠的是专门的索引算法。

这个项目选的是Milvus,国内用得最多的开源向量数据库。选择它的原因主要就两个:支持分布式,万一以后数据量上去了能水平扩展;Java SDK比较成熟,跟Spring Boot集成起来没什么障碍。

Milvus的配置很简洁,配置里指定了地址、端口和认证信息。集合名叫medical_knowledge,创建集合时定义了五个字段:vector_id作为主键、doc_id关联文档、page_num和order_num记录切片位置、vector存向量。

FieldType.newBuilder().withName("vector_id").withDataType(DataType.VarChar)
    .withPrimaryKey(true).withMaxLength(128).build(),
FieldType.newBuilder().withName("doc_id").withDataType(DataType.Int64).build(),
FieldType.newBuilder().withName("page_num").withDataType(DataType.Int64).build(),
FieldType.newBuilder().withName("order_num").withDataType(DataType.Int64).build(),
FieldType.newBuilder().withName("vector").withDataType(DataType.FloatVector)
    .withDimension(dimension).build()

索引选的是IVF_FLAT,nlist设为128。IVF的思路是先把向量聚类,相似的向量分进同一个"桶"里,查询时只搜最相关的几个桶,而不是全量遍历,效率很高。虽然HNSW在同等规模下召回率更高,但IVF内存占用更小,适合我们这个场景。

一致性级别设的是EVENTUALLY(最终一致),不是STRONG。这是故意的——向量检索这步对一致性要求没有那么高,写入后稍微等一会就能查到,换来的是一定的性能提升。

还有一个值得提的细节是集合维度的自动检测和重建。如果配置文件里改了embedding模型导致向量维度变了,启动时会自动检测到不匹配,删掉旧集合重新建,省去了手动清理的麻烦。

检索与召回——从问题到相关知识

前面把数据入库讲完了,现在来看检索这个环节。用户输入一个问题之后,系统怎么找到相关的知识片段。

整体的流程是这样的:先检查Milvus集合是否就绪,然后把用户问题转成向量,拿这个向量去Milvus做余弦相似度搜索,拿到Top-K条结果。Top-K默认是4条,这个数字是ChatAgentService里写死的。

List<KnowledgeSearchResultDTO> slices = knowledgeService.search(userMessage, 4);

搜索结果拿到之后,还要做一个关键的过滤——只保留那些关联文档状态为启用的切片。如果一篇文档已经被下架了,它的切片就算被召回了也不应该在结果里。

检索有可能失败,比如Milvus服务挂了或者网络超时。这时候不能直接报错,否则整个对话功能就全废了。项目里做了一个关键词降级搜索:用SQL的LIKE在切片表里模糊匹配用户问题中的关键词。当然这比向量检索差很多,但至少用户还能拿到一个相关的回答,而不是什么都没。

catch (Exception e) {
    log.warn("Milvus 检索失败,降级到数据库关键词检索: {}", e.getMessage());
    return keywordFallbackSearch(query, finalTopK);
}

召回的知识片段会带着来源信息一起返回——文档标题、文件路径、页码、排序号、相似度分数。这些信息一方面在构建Prompt时用来标注来源,另一方面在前端展示给用户看的引用也需要。

Prompt组装——把检索结果喂给大模型

检索返回了几段相关知识之后,怎么把这些信息组织好传给大模型,直接决定了回答的质量。Prompt设计得不好,再好的检索结果也可能被浪费掉。

这个项目里Prompt的构建在buildPrompt方法里。一开始先给模型定义身份和场景:"你是老年健康AI语音助手,回答必须基于给定医学知识库,语言要温和、清楚、适合老年人理解。"

然后是知识库片段部分。每个检索到的片段按编号排列,包含标题、来源、页码和内容。注意这里的格式设计——给每个片段标了序号,但没有直接在Prompt里告诉模型"引用时请标注编号"。引用是由系统自动追加的,不靠模型自己标注,这样更可控。

prompt.append("【知识库片段】\n");
for (int i = 0; i < slices.size(); i++) {
    KnowledgeSearchResultDTO slice = slices.get(i);
    prompt.append(i + 1).append(". 标题: ").append(slice.getTitle())
          .append("\n来源: ").append(slice.getSource())
          .append("\n页码: ").append(slice.getPageNum())
          .append("\n内容: ").append(slice.getContent()).append("\n\n");
}

最后是回答要求这一部分,这是控制幻觉的关键。明确告诉模型六件事。只能依据知识库内容回答,不要编造。优先给出健康建议,不做处方。知识库不支持的时候要明确说。不要输出引用编号。高风险症状要提醒就医。直接开始回答,不要加多余的前缀。

prompt.append("【回答要求】\n");
prompt.append("1. 只能依据上面的知识库内容作答,不要编造。\n");
prompt.append("2. 优先给出清晰可执行的健康建议,不做处方,不代替医生诊断。\n");
prompt.append("3. 如果知识库不能支持结论,要明确说"当前知识库没有足够依据"。\n");
prompt.append("4. 不要输出引用编号,引用来源由系统补充。\n");
prompt.append("5. 如果涉及持续腹痛、便血、呕吐、发热、意识异常等高风险情况,提醒及时就医。\n\n");
prompt.append("用户问题: ").append(userQuery);
prompt.append("请直接开始回答:");

整个Prompt设计紧紧围绕一个核心目标:让模型在一个受控的范围内发挥能力,既不抑制它的语言表达能力,又不给它胡说八道的空间。

引用溯源——让回答有据可查

医疗场景下,光有答案是不够的,还得告诉用户这个答案出自哪里。引用追踪不仅是用户体验问题,更是一种责任——假如回答有问题,能追溯到是哪篇文档的哪个片段。

引用信息用RagCitationDTO来承载,包含标题、来源文件、页码、排序号和相似度分数。

@Data
public class RagCitationDTO {
    private String title;
    private String source;
    private Integer pageNum;
    private Integer orderNum;
    private Float score;
}

在流式对话场景里,引用的处理方式和非流式略有不同。流式输出时,模型一边生成文字,一边往外吐,最后等生成完成之后,再把引用信息作为citations事件发给前端。前端拿到之后可以在回答下方展示"参考来源"列表,用户点开就能看到每条引用的详细来源。

sendEvent(emitter, "citations",
    new ChatStreamEventDTO("citations", null, sessionId, null,
        context.riskLevel(), context.intentType(), context.citations()));

整体来看,引用系统没有过度设计——没有让模型自己去标注编号然后后端解析(那太不可控了),也没有把所有来源揉进回答正文里(那会影响阅读体验)。而是把"检索到哪些片段"和"模型说了什么"两个信息并行传递,前端再组装。这种分离式的设计在实际工程里用起来很顺。

总结与思考

整个RAG系统做下来,最大的感受是:RAG不是一个单一的技术点,而是一个系统工程。从文档的解析、切分策略的选择、Embedding模型的选型、向量数据库的配置,到检索参数的调优、Prompt的打磨、引用系统的设计,每一个环节都影响着最终效果。

切分策略这方面,固定大小加重叠虽然简单,但对语义连续性的保护不够。如果后续要提升召回质量,值得尝试的方向是按语义边界切分,或者有条件的话用父子切割——小块检索保证精度、大块返回保证上下文完整。这两者在医疗文档这类结构清晰的内容上,效果应该会有明显提升。

向量数据库的选择上,Milvus稳定性不错但运维成本确实高,对于一个中小规模的项目来说有点重。如果重新选型,Qdrant可能是更均衡的选择——Rust写的性能好、部署简单、API设计也清晰。不过既然已经跑起来了,也没有特别充分的理由去换。

Prompt是容易被低估的环节。同一个模型、同一批检索结果,Prompt写得好与不好,回答质量的差距有时候比换模型还大。尤其是"不知道就说不知道"这条约束,在医疗场景下太关键了,没有这条约束模型很容易产生幻觉。

最后说说这个项目RAG架构的整体思路。它不算复杂,但每个环节都是经过实际考虑的——启动自动同步保证知识库不落为空、文件哈希去重避免重复导入、向量检索失败降级到关键词保证可用性、集合维度自动检测避免升级改造时的麻烦、答案后处理过滤think标签、引用和答案分离传输。没有过度设计,但该考虑的点基本都覆盖了。

写这篇文章的过程,也是我对RAG从"知道怎么用"到"理解为什么要这么用"的梳理。希望对同样在用Java做知识库问答的朋友有些参考价值。

Logo

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

更多推荐