5. 第一个 RAG 应用

6 min

RAG 系统的"数据基础"已经就位:

  • Embedding 模型:通过 Ollama 运行 qwen3-embedding:8b,把文本转为向量
  • 向量存储:SimpleVectorStore 负责存储和检索向量
  • ETL 管道:TextReader 读取文件 -> TokenTextSplitter 分块 -> VectorStore 存储

知识库准备就绪,是时候把 检索生成 串联起来,构建第一个完整的 RAG 问答系统了。

1. QuestionAnswerAdvisor

Spring AI 提供了 QuestionAnswerAdvisor,它是实现 RAG 最快的方式。它的工作原理:

  1. 用户发送问题给 ChatClient
  2. QuestionAnswerAdvisor 拦截请求,提取用户问题
  3. 用问题在 VectorStore 中进行相似度检索
  4. 将检索到的文档作为上下文,附加到发给 LLM 的 Prompt 中
  5. LLM 基于上下文生成回答

整个过程对调用者透明,你只需要正常调用 chatClient.prompt().user(question),Advisor 会自动完成检索和上下文注入。

1.1 扩展 AiConfig

在前面创建的 AiConfig 基础上,新增一个带 QuestionAnswerAdvisorragChatClient,以及启动时自动摄入知识库的 CommandLineRunner

src/main/java/com/albertstack/rag/config/AiConfig.java

java
package com.albertstack.rag.config;

import com.albertstack.rag.service.IngestionService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

import java.util.List;

@Slf4j
@Configuration
public class AiConfig {

    // 内存向量存储,开发阶段够用,后续会替换为 Qdrant
    @Bean
    public VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }

    // 基础 ChatClient,不带任何 Advisor
    @Bean
    public ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
    }

    // 带 RAG 能力的 ChatClient
    @Bean
    public ChatClient ragChatClient(ChatClient.Builder builder, VectorStore vectorStore) {
        return builder
            .defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder()
                    .topK(5)
                    .build())
                .build())
            .build();
    }

    // 启动时自动摄入知识库
    @Bean
    public CommandLineRunner ingestDocuments(IngestionService ingestionService) {
        return args -> {
            // 这里列出要摄入的文档路径,可以根据需要添加更多文件
            List<String> files = List.of(
                "docs/spring-ai-intro.txt",
                "docs/rag-concepts.txt",
                "docs/ollama-guide.txt"
            );

            for (String file : files) {
                var resource = new ClassPathResource(file);
                int chunks = ingestionService.ingest(resource);
                log.info("已摄入: {} -> {} 个分块", file, chunks);
            }
        };
    }
}

四个 Bean 各司其职:

  • vectorStoreSimpleVectorStore 内存实现,后续会替换为 Qdrant
  • chatClient:基础版,对比测试中用它演示"无 RAG"的效果
  • ragChatClient:带 QuestionAnswerAdvisor,自动检索知识库并注入上下文。先不设 similarityThreshold,让所有检索结果都参与,后面"调参"小节再讲阈值控制
  • ingestDocuments:应用启动时把知识库文件摄入向量存储,后续增加文件直接在这里追加

2. 准备知识库

为了让 RAG 的效果更明显,准备几份主题明确的知识库文件。

src/main/resources/docs/spring-ai-intro.txt

(沿用上一章创建的文件,介绍 Spring AI 的核心功能。)

src/main/resources/docs/rag-concepts.txt

text
RAG(Retrieval-Augmented Generation)是一种将信息检索与文本生成相结合的技术。它的核心思想是:在生成回答之前,先从外部知识库中检索相关信息,然后将这些信息作为上下文提供给语言模型。

RAG 系统的关键组件包括:

1. 文档摄入管道:负责将原始文档处理成可检索的格式。这包括文档读取、文本分块、向量化和存储四个步骤。

2. 向量数据库:存储文档的向量表示。常见的向量数据库包括 PGVector、Qdrant、Chroma、Milvus、Pinecone 等。向量数据库支持基于语义相似度的快速检索。

3. Embedding 模型:将文本转换为高维向量。好的 Embedding 模型能够捕捉文本的语义信息,使得语义相近的文本在向量空间中距离更近。

