Spring AI RAG - 05 RAG 检索阶段与 QuestionAnswerAdvisor

文章目录

  • 引言
  • 设计说明
    • [RAG 检索的核心问题](#RAG 检索的核心问题)
    • [Advisor 模式的优势](#Advisor 模式的优势)
  • 原理方案
    • [QuestionAnswerAdvisor 的工作流程](#QuestionAnswerAdvisor 的工作流程)
    • [默认 Prompt 模板](#默认 Prompt 模板)
    • [SearchRequest 的核心参数](#SearchRequest 的核心参数)
  • 源码解析
    • [基础版本:v1.0 的 RAG 接口](#基础版本:v1.0 的 RAG 接口)
    • [进阶版本:v2.0 的来源过滤](#进阶版本:v2.0 的来源过滤)
    • [filter expression 语法](#filter expression 语法)
    • 检索结果的传递机制
  • 调试技巧
    • [开启 Spring AI 的 Advisor debug 日志](#开启 Spring AI 的 Advisor debug 日志)
    • [直接调用 VectorStore 验证](#直接调用 VectorStore 验证)
  • 验证结果
  • 优化建议
    • [增强版 RetrievalAugmentationAdvisor](#增强版 RetrievalAugmentationAdvisor)
    • [Rerank 二次精排](#Rerank 二次精排)
  • 小结

引言

文档入库只是 RAG 的一半,更关键的另一半在检索。当用户提问时,系统需要在毫秒级时间内从向量库中召回最相关的若干片段,再将这些片段拼接到 Prompt 中,引导大模型基于事实回答。

Spring AI 通过 QuestionAnswerAdvisor 将这一切封装成了一行链式调用。本篇将深入解析它的工作原理、核心参数,以及如何通过 SearchRequest 精细控制检索行为。

设计说明

RAG 检索的核心问题

把"检索"做好,要回答几个问题:

  1. 召回多少:Top-K 设多少?太少漏,太多噪音
  2. 召回精度:相似度阈值设多少?阈值高漏召回,阈值低引入无关内容
  3. 如何过滤:能否限定只在某些文档范围内检索?
  4. 如何拼接:检索到的文档怎么塞进 Prompt?

Advisor 模式的优势

Spring AI 的 Advisor 类似 AOP 切面------你不用关心检索的具体实现,只需要在 ChatClient 调用链中挂上 QuestionAnswerAdvisor,它就会在请求发出前自动完成:

Java 复制代码
[用户消息] → QuestionAnswerAdvisor.before()
            ├─ 用 user message 做向量检索
            ├─ 把召回的文档拼接到 user prompt 末尾
            └─ 继续走后续 Advisor → ChatModel

这种设计的好处:

  • 业务代码与检索逻辑解耦,Controller 只关心"发起对话"
  • 多个 Advisor 可以叠加(记忆 + RAG + 日志),互不干扰
  • 检索参数可以通过 SearchRequest 灵活调整

原理方案

QuestionAnswerAdvisor 的工作流程

Java 复制代码
ChatClient.prompt().user(message).advisors(qa).stream().content()
                                       │
                                       ▼
              QuestionAnswerAdvisor.before()
                       │
                       ├─ 1. 取出 user message
                       ├─ 2. VectorStore.similaritySearch(searchRequest)
                       ├─ 3. 将 Documents 拼接成 context 字符串
                       ├─ 4. 用模板渲染 augmented user message
                       └─ 5. 替换原 user message
                       │
                       ▼
              [其他 Advisor] → ChatModel
                       │
                       ▼
              QuestionAnswerAdvisor.after()
                       │
                       └─ 将 retrieved documents 存入 context
                          供后续 Advisor 使用

默认 Prompt 模板

QuestionAnswerAdvisor 内置了一个默认模板,将检索到的文档作为上下文注入:

Java 复制代码
{query}

Context information is below.
---------------------
{question_answer_context}
---------------------
Given the context information and no prior knowledge, answer the query.

{query} 是用户的原始问题,{question_answer_context} 是召回的所有文档拼接。这个模板保证了大模型"基于上下文回答"而不是凭借训练时的知识。

SearchRequest 的核心参数

java 复制代码
SearchRequest.builder()
    .query("用户的问题")
    .similarityThreshold(0.1)          // 相似度阈值
    .topK(5)                            // 召回数量
    .filterExpression("source in [...]") // 元数据过滤
    .build();

similarityThreshold:余弦相似度阈值,0~1 之间。值越高匹配越严格。常见取值:

阈值 适用场景
0.1~0.3 召回为先,宁可多召回也不漏
0.5~0.7 平衡精度和召回
0.8+ 严格匹配,类似关键词检索

topK:召回的文档数量。一般 3~10 之间,过多会稀释语义,过少容易漏关键信息。

filterExpression:基于元数据的过滤表达式,使用类似 SQL 的语法。这是实现"在指定文件范围内检索"的关键。

源码解析

基础版本:v1.0 的 RAG 接口

java 复制代码
@Operation(summary = "rag post", description = "Rag对话接口POST版本")
@PostMapping(value = "/rag")
@Loggable
public Flux<String> generatePost(@RequestParam String message) throws IOException {

    Long userId = BaseContext.getCurrentId();
    return chatClient.prompt()
            .user(message)
            .system(a -> a.param("current_data", LocalDate.now().toString()))
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId))
            .advisors(QuestionAnswerAdvisor
                    .builder(vectorStore)
                    .searchRequest(
                            SearchRequest.builder()
                                .query(message)
                                .similarityThreshold(0.1)
                                .topK(5)
                                .build()
                    )
                    .build())
            .stream()
            .content();
}

关键点解析:

  1. 链式 Advisor 注册.advisors(QuestionAnswerAdvisor.builder(vectorStore)...) 在请求级别添加 RAG 能力
  2. VectorStore 注入vectorStore 是 Spring 自动装配的 Milvus 实例
  3. 流式输出.stream().content() 返回 Flux<String>,配合 SSE 实现打字机效果
  4. 多 Advisor 叠加:CONVERSATION_ID 和 QuestionAnswerAdvisor 同时生效,对话既有记忆又有知识库支持

进阶版本:v2.0 的来源过滤

v2.0 在 RAG 接口中增加了 sources 参数,允许用户限定检索范围:

java 复制代码
@PostMapping(value = "/rag")
@Loggable
public Flux<String> generatePost(
        @RequestParam(value = "sources", required = false) List<String> sources,
        @RequestParam(value = "message", defaultValue = "你好") String message) {

    // 敏感词前置过滤
    List<SensitiveWord> list = sensitiveWordService.list();
    for (SensitiveWord sensitiveWord : list) {
        if (message.contains(sensitiveWord.getWord())) {
            return Flux.just("包含敏感词:" + sensitiveWord.getWord());
        }
    }

    return processNormalRagQuery(sources, message);
}

private Flux<String> processNormalRagQuery(List<String> sources, String message) {
    Long userId = BaseContext.getCurrentId();
    
    ChatClient.ChatClientRequestSpec clientRequestSpec = chatClient.prompt()
            .user(message)
            .system(a -> a.param("current_data", LocalDate.now().toString()))
            .advisors(a -> a.param("userMessage", message))
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId));

    // 如果指定了 sources,使用 filter expression 限定检索范围
    if (sources != null && !sources.isEmpty()) {
        SearchRequest.Builder searchRequestBuilder = SearchRequest.builder()
                .query(message)
                .similarityThreshold(0.1)
                .topK(5)
                .filterExpression("source in " + JSON.toJSONString(sources));

        clientRequestSpec = clientRequestSpec
                .system(a -> a.param("rag_message", """
                        如果涉及RAG,请提供文件来源,我会提供给你文件来源,
                        请严格基于知识库内容回答用户问题,
                        不要添加任何知识库之外的信息。
                        """))
                .advisors(QuestionAnswerAdvisor.builder(vectorStore)
                        .searchRequest(searchRequestBuilder.build())
                        .build());
    }

    return clientRequestSpec.stream().content();
}

filterExpression 详解:

java 复制代码
.filterExpression("source in " + JSON.toJSONString(sources))

如果 sources = ["spring-ai.pdf", "rag-guide.pdf"],则最终表达式为:

复制代码
source in ['spring-ai.pdf','rag-guide.pdf']

Milvus 会在向量检索之前先按这个条件过滤元数据,再做相似度计算。这种"先过滤后检索"的方式比"先检索后过滤"效率高得多。

filter expression 语法

Spring AI 的 filter expression 是数据库无关的抽象语法,常见用法:

java 复制代码
// 等于
.filterExpression("source == 'doc.pdf'")

// 不等于
.filterExpression("category != 'archive'")

// IN 集合
.filterExpression("source in ['a.pdf','b.pdf']")

// 数值比较
.filterExpression("year >= 2024")

// AND / OR 组合
.filterExpression("source == 'doc.pdf' && page > 10")

底层会被翻译成 Milvus 的 boolean expression 语法。

检索结果的传递机制

QuestionAnswerAdvisor 在检索完成后,会把 Documents 存入请求上下文:

java 复制代码
// QuestionAnswerAdvisor 内部代码(简化)
context.put(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS, documents);

后续的 Advisor(比如 v2.0 的 MetadataAwareQuestionAnswerAdvisor)可以从 context 中取出这些 Documents 做二次处理:

java 复制代码
List<Document> documents = (List<Document>) baseRequest.context()
        .get(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS);

这是 Advisor 之间协作的标准方式。

调试技巧

开启 Spring AI 的 Advisor debug 日志

yaml 复制代码
logging:
  level:
    org.springframework.ai.chat.client.advisor: debug

SimpleLoggerAdvisor 会打印完整的请求体,包括拼接后的 Prompt:

Java 复制代码
==> Request:
    User: 用户原始问题

    Context information is below.
    ---------------------
    [Document 1 内容]
    [Document 2 内容]
    ...
    ---------------------
    
==> Response:
    [LLM 流式回复]

通过观察日志,可以判断:

  • 是否成功召回了相关文档
  • 召回的文档是否真的包含答案
  • Prompt 是否过长(超 token)

直接调用 VectorStore 验证

如果怀疑检索本身有问题,可以绕过 Advisor 直接测试:

java 复制代码
List<Document> docs = vectorStore.similaritySearch(
    SearchRequest.builder()
        .query("测试问题")
        .topK(5)
        .similarityThreshold(0.1)
        .build()
);

docs.forEach(doc -> {
    System.out.println("Score: " + doc.getMetadata().get("distance"));
    System.out.println("Source: " + doc.getMetadata().get("source"));
    System.out.println("Text: " + doc.getText());
    System.out.println("---");
});

验证结果

基础 RAG 测试

前置条件:已上传一份《Spring AI 入门指南》的 PDF。

请求:

Java 复制代码
POST /api/v1/ai/rag?message=Spring AI的ChatClient怎么用?

预期响应(流式):

Java 复制代码
ChatClient 是 Spring AI 提供的高级抽象,用于简化与大语言模型的交互。
你可以通过 ChatClient.builder(chatModel) 来创建实例...

回答的内容应该来自上传的 PDF,而不是大模型的原始知识。

来源过滤测试

请求:

Java 复制代码
POST /api/v1/ai/rag?sources=spring-ai.pdf&sources=rag-guide.pdf&message=什么是RAG?

只会从这两个文件中检索,不会被其他文档干扰。

阈值调整对比

降低阈值(0.1 → 召回宽松):

Java 复制代码
召回 5 条,包含部分弱相关内容

提高阈值(0.7 → 召回严格):

Java 复制代码
可能召回 0 条,AI 回复"无法在知识库中找到相关信息"

阈值的选择需要根据实际数据测试。

优化建议

增强版 RetrievalAugmentationAdvisor

QuestionAnswerAdvisor 是基础款,Spring AI 还提供了 RetrievalAugmentationAdvisor,支持更多高级特性:

java 复制代码
Advisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
        .documentRetriever(VectorStoreDocumentRetriever.builder()
                .similarityThreshold(0.50)
                .vectorStore(vectorStore)
                .build())
        // 检索为空时,返回提示
        .queryAugmenter(ContextualQueryAugmenter.builder()
                .allowEmptyContext(false)
                .emptyContextPromptTemplate(PromptTemplate.builder()
                        .template("用户查询位于知识库之外。礼貌地告知用户您无法回答")
                        .build())
                .build())
        // 查询重写
        .queryTransformers(RewriteQueryTransformer.builder()
                .chatClientBuilder(ChatClient.builder(chatModel))
                .build())
        .build();

它的优势:

  • 检索为空时可以走 fallback prompt,避免大模型胡编
  • 支持 query rewrite,让模糊问题变清晰再去检索

Rerank 二次精排

DashScope 提供了 RerankModel,可以对召回结果做二次排序:

java 复制代码
@Autowired
private DashScopeRerankModel dashScopeRerankModel;

RetrievalRerankAdvisor retrievalRerankAdvisor = new RetrievalRerankAdvisor(
        vectorStore,
        dashScopeRerankModel,
        SearchRequest.builder().topK(200).build()
);

策略是:先 topK=200 粗召回,再用 rerank 模型精排取 top 5。这种"召回-精排"两阶段架构是搜索系统的标准做法。

小结

本篇深入解析了 RAG 的检索阶段:

  • QuestionAnswerAdvisor 用一行代码就把向量检索注入到对话流程中
  • SearchRequest 提供了 topK、相似度阈值、filter expression 等精细控制
  • v2.0 通过 sources 参数 + filter expression 实现了"在指定文件范围内检索"
  • 进阶方案:RetrievalAugmentationAdvisor、Rerank 模型、查询重写

下一篇将聚焦内容安全------敏感词过滤的设计与实现。

相关推荐
情绪总是阴雨天~8 小时前
深度解析:LangChain、Agent、RAG、FC、ReAct、LangGraph、A2A、MCP — 区别、联系与全景图
python·langchain·agent·rag·langgraph·mcp·a2a
1368木林森9 小时前
多轮对话RAG【第十五篇】:工业级上下文管理方案,指代消除、会话隔离、动态检索伸缩
人工智能·rag
海兰10 小时前
【第56篇】Graph Example —— MCP-Node 模块
java·人工智能·spring boot·spring ai
梵得儿SHI11 小时前
(第四篇)Spring AI 架构设计与优化:真实生产环境复盘,从 100ms 到 10ms 的响应提速全流程
人工智能·缓存·性能优化·milvus·向量检索·rag·spring ai
养肥胖虎20 小时前
RAG学习笔记(3):区分数据库检索与RAG的使用场景
数据库·ai·rag
程序员老邢1 天前
【技术底稿 37】Spring Boot 3.x 自动装配 “死锁” 排查:3 个注解实现条件化装配与 Mock 兜底
java·spring boot·后端·自动装配·rag·技术底稿
碳基硅坊1 天前
使用RAGFlow搭建本地知识库
人工智能·知识库·rag·ragflow
1368木林森1 天前
RAG查询改写②【第十篇】:HYDE、StepBack、子问题拆分,高阶改写算法生产落地
人工智能·算法·rag
Ting-yu1 天前
Spring AI Alibaba零基础速成(3) ---- ChatClient使用
java·spring·spring cloud·spring ai