RAG 从 A 到 Z:先获取文档再回答

1. 概述

LLM 只知道训练数据中的内容。它不了解你商店的退货政策或公司的内部流程。当被问到这些问题时,它通常会编造答案。RAG(检索增强生成) 解决了这个问题:系统先检索相关文档,然后让 LLM 基于这些文档进行回答。

在本文中,你将用 Spring Boot 3 和 Spring AI 构建一个完整的 RAG 管道。管道包括文档加载、文本分块、生成嵌入、搜索、构建带引用的提示词和编写测试等步骤。文章末尾还介绍了常见错误和不应该使用 RAG 的情况。

2. RAG 如何工作

RAG 分为两个独立的阶段。

摄入阶段(ingestion) 运行一次或定期运行:

  1. 读取文档(Markdown、PDF、HTML…)。
  2. 分割成数百个 token 的 chunk。
  3. 调用 embedding 模型 将每个 chunk 转换为数字向量。
  4. 将向量和 chunk 内容保存到 vector store。

查询阶段(query) 在每次用户提问时运行:

  1. 将问题嵌入为向量。
  2. 找到向量最相近的 chunk(相似度搜索)。
  3. 将这些 chunk 插入到提示词中。这是 augment 步骤。
  4. 将提示词发送给 LLM 以生成答案。这是 generate 步骤。

核心思想非常简单。LLM 不需要“记住”你的文档,它只需要在提示词中读取正确的部分。

3. 项目准备

你需要添加 Spring AI BOM 和 OpenAI starter。这个 starter 同时提供聊天模型和嵌入模型。

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework.ai</groupId>
            <artifactId>spring-ai-bom</artifactId>
            <version>1.0.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

接下来是模型配置:

spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.1
spring.ai.openai.embedding.options.model=text-embedding-3-small

你应该设置较低的 temperature。对于 RAG,目标是紧密贴近文档进行回答,不需要创意。

最后,声明两个 bean:一个 vector store 和一个 ChatClient。

@Configuration
public class RagConfig {

    @Bean
    VectorStore vectorStore(EmbeddingModel embeddingModel) {
        return SimpleVectorStore.builder(embeddingModel).build();
    }

    @Bean
    ChatClient chatClient(ChatClient.Builder builder) {
        return builder.build();
    }
}

SimpleVectorStore 将所有向量存储在内存中。它适合学习和演示。EmbeddingModel 由 Spring AI 从 OpenAI starter 自动配置。每当你添加文档时,store 会调用这个模型来生成向量。

注意: SimpleVectorStore 在应用重启时会丢失所有数据,且使用线性搜索。对于生产环境,你应该使用 PGvector、Qdrant、Elasticsearch 等。Spring AI 为每种类型都提供了 starter,而且 VectorStore 接口保持不变,所以下面的代码不需要改变。

4. 加载文档

假设你有文件 src/main/resources/docs/return-policy.md:

# 退货政策

顾客可以在收到商品后30天内退货。
商品必须保持原状,包括标签和收据。
退款将在5-7个工作日内转账到账户。
内衣和折扣超过50%的产品不接受退货。

4.1. 读取和分块

@Component
public class DocumentIngestor implements ApplicationRunner {

    private final VectorStore vectorStore;
    private final ResourcePatternResolver resolver;

    public DocumentIngestor(VectorStore vectorStore, ResourcePatternResolver resolver) {
        this.vectorStore = vectorStore;
        this.resolver = resolver;
    }

    @Override
    public void run(ApplicationArguments args) throws IOException {
        // chunkSize=300 token, minChunkSizeChars=100, minChunkLengthToEmbed=5,
        // maxNumChunks=10000, keepSeparator=true
        var splitter = new TokenTextSplitter(300, 100, 5, 10_000, true);

        for (Resource file : resolver.getResources("classpath:docs/*.md")) {
            var reader = new TextReader(file);
            reader.getCustomMetadata().put("source", file.getFilename());

            List<Document> chunks = splitter.apply(reader.get());
            vectorStore.add(chunks);
            System.out.printf("已加载 %s: %d 个 chunk%n", file.getFilename(), chunks.size());
        }
    }
}

上面的代码做了以下工作:

  • ApplicationRunner 在 context 启动后运行一次,所以文档总是在第一个请求之前被加载。
  • TextReader 将文件读取为一个 Document。我们设置元数据 source 为文件名,以便在后面的步骤中用于提取来源。
  • TokenTextSplitter 将文本切分成大约 300 个 token 的 chunk。元数据被复制到每个 chunk。
  • vectorStore.add(...) 为每个 chunk 调用 embedding 模型,然后保存向量。

