跳到主要内容

RAG 检索增强生成

RAG(Retrieval-Augmented Generation)是目前 AI 应用最常用的架构之一。它的核心思路很简单:先检索相关文档,再基于这些文档生成答案。这样既能利用大模型的理解能力,又能保证答案有据可查。

为什么需要 RAG?

大模型虽然强大,但有两个明显的限制:

时效性问题:模型训练需要时间,训练数据都是历史数据。比如 GPT-4 的训练数据截止到 2023 年,它不知道 2024 年发生的事情。

专业领域知识缺失:模型训练用的是通用数据,对特定领域的专业知识了解有限。比如公司内部的产品手册、技术文档,模型根本没见过。

直接把文档拼接到提示词里发给模型?行不通。因为:

  1. 上下文窗口限制:即使是最新的模型,上下文窗口也有限(通常不超过 200K token)。几百页的文档根本放不下。
  2. 成本高:输入越多,token 消耗越大,成本越高。
  3. 效率低:每次都要把整份文档发过去,响应慢。

RAG 解决了这些问题:只检索相关的文档片段,把它们和问题一起发给模型。这样既能保证答案准确,又能控制成本。

RAG 的工作流程

RAG 分为两个阶段:离线准备实时查询

离线准备阶段

这个阶段主要是把文档处理成向量,存到向量数据库里。

原始文档(产品手册.pdf)
↓ 文档分块
[第1段] Spring AI 是什么... (500字)
[第2段] 如何配置 OpenAI... (400字)
[第3段] RAG 实现原理... (600字)
↓ 向量化(Embedding)
[0.2, -0.5, 0.3, ...] → 存入向量数据库
[0.3, 0.1, -0.2, ...] → 存入向量数据库

关键点:

  • 文档分块:要把文档切成小块,通常 300-800 字比较合适。太小上下文不全,太大放不进 Prompt。
  • 保持语义完整:切块时要注意,不要把同一个知识点拆散。比如一个完整的代码示例应该放在一起。
  • 特殊内容处理:表格、代码块要特殊处理,保持原有结构。

实时查询阶段

用户提问时,系统会:

用户:Spring AI 怎么配置 OpenAI?
↓ 将问题向量化
↓ 在向量数据库中检索
找到最相关的 3 段文档
↓ 组装 Prompt
Prompt = """
参考资料:
[第2段内容:如何配置 OpenAI...]
[第5段内容:配置文件示例...]

用户问题:Spring AI 怎么配置 OpenAI?
请基于参考资料回答,如果资料中没有答案,说不知道。
"""
↓ 发给 AI 模型
AI 返回答案

为什么用向量数据库?

普通数据库只能做精确匹配,比如搜索"MySQL",只能找到包含"MySQL"这个词的文档。但用户可能问"怎么连数据库",虽然没提到 MySQL,但意思是一样的。

向量数据库能理解语义相似性:

  • 用户问"怎么连 MySQL",能找到"数据源配置"相关文档
  • 用户问"接口报 500",能找到"异常处理"相关文档
  • 用户问"性能慢",能找到"优化建议"相关文档

这就是向量检索的优势:基于语义相似度,而不是关键词匹配。

Spring AI 中的 RAG 实现

Spring AI 提供了完整的 RAG 支持,核心组件包括:

  • VectorStore:向量存储接口
  • EmbeddingClient:文本向量化客户端
  • Document:文档表示
  • QuestionAnswerAdvisor:RAG 顾问(最简单的方式)
  • RetrievalAugmentationAdvisor:模块化 RAG 顾问(更灵活)

基础配置

首先需要配置向量存储和嵌入客户端:

@Configuration
public class RagConfiguration {

@Bean
public VectorStore vectorStore(DataSource dataSource, EmbeddingClient embeddingClient) {
// 使用 PostgreSQL 的 pgvector 扩展
return new PgVectorStore.Builder(dataSource, embeddingClient)
.withDistanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE)
.withDimensions(1536) // OpenAI embedding 的维度
.withRemoveExistingVectorStoreTable(true)
.build();
}

@Bean
public EmbeddingClient embeddingClient() {
return new OpenAiEmbeddingClient(
new OpenAiApi(System.getenv("OPENAI_API_KEY"))
);
}
}

文档加载

把文档加载到向量数据库:

@Service
public class DocumentService {

private final VectorStore vectorStore;
private final EmbeddingClient embeddingClient;

public DocumentService(VectorStore vectorStore, EmbeddingClient embeddingClient) {
this.vectorStore = vectorStore;
this.embeddingClient = embeddingClient;
}

public void loadDocuments(List<String> documents) {
List<Document> embeddedDocuments = documents.stream()
.map(content -> new Document(content))
.collect(Collectors.toList());

// VectorStore 会自动调用 EmbeddingClient 生成向量
vectorStore.add(embeddedDocuments);
}

// 从文件加载
public void loadFromFile(Resource resource) throws IOException {
String content = new String(resource.getInputStream().readAllBytes());
loadDocuments(List.of(content));
}
}

