Spring AI Alibaba RAG实战:基于向量存储的检索增强生成
导读:RAG(Retrieval-Augmented Generation,检索增强生成)是目前企业 AI 落地最成熟的技术路线。它解决了大模型最核心的两个痛点:知识截止日期和企业私有数据无法访问。本文从零构建完整 RAG 流水线,深入讲解检索策略、Prompt 注入机制与效果评估方法。
一、为什么 RAG 是企业 AI 的标配
直接和大模型对话有两个根本性的局限:
局限一:模型不知道你的业务知识
你的公司有几千份产品文档、几万条历史工单、一套独特的业务规范------这些内容不在模型的训练集里,模型无从回答。就算强行"喂"进去,也会遭遇上下文窗口限制和高昂的 Token 费用。
局限二:模型会产生幻觉
当模型不知道答案时,它倾向于"编造"一个听起来合理的回答,这在企业场景下是不可接受的风险。
RAG 的解法直白有效:先搜索,再回答。每次用户提问时,先从知识库里检索相关内容,把检索结果和问题一起送给模型,让模型"基于材料作答",大幅降低幻觉概率。
二、RAG 流水线全貌
【离线阶段:知识入库】
原始文档(PDF/Word/HTML)
|
Document Loader ← PdfPageReader / MarkdownPageReader
|
Text Splitter ← 分块(固定长度 / 语义分割)
|
EmbeddingModel ← text-embedding-v3(DashScope)
|
VectorStore ← Milvus / Redis / Elasticsearch
|
向量数据库(存储 Document + Embedding)
【在线阶段:检索回答】
用户提问
|
EmbeddingModel(将问题向量化)
|
VectorStore.similaritySearch(Top-K 相似文档)
|
QuestionAnswerAdvisor(拼装 Prompt)
|
ChatModel(基于检索内容生成回答)
|
最终回答返回给用户
三、环境准备与依赖
3.1 Maven 依赖
xml
<dependencies>
<!-- Spring AI Alibaba DashScope(含 Embedding 支持) -->
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-starter-dashscope</artifactId>
</dependency>
<!-- 向量存储:Redis Vector(本文示例使用 Redis) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-redis-store-spring-boot-autoconfigure</artifactId>
</dependency>
<!-- PDF 文档加载 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<!-- Spring Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
3.2 YAML 配置
yaml
spring:
ai:
dashscope:
api-key: ${AI_DASHSCOPE_API_KEY}
chat:
options:
model: qwen-plus
temperature: 0.3 # RAG 场景建议低温度,减少幻觉
max-tokens: 2048
# ① DashScope Embedding 模型配置
embedding:
options:
model: text-embedding-v3 # 1.1版新增的高性能 Embedding 模型
dimensions: 1024
# Redis Vector Store 配置
vectorstore:
redis:
uri: redis://localhost:6379
index: rag-knowledge-index
prefix: "doc:"
initialize-schema: true # 首次启动自动创建索引
redis:
host: localhost
port: 6379
四、知识入库:Document 处理流水线
4.1 文档加载器
java
package com.example.rag.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 知识入库 Service
* 负责文档加载、分块、Embedding、存储全流程
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KnowledgeIngestionService {
private final VectorStore vectorStore;
/**
* PDF 文档入库
*
* @param pdfResource PDF 文件资源
* @param metadata 元数据(如来源、分类、版本等)
*/
public void ingestPdf(Resource pdfResource, Map<String, Object> metadata) {
log.info("开始处理 PDF:{}", pdfResource.getFilename());
// 1. 加载 PDF(按页读取)
PdfDocumentReaderConfig config = PdfDocumentReaderConfig.builder()
.withPageExtractedTextFormatter(
// 配置文本提取格式:保留文档结构
PagePdfDocumentReader.PagesTextFormatter.of()
)
.withPagesPerDocument(1) // 每页作为一个 Document
.build();
PagePdfDocumentReader reader = new PagePdfDocumentReader(pdfResource, config);
List<Document> rawDocuments = reader.get();
log.info("PDF 共 {} 页,开始分块处理", rawDocuments.size());
// 2. 文本分块(Token 级别,含重叠窗口)
TokenTextSplitter splitter = new TokenTextSplitter(
512, // 每块最大 Token 数
128, // 重叠 Token 数(滑动窗口,保证上下文连续性)
5, // 最小块大小
10000, // 最大块大小
true // 保留分隔符
);
List<Document> chunks = splitter.apply(rawDocuments);
// 3. 注入元数据(文件名、时间等)
String fileName = pdfResource.getFilename();
chunks.forEach(doc -> {
Map<String, Object> docMetadata = new HashMap<>(metadata);
docMetadata.put("source", fileName);
docMetadata.put("ingestTime", System.currentTimeMillis());
doc.getMetadata().putAll(docMetadata);
});
// 4. 向量化并存储(VectorStore 内部调用 EmbeddingModel)
vectorStore.add(chunks);
log.info("成功入库 {} 个文本块,文件:{}", chunks.size(), fileName);
}
/**
* 纯文本入库(适合 FAQ、规则等结构化内容)
*/
public void ingestText(String content, String title, Map<String, Object> metadata) {
Document document = new Document(content, metadata);
document.getMetadata().put("title", title);
document.getMetadata().put("type", "text");
document.getMetadata().put("ingestTime", System.currentTimeMillis());
// 如果内容较长,同样需要分块
if (content.length() > 1000) {
TokenTextSplitter splitter = new TokenTextSplitter(512, 64, 5, 10000, true);
List<Document> chunks = splitter.apply(List.of(document));
vectorStore.add(chunks);
log.info("长文本已分为 {} 块入库,标题:{}", chunks.size(), title);
} else {
vectorStore.add(List.of(document));
log.info("短文本入库成功,标题:{}", title);
}
}
/**
* 批量删除(根据元数据条件)
*/
public void deleteBySource(String source) {
// 使用过滤表达式删除指定来源的所有文档
vectorStore.delete(List.of(source));
log.info("已删除来源为 '{}' 的所有文档", source);
}
}
4.2 入库 REST 接口
java
@RestController
@RequestMapping("/api/knowledge")
@RequiredArgsConstructor
public class KnowledgeController {
private final KnowledgeIngestionService ingestionService;
@PostMapping("/ingest/pdf")
public ResponseEntity<Map<String, Object>> ingestPdf(
@RequestParam("file") MultipartFile file,
@RequestParam(defaultValue = "general") String category,
@RequestParam(defaultValue = "") String tags) {
try {
Resource resource = file.getResource();
Map<String, Object> metadata = Map.of(
"category", category,
"tags", tags,
"uploadUser", "system"
);
ingestionService.ingestPdf(resource, metadata);
return ResponseEntity.ok(Map.of(
"status", "success",
"fileName", file.getOriginalFilename(),
"size", file.getSize()
));
} catch (Exception e) {
return ResponseEntity.internalServerError()
.body(Map.of("error", e.getMessage()));
}
}
}
五、检索问答:RAG 核心实现
5.1 使用 QuestionAnswerAdvisor 自动注入上下文
java
package com.example.rag.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
/**
* RAG 检索问答 Service
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class RagQueryService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
/**
* 基础 RAG 问答
* QuestionAnswerAdvisor 会自动:
* 1. 将问题向量化
* 2. 从 VectorStore 检索 TopK 相关文档
* 3. 将检索结果拼入 Prompt
*/
public String query(String question) {
log.info("RAG 查询:{}", question);
// 构建 QuestionAnswerAdvisor(核心 RAG 组件)
QuestionAnswerAdvisor ragAdvisor = new QuestionAnswerAdvisor(
vectorStore,
SearchRequest.defaults()
.withTopK(5) // 检索 Top-5 相关文档
.withSimilarityThreshold(0.7) // 相似度阈值,低于此值的不使用
);
return chatClient.prompt()
.system("""
你是一个企业知识库问答助手。请严格基于提供的参考资料回答问题。
如果参考资料中没有相关信息,请明确告知用户"知识库中暂无相关信息",
不要凭空编造答案。
""")
.user(question)
.advisors(ragAdvisor)
.call()
.content();
}
/**
* 带元数据过滤的 RAG 问答
* 只在特定分类的文档中检索
*/
public String queryWithFilter(String question, String category) {
SearchRequest searchRequest = SearchRequest.defaults()
.withTopK(5)
.withSimilarityThreshold(0.65)
// 元数据过滤:只检索指定分类的文档
.withFilterExpression("category == '" + category + "'");
QuestionAnswerAdvisor ragAdvisor = new QuestionAnswerAdvisor(
vectorStore, searchRequest);
return chatClient.prompt()
.user(question)
.advisors(ragAdvisor)
.call()
.content();
}
/**
* 流式 RAG 问答
*/
public Flux<String> streamQuery(String question) {
QuestionAnswerAdvisor ragAdvisor = new QuestionAnswerAdvisor(
vectorStore,
SearchRequest.defaults().withTopK(3));
return chatClient.prompt()
.user(question)
.advisors(ragAdvisor)
.stream()
.content();
}
}
5.2 检索策略对比
三种主要检索策略的适用场景:
+------------------+--------------------------------------------+------------------+
| 检索策略 | 原理 | 适用场景 |
+------------------+--------------------------------------------+------------------+
| Top-K 相似度 | 返回向量距离最近的 K 个文档 | 通用问答 |
| MMR 多样性算法 | 在相似度基础上,增加结果多样性(避免重复内容) | 多角度综合分析 |
| 混合检索 | 向量检索 + 关键词(BM25)检索结果融合 | 专业术语精准查询 |
+------------------+--------------------------------------------+------------------+
Top-K 检索(默认,最简单):
java
SearchRequest.defaults()
.withTopK(5)
.withSimilarityThreshold(0.7)
MMR 多样性检索(结果更丰富,避免检索到重复内容):
java
// Spring AI 的 VectorStore 部分实现支持 MMR
SearchRequest.builder()
.withTopK(10) // 先取 10 个候选
.withSimilarityThreshold(0.6)
// 通过 filterExpression 配合实现 MMR(具体实现依赖 VectorStore)
.build()
六、Prompt 组装与 Token 超限处理
6.1 QuestionAnswerAdvisor 的 Prompt 模板
QuestionAnswerAdvisor 使用的默认 Prompt 模板大致如下(可以自定义):
系统提示:{你设置的 defaultSystem}
参考资料(从知识库检索到的相关内容):
------
{document_1_content}
------
{document_2_content}
------
...
用户问题:{user_question}
请基于以上参考资料回答问题。
自定义 Prompt 模板:
java
// 自定义 QuestionAnswerAdvisor 的用户文本模板
String customUserTextAdvice = """
以下是从知识库中检索到的相关参考资料:
{question_answer_context}
---------------------
基于以上资料,请回答:{user_input}
注意事项:
1. 如果资料中有明确答案,直接引用
2. 如果资料不足以回答,请告知用户
3. 不要捏造资料中没有的信息
""";
QuestionAnswerAdvisor ragAdvisor = new QuestionAnswerAdvisor(
vectorStore,
SearchRequest.defaults().withTopK(5),
customUserTextAdvice // 传入自定义模板
);
6.2 Token 超限截断策略
当检索到的文档过多,拼接后的 Prompt 可能超过模型的上下文限制。处理策略:
java
@Component
public class TokenLimitHandler {
// qwen-plus 的上下文窗口为 131072 tokens,留出 2048 用于回答
private static final int MAX_CONTEXT_TOKENS = 120000;
/**
* 截断过长的检索文档列表
*/
public List<Document> truncateDocuments(List<Document> documents) {
List<Document> result = new ArrayList<>();
int totalTokens = 0;
for (Document doc : documents) {
// 粗估 Token 数(中文约 1.5字/token,英文约 4字/token)
int docTokens = estimateTokens(doc.getContent());
if (totalTokens + docTokens <= MAX_CONTEXT_TOKENS) {
result.add(doc);
totalTokens += docTokens;
} else {
log.warn("文档列表 Token 超限,已截断,保留 {} 个文档,约 {} tokens",
result.size(), totalTokens);
break;
}
}
return result;
}
private int estimateTokens(String text) {
if (text == null) return 0;
// 中英文混合粗估
long chineseCount = text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF).count();
long otherCount = text.length() - chineseCount;
return (int) (chineseCount / 1.5 + otherCount / 4);
}
}
七、效果评估指标
7.1 三个核心指标
1. 检索准确率(Retrieval Precision)
= 检索到的相关文档数 / 检索到的总文档数
含义:检索结果中有多少是真正有用的
2. 答案相关性(Answer Relevancy)
= 答案与问题的语义相似度
评估方式:用 EmbeddingModel 计算相似度,或用 LLM 作为评判者
3. 幻觉检测(Faithfulness)
= 答案中有多少内容有文档支撑
评估方式:让 LLM 判断答案的每句话是否能在参考资料中找到依据
7.2 简单评估实现
java
@Service
@RequiredArgsConstructor
public class RagEvaluationService {
private final ChatClient evaluatorClient;
private final EmbeddingModel embeddingModel;
/**
* 幻觉检测:让 LLM 判断答案是否忠于参考资料
*/
public double evaluateFaithfulness(String answer,
String context,
String question) {
String prompt = String.format("""
请判断以下 AI 回答是否完全基于提供的参考资料,没有捏造信息。
参考资料:
%s
问题:%s
AI 回答:
%s
请以 0-1 的数值评分(1=完全忠于资料,0=严重捏造):
只输出数字,不要解释。
""", context, question, answer);
String scoreStr = evaluatorClient.prompt(prompt).call().content().trim();
try {
return Double.parseDouble(scoreStr);
} catch (NumberFormatException e) {
log.warn("评分解析失败:{}", scoreStr);
return 0.5;
}
}
/**
* 答案相关性评估:基于向量相似度
*/
public double evaluateRelevancy(String question, String answer) {
float[] questionEmbedding = embeddingModel.embed(question);
float[] answerEmbedding = embeddingModel.embed(answer);
return cosineSimilarity(questionEmbedding, answerEmbedding);
}
private double cosineSimilarity(float[] a, float[] b) {
double dotProduct = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
}
八、DashScope text-embedding-v3 特性说明
Spring AI Alibaba 1.1 版新增了 text-embedding-v3 的专项支持,这个模型相比旧版有几个显著提升:
+------------------------+------------------+------------------+
| 特性 | text-embedding-v1 | text-embedding-v3 |
+------------------------+------------------+------------------+
| 向量维度 | 1536 | 1024(可调) |
| 支持语言 | 中英文 | 多语言(50+语种) |
| 最大 Token | 2048 | 8192 |
| 中文效果 | 良好 | 优秀 |
| 批量请求 | 支持 | 支持 |
+------------------------+------------------+------------------+
配置 text-embedding-v3:
yaml
spring:
ai:
dashscope:
embedding:
options:
model: text-embedding-v3
# 可选:指定输出维度(512/768/1024,维度越小,存储和计算越快)
dimensions: 1024
九、生产注意事项
1. 入库幂等性:同一文档多次入库会产生重复向量,需要基于文档 hash 做去重检查。
2. 增量更新:文档变更时,先删除旧向量(按元数据过滤),再重新入库。
3. 相似度阈值调优 :0.7 是经验值,实际需要根据业务语料测试。阈值过高则召回太少,过低则引入不相关内容。
4. 分块策略选择:
- 固定 512 Token 分块:简单通用,适合大多数场景;
- 按段落分块:适合结构化文档(技术文档、规范等);
- 语义分块:效果最好但实现复杂,适合对质量要求极高的场景。
十、总结
RAG 体系的核心四步:加载 → 分块 → 向量化 → 检索问答。本文的关键收获:
QuestionAnswerAdvisor是 Spring AI 中 RAG 的"一键开关",极大简化了实现复杂度;text-embedding-v3是 DashScope 目前综合效果最好的 Embedding 模型,1.1 版已完整支持;- 相似度阈值、Top-K 数量需要结合实际语料反复调优;
- 幻觉检测是 RAG 质量保障的关键一环,不能省略。
下一篇深入向量数据库的生产级配置:Milvus 集群部署、ES 向量字段优化,以及混合查询的实现细节。
参考资料