Spring AI RAG - 10 来源追溯:自定义 Advisor 实现

文章目录

  • 引言
  • 设计说明
    • [为什么默认的 QuestionAnswerAdvisor 不够用?](#为什么默认的 QuestionAnswerAdvisor 不够用?)
    • 解决思路
    • 实现方案
  • 原理方案
    • [Advisor 优先级机制](#Advisor 优先级机制)
    • [Advisor 之间的通信](#Advisor 之间的通信)
    • [Document.source 元数据](#Document.source 元数据)
  • 代码解析
    • [MetadataAwareQuestionAnswerAdvisor 完整实现](#MetadataAwareQuestionAnswerAdvisor 完整实现)
    • [注册到 ChatClient](#注册到 ChatClient)
    • [调用时传入 userMessage 参数](#调用时传入 userMessage 参数)
    • [System Prompt 模板设计](#System Prompt 模板设计)
  • 验证结果
  • 进阶方案
  • 常见坑
    • [Order 设错导致拿不到文档](#Order 设错导致拿不到文档)
    • [userMessage 取不到](#userMessage 取不到)
    • [Tika 没注入 source](#Tika 没注入 source)
    • [默认 Advisor 与请求级 Advisor 的混用](#默认 Advisor 与请求级 Advisor 的混用)
  • 小结

引言

"AI 说的对吗?我能在哪里查证?"------这是知识库系统用户最常问的两个问题。一个回答如果没有来源,就只能算大模型的"自由发挥",可信度大打折扣。

来源追溯(Source Citation)让 AI 在回答时附上具体引用了哪个文件、哪一段,是企业级 RAG 必不可少的能力。本篇将深入解析项目中通过自定义 BaseAdvisor 实现来源追溯的完整方案。

设计说明

为什么默认的 QuestionAnswerAdvisor 不够用?

QuestionAnswerAdvisor 在检索后会把文档拼接到 Prompt 中,但默认拼接的只有 text 字段:

java 复制代码
Context information is below.
---------------------
[文档1的文本]
[文档2的文本]
---------------------

大模型看到这些文本,根本不知道它们来自哪个文件。即使在 System Prompt 里要求"请提供来源",AI 也只能瞎编(典型的"幻觉")。

解决思路

要让 AI 知道每段文本的来源,必须把 Document 的 metadata.source 字段也传给它:

java 复制代码
[文档1的文本]
来源文件:spring-ai.pdf

[文档2的文本]
来源文件:rag-guide.pdf

这样 AI 就能在回答中准确引用,比如:

"根据 spring-ai.pdf,ChatClient 是..."

实现方案

Spring AI 的 Advisor 链是有顺序的,可以在 QuestionAnswerAdvisor 之后挂一个自定义 Advisor,专门做这件事:

java 复制代码
[请求] → QuestionAnswerAdvisor.before()  // 检索文档存入 context
       → 自定义 MetadataAwareAdvisor.before()  // 取出文档,重新拼接 Prompt
       → ChatModel
[响应] → 自定义 MetadataAwareAdvisor.after()
       → QuestionAnswerAdvisor.after()

关键是控制 Advisor 的执行顺序------必须保证检索完成后,自定义 Advisor 才能拿到文档。

原理方案

Advisor 优先级机制

Spring AI 的 Advisor 通过 getOrder() 方法决定执行顺序,值越大优先级越低(执行越靠后):

Advisor 默认 Order 说明
QuestionAnswerAdvisor 0 RAG 检索
ChatModelCallAdvisor Integer.MAX_VALUE 实际调用 LLM 的终点

自定义 Advisor 想在 QuestionAnswerAdvisor 之后、ChatModelCallAdvisor 之前执行,order 应该设为 Integer.MAX_VALUE - 1

java 复制代码
@Override
public int getOrder() {
    return Integer.MAX_VALUE - 1;
}

Advisor 之间的通信

每个 Advisor 共享一个 Map<String, Object> context,可以通过它传递数据:

java 复制代码
// QuestionAnswerAdvisor 写入
context.put(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS, documents);

// 自定义 Advisor 读取
List<Document> documents = (List<Document>) context.get(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS);

这是 Spring AI 推荐的 Advisor 协作方式。

Document.source 元数据

Tika 解析时会自动在每个 Document 的 metadata 中写入 source 字段,值为原始文件名:

java 复制代码
Document doc = ...
String source = (String) doc.getMetadata().get("source");
// 例如: "spring-ai-guide.pdf"

向量化入库后,metadata 会被 Milvus 一并存储。检索时也会原样返回。

代码解析

MetadataAwareQuestionAnswerAdvisor 完整实现

java 复制代码
public class MetadataAwareQuestionAnswerAdvisor implements BaseAdvisor {

    private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
            {query}

            Context information is below, surrounded by ---------------------

            ---------------------
            {question_answer_context}
            ---------------------

            Given the context and provided history information and not prior knowledge,
            reply to the user comment. If the answer is not in the context, inform
            the user that you can't answer the question.
            """);

    @Override
    public ChatClientRequest before(ChatClientRequest baseRequest, AdvisorChain advisorChain) {
        // 1. 从 context 中取出已检索的文档
        List<Document> documents = (List<Document>) baseRequest.context()
                .get(QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS);

        // 2. 取出原始 user message(需要在请求时显式 param 传入)
        String userMessage = (String) baseRequest.context().get("userMessage");

        if (!CollectionUtils.isEmpty(documents)) {
            // 3. 拼接文档文本 + source 元数据
            String documentContext = documents.stream()
                    .map(doc -> doc.getText() + "\n来源文件:" 
                            + doc.getMetadata().getOrDefault("source", "unknown").toString())
                    .collect(Collectors.joining(System.lineSeparator()));

            // 4. 用模板渲染新的 user message
            String augmentedUserText = DEFAULT_PROMPT_TEMPLATE.render(Map.of(
                    "query", userMessage,
                    "question_answer_context", documentContext
            ));

            // 5. 替换原 user message
            return baseRequest.mutate()
                    .prompt(baseRequest.prompt().augmentUserMessage(augmentedUserText))
                    .build();
        } else {
            return baseRequest;
        }
    }

    @Override
    public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
        return chatClientResponse;
    }

    @Override
    public int getOrder() {
        // 优先级最低、因为要保证 QuestionAnswerAdvisor 执行完才能拿到文档信息
        // 不直接设置 MAX_VALUE,是因为 ChatModelCallAdvisor 也用了 MAX_VALUE,
        // 如果在它之后就拿不到机会执行
        return Integer.MAX_VALUE - 1;
    }
}

逐步解析:

  1. 取检索结果QuestionAnswerAdvisor.RETRIEVED_DOCUMENTS 是个常量,值为 "qa_retrieved_documents"QuestionAnswerAdvisor 会自动把检索结果存到这个 key 下
  2. 取原始 user message:因为 user message 在 QuestionAnswerAdvisor 处理后已经被拼接修改过,需要从外部 param 传入原始 message
  3. 拼接 source :每个文档的文本后追加 \n来源文件:xxx,让大模型知道来源
  4. 渲染模板:用 PromptTemplate 重新组装 Prompt
  5. 替换 user messageaugmentUserMessage 方法用新文本替换原 user message
  6. getOrder 的精妙之处Integer.MAX_VALUE - 1 保证执行顺序在所有检索类 Advisor 之后,但又在 ChatModelCallAdvisor(实际调 LLM)之前

注册到 ChatClient

java 复制代码
public AiRagController(ChatModel chatModel, ChatMemory chatMemory,
                       VectorStore vectorStore, RagTool ragTool) {
    this.chatModel = chatModel;
    this.chatClient = ChatClient.builder(chatModel)
            .defaultSystem(DEFAULT_SYSTEM_PROMPT)
            .defaultSystem(p -> p.param("rag_message", ""))
            .defaultAdvisors(
                    PromptChatMemoryAdvisor.builder(chatMemory).build(),
                    SimpleLoggerAdvisor.builder().build(),
                    new MetadataAwareQuestionAnswerAdvisor()  // 注册自定义 Advisor
            )
            .defaultTools(ragTool)
            .build();
    this.vectorStore = vectorStore;
}

关键点:

  • MetadataAwareQuestionAnswerAdvisor 作为 default advisor 注册,每次调用都生效
  • PromptChatMemoryAdvisor 等其他 Advisor 共存,互不干扰

调用时传入 userMessage 参数

java 复制代码
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()))
            // 传入原始 message 给 MetadataAwareQuestionAnswerAdvisor
            .advisors(a -> a.param("userMessage", message))
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, userId));

    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();
}

几个细节:

  1. .advisors(a -> a.param("userMessage", message)) ------ 把原始 message 通过 param 传给后续 Advisor
  2. System Prompt 强调来源 ------ rag_message 中明确要求 AI 提供文件来源
  3. filter expression 限定范围 ------ source in [...] 让检索只在用户选定的文件中进行

System Prompt 模板设计

java 复制代码
private static final String DEFAULT_SYSTEM_PROMPT = """
        你是知识库系统的对话助手,请以乐于助人的方式进行对话,
        {rag_message}
        今天的日期:{current_data}
        """;

{rag_message} 是个占位符,根据请求是否带 sources 动态填充:

  • 不带 sources(普通对话):rag_message 为空字符串
  • 带 sources(RAG 模式):rag_message 填入"必须基于知识库回答"的指令

这种"按需注入"的方式让同一个 ChatClient 实例既能做普通对话也能做 RAG 问答。

验证结果

普通模式(无 sources)

请求:

java 复制代码
POST /api/v1/ai/rag?message=你好

响应:

java 复制代码
你好!我是知识库系统的对话助手,很高兴为您服务...

不会触发 RAG 检索,回答中也没有来源引用。

RAG 模式(带 sources)

前置 :上传了 spring-ai-guide.pdf,包含 ChatClient 的介绍。

请求:

java 复制代码
POST /api/v1/ai/rag?sources=spring-ai-guide.pdf&message=ChatClient怎么用?

响应:

java 复制代码
ChatClient 是 Spring AI 提供的高级抽象,用于简化与大语言模型的交互。
你可以通过 ChatClient.builder(chatModel).build() 创建实例,然后通过链式
调用 .prompt().user(...).call().content() 完成对话。

来源文件:spring-ai-guide.pdf

AI 在回答末尾明确给出了来源。

多文件混合检索

请求:

java 复制代码
POST /api/v1/ai/rag?sources=spring-ai-guide.pdf&sources=rag-guide.pdf&message=如何实现RAG?

响应:

java 复制代码
要实现 RAG,主要分两个阶段:

1. 文档入库阶段:将文档上传、解析、切分、向量化、存入向量库。
   (来源:rag-guide.pdf)

2. 问答阶段:用户提问后,先检索向量库召回相关文档,
   再让大模型基于这些文档生成回答。
   (来源:rag-guide.pdf)

Spring AI 提供了 QuestionAnswerAdvisor 来简化这一流程,
你只需要把它挂到 ChatClient 的 advisor 链上即可。
(来源:spring-ai-guide.pdf)

AI 准确区分了不同来源,并在每个事实点后注明出处。

调试日志

开启 SimpleLoggerAdvisor 后,可以看到拼接后的完整 Prompt:

java 复制代码
==> Request:
    User: ChatClient怎么用?
    
    Context information is below, surrounded by ---------------------
    
    ---------------------
    ChatClient 是 Spring AI 的高级抽象...
    来源文件:spring-ai-guide.pdf
    
    通过 ChatClient.builder() 可以构建实例...
    来源文件:spring-ai-guide.pdf
    ---------------------
    
    Given the context and provided history information and not prior knowledge,
    reply to the user comment...

可以清楚看到每段文本都被附上了来源。

进阶方案

结构化的来源返回

让 AI 同时返回结构化数据,便于前端展示:

java 复制代码
String augmentedUserText = DEFAULT_PROMPT_TEMPLATE.render(Map.of(
        "query", userMessage,
        "question_answer_context", documentContext,
        "instruction", "请在回答末尾以 JSON 数组形式列出所有引用的来源:[{\"source\":\"xxx.pdf\",\"page\":1}]"
));

前端可以解析出来源,渲染为可点击的引用链接。

来源去重

同一个文件可能被切成多个 chunk,多次出现在 context 中。可以去重:

java 复制代码
String documentContext = documents.stream()
        .map(doc -> doc.getText() + "\n来源文件:" 
                + doc.getMetadata().getOrDefault("source", "unknown"))
        .collect(Collectors.joining(System.lineSeparator()));

// 收集去重后的来源列表
Set<String> uniqueSources = documents.stream()
        .map(doc -> (String) doc.getMetadata().getOrDefault("source", "unknown"))
        .collect(Collectors.toSet());

// 在 Prompt 末尾追加 "已检索的文件列表"
String augmentedUserText = ...;
augmentedUserText += "\n\n本次检索的文件:" + String.join(", ", uniqueSources);

页码与位置追溯

如果 Reader 支持,metadata 中还可以有 pageparagraph 等字段:

java 复制代码
String source = doc.getMetadata().get("source") + " 第" 
              + doc.getMetadata().getOrDefault("page", "?") + "页";

让来源精确到具体段落,便于用户复核。

来源高亮显示

前端拿到回答后,可以做关键词匹配:

  • 在原文档中高亮回答用到的句子
  • 点击来源跳转到 OSS 中的原文件预览
  • 同步展示文档的具体位置(书签、滚动到段落)

反向引用统计

记录每次回答用到了哪些文档,做"被引用 Top10"统计:

java 复制代码
@Override
public ChatClientResponse after(ChatClientResponse response, AdvisorChain chain) {
    List<Document> docs = (List<Document>) response.context().get(RETRIEVED_DOCUMENTS);
    docs.forEach(d -> citationService.recordCitation(d.getMetadata().get("source")));
    return response;
}

可以基于此分析哪些文档对用户最有价值。

常见坑

Order 设错导致拿不到文档

如果把自定义 Advisor 的 getOrder 设为 0 或负数,会在 QuestionAnswerAdvisor 之前执行,那时 context 中还没有 RETRIEVED_DOCUMENTS。

正确做法 :设为 Integer.MAX_VALUE - 1,确保排在所有检索之后。

userMessage 取不到

MetadataAwareAdvisor 内部用 context.get("userMessage") 取原始消息。如果调用时没传 param("userMessage", ...),会得到 null。

正确做法 :每次调用都加 .advisors(a -> a.param("userMessage", message))

Tika 没注入 source

如果用了非 Tika 的 Reader(比如 TextReader),可能没有自动注入 source 字段。

正确做法:手动补充:

java 复制代码
documents.forEach(doc -> doc.getMetadata().put("source", originalFilename));

默认 Advisor 与请求级 Advisor 的混用

如果 MetadataAwareAdvisor 注册为 default,但同一请求又通过 .advisors(...) 添加了 QuestionAnswerAdvisor,需要保证两者都执行。Spring AI 会合并 default + 请求级 advisor 列表,按 order 排序执行。

小结

本篇通过自定义 Advisor 完整实现了 RAG 的来源追溯:

  • 利用 BaseAdvisor 接口扩展 ChatClient
  • 通过 getOrder() 控制执行顺序,保证拿到检索结果
  • 通过 context 在 Advisor 之间传递数据
  • 在 Prompt 中注入 source 元数据,引导 AI 准确引用
  • System Prompt 强化来源要求,进一步降低幻觉

下一篇将进入更挑战性的话题------跨向量聚合,看看 Text-to-SQL 如何解决 Top-K 检索的天花板。

相关推荐
恼书:-(空寄2 天前
Spring AI实战|ChatMemory Advisor记忆优化:Redis + Kryo持久化方案
spring ai·会话记忆
中间件XL4 天前
ai-agent框架spring ai/alibaba原理源码分析(三) 外部调用III-skills
ai agent·calling·spring ai·springaialibaba·skills
大龄码农有梦想6 天前
Spring AI Alibaba和CrewAI:多智能体开源框架对比与选型
spring ai·crewai·多智能体协作框架·spring ai阿里巴巴·spring ai aliba·java智能体框架·python智能体框架
海兰7 天前
【第39篇】spring-ai-alibaba-graph-example学习路径概览
人工智能·spring boot·学习·spring·spring ai
海兰9 天前
【第35篇】文本摘要微服务
人工智能·spring boot·微服务·架构·spring ai
梵得儿SHI10 天前
(第三篇)Spring AI 架构设计与优化:容器化与云原生部署,基于 K8s 的 AI 应用全生命周期管理
java·ci/cd·docker·云原生·kubernetes·容器化·spring ai
行者-全栈开发11 天前
GPT-4 vs Claude vs 通义千问:Spring AI 接入三大模型对比测评(2026最新)
claude·通义千问·spring ai·企业级开发·chatmodel·大模型 api·多模型切换
海兰11 天前
【第32篇】场景示例项目
人工智能·spring boot·状态模式·spring ai
海兰13 天前
【第27篇】Micrometer + Zipkin
人工智能·spring boot·alibaba·spring ai