Spring AI + RAG 实战——Java 版检索增强生成的完整实现
Spring AI + RAG 实战——Java 版检索增强生成的完整实现
适读人群:Java 工程师、企业知识库建设者 | 阅读时长:约20分钟 | 核心价值:用 Spring AI 从零实现生产级 RAG 系统,含 Embedding、向量存储、检索全链路
去年帮一家制造企业做内部技术文档问答系统。他们有几千份设备维修手册,PDF 格式,总计 3.2GB。产品需求是:工人在设备旁边用手机提问,AI 给出维修步骤。
最开始我用最简单的方案:把所有 PDF 文本提取出来,全部塞进 System Prompt。结果一个文档就 8 万 tokens,超出了模型上下文限制。
后来改用 RAG,效果很好,平均检索时间 180ms,回答准确率达到 87%(专家评估)。这篇文章就复盘这个系统的完整实现。
RAG 的完整流程
RAG(检索增强生成)分两个阶段:
索引阶段(一次性,或增量更新): 文档 → 文本提取 → 文本分块(Chunking)→ Embedding 向量化 → 存入向量数据库
查询阶段(每次请求): 用户问题 → 问题 Embedding → 向量相似检索 → 取 Top-K 文档片段 → 组合 Prompt → 模型生成回答
依赖配置
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
</dependency>
<!-- 向量数据库:pgvector(PostgreSQL 插件)-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
<!-- PDF 解析 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<!-- Tika 文档解析(Word、Excel 等)-->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
</dependencies>spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
embedding:
options:
model: text-embedding-3-small # 1536 维,成本低
datasource:
url: jdbc:postgresql://localhost:5432/ragdb
username: postgres
password: ${DB_PASSWORD}
ai:
vectorstore:
pgvector:
initialize-schema: true # 自动建表
index-type: HNSW # 近似最近邻索引,生产推荐
distance-type: COSINE_DISTANCE
dimensions: 1536文档索引服务
@Service
@Slf4j
public class DocumentIndexService {
private final VectorStore vectorStore;
private final TokenTextSplitter textSplitter;
public DocumentIndexService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
// 配置分块策略:每块 512 tokens,重叠 50 tokens
this.textSplitter = new TokenTextSplitter(512, 50, 5, 10000, true);
}
/**
* 索引 PDF 文档
*/
public IndexResult indexPdf(String docId, String category, InputStream pdfStream) {
long startTime = System.currentTimeMillis();
// 1. 解析 PDF
PagePdfDocumentReader pdfReader = new PagePdfDocumentReader(
new InputStreamResource(pdfStream),
PdfDocumentReaderConfig.builder()
.withPageExtractedTextFormatter(
ExtractedTextFormatter.builder()
.withNumberOfBottomTextLinesToDelete(3) // 删除页脚
.withNumberOfTopPagesToSkipBeforeDelete(1)
.build()
)
.withPagesPerDocument(1) // 每页作为一个初始文档
.build()
);
List<Document> pages = pdfReader.get();
log.info("PDF 解析完成,共 {} 页", pages.size());
// 2. 分块
List<Document> chunks = textSplitter.apply(pages);
log.info("文档分块完成,共 {} 个 chunk", chunks.size());
// 3. 添加元数据(用于后续过滤)
chunks.forEach(chunk -> {
chunk.getMetadata().put("docId", docId);
chunk.getMetadata().put("category", category);
chunk.getMetadata().put("indexedAt", Instant.now().toString());
});
// 4. 向量化并存储(批量处理,每批 20 个,避免超过 API 限制)
List<List<Document>> batches = partition(chunks, 20);
for (List<Document> batch : batches) {
vectorStore.add(batch);
}
long elapsed = System.currentTimeMillis() - startTime;
log.info("索引完成,文档ID={}, chunks={}, 耗时={}ms", docId, chunks.size(), elapsed);
return new IndexResult(docId, chunks.size(), elapsed);
}
/**
* 删除文档索引(更新文档时先删后添)
*/
public void deleteDocument(String docId) {
vectorStore.delete(List.of(docId));
// pgvector 支持按元数据过滤删除
// 也可以用:vectorStore.delete(Filter.expression("docId == '" + docId + "'"));
}
private <T> List<List<T>> partition(List<T> list, int size) {
List<List<T>> partitions = new ArrayList<>();
for (int i = 0; i < list.size(); i += size) {
partitions.add(list.subList(i, Math.min(i + size, list.size())));
}
return partitions;
}
}RAG 查询服务
@Service
@Slf4j
public class RagQueryService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
public RagQueryService(ChatClient.Builder builder, VectorStore vectorStore) {
this.vectorStore = vectorStore;
this.chatClient = builder
.defaultSystem("""
你是一个专业的技术文档助手。
请基于提供的参考文档内容回答用户问题。
回答要求:
1. 只基于参考文档中的信息回答,不要添加文档中没有的内容
2. 如果文档中没有相关信息,明确告知用户
3. 引用具体的文档内容时,指出是哪份文档的哪个部分
4. 技术步骤要列清楚,便于操作
""")
.build();
}
public RagResponse query(String question, String category) {
long startTime = System.currentTimeMillis();
// 1. 检索相关文档(带元数据过滤)
SearchRequest searchRequest = SearchRequest.builder()
.query(question)
.topK(5) // 取最相关的 5 个 chunk
.similarityThreshold(0.7) // 相似度阈值,过低的不要
.filterExpression(category != null ? "category == '" + category + "'" : null)
.build();
List<Document> relevantDocs = vectorStore.similaritySearch(searchRequest);
long retrievalTime = System.currentTimeMillis() - startTime;
log.info("检索完成,找到 {} 个相关文档,耗时 {}ms", relevantDocs.size(), retrievalTime);
if (relevantDocs.isEmpty()) {
return RagResponse.noContext("抱歉,知识库中没有找到与您问题相关的内容。");
}
// 2. 构建上下文
String context = buildContext(relevantDocs);
// 3. 调用模型生成回答
String answer = chatClient.prompt()
.system(s -> s.param("context", context))
.user(u -> u.text("""
参考文档:
{context}
用户问题:{question}
""")
.param("context", context)
.param("question", question))
.call()
.content();
long totalTime = System.currentTimeMillis() - startTime;
return RagResponse.builder()
.answer(answer)
.sources(extractSources(relevantDocs))
.retrievalTimeMs(retrievalTime)
.totalTimeMs(totalTime)
.relevantChunkCount(relevantDocs.size())
.build();
}
private String buildContext(List<Document> docs) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < docs.size(); i++) {
Document doc = docs.get(i);
sb.append("--- 文档片段 ").append(i + 1).append(" ---\n");
sb.append("来源:").append(doc.getMetadata().get("docId")).append("\n");
sb.append(doc.getText()).append("\n\n");
}
return sb.toString();
}
private List<SourceReference> extractSources(List<Document> docs) {
return docs.stream()
.map(doc -> new SourceReference(
(String) doc.getMetadata().get("docId"),
(String) doc.getMetadata().get("category")
))
.distinct()
.collect(toList());
}
}Spring AI 内置的 QuestionAnswerAdvisor
Spring AI 提供了开箱即用的 RAG Advisor,可以更简洁:
@Bean
public ChatClient ragChatClient(
ChatClient.Builder builder,
VectorStore vectorStore) {
return builder
.defaultSystem("你是专业技术文档助手,基于知识库内容回答问题。")
.defaultAdvisors(
new QuestionAnswerAdvisor(
vectorStore,
SearchRequest.builder()
.topK(5)
.similarityThreshold(0.7)
.build()
)
)
.build();
}这个 Advisor 自动完成检索 → 注入上下文 → 生成回答的全流程。适合简单场景,复杂场景还是建议自己实现,方便定制。
踩坑实录
坑一:Embedding 维度不匹配
现象:换了 Embedding 模型之后,查询报错 vector dimension mismatch: expected 1536, got 3072。
原因:旧数据用 text-embedding-3-small(1536维)建索引,换了 text-embedding-3-large(3072维)后维度不一致。
解法:换模型时必须重新建表、重新索引所有文档。在 application.yml 里的 dimensions 和实际模型维度要一致。最好把 Embedding 模型版本写进文档元数据,方便后续追踪。
spring:
ai:
vectorstore:
pgvector:
dimensions: 1536 # 一定要和 embedding 模型维度一致坑二:检索质量差——找不到明明存在的内容
现象:用户问"电机过热怎么处理",知识库里明明有相关章节,但检索结果里没有。
原因:分块策略太激进,把一段完整的故障处理步骤切断了,切断后每个 chunk 单独看语义不完整,导致相似度得分低。
解法:
- 增大 chunk 的 overlap(重叠部分),比如从 50 tokens 提高到 100 tokens
- 对于有明显结构的技术文档,按段落/小节切割而不是按固定 token 数
- 补充 parent-child 分块策略:存储时用小块,但检索时返回父块(更大的上下文)
// 调整后的分块策略
this.textSplitter = new TokenTextSplitter(
800, // 每块 800 tokens(适当增大)
150, // 重叠 150 tokens(增大重叠)
5, // 最小 chunk 大小
10000,
true
);坑三:中文检索效果差
现象:中文问题的检索效果明显比英文差,同样的内容中文提问召回率只有 60% 左右。
原因:OpenAI 的 text-embedding-3-small 对中文支持弱于英文,中文字符 token 化效率低,同等 token 数下包含的语义信息更少。
解法:换用对中文优化的 Embedding 模型。国产模型里智谱 AI 的 embedding-3 对中文效果更好,或者用百度的 ernie-text-embedding。
// 切换到智谱 AI 的 Embedding
@Bean
public EmbeddingModel embeddingModel() {
return new ZhipuAiEmbeddingModel(
new ZhipuAiApi(System.getenv("ZHIPU_API_KEY")),
MetadataMode.EMBED,
ZhipuAiEmbeddingOptions.builder()
.model("embedding-3")
.build()
);
}生产优化清单
几个影响较大的优化点:
1. 增量索引而不是全量重建
public void indexOrUpdateDocument(String docId, InputStream stream) {
// 先删除旧索引
vectorStore.delete(List.of(docId));
// 再添加新索引
indexPdf(docId, stream);
}2. 查询缓存(高频问题直接命中缓存)
@Cacheable(value = "ragCache", key = "#question.hashCode()",
unless = "#result.relevantChunkCount == 0")
public RagResponse query(String question, String category) {
// ...
}3. 混合检索(向量检索 + 关键词检索)
纯向量检索有时会漏掉精确关键词匹配的内容(比如产品型号、错误码)。生产环境建议混合检索:
- 向量检索:语义相似
- BM25 全文检索:关键词精确匹配
- 结果 RRF 融合排序
总结
Spring AI 的 RAG 实现相对完整,能覆盖大多数企业知识库场景。核心要点:
- 分块策略是质量关键:太小丢语义,太大超 context,overlap 要够
- Embedding 模型选对:中文场景用中文优化模型
- 相似度阈值别太低:低质量检索结果比没有还差(会误导模型)
- 元数据设计要考虑过滤需求:分类、部门、权限等
下一篇讲 LangChain4j,另一个 Java AI 框架,和 Spring AI 各有优劣,看完再选。
企业知识库的完整架构设计
上面讲了 RAG 的核心实现,但一个真正可用的企业知识库还需要很多配套工程。让我把完整的架构说一遍。
文档管理层:
文档不是一次性索引就不管了,需要持续维护。一个完整的文档管理系统需要支持: 文档的上传、格式转换(PDF/Word/Excel 统一处理)、质量检查(扫描件清晰度、是否加密)、索引状态追踪(成功/失败/待更新)、版本管理(文档更新时如何处理旧索引)。
你需要一张文档管理表记录所有文档的状态:
@Entity
public class KnowledgeDocument {
private String id;
private String title;
private String category;
private String filePath;
private String indexStatus; // PENDING / INDEXING / INDEXED / FAILED
private int chunkCount;
private LocalDateTime indexedAt;
private String indexedBy;
private String version;
private boolean isLatest; // 同一文档允许有多个版本,只有最新版生效
}查询路由层:
不是所有问题都适合用向量检索。结构化的查询("上个月的销售额是多少")应该转到数据库查询,而不是向量检索;程序相关的问题("这个API怎么调")应该去代码文档里找,而不是业务文档。
可以加一个意图分类器,根据问题类型路由到不同的处理链路:
public QueryResult query(String question) {
// 先分类问题类型
QuestionType type = classifyQuestion(question);
return switch (type) {
case BUSINESS_POLICY -> ragQuery(question, "business_docs");
case TECHNICAL_API -> ragQuery(question, "api_docs");
case DATA_QUERY -> dataQuery(question); // 转数据库查询
case GENERAL -> ragQuery(question, null); // 全库搜索
};
}权限控制层:
企业知识库里有不同密级的文档。HR 的薪酬文档不能被所有人搜到;法务的合同模板只有法务部门能看。
在元数据里加权限标签,在检索时加过滤:
SearchRequest searchRequest = SearchRequest.builder()
.query(question)
.topK(5)
.filterExpression(
// 只搜索用户有权限的文档
"accessLevel in " + userAccessLevels + " AND " +
"(department == 'PUBLIC' OR department == '" + userDepartment + "')"
)
.build();这一层非常重要,漏了会出数据泄露的安全事故。
反馈与迭代层:
没有反馈机制的知识库是没有生命力的。当用户认为回答不正确时,应该能标记,并且这些标记要被收集起来用于改进索引质量。
建立一个简单的反馈表,记录:问题、AI回答、用户评价(好/坏)、用户补充的正确答案(可选)。
定期分析差评,找到知识库里缺失的内容或者质量差的文档,针对性地补充和优化。
RAG vs 微调:什么时候该选哪个
这是很多人纠结的问题,简单说一下我的判断。
选 RAG 的场景: 知识是动态变化的(政策文件经常更新、产品信息随时变); 知识量很大(几千到几万篇文档); 需要溯源(每个回答要能指向原始文档); 预算有限,实施周期要短。
选微调的场景: 你需要模型掌握一种特定的输出格式或风格(比如按公司固定格式写报告); 知识是相对稳定的(比如公司内部的技术规范,一年才更新一次); 有高质量的训练数据(至少几百条高质量的问答对); 对推理延迟有极高要求(微调后的模型不需要检索步骤,延迟更低)。
大多数企业场景,RAG 是更务实的选择。微调的门槛更高,维护成本也更高,除非有明确的需求,不建议一上来就搞微调。
