RAG 从 A 到 Z:先获取文档再回答
1. 概述
LLM 只知道训练数据中的内容。它不了解你商店的退货政策或公司的内部流程。当被问到这些问题时,它通常会编造答案。RAG(检索增强生成) 解决了这个问题:系统先检索相关文档,然后让 LLM 基于这些文档进行回答。
在本文中,你将用 Spring Boot 3 和 Spring AI 构建一个完整的 RAG 管道。管道包括文档加载、文本分块、生成嵌入、搜索、构建带引用的提示词和编写测试等步骤。文章末尾还介绍了常见错误和不应该使用 RAG 的情况。
2. RAG 如何工作
RAG 分为两个独立的阶段。
摄入阶段(ingestion) 运行一次或定期运行:
- 读取文档(Markdown、PDF、HTML…)。
- 分割成数百个 token 的 chunk。
- 调用 embedding 模型 将每个 chunk 转换为数字向量。
- 将向量和 chunk 内容保存到 vector store。
查询阶段(query) 在每次用户提问时运行:
- 将问题嵌入为向量。
- 找到向量最相近的 chunk(相似度搜索)。
- 将这些 chunk 插入到提示词中。这是 augment 步骤。
- 将提示词发送给 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);
}
}
模板中有三个重要部分:
- “仅基于下面的上下文进行回答” 限制 LLM 使用文档,减少编造答案的可能性。
- 固定的拒绝回答句子 给 LLM 一个合法的出口,当文档不包含信息时。
- 每个 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、按元数据过滤(例如仅搜索某个部门的文档),或添加重排步骤在将结果放入提示词前重新排序。