启动时,你会看到:

已加载 return-policy.md: 1 个 chunk
已加载 shipping.md: 3 个 chunk

4.2. 选择 Chunk 大小

Chunk 大小直接影响答案质量:

  • Chunk 太大: 一个 chunk 包含多个主题,向量变得“稀疏”,难以精确匹配。提示词也会消耗更多 token。
  • Chunk 太小: 句子失去上下文。例如“30天内”在一个 chunk 中,而“退货”在另一个 chunk 中。

对于散文文本,200-500 token 是一个合理的起点。对于结构化文档(FAQ、目录),应该按标题切分,使每个 chunk 都是一个完整的概念。

5. 检索相关文档

检索步骤只需要一个调用:

List<Document> docs = vectorStore.similaritySearch(
        SearchRequest.builder()
                .query("我能在多久内退货?")
                .topK(4)
                .similarityThreshold(0.5)
                .build());
  • query:问题。Vector store 会自动使用加载时使用的相同模型来嵌入问题。
  • topK(4):最多返回4个最相近的 chunk。
  • similarityThreshold(0.5):过滤相似度余弦距离小于0.5的 chunk。如果不设置阈值,store 总是返回足够的 topK 结果,即使它们与问题完全无关。

没有适用于所有情况的最优阈值。你应该使用真实问题记录评分,然后选择一个能分离相关结果和噪声的阈值。

6. 构建提示词并生成答案

6.1. 提示词构建器

将提示词构建逻辑分离成一个纯函数会使测试更容易:

public final class PromptBuilder {

    private static final String TEMPLATE = """
            你是一个客户服务助手。
            仅基于下面的上下文进行回答。
            如果上下文不包含信息,请回答确切的句子:
            "我在文档中没有找到这个信息。"
            在回答结尾,在方括号中标注来源,例如 [return-policy.md]。

            上下文:
            {context}

            问题:{question}
            """;

    private PromptBuilder() {}

    public static String build(String question, List<Document> docs) {
        String context = docs.stream()
                .map(d -> "[" + d.getMetadata().getOrDefault("source", "unknown") + "]\n" + d.getText())
                .collect(Collectors.joining("\n---\n"));
        return TEMPLATE.replace("{context}", context)
                       .replace("{question}", question);
    }
}

模板中有三个重要部分:

  1. “仅基于下面的上下文进行回答” 限制 LLM 使用文档,减少编造答案的可能性。
  2. 固定的拒绝回答句子 给 LLM 一个合法的出口,当文档不包含信息时。
  3. 每个 chunk 都带有来源名称,这样 LLM 可以引用来源,用户也可以验证。

这里我们使用 replace 而不是 String.formatted。原因是:文档内容可能包含 % 字符(例如“折扣超过50%”),formatted 遇到它会抛出 UnknownFormatConversionException。

6.2. RagService

@Service
public class RagService {

    static final String NOT_FOUND = "我在文档中没有找到这个信息。";

    private final VectorStore vectorStore;
    private final ChatClient chatClient;

    public RagService(VectorStore vectorStore, ChatClient chatClient) {
        this.vectorStore = vectorStore;
        this.chatClient = chatClient;
    }

    public String answer(String question) {
        List<Document> docs = vectorStore.similaritySearch(
                SearchRequest.builder()
                        .query(question)
                        .topK(4)
                        .similarityThreshold(0.5)
                        .build());

        if (docs == null || docs.isEmpty()) {
            return NOT_FOUND;
        }

        return chatClient.prompt()
                .user(PromptBuilder.build(question, docs))
                .call()
                .content();
    }
}

当找不到任何 chunk 时,服务立即返回拒绝回答,而不调用 LLM。这样既节省成本,又消除了 LLM 从常识中编造答案的可能性。

最后是 controller:

@RestController
public class AskController {

    private final RagService ragService;

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

    @GetMapping("/ask")
    public String ask(@RequestParam String q) {
        return ragService.answer(q);
    }
}

现在你可以测试 API:

curl -G "http://localhost:8080/ask" --data-urlencode "q=我能在多久内退货?"
curl -G "http://localhost:8080/ask" --data-urlencode "q=你们卖笔记本电脑吗?"
你可以在收到商品后30天内退货,前提是商品保持原状、标签和收据完整。 [return-policy.md]
我在文档中没有找到这个信息。

