Spring AI Agent 漫画实战
第 07 话 KNOWLEDGE

外挂大脑:RAG 工程与模块化检索 工具让 Agent 能"做",RAG 让 Agent 能"知道"

这一话解决三件事:知识怎么进向量库(ETL Pipeline)、怎么检索得准(分块、过滤、阈值)、 怎么组织检索链路(模块化 RAG)。最后给你一个最实用的开关:让模型在检索不到时老实说"不知道"。

老 K
RAG 我知道:把文档切碎、转向量、查相似、塞进提示词。一个 SELECT ... ORDER BY distance LIMIT 5 的事。
小 S
那是 R,不是 RAG。
真正的难点在这:用户问的是"怎么申请退款",你的文档写的是"退货流程 v3"—— 这两个词一个都不重合,向量相似度也救不了你。

一、RAG 全链路:离线灌库 + 在线检索

离线:ETL Pipeline(启动 / 定时任务 / 管理后台触发) DocumentReader PDF / Word / HTML / JSON Transformer 切分 / 元数据增强 / 清洗 EmbeddingModel 文本 → 向量 DocumentWriter VectorStore / File 向量库 PgVector / Redis / Milvus 在线:RetrievalAugmentationAdvisor(一次用户提问) 原始 Query "怎么申请退款" QueryTransformer 改写 / 翻译 / 压缩上下文 QueryExpander 多路查询扩展 DocumentRetriever 向量库召回 + 过滤 Joiner 合并去重 Aug 增强 QueryAugmenter 做的事:把检索到的文档塞进 UserMessage,并明确"只依据上下文回答,找不到就说不知道" 这行指令才是"防幻觉"的真正开关 —— 而不是把 temperature 调低。 代价提示:改写 + 扩展各要一次 LLM 调用,topK 越大上下文越长。每一格都是 token 账单上的一行。

二、离线:ETL Pipeline 三件套

@Bean
CommandLineRunner ingest(VectorStore vectorStore,
                         @Value("classpath:/docs/售后手册.pdf") Resource pdf) {
    return args -> {
        // 1) 读:PDF 按页读;还有 TextReader / JsonReader / TikaDocumentReader(Word、PPT、HTML…)
        var reader = new PagePdfDocumentReader(pdf);

        // 2) 切:TokenTextSplitter(defaultChunkSize, minChunkSizeChars,
        //                          minChunkLengthToEmbed, maxNumChunks, keepSeparator)
        var splitter = new TokenTextSplitter(800, 350, 5, 10000, true);   //!hl

        // 3) 增强:给每个 chunk 自动生成关键字元数据,后续可做元数据过滤
        var enricher = new KeywordMetadataEnricher(chatModel, 5);         //!hl

        List<Document> docs = enricher.apply(splitter.apply(reader.get()));
        docs.forEach(d -> d.getMetadata().putAll(Map.of(
                "tenant", "default",        // 多租户隔离的关键字段 //!hl
                "type", "aftersale",
                "source", "售后手册.pdf")));

        vectorStore.accept(docs);   //!hl
    };
}
参数解剖

TokenTextSplitter 不是按字符硬切,而是按 token 计数滚动累积:

经验值:中文技术文档 300~600 token、问答对类 200~400 token、法律/合同类按条款切(不要按字数切)。

灌库踩坑

三、检索:SearchRequest 与元数据过滤

var b = new FilterExpressionBuilder();

// 多租户隔离 + 文档类型限定 —— 必须在检索层做,不能靠模型自觉
Expression filter = b.eq("tenant", tenantId)                 //!hl
                     .and(b.in("type", "aftersale", "faq"))  //!hl
                     .build();

SearchRequest req = SearchRequest.builder()
        .query(userQuestion)
        .topK(6)                     //!hl
        .similarityThreshold(0.65)   //!hl
        .filterExpression(filter)
        .build();

List<Document> docs = vectorStore.similaritySearch(req);
参数含义与工程建议
topK召回条数。不是越大越好:每多一条都占用上下文 token 并稀释注意力。通用问答 4~8;需要跨段落推理时可先召回 20 再重排序取 5。
similarityThreshold低于该相似度分数的结果被过滤(不同向量库的分数区间与距离度量不同,0.5~0.75 需实测调参)。设太高会大量"无结果",设太低会引入噪声。
filterExpression元数据前置过滤(pre-filter)。多租户、权限、时效必须在这一层做,靠语义相似度做权限过滤是不安全的。
版本注意

Spring AI 早期版本中 similarityThreshold 语义与向量库距离度量耦合较深,升级大版本后务必用一批固定问答回归验证召回质量。 另外 QuestionAnswerAdvisor(经典写法)在 1.x 中逐步让位于模块化 RAG,新项目建议直接用后者。

四、模块化 RAG:把检索链路拆成可插拔零件

经典 QuestionAnswerAdvisor 是个黑盒:查询进去、上下文出来。模块化 RAG 把这条链路拆成六个可替换的角色 —— 这正是十年工程师最喜欢的部分。

