LLM 需要证据来回答正确:RAG 详解与 Java 示例

本文有一个短视频:YouTubeTikTok

1. 总览

一个再强大的 LLM 也不会自动知道你公司的内部文档内容 — 它只知道输入中的信息。本文解释 Retrieval-Augmented Generation (RAG) 的机制:为什么需要它、检索(retrieval)如何工作,以及为什么答案质量几乎完全取决于文本生成前的检索步骤。读完后,你将理解 RAG 系统中的数据流,并能用 Java 编写一个简单的检索管道。

2. 问题:LLM 没有关于私有数据的“记忆”

模型是在一个公开数据集上训练的,在某个时间点冻结。它不知道你公司上周发布的新退款政策,也不知道某份内部合同的内容。

有两种方式来输入私有信息:

  1. 直接塞入提示词 — 仅当文档很小、能放进上下文窗口时才可行。
  2. 检索(retrieval) — 当文档集很大时,应用程序只选出最相关的几个段落,然后再放入提示词。
// 方式 1:塞入整个文档 — 仅适用于短文本
String prompt = """
    根据以下政策回答客户问题。
    政策:%s
    问题:%s
    """.formatted(fullPolicyText, userQuestion);

这个方式很简单,但不具可扩展性:上下文窗口有 token 限制,塞入越多无关文本,模型越容易“迷失方向”(lost in the middle)。当文档集有数千页时,你必须使用检索。

3. RAG 是什么和它如何工作

RAG (Retrieval-Augmented Generation) 将问题分成两个独立阶段:

  • 检索:找到与问题相关的正确文本段落。
  • 生成:LLM 用这些段落生成答案,并附上引用源。
用户问题
      │
      ▼
[嵌入模型] ── 将问题转换为向量
      │
      ▼
[向量数据库] ── 找出最相似的文本段落
      │
      ▼
[原始段落] ── 将原文放入提示词
      │
      ▼
[LLM] ── 生成答案并附上引用源

关键点:LLM 不直接“查询”向量数据库。它只接收应用程序在完成检索后提供的纯文本(plain text)。向量数据库只是搜索工具,不是模型能读取“知识”的地方。

3.1. 嵌入 — 将问题转换为数字

嵌入模型将文本段落转换为实值向量(例如 1536 维)。两个意义相近的文本段落会产生相似的向量,在这个空间中位置也接近。

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-openai-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
@Service
public class PolicyEmbeddingService {

    private final EmbeddingModel embeddingModel;

    public PolicyEmbeddingService(EmbeddingModel embeddingModel) {
        this.embeddingModel = embeddingModel;
    }

    public float[] embed(String text) {
        EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text));
        return response.getResult().getOutput();
    }
}

embed() 接收一个文本段落(例如:“酒店取消政策是什么?”),返回一个 float[] 数组 — 代表该句子语义的向量。这正是将与向量数据库中已保存的向量进行比较的内容。

3.2. 向量数据库 — 找出最相似的文本段落

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
    <version>1.0.0</version>
</dependency>
@Service
public class PolicyRetrievalService {

    private final VectorStore vectorStore;

    public PolicyRetrievalService(VectorStore vectorStore) {
        this.vectorStore = vectorStore;
    }

    public void indexPolicy(String policyId, String content) {
        Document document = new Document(content, Map.of("policyId", policyId));
        vectorStore.add(List.of(document));
    }

    public List<Document> search(String question) {
        SearchRequest request = SearchRequest.query(question).withTopK(3);
        return vectorStore.similaritySearch(request);
    }
}
  • indexPolicy() 将一个政策段落保存到向量数据库中,并附加元数据(policyId),方便日后识别该文本属于哪份文档。
  • search() 接收用户问题,自动进行内部嵌入,然后找出 3 个最相似的段落(topK(3))。
search("酒店取消政策是什么?") 的结果:
1. "客户可在入住前 24 小时内免费取消房间..."
2. "非退款预订的情况(non-refundable)..."
3. "5 人或以上团体预订适用取消费..."

3.3. 生成 — LLM 基于证据进行回答

@Service
public class PolicyAnswerService {

    private final ChatClient chatClient;
    private final PolicyRetrievalService retrievalService;

    public PolicyAnswerService(ChatClient.Builder builder,
                                PolicyRetrievalService retrievalService) {
        this.chatClient = builder.build();
        this.retrievalService = retrievalService;
    }

    public String answer(String question) {
        List<Document> passages = retrievalService.search(question);

        String context = passages.stream()
            .map(Document::getContent)
            .collect(Collectors.joining("\n---\n"));

        String prompt = """
            仅基于以下文本段落回答问题。如果信息不足,请明确说不知道。
            文本段落:
            %s

            问题:%s
            """.formatted(context, question);

        return chatClient.prompt(prompt).call().content();
    }
}

