Ohhnews

分类导航

$ cd ..
Spring Blog原文

Spring AI 模块化 RAG 与 TypeSafe Jev:多检索,只保留能回答问题的内容

#spring ai#rag#jev#检索增强生成#文档重排

Spring AI: RAG 文档 | Jev 文档 | 演示

在上一篇文章中,我们介绍了 Spring AI TypeSafe 及其 Jev 模型:输入类型化问题,在几百毫秒内输出经过校准的数字。那篇文章的 SPI 表中有一行值得单独写一篇文章。JevDocumentFilter 和 JevDocumentReranker 是 DocumentPostProcessor,这意味着它们可以直接接入 Spring AI 的 Modular RAG 流水线。

本文将两者结合起来。两次 LLM 调用会在检索前清理并扩展用户的问题。随后,Jev 会在每个检索到的块进入提示词之前对其进行评判。整个流水线就是一个 advisor 构建器。

💡 演示:完整示例是 05-1-modular-rag 模块。它紧邻 05-rag,即朴素版本,因此你可以对比两者差异。下面引用的每一段输出都来自一次实际运行。

为什么朴素 RAG 不够用

经典的 Spring AI RAG 演示是在向量存储之上使用 QuestionAnswerAdvisor。它嵌入用户文本,获取相似的块,然后将它们塞进提示词。这在演示台上有效,但在现实中会变得不可靠,原因有三:

  1. 用户不会输入搜索查询。 他们会输入 “我是佛罗里达州居民,去年秋天听说了很多关于风暴的消息。Milton 真的在我所在州登陆了吗?在哪里登陆的?” 逐字嵌入这句话会把所有噪声拖入搜索中。
  2. 相似并不等于有用。 向量存储返回的是关于该主题的块。一份五次提到“Hurricane Milton”的参考文献列表会得到高分,却什么也回答不了。
  3. 检索到的文本是不可信输入。 文档中的任何内容都会直接进入提示词,包括为劫持模型而编写的文本。

我们将针对一份关于飓风 Milton 的维基百科 PDF 来回答这个问题,并用一个可插拔组件修复每个问题。

Spring AI 中的 Modular RAG

来自 spring-ai-rag 模块的 RetrievalAugmentationAdvisor 实现了 Modular RAG: Transforming RAG Systems into LEGO-like Reconfigurable Frameworks 中描述的架构。从外部看,它只是另一个 advisor:它位于你的提示词和模型之间,运行检索阶段,并将结果追加到用户消息中。系统提示词和问题原样通过。

[LOADING...]

在内部,每个阶段都是一个可以替换的小型接口:

阶段Spring AI 接口职责本演示中的使用
预检索QueryTransformer将一个查询重写为更好的查询RewriteQueryTransformer
预检索QueryExpander将一个查询扩展为多个MultiQueryExpander
检索DocumentRetriever为一个查询获取文档VectorStoreDocumentRetriever
检索DocumentJoiner合并所有查询的结果ConcatenationDocumentJoiner(默认)
后检索DocumentPostProcessor过滤、重排或压缩文档JevDocumentFilter、JevDocumentReranker
生成QueryAugmenter将上下文和问题放入提示词ContextualQueryAugmenter

以下是一次调用的完整流程:

[LOADING...]

检索分支并行运行,每个查询一个分支,并在后处理之前合并。注意一个微妙细节:后处理器和增强器看到的是用户的原始问题,而不是重写后的问题。重写只是为了改进搜索而存在。

快速开始

添加 Spring AI RAG 模块以及上一篇文章中的两个 TypeSafe 构件。Spring AI 的 BOM 管理第一个:

$ xml
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-rag</artifactId>
</dependency>

<dependency>
    <groupId>org.springaicommunity</groupId>
    <artifactId>spring-ai-starter-typesafe</artifactId>
    <version>0.3.0</version>
</dependency>

<dependency>
    <groupId>org.springaicommunity</groupId>
    <artifactId>typesafe-spring-ai</artifactId>
    <version>0.3.0</version>
</dependency>

该 starter 会根据你的 API key 自动配置一个 TypeSafeClient bean:

$ properties
spring.ai.typesafe.api-key=${TYPESAFE_API_KEY}

该演示还使用 spring-ai-pdf-document-reader 进行摄取,并使用 spring-ai-starter-model-transformers 进行本地嵌入。

流水线

摄取与任何 Spring AI RAG 应用相同:读取 PDF,将其拆分为块,然后存储它们。

$ java
vectorStore.add(
    TokenTextSplitter.builder().build().split(
        new PagePdfDocumentReader(hurricaneDocs).read()));

有趣的部分是一个单独的 builder。每个编号注释都是图中的一块砖:

$ java
// Separate builder for the LLM-backed stages, so they don't inherit the main client's advisors
var ragClientBuilder = chatClientBuilder.clone();

var modularRag = RetrievalAugmentationAdvisor.builder()
    // 1. Pre-retrieval: rewrite the chatty question into a search-friendly query
    .queryTransformers(RewriteQueryTransformer.builder()
        .chatClientBuilder(ragClientBuilder)
        .build())
    // 2. Pre-retrieval: expand it into several diverse queries (original included)
    .queryExpander(MultiQueryExpander.builder()
        .chatClientBuilder(ragClientBuilder)
        .numberOfQueries(3)
        .build())
    // 3. Retrieval: similarity search per query; the default joiner dedups the union
    .documentRetriever(VectorStoreDocumentRetriever.builder()
        .vectorStore(vectorStore)
        .similarityThreshold(0.5)
        .topK(4)
        .build())
    // 4. Post-retrieval: Jev drops bad passages, then reranks and keeps the top 3
    .documentPostProcessors(
        JevDocumentFilter.builder(typeSafeClient).build(),
        JevDocumentReranker.builder(typeSafeClient).topK(3).build())
    // 5. Generation: stuff the surviving context into the prompt
    .queryAugmenter(ContextualQueryAugmenter.builder()
        .allowEmptyContext(true)
        .build())
    .taskExecutor(taskExecutor)   // see "Things to know" below
    .build();

