RAG 检索增强生成
RAG(Retrieval-Augmented Generation)是解决 LLM 知识局限性的核心范式,Spring AI 提供了完整的模块化 RAG 流水线。
为什么需要 RAG?
LLM 存在三大固有局限:
| 问题 | 描述 | RAG 解法 |
|---|---|---|
| 知识截止 | 训练数据有时效性 | 注入实时/私有知识 |
| 幻觉问题 | 模型可能编造事实 | 基于检索到的真实文档回答 |
| 私有数据 | 无法访问企业内部数据 | 将私有数据向量化后检索 |
传统方式:用户问题 → LLM → 回答(可能不准确)
RAG 方式:
用户问题 → 检索相关文档 → 问题 + 文档上下文 → LLM → 基于事实的回答RAG 完整流水线
Spring AI 将 RAG 分为两个阶段:
【离线阶段:知识入库】
原始文档(PDF/Word/HTML)
│
▼ DocumentReader
文档对象 List<Document>
│
▼ TextSplitter
文档分块 List<Document>(每块 512 tokens)
│
▼ EmbeddingModel
向量化 float[]
│
▼ VectorStore.add()
存入向量数据库 ✅
【在线阶段:检索增强】
用户问题
│
▼ EmbeddingModel
问题向量化
│
▼ VectorStore.similaritySearch()
检索 Top-K 相关文档
│
▼ PromptTemplate
构建增强 Prompt(问题 + 文档上下文)
│
▼ ChatModel
生成最终回答Advisor API:模块化 RAG
Spring AI 通过 Advisor 机制将 RAG 无缝集成到 ChatClient 调用链中:
QuestionAnswerAdvisor
最常用的 RAG Advisor,自动完成检索和 Prompt 增强:
java
@Configuration
public class RagConfig {
@Bean
public ChatClient ragChatClient(
ChatClient.Builder builder,
VectorStore vectorStore
) {
return builder
.defaultAdvisors(
new QuestionAnswerAdvisor(
vectorStore,
SearchRequest.builder()
.topK(5)
.similarityThreshold(0.75)
.build()
)
)
.build();
}
}
// 使用:和普通 ChatClient 完全一样
@GetMapping("/ask")
public String ask(@RequestParam String question) {
return ragChatClient.prompt()
.user(question)
.call()
.content();
// 框架自动:检索 → 增强 Prompt → 调用 LLM
}运行时动态 RAG
java
// 不同问题使用不同知识库
@GetMapping("/ask")
public String ask(
@RequestParam String question,
@RequestParam String domain // "tech" / "legal" / "finance"
) {
VectorStore targetStore = vectorStoreRegistry.get(domain);
return chatClient.prompt()
.user(question)
.advisors(new QuestionAnswerAdvisor(targetStore)) // 运行时注入
.call()
.content();
}文档读取(DocumentReader)
Spring AI 支持多种文档格式:
java
// PDF 文档
DocumentReader pdfReader = new PagePdfDocumentReader(
new ClassPathResource("docs/manual.pdf"),
PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPageExtractedTextFormatter(
ExtractedTextFormatter.builder()
.withNumberOfTopTextLinesToDelete(0)
.build()
)
.withPagesPerDocument(1)
.build()
);
// Word 文档 / HTML / 纯文本(需要 Tika)
DocumentReader tikaReader = new TikaDocumentReader(resource);
// JSON 文档
DocumentReader jsonReader = new JsonReader(
new ClassPathResource("data/products.json"),
"name", "description", "category" // 提取的字段
);
// 读取文档
List<Document> docs = pdfReader.get();文档分块(TextSplitter)
分块策略直接影响检索质量:
java
// Token 分块(推荐)
TextSplitter tokenSplitter = new TokenTextSplitter(
512, // 每块最大 token 数
128, // 重叠 token 数(保持上下文连贯)
5, // 最小块大小
10000, // 最大块大小
true // 保留分隔符
);
// 字符分块
TextSplitter charSplitter = new CharacterTextSplitter(
"\n\n", // 分隔符(段落)
1000, // 块大小(字符)
200 // 重叠大小
);
List<Document> chunks = tokenSplitter.apply(rawDocs);分块策略选择
| 场景 | 推荐策略 | chunk size |
|---|---|---|
| 技术文档 | TokenTextSplitter | 512 tokens |
| 法律合同 | 按段落分块 | 1000 chars |
| 新闻文章 | 按句子分块 | 3-5 句 |
| 代码文件 | 按函数/类分块 | 自定义 |
元数据过滤
通过元数据精确控制检索范围:
java
// 存储时添加元数据
List<Document> docs = rawDocs.stream()
.map(doc -> {
doc.getMetadata().put("department", "技术部");
doc.getMetadata().put("version", "2024-Q4");
doc.getMetadata().put("confidential", false);
return doc;
})
.toList();
vectorStore.add(docs);
// 检索时按元数据过滤
SearchRequest request = SearchRequest.builder()
.query(userQuestion)
.topK(5)
.similarityThreshold(0.7)
// 只检索技术部的非机密文档
.filterExpression("department == '技术部' && confidential == false")
.build();
List<Document> results = vectorStore.similaritySearch(request);过滤表达式语法
// 等值
"source == 'official-docs'"
// 比较
"version >= '2024'"
// 逻辑组合
"department == 'tech' && level <= 3"
// IN 操作
"category in ['Java', 'Spring', 'AI']"
// NOT
"NOT confidential"高级 RAG 模式
1. 混合检索(Hybrid Search)
结合向量检索和关键词检索,提升召回率:
java
@Service
public class HybridSearchService {
@Autowired
private VectorStore vectorStore;
@Autowired
private ElasticsearchClient esClient;
public List<Document> hybridSearch(String query, int topK) {
// 向量检索(语义相关)
List<Document> semanticResults = vectorStore.similaritySearch(
SearchRequest.builder().query(query).topK(topK).build()
);
// 关键词检索(精确匹配)
List<Document> keywordResults = esClient.search(query, topK);
// RRF 融合排序(Reciprocal Rank Fusion)
return rrfMerge(semanticResults, keywordResults, topK);
}
private List<Document> rrfMerge(
List<Document> list1, List<Document> list2, int topK
) {
Map<String, Double> scores = new HashMap<>();
int k = 60; // RRF 常数
for (int i = 0; i < list1.size(); i++) {
String id = list1.get(i).getId();
scores.merge(id, 1.0 / (k + i + 1), Double::sum);
}
for (int i = 0; i < list2.size(); i++) {
String id = list2.get(i).getId();
scores.merge(id, 1.0 / (k + i + 1), Double::sum);
}
// 按 RRF 分数排序,返回 topK
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(e -> findDocById(e.getKey(), list1, list2))
.toList();
}
}2. 查询改写(Query Rewriting)
用 LLM 改写用户问题,提升检索质量:
java
@Service
public class QueryRewriteRagService {
@Autowired
private ChatClient chatClient;
@Autowired
private VectorStore vectorStore;
public String ragWithQueryRewrite(String originalQuery) {
// Step 1: 用 LLM 改写查询(生成多个变体)
String rewritePrompt = """
将以下问题改写为 3 个不同角度的搜索查询,每行一个:
原始问题:%s
""".formatted(originalQuery);
String rewrittenQueries = chatClient.prompt()
.user(rewritePrompt)
.call()
.content();
// Step 2: 用多个查询检索,合并去重
Set<Document> allDocs = new LinkedHashSet<>();
for (String query : rewrittenQueries.split("\n")) {
allDocs.addAll(vectorStore.similaritySearch(
SearchRequest.builder().query(query.trim()).topK(3).build()
));
}
// Step 3: 用检索结果回答原始问题
String context = allDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n\n---\n\n"));
return chatClient.prompt()
.system("基于以下上下文回答问题,如果上下文中没有相关信息,请说明。")
.user("上下文:\n" + context + "\n\n问题:" + originalQuery)
.call()
.content();
}
}3. 重排序(Reranking)
检索后用更精准的模型重新排序:
java
@Service
public class RerankRagService {
@Autowired
private VectorStore vectorStore;
@Autowired
private RerankModel rerankModel; // 如 DashScope Rerank
public List<Document> rerankSearch(String query, int topK) {
// 先粗检索更多候选
List<Document> candidates = vectorStore.similaritySearch(
SearchRequest.builder().query(query).topK(topK * 3).build()
);
// 用 Rerank 模型精排
return rerankModel.rerank(query, candidates, topK);
}
}完整 RAG 应用示例
java
@Service
public class EnterpriseKnowledgeService {
@Autowired
private VectorStore vectorStore;
@Autowired
private ChatClient chatClient;
@Autowired
private ChatMemory chatMemory;
/**
* 企业知识库问答(RAG + 多轮对话)
*/
public String ask(String question, String sessionId) {
return chatClient.prompt()
.system("""
你是企业内部知识库助手。
请基于提供的文档内容回答问题。
如果文档中没有相关信息,请明确说明"文档中未找到相关信息",不要编造答案。
回答要简洁准确,必要时引用文档来源。
""")
.user(question)
.advisors(
// 多轮对话记忆
new MessageChatMemoryAdvisor(chatMemory, sessionId, 10),
// RAG 检索增强
new QuestionAnswerAdvisor(
vectorStore,
SearchRequest.builder()
.topK(5)
.similarityThreshold(0.72)
.build()
)
)
.call()
.content();
}
/**
* 批量导入文档
*/
public void ingestDocuments(List<Resource> resources) {
resources.parallelStream().forEach(resource -> {
try {
List<Document> docs = new TikaDocumentReader(resource).get();
List<Document> chunks = new TokenTextSplitter(512, 128).apply(docs);
// 添加来源元数据
chunks.forEach(doc ->
doc.getMetadata().put("source", resource.getFilename())
);
vectorStore.add(chunks);
log.info("导入完成:{} ({} 块)", resource.getFilename(), chunks.size());
} catch (Exception e) {
log.error("导入失败:{}", resource.getFilename(), e);
}
});
}
}RAG 质量评估
java
@Component
public class RagEvaluator {
@Autowired
private ChatClient evaluatorClient;
/**
* 评估 RAG 回答质量(忠实度 + 相关性)
*/
public EvaluationResult evaluate(
String question,
String answer,
List<Document> retrievedDocs
) {
String context = retrievedDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n"));
String evalPrompt = """
评估以下 RAG 系统的回答质量,以 JSON 格式输出:
问题:%s
检索到的上下文:%s
系统回答:%s
评估维度:
1. faithfulness(忠实度):回答是否完全基于上下文,0-1 分
2. relevance(相关性):回答是否回答了问题,0-1 分
3. completeness(完整性):是否充分利用了上下文信息,0-1 分
输出格式:{"faithfulness": 0.9, "relevance": 0.85, "completeness": 0.8, "comment": "..."}
""".formatted(question, context, answer);
return evaluatorClient.prompt()
.user(evalPrompt)
.call()
.entity(EvaluationResult.class);
}
}
record EvaluationResult(
double faithfulness,
double relevance,
double completeness,
String comment
) {}最佳实践
RAG 调优清单
- 分块大小:512 tokens 是通用起点,根据文档类型调整
- 重叠比例:10-20% 重叠,保持语义连贯
- 相似度阈值:0.7-0.8,过低引入噪声,过高召回不足
- Top-K:3-5 个文档,过多会稀释关键信息
- 元数据过滤:多租户场景必须用元数据隔离数据
- 定期重建索引:文档更新后及时重新向量化
常见陷阱
- 分块过小:丢失上下文,检索结果碎片化
- 分块过大:超出 LLM 上下文窗口,关键信息被稀释
- 不设相似度阈值:低质量文档污染回答
- 忽略文档预处理:PDF 乱码、HTML 标签等噪声影响向量质量