4. 检索策略:决定如何从向量数据库中获取最相关的文档。包括 Top-K 检索、相似度阈值过滤、元数据过滤等策略。

5. Prompt 工程:将检索到的文档与用户问题组合成有效的 Prompt。好的 Prompt 模板能显著提升回答质量。

RAG 相比直接使用 LLM 的优势:
- 知识实时更新:只需更新知识库,无需重新训练模型
- 减少幻觉:回答基于真实文档,而非模型的参数记忆
- 可追溯:可以标注回答的来源文档
- 成本低:不需要微调模型,普通硬件即可运行

src/main/resources/docs/ollama-guide.txt

text
Ollama 是一个本地运行大语言模型的工具,让你无需云端 API 就能在自己的电脑上使用 LLM。

Ollama 的主要特点:

1. 简单易用:一条命令即可下载并运行模型。例如 ollama run llama3 就能启动 Meta 的 LLaMA 3 模型进行对话。
2. 模型丰富:支持 LLaMA 3、Mistral、Gemma、Qwen 等主流开源模型,以及 qwen3-embedding、nomic-embed-text 等 Embedding 模型。
3. API 兼容:提供 REST API 接口,兼容 OpenAI API 格式,方便集成到各种应用中。默认运行在 http://localhost:11434。
4. 资源管理:根据模型大小自动分配 CPU 或 GPU 资源。支持 Apple Silicon 的 Metal 加速和 NVIDIA CUDA 加速。
5. 模型自定义:通过 Modelfile 可以自定义模型参数、系统提示词等。

在 Spring AI 中使用 Ollama 非常简单,只需要在配置文件中指定模型名称和服务地址即可。Spring AI 的 Ollama 集成支持 Chat 和 Embedding 两种模型类型。

3. 问答服务

src/main/java/com/albertstack/rag/service/RagService.java

java
package com.albertstack.rag.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

@Service
public class RagService {

    private final ChatClient ragChatClient;

    public RagService(@Qualifier("ragChatClient") ChatClient ragChatClient) {
        this.ragChatClient = ragChatClient;
    }

    /**
     * 同步问答:等待完整回答后一次性返回
     */
    public String chat(String question) {
        return ragChatClient.prompt()
            .user(question)
            .call()
            .content();
    }

    /**
     * 流式问答:逐 Token 返回纯文本,适合 SSE 接口
     */
    public Flux<String> chatStream(String question) {
        return ragChatClient.prompt()
            .user(question)
            .stream()
            .content();
    }

    /**
     * 流式问答(详细版):返回完整的 ChatResponse,可提取思考过程和元数据
     */
    public Flux<ChatResponse> chatStreamDetail(String question) {
        return ragChatClient.prompt()
            .user(question)
            .stream()
            .chatResponse();
    }
}

通过 @Qualifier("ragChatClient") 注入带 RAG 能力的 ChatClient。Advisor 在构建时就绑定了,所以 RagService 不需要任何检索逻辑。

  • chatStream():返回 Flux<String>,纯文本 token 流,给 Controller 的 SSE 接口用
  • chatStreamDetail():返回 Flux<ChatResponse>,包含完整 metadata,可以从中提取模型的思考过程

4. 问答接口

src/main/java/com/albertstack/rag/controller/RagController.java

java
package com.albertstack.rag.controller;

import com.albertstack.rag.service.RagService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;

import java.util.Map;

@RestController
@RequestMapping("/api/rag")
public class RagController {

    private final RagService ragService;

    public RagController(RagService ragService) {
        this.ragService = ragService;
    }

    @PostMapping("/chat")
    public Map<String, String> chat(@RequestBody Map<String, String> request) {
        String question = request.get("question");
        String answer = ragService.chat(question);
        return Map.of("question", question, "answer", answer);
    }

    @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chatStream(@RequestParam String question) {
        return ragService.chatStream(question);
    }
}
  • POST /api/rag/chat:同步接口,接收 JSON 请求体,返回完整回答
  • GET /api/rag/chat/stream:SSE 流式接口,逐 Token 推送回答

5. 效果对比

用一个测试类直观感受 RAG 带来的差异。构建两个 ChatClient:一个带 QuestionAnswerAdvisor,一个不带,对同一个问题分别提问,对比回答质量。

