Christian Tzolov 在 2026-10-02 的博文里,把 Spring AI Modular RAG 与 TypeSafe Jev 接到同一条 RetrievalAugmentationAdvisor 管道:检索前改写并扩展问题,检索后用 JevDocumentFilter 与 JevDocumentReranker 筛掉无法回答的 chunk,再喂进 prompt。站内尚无 TypeSafe / Jev / Modular RAG 专文。本稿按官博、RAG/Jev 文档与 demo 核对,配置与输出未在本地跑通。

朴素 RAG 卡在哪

经典写法是 QuestionAnswerAdvisor:嵌入原句、取相似片段塞进 prompt。线上常见:闲聊噪声拖累搜索;相近不等于能答(参考文献分数高却无证据);检索文本不受信,索引投毒可能直进 prompt。

官博 demo 用 Hurricane Milton Wikipedia PDF;对照 05-rag 与 05-1-modular-rag。

Modular RAG 管道

spring-ai-rag 的 RetrievalAugmentationAdvisor 实现 Modular RAG:外看仍是 Advisor;系统提示与用户原问原样穿过,检索结果追加到用户消息。各阶段可替换。

阶段接口本 demo 实现
检索前QueryTransformerRewriteQueryTransformer
检索前QueryExpanderMultiQueryExpander
检索DocumentRetrieverVectorStoreDocumentRetriever
检索DocumentJoiner默认 ConcatenationDocumentJoiner
检索后DocumentPostProcessorJevDocumentFilter、JevDocumentReranker
生成QueryAugmenterContextualQueryAugmenter

各查询检索并行,join 后再 post-process。Post-processor 与 augmenter 看到的是用户原问,改写只服务搜索。

依赖与配置

官博与 demo README 对齐 TypeSafe 0.3.0。spring-ai-rag 由 Spring AI BOM 管理:

<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>
spring.ai.typesafe.api-key=${TYPESAFE_API_KEY}

Starter 按 API key 自动配置 TypeSafeClient。Demo 另用 PDF 读入与本地 transformers 嵌入,并需聊天模型密钥(demo 用 Anthropic)。

一条 builder 串起全流程

入库与朴素 RAG 相同。关键在 Advisor builder:

var ragClientBuilder = chatClientBuilder.clone();

var modularRag = RetrievalAugmentationAdvisor.builder()
    .queryTransformers(RewriteQueryTransformer.builder()
        .chatClientBuilder(ragClientBuilder)
        .build())
    .queryExpander(MultiQueryExpander.builder()
        .chatClientBuilder(ragClientBuilder)
        .numberOfQueries(3)
        .build())
    .documentRetriever(VectorStoreDocumentRetriever.builder()
        .vectorStore(vectorStore)
        .similarityThreshold(0.5)
        .topK(4)
        .build())
    .documentPostProcessors(
        JevDocumentFilter.builder(typeSafeClient).build(),
        JevDocumentReranker.builder(typeSafeClient).topK(3).build())
    .queryAugmenter(ContextualQueryAugmenter.builder()
        .allowEmptyContext(true)
        .build())
    .taskExecutor(taskExecutor)
    .build();
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();

clone() 让改写/扩展不继承主客户端 Advisor。较新 Claude 会拒 temperature,demo 因此省略文档建议的低温。CLI 应传入 Boot 的 TaskExecutor(默认池非 daemon,答完可能挂住);可开 spring.threads.virtual.enabled=true。

Jev:先滤后排

两者都是 DocumentPostProcessor:每段一次 Jev,状态为 query + passage。

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 问「这段能否回答该查询?」,按分排序并保留 top K,分数在 jev.rerank.score。焦点是能否作答,不是是否同主题。

顺序固定:先 Filter,再 Reranker。两边都是每段一调用;先缩小集合再打分更省,也避免注入文本进入排序。

官博实跑摘录

改写与扩展产出多条检索式。Joiner 留下 6 个相似度 ≥ 0.5 的 chunk,多条是参考文献与归档链接。Filter 丢掉无证据段后,Reranker 给两段约 0.97 / 0.88;topK(3) 实际只剩 2 段。模型答出 Milton 于 2024-10-09 傍晚在 Siesta Key 附近以 Category 3 登陆。

向量相似度回答「这段在说什么」;Jev 回答「这段能不能答这个问题」。

落地注意

  • Fail-open:Jev 不可达时段落原样通过并打警告;管道退化为普通 Modular RAG。应对该警告做告警,否则注入筛查可能静默失效。
  • 成本:检索前多 2 次 LLM;检索后每 chunk 各一轮 Filter/Reranker(默认并发 4)。服务端应共用 batchOptions executor。
  • 空上下文:demo 用 allowEmptyContext(true);默认空上下文时指示模型不要答。

参考

— 感谢阅读 —

一起交流

分享你的思考,让讨论更进一步。