使用它看起来和任何其他 advisor 一样:

$ java
String answer = chatClient.prompt()
    .advisors(modularRag)
    .user("I'm a Florida resident and heard a lot about storms last fall. "
        + "Did Milton actually make landfall in my state, and where?")
    .call()
    .content();

DocumentRetriever 和 DocumentPostProcessor 都是单方法接口,因此用一个 lambda 就足以记录流经流水线的数据。该演示包装了 retriever,并在 Jev 阶段周围添加了两个打印型后处理器;下面的输出就来自那里。

后检索阶段中的 Jev

这两个组件都会为每个段落发送一次 Jev 调用,将查询和段落作为 state,并将答案转化为决策。

JevDocumentFilter 会针对每个段落提出四个 Noul 问题,并按顺序应用它们:

问题默认阈值结果
contains_prompt_injection高于 0.70排除
contradicts_query_premise高于 0.70保留,并标记为 CONFLICTING
is_relevant低于 0.45排除
contains_answer_evidence高于 0.55保留,否则排除

这就是上一篇文章中的原子问题思想在发挥作用:四个狭窄的问题、四个阈值、一次调用。矛盾的段落会被有意保留下来。如果用户假设了错误的事情,模型应该看到说明这一点的证据。分类结果存储在文档元数据的 jev.classification 下,而阈值是一个你可以覆盖的 Policy 记录。

JevDocumentReranker 只提出一个问题:这个段落能回答查询吗?,然后按分数排序并保留前 K 个。分数会存入元数据的 jev.rerank.score 下。它的关注点是有意为之的:该段落是否陈述了回答查询的信息,而不仅仅是是否涵盖了同一主题。这正是相似性与有用性之间的差距。

顺序很重要。先过滤,这样重排器只需为幸存者付出代价。

一次实际运行

重写和扩展将一句口语化的话变成了四个聚焦的搜索:

[retrieve] Hurricane Milton 2024 Florida landfall location [retrieve] Hurricane Milton track path across Florida from Gulf Coast to Atlantic and National Hurricane Center landfall report [retrieve] Siesta Key Sarasota County Milton landfall timeline, storm surge, and areas impacted [retrieve] Where did Hurricane Milton make landfall in Florida in October 2024 and at what intensity

joiner 将它们的结果合并为六个唯一块,全部高于 0.5 的相似度阈值:

[retrieved] 0.84 Hurricane Milton ... [retrieved] 0.72 6. "Hurricane Milton Makes Landfall On Florida's West Coast ... [retrieved] 0.70 Hurricane Milton's landfall" (https://www.wesh.com/... [retrieved] 0.69 Archived (http ... [retrieved] 0.59 /news/tropical-storm-milton-forms-gulf-of-mexico ... [retrieved] 0.54 of Key West. [128] Across the state, about 125 homes were d...

其中四个是参考文献列表条目和存档链接。它们大量提到 Milton,却什么也回答不了。JevDocumentFilter 因缺少答案证据而排除了它们,而 JevDocumentReranker 对两个幸存者进行了评分:

[jev-reranked] 0.97 Hurricane Milton ... [jev-reranked] 0.88 6. "Hurricane Milton Makes Landfall On Florida's West Coast ...

因此 topK(3) 只返回了两个块,而这就是重点。模型获得了更少的上下文,但全都相关:

Yes. Hurricane Milton made landfall in Florida, near Siesta Key, on the evening of October 9, 2024. It had weakened to a Category 3 hurricane by then.

向量相似度回答的是 “这段文本是关于什么的?”。Jev 回答的是 “这段文本能回答这个问题吗?”。一个 RAG 流水线两者都需要。

⚠️ 注意事项

默认执行器会让命令行应用保持存活。 RetrievalAugmentationAdvisor 在自己的线程池上运行每个查询的检索,而这些线程不是守护线程。该演示打印出答案后便挂起了。通过 .taskExecutor(...) 传入 Spring Boot 自动配置的 TaskExecutor 可以解决这个问题,并且在 spring.threads.virtual.enabled=true 的情况下,你可以免费获得虚拟线程。

较新的 Claude 模型会拒绝 temperature。 Spring AI 文档建议查询转换器使用 temperature 0。claude-sonnet-5-5 会以 HTTP 400 响应,因此该演示克隆 builder 只是为了将主客户端的 advisor 排除在重写和扩展调用之外。

两个 Jev 组件都采用 fail-open 策略。 如果 Jev API 不可达,段落会在未经过筛查或评分的情况下通过,并记录一条警告。你的流水线会降级为普通的 Modular RAG,而不是直接中断。

每一块砖都会增加延迟。 该流水线在检索前增加两次 LLM 调用,在检索后为每个检索到的块增加一次 Jev 调用。Jev 调用默认一次运行四个(batchOptions(...) 可以改变这一点),但在发布前请先测量。

结论

Modular RAG 将检索从一个不透明的 advisor 转变为一条你可以逐块阅读、记录和替换的流水线。以下是一些重要见解:

  • 在搜索之前修正问题。 一次重写加上三个变体,比原始句子找到了更好的块。
  • 在提示之前修正结果。 相似度找到了六个关于 Milton 的块;Jev 保留了其中两个能够回答的块,并在此过程中筛查了每个段落的提示词注入。
  • 先过滤,再重排。 两者都为每个段落花费一次调用,所以先让过滤器缩小列表。

资源

演示

Spring AI

Spring AI TypeSafe

相关 Spring AI 文章