src/test/java/com/albertstack/rag/RagCompareTest.java

java
package com.albertstack.rag;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class RagCompareTest {

    @Autowired
    private ChatClient chatClient; // 基础版,无 Advisor

    @Autowired
    @Qualifier("ragChatClient")
    private ChatClient ragChatClient; // 带 QuestionAnswerAdvisor

    @Test
    void compareWithAndWithoutRag() {
        String question = "Spring AI 支持哪些向量数据库?";

        // 无 RAG:模型凭训练数据回答,可能不准确
        String plainAnswer = chatClient.prompt()
            .user(question)
            .call()
            .content();
        log.info("无 RAG 回答:\n{}", plainAnswer);

        // 有 RAG:基于知识库文档回答
        String ragAnswer = ragChatClient.prompt()
            .user(question)
            .call()
            .content();
        log.info("有 RAG 回答:\n{}", ragAnswer);

        // RAG 版本应该包含知识库中的具体内容
        assertThat(ragAnswer).satisfiesAnyOf(
            a -> assertThat(a).containsIgnoringCase("SimpleVectorStore"),
            a -> assertThat(a).containsIgnoringCase("PGVector"),
            a -> assertThat(a).containsIgnoringCase("Chroma")
        );
    }

    @Test
    void ragShouldAnswerSpecificFacts() {
        // 知识库中明确写了 http://localhost:11434
        String answer = ragChatClient.prompt()
            .user("Ollama 默认运行在哪个端口?")
            .call()
            .content();
        log.info("回答:{}", answer);

        assertThat(answer).contains("11434");
    }
}

在 IDEA 中运行测试,对比控制台输出的两个回答:

无 RAG 有 RAG
回答内容 可能列出一些向量数据库,但信息不一定准确 准确列出 SimpleVectorStore、PGVector、Chroma、Milvus 等(来自知识库原文)
可靠性 模型"猜测",可能编造不存在的集成 基于实际文档,信息可追溯
详细程度 笼统的描述 结合知识库中的具体描述,更有针对性

6. SearchRequest 调参

QuestionAnswerAdvisor 的检索效果很大程度上取决于 SearchRequest 的参数配置。

6.1 topK 的影响

java
// topK = 1:只检索最相关的 1 个文档块
SearchRequest.builder().topK(1).build()

// topK = 10:检索最相关的 10 个文档块
SearchRequest.builder().topK(10).build()
topK 值 效果 风险
1 回答高度聚焦,但可能遗漏重要信息 如果最相关的那个块恰好不含答案,就会失败
3-5 平衡精准度和覆盖面,推荐起点 -
10+ 覆盖面广,适合复杂问题 引入噪声,可能超出 LLM 上下文窗口

6.2 similarityThreshold 的影响

java
// 低阈值:宽松匹配
SearchRequest.builder().similarityThreshold(0.5).build()

// 高阈值:严格匹配
SearchRequest.builder().similarityThreshold(0.9).build()
阈值 效果 风险
0.5 宽松匹配,可能返回一些"沾边"的内容 引入不相关的信息,降低回答质量
0.7 适中,推荐起点 -
0.9 只返回高度相关的内容 可能过于严格,导致没有检索结果,LLM 无法回答

7. 自定义 Prompt 模板

QuestionAnswerAdvisor 默认使用英文的 Prompt 模板。对于中文 RAG 应用,自定义中文模板能显著提升回答质量。

java
@Bean
public ChatClient ragChatClient(ChatClient.Builder builder, VectorStore vectorStore) {
    return builder
        .defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore)
            .searchRequest(SearchRequest.builder()
                .topK(5)
                .similarityThreshold(0.7)
                .build())
            .promptTemplate(new PromptTemplate("""
                基于以下上下文信息回答用户的问题。
                如果上下文中没有相关信息,请明确告知用户"根据现有资料无法回答该问题",不要编造答案。
                回答时请引用相关的上下文内容来支持你的观点。

                上下文:
                {question_answer_context}

                用户问题:{query}
                """))
            .build())
        .build();
}