第一个答案严格遵循文档并包含来源引用。第二个答案没有匹配的文档,所以系统拒绝回答而不是编造。

7. 编写测试

不应该在单元测试中调用真实的 LLM,因为它很慢、花费金钱,且结果不稳定。相反,应该测试两个确定性部分:提示词构建器和没有文档时的拒绝逻辑。

class RagServiceTest {

    @Test
    void promptContainsContextSourceAndQuestion() {
        var doc = new Document("30天内可以退货。", Map.of("source", "return-policy.md"));

        String prompt = PromptBuilder.build("退货多久?", List.of(doc));

        assertThat(prompt)
                .contains("[return-policy.md]")
                .contains("30天内可以退货。")
                .contains("问题:退货多久?")
                .doesNotContain("{context}", "{question}");
    }

    @Test
    void percentSignInDocumentDoesNotBreakPrompt() {
        var doc = new Document("折扣超过50%的产品不接受退货。", Map.of("source", "return-policy.md"));

        assertThat(PromptBuilder.build("折扣商品?", List.of(doc)))
                .contains("50%");
    }

    @Test
    void returnsNotFoundWithoutCallingLlmWhenNoDocuments() {
        VectorStore vectorStore = mock(VectorStore.class);
        ChatClient chatClient = mock(ChatClient.class);
        when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());

        String answer = new RagService(vectorStore, chatClient).answer("你们卖笔记本吗?");

        assertThat(answer).isEqualTo(RagService.NOT_FOUND);
        verifyNoInteractions(chatClient);
    }
}

第三个测试检查两件事。首先,答案是正确的拒绝句子。其次,ChatClient 完全没有被调用。如果以后有人不小心删除了 isEmpty() 检查,测试会立即失败。

为了评估检索质量,你可以另外编写集成测试。准备一组带有预期源文件名的问题,然后检查该文件是否在 topK 结果中。这套测试应该定期运行,不应该在每次构建时都运行。

8. 使用 QuestionAnswerAdvisor 简化代码

一旦理解了每个步骤,你可以使用 Spring AI 的内置 advisor。这个 advisor 封装了检索和提示词构建部分。你需要添加依赖:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
String answer = chatClient.prompt()
        .advisors(QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().topK(4).similarityThreshold(0.5).build())
                .build())
        .user("我能在多久内退货?")
        .call()
        .content();

Advisor 会自动嵌入问题、查找 chunk 并按默认模板插入提示词。这很方便,但你对来源格式和早期拒绝逻辑的控制较少。因此,第 6 节中自己编写的方式在你需要这些要求时仍然值得保留。

9. 常见错误和何时不应该使用 RAG

更改 embedding 模型但没有重新加载数据。 不同模型的向量无法相互比较。每次更改模型,你都必须重新嵌入所有文档。

不设置 similarityThreshold。 Vector store 总是返回足够的 topK 结果。这时 LLM 会收到垃圾上下文并错误地回答。

对持久化 store 每次启动时都重新加载文档。 使用 PGvector 时,上面的 ApplicationRunner 代码会在每次部署后创建重复项。你应该为每个 chunk 分配固定的 ID(例如从内容和文件名生成的哈希),或在重新加载前按 source 删除旧数据。

向提示词中塞入过多 chunk。 将 topK 增加到 20 不会使答案更好。LLM 容易遗漏长上下文中间的信息,成本也会随 token 数量增加。

完全信任文档内容。 如果文档来自用户,它们可能包含“忽略之前的所有指示”之类的提示词注入。你需要控制允许哪些数据源被加载到 store。

注意: RAG 并不适合所有问题。需要聚合整个数据集的问题,例如“9月份有多少订单被退回了”,应该转换为 SQL 查询。相似度搜索只返回几个 chunk,无法进行计数或求和。如果你的所有文档只有几页,直接放在提示词中通常比构建整个 RAG 管道更简单、更准确。

10. 总结

RAG 将知识与模型分离。文档存储在 vector store 中,LLM 在每个问题中只读取需要的部分。在本文中,你构建了完整的两个阶段:摄入阶段(读取、分块、嵌入、保存)和查询阶段(带阈值的搜索、构建带来源的提示词、调用 LLM)。你还为提示词构建和拒绝逻辑编写了测试。

从这个基础上,你可以沿以下方向继续:用 PGvector 替代 SimpleVectorStore、按标题切分 chunk、按元数据过滤(例如仅搜索某个部门的文档),或添加重排步骤在将结果放入提示词前重新排序。

ragspring-aillmvector-storespring-boot