@Bean
Advisor ragAdvisor(ChatClient.Builder builder, VectorStore vectorStore) {
    return RetrievalAugmentationAdvisor.builder()   //!hl
        .queryTransformers(
            // 上下文压缩:把"它多少钱?"结合历史改写成"iPhone 15 的售价是多少?"
            CompressionQueryTransformer.builder()
                .chatClientBuilder(builder).build(),                       //!hl
            RewriteQueryTransformer.builder()
                .chatClientBuilder(builder).build(),
            TranslationQueryTransformer.builder()
                .chatClientBuilder(builder).targetLanguage("zh").build())
        .queryExpander(MultiQueryExpander.builder()      //!hl
                .chatClientBuilder(builder)
                .numberOfQueries(3)      // 生成 3 个语义等价的不同表述
                .includeOriginal(false)
                .build())
        .documentRetriever(VectorStoreDocumentRetriever.builder()  //!hl
                .vectorStore(vectorStore)
                .similarityThreshold(0.6)
                .topK(8)
                .filterExpression(currentTenantFilter())
                .build())
        .documentJoiner(new ConcatenationDocumentJoiner())   //!hl
        .documentPostProcessor(docs -> rerank(docs))          // 自定义重排序 //!hl
        .queryAugmenter(ContextualQueryAugmenter.builder()
                .allowEmptyContext(false)   //!hl 检索为空时让模型说"不知道",禁止编造
                .build())
        .build();
}
源码解剖 · 六个角色
角色职责不装会怎样
QueryTransformer把原始问题变成更适合检索的查询(压缩 / 改写 / 翻译 / 多跳分解)多轮对话里"它呢?""为什么?"这类省略句检索不到任何东西
QueryExpander一个查询扩成 N 个表述,多路召回召回率偏低,尤其术语与口语不一致时
DocumentRetriever真正去向量库查(阈值、topK、过滤)链路无法工作
DocumentJoiner把多路结果合并去重(ConcatenationDocumentJoiner)多路扩展的结果重复占位
DocumentPostProcessor重排序、截断、脱敏、按 token 预算裁剪噪声混入,长尾上下文挤爆窗口
QueryAugmenter把文档组装进 UserMessage 并注入"只依据上下文"的指令模型会忽略上下文,直接用参数知识编答案

allowEmptyContext(false) 是本话最该记住的一行配置。当检索结果为空时,ContextualQueryAugmenter 不再"带着空上下文去问模型",而是让模型按指令回答"未找到相关信息"。这一个开关能消灭绝大多数知识库幻觉。

自定义重排序(PostProcessor 的实战价值)

.documentPostProcessor(docs -> {
    // 1) 按业务权重(时效、权威度)与相似度加权,重新排序
    // 2) 按 token 预算截断,只保留能塞进上下文的内容
    // 3) 去重(同一 source 的相邻 chunk 合并)
    return rerankService.rerankAndTrim(docs, tokenBudget);
})

如果厂商提供 rerank 模型,把它放在 PostProcessor 里做二次排序,通常能把命中率提升 10~20 个百分点 —— 这是 RAG 里性价比最高的优化。

五、怎么评估 RAG 好不好?

// 相关性评估:判断检索到的文档是否真的能支撑这个回答
var evaluator = new RelevancyEvaluator(ChatClient.builder(chatModel).build()); //!hl

EvaluationRequest req = new EvaluationRequest(
        userQuestion,          // 原始问题
        retrievedDocuments,    // 召回的文档
        modelAnswer);          // 模型最终回答

EvaluationResponse res = evaluator.evaluate(req);   //!hl
boolean pass = res.isPass();   //!hl
指标怎么算健康线
检索命中率(Recall@K)标准问答集中,正确文档出现在 topK 里的比例≥ 85%(内部知识库)
忠实度(Faithfulness)回答中的事实是否都能在召回文档中找到出处≥ 90%
拒答率无答案问题中被正确拒绝的比例越高越好,但别误拒
平均上下文 token单次请求的上下文长度与成本挂钩,需设上限
落地建议

建一个 50~200 条的黄金问答集(问题 + 期望答案 + 期望来源文档),每次改分块策略 / 阈值 / 模型就跑一遍,把结果做成趋势图。 没有评测集的 RAG 调优,本质是玄学调参。

六、踩坑清单(血泪版)

症状根因 / 解法
答案里混进别的租户的数据只在语义上"期望"模型区分 → 必须加 filterExpression 做前置过滤
回答凭空捏造,跟文档不符没开 allowEmptyContext(false),或 QueryAugmenter 指令被自定义模板覆盖
换个问法就查不到缺 QueryTransformer / Expander;术语不一致 → 建同义词表或用关键词元数据增强
上下文超长被截断topK 或 chunk 太大 → 在 PostProcessor 里做 token 预算裁剪
PDF 抽出乱码 / 乱序换 TikaDocumentReader 或人工转 Markdown;表格单独结构化
上线后越用越慢向量库没建索引(PgVector 需 HNSW / IVFFlat),或每次全表扫描
小 S
RAG 的真相:80% 的功夫在检索质量,20% 在提示词。别再调 system prompt 了,去调你的切分策略和评测集。
← 上一话:工具调用 目录 下一话:MCP 协议 →