几个自定义模板的要点:

  • 明确"不知道"的行为:最重要的一条。如果不指示模型在缺乏上下文时拒绝回答,它会基于自己的训练数据"编"一个答案,这就失去了 RAG 的意义
  • 要求引用来源:让模型标注回答基于哪部分上下文,增强可信度
  • 使用中文模板:对中文 LLM(如 Qwen)来说,中文指令的遵循效果通常更好
  • {question_answer_context}{query}:这两个是 QuestionAnswerAdvisor 固定的占位符,分别会被填充为检索到的文档内容和用户问题。变量名必须完全匹配,否则会报 Not all variables were replaced 错误

8. 测试 RagService

编写端到端测试,验证整个 RAG 管道:

src/test/java/com/albertstack/rag/service/RagServiceTest.java

java
package com.albertstack.rag.service;

import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
@SpringBootTest
class RagServiceTest {

    // AiConfig 中的 CommandLineRunner 会在测试启动时自动摄入知识库

    @Autowired
    private RagService ragService;

    @Test
    void shouldAnswerFromKnowledgeBase() {
        String question = "Spring AI 支持哪些向量数据库?";
        String answer = ragService.chat(question);

        log.info("问题:{}", question);
        log.info("回答:{}", answer);

        assertThat(answer).satisfiesAnyOf(
                a -> assertThat(a).containsIgnoringCase("PGVector"),
                a -> assertThat(a).containsIgnoringCase("SimpleVectorStore"),
                a -> assertThat(a).containsIgnoringCase("Chroma")
        );
    }

    @Test
    void shouldAnswerAboutOllama() {
        String question = "Ollama 支持哪些模型?";
        String answer = ragService.chat(question);

        log.info("问题:{}", question);
        log.info("回答:{}", answer);

        assertThat(answer).satisfiesAnyOf(
                a -> assertThat(a).containsIgnoringCase("LLaMA"),
                a -> assertThat(a).containsIgnoringCase("Mistral"),
                a -> assertThat(a).containsIgnoringCase("Qwen")
        );
    }

    @Test
    void shouldStreamWithThinking() {
        String question = "RAG 的核心组件有哪些?";
        log.info("问题:{}", question);

        var thinkingText = new StringBuilder();
        var contentText = new StringBuilder();

        ragService.chatStreamDetail(question)
                .doOnNext(chunk -> {
                    var result = chunk.getResult();
                    if (result == null || result.getOutput() == null) return;

                    // 思考过程在 Generation metadata 的 "thinking" 字段中
                    Object thinking = result.getMetadata().get("thinking");
                    if (thinking != null && !thinking.toString().isEmpty()) {
                        thinkingText.append(thinking);
                        System.out.print(thinking);
                        return;
                    }

                    // 回答内容
                    String text = result.getOutput().getText();
                    if (text != null && !text.isEmpty()) {
                        contentText.append(text);
                        System.out.print(text);
                    }
                })
                .doOnComplete(System.out::println)
                .blockLast();

        log.info("思考过程:{} 字符", thinkingText.length());
        log.info("回答内容:{} 字符", contentText.length());

        assertThat(contentText.toString()).isNotBlank();
    }
}
  • 日志 + 断言log.info() 让你在控制台直接看到模型的完整回答,断言验证回答是否包含预期内容。只看断言通过与否不够直观,日志能帮助判断回答质量
  • satisfiesAnyOf:LLM 的回答不是确定性的,用宽松断言,包含关键词之一即可
  • 流式测试doOnNext 逐 token 打印到控制台,运行时能直观看到文字逐步输出的效果。blockLast() 等待流结束后再做断言

9. 本章小结

RAG 问答系统,的数据流程图如下:

RAG数据流程
知识点 说明
QuestionAnswerAdvisor Spring AI 的 RAG Advisor,自动完成检索和上下文注入
有无 RAG 对比 有 RAG 时模型基于文档回答,无 RAG 时只能凭训练数据猜测
SearchRequest 调参 topK 控制召回数量,similarityThreshold 过滤低质量结果
自定义 Prompt 模板 用中文模板引导模型输出格式和风格,提升回答质量
端到端测试 用宽松断言验证 LLM 的非确定性输出,覆盖同步和流式接口

目前只支持纯文本文件,但现实世界的知识库充满了 PDF、Markdown、Word 文档。接下来解决多格式文档的处理问题。