注意“仅基于以下文本段落”这一指令 — 这对减少幻觉(hallucination)很关键:模型被要求紧贴证据(evidence),而不是混入训练数据中的通用知识。

注意: 如果不明确指示“仅基于上下文”,模型仍可能混合其已有的培训知识与检索到的内容,导致答案听起来合理但与实际文档有偏差。

4. 为什么检索不好,答案也就不好

这是容易被忽视的部分:LLM 只擅长解释它收到的东西,但无法自主检查这些信息是否正确或充分。

两个常见情形:

情形 1 — 检索到过时的文档。 如果向量数据库仍索引着旧版本的政策(未更新或删除),系统会返回过时的文本。LLM 读到这段文字并非常自信地回答 — 但答案是错的。

情形 2 — 检索缺少重要上下文。 假设原文结构如下:

第 5 条。取消房间政策
客户可在入住前 24 小时内免费取消房间。

第 5.1 条。例外情况
春节期间预订不适用免费取消;
固定取消费为预订价值的 50%。

如果分块的步骤将“第 5 条”和“第 5.1 条”分成两个独立的块,而检索只获得了“第 5 条”(因为问题中没有“春节”或“例外”这样的词),LLM 会回答“可以在 24 小时内免费取消” — 听起来非常合理,但漏掉了重要的例外条款。

@Test
void 当缺少例外块时_LLM会遗漏条件() {
    // 模拟检索仅返回一个块,缺少例外部分
    List<Document> incompletePassages = List.of(
        new Document("客户可在入住前 24 小时内免费取消房间。")
    );

    String answer = fakeAnswerService.answerFromPassages(
        "我春节期间预订了房间,取消要收费吗?",
        incompletePassages
    );

    // 答案会是错误的,因为缺少关于节假日例外的证据
    assertThat(answer).doesNotContain("50%");
}

这个测试不是为了抓 LLM 的错 — 它说明问题在于检索,而非模型的推理能力。如果你提供了充分的证据(两个块都有),即使是中等水平的模型也会给出正确答案。

5. 分块:检索“正确粒度”的关键

既然上面的例子展示了不当分块的后果,这是构建 RAG 时投入最多的地方。

@Service
public class PolicyChunkingService {

    public List<String> splitByHeading(String document) {
        // 按"第 X 条"或"第 X.Y 条"的模式分割,保持父子关系的上下文
        String[] rawChunks = document.split("(?=第 \\d+)");
        return Arrays.stream(rawChunks)
            .map(String::trim)
            .filter(chunk -> !chunk.isBlank())
            .toList();
    }
}

与其按固定字符数分割(例如每 500 字截一次 — 容易在条款和例外之间切割),这个方法按语义边界(“第 X 条”)来分割。这样每个块保持一个完整的信息单位,减少了将规则主体与其例外条款分开的风险。

@Test
void 按标题分块_应该包含条文和例外() {
    String document = """
        第 5 条。取消房间政策
        客户可在入住前 24 小时内免费取消房间。

        第 5.1 条。例外情况
        春节期间,固定取消费为预订价值的 50%。
        """;

    List<String> chunks = chunkingService.splitByHeading(document);

    assertThat(chunks).hasSize(2);
    assertThat(chunks.get(1)).contains("例外情况", "50%");
}

注意: 按标题分块仅在文档具有明确结构(合同、规程、技术文档)时有效。对于自由格式文本(邮件、聊天记录),你需要不同的策略 — 例如按段落分割并添加重叠,或使用基于语义相似度的分割。

6. 何时不应该使用 RAG

RAG 不是万能方案:

  • 数据集小且固定:如果全部文档仅几千 token,直接放入提示词比构建整个检索管道更简单、更少出错。
  • 问题需要复杂的多步推理,而答案不直接位于任何单一文本段落中 — RAG 寻找“与问题相似的段落”,不寻找“包含推理答案的段落”。
  • 数据实时频繁变化(股票价格、库存):应该直接调用 API(function calling)而非频繁重新索引向量数据库。

7. 结论

LLM 并不“知道”你的私有数据 — 它只擅长解释所提供的证据。RAG 通过将检索(查找正确的文本段落)与生成(生成答案)分离来解决这个问题,嵌入模型和向量数据库充当中介。最终答案的质量几乎完全取决于你对数据的索引和分块有多好 — 再强的 LLM 也救不了糟糕的检索。如果你在构建 RAG,在考虑升级到更大的模型之前,先花时间检查分块策略和检索准确率/误率。

ragllmvector-databasespring-aijava