LLM 需要证据来回答正确:RAG 详解与 Java 示例
1. 总览
一个再强大的 LLM 也不会自动知道你公司的内部文档内容 — 它只知道输入中的信息。本文解释 Retrieval-Augmented Generation (RAG) 的机制:为什么需要它、检索(retrieval)如何工作,以及为什么答案质量几乎完全取决于文本生成前的检索步骤。读完后,你将理解 RAG 系统中的数据流,并能用 Java 编写一个简单的检索管道。
2. 问题:LLM 没有关于私有数据的“记忆”
模型是在一个公开数据集上训练的,在某个时间点冻结。它不知道你公司上周发布的新退款政策,也不知道某份内部合同的内容。
有两种方式来输入私有信息:
- 直接塞入提示词 — 仅当文档很小、能放进上下文窗口时才可行。
- 检索(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,在考虑升级到更大的模型之前,先花时间检查分块策略和检索准确率/误率。