方式一:使用 QuestionAnswerAdvisor(最简单)

这是最简单的 RAG 实现方式,直接在 ChatClient 中配置:

@Bean
public ChatClient chatClient(ChatModel chatModel, VectorStore vectorStore) {
return ChatClient.builder(chatModel)
.defaultAdvisors(
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(3) // 检索最相关的 3 个文档片段
.build())
.build()
)
.build();
}

使用时直接提问,QuestionAnswerAdvisor 会自动检索相关文档并增强提示:

@RestController
public class RagController {

private final ChatClient chatClient;

public RagController(ChatClient chatClient) {
this.chatClient = chatClient;
}

@GetMapping("/ask")
public String ask(String question) {
return chatClient.prompt()
.user(question)
.call()
.content();
}
}

方式二:手动实现 RAG(更灵活)

如果需要更多控制,可以手动实现:

@Service
public class RagService {

private final VectorStore vectorStore;
private final ChatClient chatClient;
private final EmbeddingClient embeddingClient;

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

public String answerQuestion(String question) {
// 1. 将问题转换为向量
Embedding questionEmbedding = embeddingClient.embed(question);

// 2. 检索相关文档
List<Document> relevantDocuments = vectorStore.similaritySearch(
SearchRequest.query(question)
.withTopK(3)
.withSimilarityThreshold(0.7) // 相似度阈值
);

// 3. 如果没有找到相关文档
if (relevantDocuments.isEmpty()) {
return "抱歉,我没有找到相关的资料来回答这个问题。";
}

// 4. 构造上下文
String context = relevantDocuments.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n"));

// 5. 构造增强的提示
String answer = chatClient.prompt()
.system("""
你是一个专业的助手。请基于提供的参考资料回答问题。
如果参考资料中没有相关信息,请明确说明无法基于提供的资料回答。
回答要准确、简洁,并引用具体的文档内容。
""")
.user(u -> u
.text("""
参考资料:
{context}

问题:{question}
""")
.param("context", context)
.param("question", question))
.call()
.content();

return answer;
}
}

方式三:使用 RetrievalAugmentationAdvisor(模块化)

这是 Spring AI 1.0 引入的模块化 RAG 实现,提供了更细粒度的控制:

@Bean
public RetrievalAugmentationAdvisor ragAdvisor(
VectorStore vectorStore,
EmbeddingClient embeddingClient) {

return RetrievalAugmentationAdvisor.builder()
.vectorStore(vectorStore)
.embeddingClient(embeddingClient)
.searchRequest(SearchRequest.builder()
.topK(5)
.withSimilarityThreshold(0.75)
.build())
.promptTemplate("""
基于以下参考资料回答问题:

{documents}

问题:{question}

回答:
""")
.build();
}

@Bean
public ChatClient chatClient(ChatModel chatModel, RetrievalAugmentationAdvisor ragAdvisor) {
return ChatClient.builder(chatModel)
.defaultAdvisors(ragAdvisor)
.build();
}

文档分块策略

文档分块是 RAG 的关键环节,直接影响检索效果。

简单文本分块

@Service
public class DocumentChunkingService {

public List<String> chunkText(String text, int chunkSize, int overlap) {
List<String> chunks = new ArrayList<>();
int start = 0;

while (start < text.length()) {
int end = Math.min(start + chunkSize, text.length());
String chunk = text.substring(start, end);
chunks.add(chunk);

// 重叠部分,避免切分点丢失上下文
start = end - overlap;
}

return chunks;
}
}

使用 Spring AI 的文档分割器

Spring AI 提供了更智能的分割器:

@Service
public class SmartDocumentService {

private final VectorStore vectorStore;
private final TextSplitter textSplitter;

public SmartDocumentService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
// 按段落分割,每段最多 1000 字符,重叠 200 字符
this.textSplitter = new TokenTextSplitter(1000, 200);
}

public void loadDocument(String content) {
// 自动分割文档
List<Document> documents = textSplitter.split(content);

// 添加元数据
documents.forEach(doc -> {
doc.getMetadata().put("source", "product-manual");
doc.getMetadata().put("timestamp", Instant.now().toString());
});

vectorStore.add(documents);
}
}

分块最佳实践

  1. 保持语义完整:不要在句子中间切分,尽量在段落边界切分
  2. 合理设置大小:通常 300-800 字比较合适,太小上下文不全,太大影响检索精度
  3. 使用重叠:相邻块之间保留 10-20% 的重叠,避免边界信息丢失
  4. 保留结构信息:代码块、表格、列表要保持完整
  5. 添加元数据:记录文档来源、章节、时间等信息,方便后续过滤

检索优化

调整检索参数

SearchRequest searchRequest = SearchRequest.builder()
.query(question)
.withTopK(5) // 返回最相关的 5 个文档
.withSimilarityThreshold(0.7) // 相似度阈值,低于此值的不返回
.build();

List<Document> results = vectorStore.similaritySearch(searchRequest);

结合关键词检索和向量检索:

@Service
public class HybridSearchService {

private final VectorStore vectorStore;

public List<Document> hybridSearch(String query) {
// 向量检索
List<Document> vectorResults = vectorStore.similaritySearch(
SearchRequest.query(query).withTopK(5).build()
);

// 关键词检索(如果 VectorStore 支持)
// 这里需要根据具体的 VectorStore 实现来调整

// 合并结果并去重
return mergeAndDeduplicate(vectorResults);
}
}

查询重写

有时候用户的问题不够精确,可以先重写查询:

@Service
public class QueryRewriteService {

private final ChatClient chatClient;

public String rewriteQuery(String originalQuery) {
String rewritten = chatClient.prompt()
.system("""
你是一个查询优化专家。请将用户的问题重写为更精确的搜索查询。
保持原意,但使用更专业、更具体的术语。
""")
.user("原始查询:{query}\n\n重写后的查询:")
.param("query", originalQuery)
.call()
.content();

return rewritten.trim();
}
}

高级技巧

多轮对话中的 RAG

在多轮对话中,需要考虑对话历史:

@Bean
public ChatClient chatClientWithMemory(
ChatModel chatModel,
VectorStore vectorStore,
ChatMemoryRepository memoryRepository) {

ChatMemory chatMemory = ChatMemory.builder()
.id("user-session")
.repository(memoryRepository)
.maxSize(20)
.build();

return ChatClient.builder(chatModel)
.defaultAdvisors(
// 先添加对话历史
MessageChatMemoryAdvisor.builder(chatMemory).build(),
// 再基于问题和历史进行 RAG 检索
QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().topK(3).build())
.build()
)
.build();
}

答案验证

生成答案后,可以验证答案是否基于提供的文档:

@Service
public class AnswerValidationService {

private final ChatClient chatClient;

public boolean validateAnswer(String question, String answer, String context) {
String validation = chatClient.prompt()
.system("""
请判断回答是否准确基于提供的上下文。
只回答"是"或"否"。
""")
.user("""
问题:{question}
回答:{answer}
上下文:{context}

回答是否基于上下文?
""")
.param("question", question)
.param("answer", answer)
.param("context", context)
.call()
.content();

return validation.trim().equalsIgnoreCase("是");
}
}

引用来源

让模型在回答时引用具体的文档来源:

public String answerWithSources(String question) {
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.query(question).withTopK(3).build()
);

// 为每个文档片段编号
StringBuilder context = new StringBuilder();
for (int i = 0; i < docs.size(); i++) {
context.append(String.format("[文档%d]\n%s\n\n", i + 1, docs.get(i).getContent()));
}

String answer = chatClient.prompt()
.system("""
请基于提供的参考资料回答问题。
在回答中引用具体的文档编号,例如:[文档1]。
如果资料中没有相关信息,请说明。
""")
.user("""
参考资料:
{context}

问题:{question}
""")
.param("context", context.toString())
.param("question", question)
.call()
.content();

return answer;
}

常见问题

检索不到相关文档

可能原因:

  1. 文档分块太大或太小
  2. 相似度阈值设置过高
  3. 文档没有正确向量化

解决方案:

  • 调整分块策略,尝试不同的 chunk size
  • 降低相似度阈值
  • 检查文档是否正确加载到向量数据库

答案不准确

可能原因:

  1. 检索到的文档不相关
  2. Prompt 设计不合理
  3. 模型理解有偏差

解决方案:

  • 优化检索策略,增加 topK 数量
  • 改进 Prompt,明确要求基于文档回答
  • 添加答案验证步骤

性能问题

可能原因:

  1. 向量检索慢
  2. 文档数量太多
  3. Embedding 生成慢

解决方案:

  • 使用更高效的向量数据库(如 Milvus、Qdrant)
  • 对文档进行索引优化
  • 使用缓存减少重复的 Embedding 计算

最佳实践

  1. 文档质量优先:确保文档内容准确、结构清晰,这是 RAG 效果的基础
  2. 合理分块:根据文档类型选择合适的分块策略,代码、表格、列表要特殊处理
  3. 优化检索:根据实际效果调整 topK 和相似度阈值
  4. 设计好 Prompt:明确要求模型基于文档回答,避免幻觉
  5. 添加验证:对生成的答案进行验证,确保基于提供的文档
  6. 监控效果:记录检索到的文档、生成的答案,持续优化
  7. 处理边界情况:没有相关文档时给出友好提示,不要强行回答

总结

RAG 是让 AI 应用更实用的关键技术。Spring AI 提供了完整的 RAG 支持,从简单的 QuestionAnswerAdvisor 到灵活的 RetrievalAugmentationAdvisor,可以满足不同场景的需求。

关键是要理解 RAG 的本质:先检索,再生成。检索的质量直接影响最终答案的质量,所以文档处理、向量化、检索策略都需要仔细设计。

在实际应用中,要根据具体场景调整参数,持续优化效果。RAG 不是一次配置就能完美工作的,需要根据反馈不断改进。