前言
在大语言模型(LLM)落地的真实业务里,我们很快会撞见一堵墙:模型只认识自己训练时见过的知识。它不知道你公司内部的接口文档、不知道上周刚发布的产品手册、更不知道用户私域数据里的任何一条记录。官方文档对 RAG(Retrieval Augmented Generation,检索增强生成)的定位非常直白——这是一种用来克服大模型在长文本处理、事实准确性、上下文感知方面局限的技术。
Spring AI 对 RAG 的支持方式很「Spring」:它既不替你包办一切,也不把你丢给一堆底层 SDK,而是提供一套模块化架构——你可以用 Advisor API 直接拿官方开箱即用的 RAG 流程,也可以像拼乐高一样,把查询改写、检索、后处理、查询增强等模块自由组合成自己的流程。同时,配合 ETL Pipeline 与 VectorStore 抽象,从 PDF、Word、JSON 等原始文档到最终塞进 Prompt 的上下文,整条链路都有标准接口可循。
本文是官方文档的带读笔记,逐章精读 Spring AI 2.0.x 参考文档中 Retrieval Augmented Generation、ETL Pipeline、Vector Databases 三章的内容,配合 PgVector 与 Redis 两个具体实现的文档,把「数据怎么进来、怎么切块、怎么存、怎么检索、怎么接进对话」这条主线完整走一遍。文中所有 API 名称、配置项、默认值均以当前版本官方文档为准。
整体架构与核心概念
在动手之前,先把三张拼图摆在桌面上。第一张图是 Spring AI 官方 ETL 文档里的一句话:ETL 框架是 RAG 用例中数据处理的骨架,它编排了从原始数据源到结构化向量库的流转,确保数据以最适合 AI 模型检索的格式存在。第二张图来自向量库文档:向量库查询与传统关系库不同,它不做精确匹配,而是做相似度检索——给定一个查询向量,返回与之「相似」的向量。第三张图来自 RAG 文档:Spring AI 受论文《Modular RAG: Transforming RAG Systems into LEGO-like Reconfigurable Frameworks》启发,把 RAG 拆成了可自由重排的模块。
把三者叠在一起,整条链路如下:
+-------------------+ +-------------------+ +-------------------+
| 原始数据源 | | 元数据 (metadata) | | EmbeddingModel |
| PDF/Word/JSON/ | | filename/group/ | | (text -> float[])|
| HTML/MD/TXT | | version/country | +-------------------+
+---------+---------+ +-------------------+ |
| v
v 向量 + 元数据
+-------------------+ +-------------------+
| DocumentReader | 读取为 Document | VectorStore |
| PagePdf/Json/ |------------------------>| (PgVector/Redis/ |
| Text/Tika/... | | ES/Qdrant/...) |
+-------------------+ +---------+---------+
v ^
+-------------------+ 切块 | similaritySearch
| DocumentTransformer| TokenTextSplitter 等 | (topK/threshold/
| (TextSplitter/ |-------------------------------+ | filterExpression)
| Keyword/Summary | |
| MetadataEnricher) | v
+-------------------+ +-------------------+
| DocumentRetriever |
+-------------------+ 查询改写/扩展 | VectorStoreDocument-|
| QueryTransformer |-------------------------->| Retriever |
| /QueryExpander | +-------------------+
+-------------------+ |
+-------------------+ 查询增强 + 生成 v
| RetrievalAugment- | ContextualQueryAugmenter +-----------+
| ationAdvisor / |---------------------------> | ChatModel |
| QuestionAnswerAd- | +-----------+
| visor |
+-------------------+
这条链路的核心角色可以归纳为四类:
| 角色 | 对应接口/类 | 职责 |
|---|---|---|
| 数据装载 | DocumentReader / DocumentTransformer / DocumentWriter | ETL:读取、转换(切块、富化)、写入 |
| 存储与检索 | VectorStore / VectorStoreRetriever | 写入向量、按相似度检索 |
| 检索编排 | DocumentRetriever / DocumentJoiner / DocumentPostProcessor | 取回、合并去重、后处理 |
| 生成编排 | Advisor(QuestionAnswerAdvisor、RetrievalAugmentationAdvisor) | 把检索结果拼进 Prompt 交给模型 |
你现在应该掌握了什么:RAG 不是「一个类」而是一整条流水线,Spring AI 用 ETL Pipeline 负责数据侧、VectorStore 负责存储侧、Advisor/Modular RAG 模块负责检索生成侧,三部分解耦又可组合。
RAG 的动机与检索增强全链路
先看官方对 RAG 动机的原文表述:大语言模型在长文本、事实准确性、上下文感知上有固有局限,RAG 通过在生成前先从外部知识库检索相关文档、并把检索结果作为上下文提供给模型,来缓解这些问题。向量库文档则把使用流程拆成两步:第一步是把你的数据加载进向量库;第二步是当用户提问时,先检索出一组相似文档,把它们和用户问题一起发给 AI 模型——这个技巧就叫做 RAG。
这两个描述放在一起读,就能理解 Spring AI 设计的用意:「数据进库」是一次性的离线工作,对应 ETL Pipeline;「检索增强」是每次请求都在线的实时工作,对应 Advisor 链路。两者唯一的交汇点是 VectorStore 接口——写入方是 DocumentWriter,读取方是 similaritySearch。正因如此,你在开发时可以把索引构建做成一个独立的批处理 Job,把问答做成一个独立的在线服务,二者只靠向量库表结构(schema)约定耦合。
一个值得注意的细节是职责边界:官方文档明确写道,向量库的职责是存储这些嵌入向量并支持相似度检索,它本身不生成嵌入;生成嵌入应当使用 EmbeddingModel。换句话说,向量库里存什么维度的向量、用什么模型生成,是 ETL 阶段决定的,检索阶段只消费结果。如果你在 ETL 阶段用模型 A 生成向量、在线上检索时用模型 B 给查询做嵌入,两者维度或语义空间不一致,检索质量会直接崩塌——这是新手最容易忽视的坑,后面「踩坑经验」会展开。
你现在应该掌握了什么:RAG 全链路被 Spring AI 拆成离线索引构建(ETL)与在线检索生成(Advisor)两个阶段,二者通过 VectorStore 解耦;向量库不生产嵌入,EmbeddingModel 才是嵌入的唯一来源。
ETL Pipeline:从原始文档到向量库
三个函数式接口
官方文档对 ETL 的定义极其简洁:ETL Pipeline 创建、转换并存储 Document 实例。它由三个组件构成——
- DocumentReader 实现
Supplier<List<Document>> - DocumentTransformer 实现
Function<List<Document>, List<Document>> - DocumentWriter 实现
Consumer<List<Document>>
看到这三个 JDK 标准函数式接口,熟悉 Spring 生态的读者会会心一笑:这意味着任何一个环节都可以用一个 lambda 替换掉,整条流水线天然可以链式组合。官方给出的最简 ETL 代码只有一行核心逻辑:
vectorStore.accept(tokenTextSplitter.apply(pdfReader.get()));
这行代码把 PDF 按页读成 Document、按 token 切成块、再写入向量库三件事串了起来。新版 API 中 reader.read()、transformer.transform()、writer.write() 这些语义化默认方法与 get()/apply()/accept() 等价,两种写法官方文档均给出了示例。
读取端:DocumentReader 家族
Reader 的实现覆盖了主流数据源。JsonReader 把 JSON 文档转成 Document 列表,构造时通过 jsonKeysToUse 指定哪些 JSON 键的值应成为 Document 的文本内容,还可以传入可选的 JsonMetadataGenerator 为每个 Document 生成元数据;它底层用 Jackson 解析,2.x 版本还支持用 JSON Pointer 提取嵌套结构——例如 jsonReader.get("/store/books/0") 可以只读取数组里某个位置的元素。TextReader 读纯文本,整个文件内容进入单个 Document,文件名等元数据会自动附加,同时支持通过 getCustomMetadata() 追加自定义元数据。JsoupDocumentReader 基于 JSoup 解析 HTML,MarkdownDocumentReader 解析 Markdown——后者会把标题变成元数据、段落变成 Document 内容,还可以配置水平分割线是否切分新 Document、代码块和引用块是否独立成文。
PDF 有两条路:PagePdfDocumentReader 基于 Apache PdfBox 按页解析;ParagraphPdfDocumentReader 则利用 PDF 的目录(TOC)信息按段落切分,每个段落输出一个 Document——但官方特别提醒,并非所有 PDF 都包含目录信息。如果你的数据源是 DOCX、PPTX、HTML 等各种格式的杂烩,TikaDocumentReader 用 Apache Tika 统一抽取文本,一个依赖覆盖几十种格式。相关 Maven 依赖是:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
</dependency>
PDF 场景则对应 spring-ai-pdf-document-reader。
转换端:切块与元数据富化
TextSplitter 抽象类的使命是把文档切到能放进模型上下文窗口的大小。主力实现是 TokenTextSplitter:它把文本编码成 token 再按 token 数切块,默认编码类型是 CL100K_BASE(来自 jtokkit 库,兼容新版 OpenAI 模型)。它的行为值得逐条细读:只有当 token 总数超过 chunkSize 时,才会尝试在 minChunkSizeChars 之后寻找标点符号断点截断;原始文档的元数据会被复制到所有派生块上;小于 minChunkLengthToEmbed 的碎块会被丢弃。
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(1000) // 每个 chunk 的目标 token 数,默认 800
.withMinChunkSizeChars(400) // 字符级下限,默认 350
.withMinChunkLengthToEmbed(10) // 低于该长度不嵌入,默认 5
.withMaxNumChunks(5000) // 最大块数,默认 10000
.withKeepSeparator(true) // 是否保留换行等分隔符
.build();
List<Document> splitDocuments = splitter.apply(documents);
对中文内容有个关键配置:withPunctuationMarks(List.of('。', '?', '!', ';')) 可以把切句标点换成中文标点,否则默认的 . , ? ! \n 对中文文本几乎起不到断句作用。官方还提醒标点列表建议保持在 20 个字符以内,因为每个断点都要逐个检查。
切块之外,官方还提供了两个基于生成式模型的元数据富化器。KeywordMetadataEnricher 调用 ChatModel 为每个文档提取关键词,写入 excerpt_keywords 元数据键;SummaryMetadataEnricher 生成摘要,支持 PREVIOUS、CURRENT、NEXT 三种 SummaryType,即除了当前块的摘要 section_summary,还能写入前一块的 prev_section_summary 和后一块的 next_section_summary,让相邻块之间保留上下文线索——对缓解「块间语义断裂」非常实用。
写入端与批量策略
写入端最常见的就是 VectorStore 本身(它实现了 DocumentWriter);FileDocumentWriter 则可以把处理后的 Document 落盘成文本文件,便于调试 ETL 中间产物。
批量策略是 2.x 文档着重展开的话题。官方文档解释了为什么需要它:嵌入模型按 token 处理文本,有最大上下文窗口限制,一次调用嵌入过多 token 会报错或得到被截断的嵌入。Spring AI 通过 BatchingStrategy 接口解决——List<List<Document>> batch(List<Document> documents) 把大批文档按 token 数拆成不超过上限的子批。默认实现 TokenCountBatchingStrategy 用 OpenAI 的 8191 作为默认上限,再留 10% 的 reservePercentage 缓冲,实际上限计算公式为 actualMaxInputTokenCount = originalMaxInputTokenCount * (1 - RESERVE_PERCENTAGE)。单个文档超过上限会直接抛异常。可以自定义:
@Bean
public BatchingStrategy customTokenCountBatchingStrategy() {
return new TokenCountBatchingStrategy(
EncodingType.CL100K_BASE, // 编码类型
8000, // 最大输入 token 数
0.1 // 预留比例
);
}
这个自定义 Bean 一旦声明,就会自动替换掉所有 EmbeddingModel 实现使用的默认策略。另一个进阶场景是 auto-truncation:像 Vertex AI 这类模型支持超长输入自动截断,此时应把 batching 策略的上限调成模型实际上限的 5 到 10 倍(文档示例中 Vertex AI 实际支持 20000,配置为 132900),让截断交给模型处理而非在策略层抛异常——但官方也警告,静默截断可能丢掉长文档末尾的关键信息。
你现在应该掌握了什么:ETL 由 Reader→Transformer→Writer 三个函数式接口串成;TokenTextSplitter 按 token 切块且元数据随块复制;中文场景要配中文标点;大批写入受嵌入模型上下文限制,由 TokenCountBatchingStrategy 按 8191×90% 的默认上限自动分批,也可自定义或配合模型自动截断策略使用。
Document 与元数据
Document 是整个体系的「通用货币」。按官方定义,Document 封装了来自数据源(PDF、Word 等)的内容,文本以 String 表示,同时携带键值对形式的元数据(metadata),内容还包含文件名等细节信息;ETL 文档进一步补充说它还可以选择性地携带图像、音频、视频等媒体类型。插入向量库时,文本内容通过嵌入模型转换成 float[] 向量,而元数据原样存储——这为后文的过滤表达式打下了基础。
构造 Document 并附加元数据是最基本的操作,几乎处处都在出现:
Document document = new Document(
"AI and Machine Learning Best Practices",
Map.of(
"docId", "AIML-001",
"version", "1.0",
"lastUpdated", "2024-01-01"
)
);
vectorStore.add(List.of(document));
元数据的价值在官方文档中被反复强调,最重要的场景是「共享索引的分区」。实践中一个向量索引往往被多个逻辑组共享——多个客户、多个项目或多个知识库共用一个 index。官方给出的规范做法是:写入时为每个 Document 打上标识所属组的元数据键,检索和删除时始终带上匹配的过滤器:
// 写入时记录所属组
Document document = new Document(content, Map.of("group", groupId));
vectorStore.add(List.of(document));
// 检索时只选该组
List<Document> results = vectorStore.similaritySearch(SearchRequest.builder()
.query(query)
.filterExpression("group == '" + groupId + "'")
.build());
// 删除时同样按组过滤
Filter.Expression scoped = new FilterExpressionBuilder().eq("group", groupId).build();
vectorStore.delete(scoped);
配套的删除 API 也值得记牢:VectorStore 提供三种删除方式——按 ID 列表 delete(List<String> idList)(不存在的 ID 会被忽略)、按 Filter 表达式对象 delete(Filter.Expression)、按字符串表达式 delete(String filterExpression)。官方还给出了「文档版本管理」的典型用例:上传新版本的文档时,先用 docId == 'AIML-001' AND version == '1.0' 这样的过滤器删掉旧版本,再写入新版本。性能上官方提示:按 ID 删通常最快,过滤器删除可能需要扫描索引,大批量删除应分批进行。
你现在应该掌握了什么:Document = 文本内容 + 键值对元数据;元数据不是附属品而是分区、过滤、版本管理的唯一依据;共享索引场景下「写入打标、检索删除必带过滤」是必须遵守的纪律;删除有按 ID、按表达式对象、按字符串三种入口。
向量库抽象与 PgVector/Redis 实现选择
接口层:VectorStore 与 VectorStoreRetriever
向量库文档的核心是两个接口。VectorStoreRetriever 是只读接口,只暴露 similaritySearch:
@FunctionalInterface
public interface VectorStoreRetriever {
List<Document> similaritySearch(SearchRequest request);
default List<Document> similaritySearch(String query) {
return this.similaritySearch(SearchRequest.builder().query(query).build());
}
}
VectorStore 继承 DocumentWriter 和 VectorStoreRetriever,补充了写能力:
public interface VectorStore extends DocumentWriter, VectorStoreRetriever {
default String getName() { return this.getClass().getSimpleName(); }
void add(List<Document> documents);
void delete(List<String> idList);
void delete(Filter.Expression filterExpression);
default void delete(String filterExpression) { ... }
default <T> Optional<T> getNativeClient() { return Optional.empty(); }
}
官方推荐的用法是按「最小权限原则」拆分依赖:索引构建服务注入完整的 VectorStore,而在线检索服务只注入 VectorStoreRetriever——文档原话是这遵循接口隔离原则,只读的客户端不再被暴露任何变更方法。这对团队代码评审是个很实用的约束。
Schema 初始化是一个容易漏掉的开关:部分向量库要求后端 schema 先初始化才能用,而默认不会帮你初始化,必须显式 opt-in——要么传构造参数,要么在 application.properties / application.yml 里把对应实现的 initialize-schema 属性设为 true。具体属性名要查所用实现的文档。
实现清单方面,官方列出的可用实现包括 PgVector(PostgreSQL)、Redis、Elasticsearch、OpenSearch、Qdrant、Milvus、MongoDB Atlas、Neo4j、Cassandra、Chroma、Pinecone、Weaviate、Oracle、MariaDB、Typesense、GemFire、S3 等近二十种,外加一个 SimpleVectorStore——官方明确声明它仅供测试和演示,不能用于生产。
PgVector:业务库顺手变向量库
PgVector 实现把向量存在 PostgreSQL 里。自建 schema 时需要先装扩展并建索引:
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS hstore;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE INDEX ON vector_store USING HNSW (embedding vector_cosine_ops);
官方提醒:HNSW 索引最多支持 2000 维,如果你的嵌入维度超过这个数(比如 3072 维的模型)需要特别留意;建索引时示例里的 1536 要替换成实际维度。Spring Boot 配置项如下:
spring:
ai:
vectorstore:
pgvector:
index-type: HNSW
distance-type: COSINE_DISTANCE
dimensions: 1536
initialize-schema: true
table-name: vector_store
schema-validation: false
max-document-batch-size: 10000
这里 dimensions 有个细节:不指定时 PgVectorStore 会从注入的 EmbeddingModel 获取维度;维度是在建表时写入 embedding 列的,改维度必须重建 vector_store 表。index-type 与 distance-type 均有默认值(HNSW、COSINE_DISTANCE)。用编程式构造时对应 PgVectorStore.builder(jdbcTemplate, embeddingModel).dimensions(1536).distanceType(COSINE_DISTANCE).indexType(HNSW).build()。如果自定义了 schema 名或表名,官方建议打开 schema-validation=true 做校验。
Redis:不只是缓存
Redis 实现的配置前缀是 spring.ai.vectorstore.redis.*,主要属性包括:
spring:
ai:
vectorstore:
redis:
initialize-schema: true
index-name: custom-index # 默认 spring-ai-index
prefix: custom-prefix # key 前缀,默认 embedding:
distance-metric: COSINE
vector-algorithm: HNSW
hnsw-m: 16 # HNSW 图参数
hnsw-ef-construction: 200
hnsw-ef-runtime: 200
对应 builder 写法为 RedisVectorStore.builder(redisConnectionFactory, embeddingModel).indexName("custom-index").prefix("custom-prefix").initializeSchema(true).build()。Redis 文档还介绍了它的独有特性 Semantic Cache——用向量相似度做查询结果的语义级缓存,配置前缀是 spring.ai.vectorstore.redis.semantic-cache.*,对重复度高的问答场景能显著省 token。
两者如何选?从官方文档能提炼出这样的对比:
| 维度 | PgVector | Redis |
|---|---|---|
| 基础设施 | 复用已有 PostgreSQL,无新组件 | 需要 Redis Stack(支持向量索引的 Redis) |
| 数据形态 | 向量和业务数据同库,可 SQL JOIN | 向量以 key 形式存,prefix 隔离 |
| 索引参数 | index-type、distance-type、dimensions | HNSW 三参数(m / ef-construction / ef-runtime)可调 |
| 维度限制 | HNSW 索引最多 2000 维 | 以当前版本官方文档为准 |
| 独有特性 | schema-validation 校验 | Semantic Cache 语义缓存 |
官方文档没有给出严格的选型裁决,实际建议是:已有 PostgreSQL 运维能力、希望向量和业务事务数据放在一起的团队选 PgVector;已有 Redis 基础设施、追求低延迟检索或需要语义缓存的团队选 Redis。无论选哪个,initialize-schema 都是必须显式打开的开关,这一点各实现文档反复出现。
你现在应该掌握了什么:VectorStore/VectorStoreRetriever 读写分离的接口设计;近二十种实现中生产可用的常见选择是 PgVector 和 Redis;PgVector 注意 HNSW 2000 维上限和 dimensions 建表即定型,Redis 注意 prefix/index-name 与 HNSW 参数;两者都必须显式开启 initialize-schema。
相似度检索与过滤表达式
SearchRequest 三要素
相似度检索的入口是 similaritySearch,可微调参数有三个:k(返回的最大相似文档数,即 top K / KNN)、threshold(0 到 1 的相似度阈值,越接近 1 相似度越高,默认不过滤)、Filter.Expression(只作用于 Document 元数据的类 SQL where 子句)。SearchRequest 的默认值在源码常量里写得很清楚:DEFAULT_TOP_K = 4,SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0。
SearchRequest request = SearchRequest.builder()
.query(question)
.topK(5)
.similarityThreshold(0.7)
.build();
List<Document> documents = vectorStore.similaritySearch(request);
两种过滤表达式写法
官方提供了等价的两条路径。字符串表达式走的是基于 ANTLR4 的外部 DSL,直接写在 SearchRequest 或 delete 方法里:
"country == 'BG'"
"genre == 'drama' && year >= 2020"
"genre in ['comedy', 'documentary', 'drama']"
FilterExpressionBuilder 则是类型安全的流式 API,可用运算符包括 ==、!=、>、>=、<、<=,组合符 AND/OR(支持 &&/|| 写法),以及 IN、NIN、NOT、IS NULL、IS NOT NULL:
FilterExpressionBuilder b = new FilterExpressionBuilder();
Expression exp = b.and(
b.in("genre", "drama", "documentary"),
b.not(b.lt("year", 2020))
).build();
需要如实说明:官方文档明确标注 IS NULL 和 IS NOT NULL 尚未在所有向量库实现中落地,用之前要查目标实现的文档。此外过滤器只作用于元数据键值对——不能对向量内容本身做 where 过滤,这是理解过滤表达式适用边界的关键。
你现在应该掌握了什么:检索三参数 topK(默认 4)、threshold(默认 0 即全收)、filterExpression;过滤有字符串 DSL 和 builder 两种等价写法,支持丰富的比较与逻辑运算符,但只过滤元数据,且 NULL 系列运算符的实现覆盖不全。
检索质量调优
官方文档没有一章叫「调优」,但把散落在 TokenTextSplitter、SearchRequest、BatchingStrategy、VectorStoreDocumentRetriever 各处的参数串起来,就是一份完整的检索质量调优清单。
分片大小是第一杠杆。 chunkSize 默认 800 token。切太大,一个块里主题混杂,嵌入向量的语义被「平均化」,检索精度下降,还会加速吃满模型上下文;切太小,关键句被拦腰截断,且 TokenTextSplitter 只会丢弃低于 minChunkLengthToEmbed 的碎块。官方的分块行为说明还揭示了一个细节:标点断点只在 token 数超过 chunkSize 之后才生效,因此调 chunkSize 时也要联动考虑 minChunkSizeChars 与 punctuationMarks。对中文语料,punctuationMarks 配中文标点是必做项。进阶做法是用 SummaryMetadataEnricher 给每个块生成前后块摘要写入元数据,检索时可把摘要一并纳入匹配视野。
topK 与阈值要一起调。 topK 默认只有 4,知识库较大或问题较宽泛时应提高;similarityThreshold 默认 0 表示不过滤,设成 0.7~0.8 可以拦掉明显不相关的文档。但两者有张力:阈值定太高会召回不足,导致 RAG 链路拿到空上下文;QuestionAnswerAdvisor 和 RetrievalAugmentationAdvisor 的默认行为都是不允许空上下文,检索为空时直接指示模型不要回答用户问题——这在客服类场景是安全特性,在开放问答场景则可能需要显式放开。
嵌入一致性是隐藏前提。 如前文所述,ETL 写入和在线查询必须用同一个 EmbeddingModel(至少同维度、同语义空间)。Threshold 的语义也随模型而变:不同嵌入模型的相似度分布不同,0.7 在模型 A 上很严,在模型 B 上可能形同虚设,阈值要在真实语料上实测校准。
写入侧的参数影响召回。 max-document-batch-size(如 PgVector 的该属性)控制单次写入批大小;TokenCountBatchingStrategy 的 8191×(1-10%) 上限决定了极端长文会不会在写入时直接抛异常——如果业务文档普遍很长,应优先调整切块策略,而不是把 batching 上限一味调高。
下表汇总了主要调优参数:
| 参数 | 位置 | 默认值 | 调优方向 |
|---|---|---|---|
| chunkSize | TokenTextSplitter | 800 token | 知识密集文本适当调小,叙述性文本可调大 |
| punctuationMarks | TokenTextSplitter | . , ? ! \n |
中文语料换成 。 ? ! ; |
| topK | SearchRequest | 4 | 召回不足时增大 |
| similarityThreshold | SearchRequest | 0.0(全收) | 噪声多时上调至 0.7~0.8 |
| reservePercentage | TokenCountBatchingStrategy | 10% | 一般保持默认 |
| hnsw-ef-runtime | Redis | 以当前版本官方文档为准 | 检索精度不足时增大 |
你现在应该掌握了什么:检索质量由「切块质量 × 嵌入一致性 × topK/阈值平衡」三者共同决定;空上下文默认会导致拒答,要按场景决定是否 allowEmptyContext;阈值必须按所用嵌入模型实测校准,不存在通用最优值。
把 RAG 接进 Advisor
快速通道:QuestionAnswerAdvisor
最简单的方式是 QuestionAnswerAdvisor。官方文档的机制说明:向量库里存着模型不知道的数据,用户提问时 advisor 去向量库检索相关文档,把检索结果追加到用户文本里作为上下文。前提是数据已经加载进 VectorStore,然后一行 advisor 接入 ChatClient:
ChatResponse response = ChatClient.builder(chatModel)
.build().prompt()
.advisors(QuestionAnswerAdvisor.builder(vectorStore).build())
.user(userText)
.call()
.chatResponse();
需要引入 spring-ai-vector-store-advisor 依赖。它内部用 SearchRequest 做相似度检索,而 SearchRequest 支持跨所有 VectorStore 可移植的类 SQL 过滤表达式——既可以在创建 advisor 时固化(对所有请求生效),也可以运行时用 FILTER_EXPRESSION 上下文参数动态传入:
String content = this.chatClient.prompt()
.user("Please answer my question XYZ")
.advisors(a -> a.param(QuestionAnswerAdvisor.FILTER_EXPRESSION, "type == 'Spring'"))
.call()
.content();
默认的增强模板也可以替换:通过 builder 的 .promptTemplate() 传入自定义 PromptTemplate,模板必须包含两个占位符——接收用户问题的 query 占位符和接收检索上下文的 question_answer_context 占位符。官方示例还演示了用 StTemplateRenderer 把定界符换成 < 和 > 的用法,并提示旧的 userTextAdvise() 方法已废弃。值得注意的边界:这里配的 PromptTemplate 管的是 advisor 如何把检索上下文与用户查询合并,与 ChatClient 层面的模板渲染是两回事,别混淆。
模块化通道:RetrievalAugmentationAdvisor
更完整的方案是 spring-ai-rag 依赖提供的 RetrievalAugmentationAdvisor,它基于 Modular RAG 架构,把链路拆成检索前(Pre-Retrieval)、检索(Retrieval)、检索后(Post-Retrieval)、生成(Generation)四个阶段的模块,全部可插拔。
一个 Naive RAG 的最小配置:
Advisor retrievalAugmentationAdvisor = RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.similarityThreshold(0.50)
.vectorStore(vectorStore)
.build())
.build();
进阶 RAG 可以在检索前加查询变换。官方给出三种 QueryTransformer:CompressionQueryTransformer 把对话历史和追问压缩成独立查询,RewriteQueryTransformer 改写冗长含糊的原始查询,TranslationQueryTransformer 把查询翻译成嵌入模型支持的目标语言。文档特别提醒:给这些 transformer 用的 ChatClient.Builder 应配低 temperature(如 0.0),因为大多数模型的默认温度对查询改写这类任务偏高,会降低检索效果。查询扩展侧有 MultiQueryExpander,用 LLM 把一个查询扩展成多个语义多样的变体,默认包含原始查询,可用 includeOriginal(false) 关闭。
检索阶段用 VectorStoreDocumentRetriever,支持阈值、topK、过滤器,过滤器还可以传 Supplier 实现动态过滤(比如按当前租户注入 tenant == ... 的过滤条件)。检索后有 DocumentPostProcessor 接口,官方建议的典型用途是按与查询的相关性重排序、移除无关或冗余文档、压缩内容降噪。生成阶段的 ContextualQueryAugmenter 负责把文档内容拼进查询,默认空上下文时拒绝回答,可用 allowEmptyContext(true) 放开。
读端再收个尾:手动 RAG
如果不想用 advisor,只用 VectorStoreRetriever 也能手写 RAG——官方文档专门给了这个模式:检索出 Document 后取出 content 拼接成上下文字符串,与问题一起交给 ChatModel。这适合需要完全掌控 Prompt 结构的场景,代价是你要自己维护模板、空上下文策略和过滤逻辑。
你现在应该掌握了什么:两条接入路径——QuestionAnswerAdvisor 适合标准问答(记得依赖与两个模板占位符),RetrievalAugmentationAdvisor 适合需要查询改写、多路检索、重排序的复杂链路;FILTER_EXPRESSION 上下文参数可以在请求级动态覆盖过滤器;查询改写用的 ChatClient 要配低温。
踩坑经验
以下是结合官方文档容易踩的坑,按 ❌错误做法 / ✅正确做法 对照列出。
1. 依赖缺失导致编译都过不了
❌ 代码里直接用 QuestionAnswerAdvisor,却没加依赖,运行时或编译期才发现类不存在。
✅ 按官方文档,使用 QuestionAnswerAdvisor 或 VectorStoreChatMemoryAdvisor 需要 spring-ai-vector-store-advisor 依赖;使用 RetrievalAugmentationAdvisor 需要 spring-ai-rag 依赖:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-vector-store-advisor</artifactId>
</dependency>
2. 向量表不存在,启动即报错
❌ 配好了 PgVector/Redis 的连接信息,直接跑检索,发现表或索引根本没建。
✅ schema 不会自动初始化。官方文档反复强调必须 opt-in——PgVector 设 spring.ai.vectorstore.pgvector.initialize-schema=true,Redis 设 spring.ai.vectorstore.redis.initialize-schema=true;自建 schema 时则要手动执行 CREATE EXTENSION 与 CREATE INDEX。
3. 中文文档检索效果差,以为是向量库不行
❌ 用默认的 TokenTextSplitter 切中文文档,默认标点(. , ? ! \n)在中文里几乎不出现,切点完全失控。
✅ 用中文标点配置 punctuationMarks,并联动 chunkSize/minChunkSizeChars 实测调优:
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(800)
.withPunctuationMarks(List.of('。', '?', '!', ';'))
.build();
4. ETL 和在线用不同嵌入模型
❌ 离线索引用一个嵌入模型,在线服务注入另一个 EmbeddingModel(甚至只是版本不同),检索结果随机、阈值失灵。
✅ 保证两端模型与维度一致;阈值不照搬别人的数字,用自己的语料实测校准。记住向量库不生成嵌入,EmbeddingModel 才是唯一来源。
5. 更新文档导致新旧版本同时被召回
❌ 文档更新后直接 add 新版本,旧版本还留在索引里,问答时新旧知识混着被检索出来。
✅ 借鉴官方「文档版本管理」用例:写入新版本前先按 docId 和 version 过滤器删除旧版本:
vectorStore.delete("docId == 'AIML-001' AND version == '1.0'");
vectorStore.add(List.of(documentV2));
6. 空上下文被静默吞掉,排查半天
❌ 检索为空时模型表现得「拒答」或答非所问,以为是 Prompt 问题。
✅ 这是 QuestionAnswerAdvisor / ContextualQueryAugmenter 的默认安全行为——空上下文时指示模型不要回答。需要放开时显式配置 ContextualQueryAugmenter.builder().allowEmptyContext(true).build();反过来,客服等强事实场景则应保留默认行为。
7. 查无结果的删除不报错就当成功
❌ 以为 delete 会校验 ID 存在性,依赖它的返回值做幂等判断。
✅ 官方明确:按 ID 删除时,列表中不存在的 ID 会被静默忽略;过滤器删除也可能扫描索引。批量删除要分批,删除操作建议包在 try-catch 里并记录日志。
最佳实践清单
- 读写分离注入:索引 Job 注入 VectorStore,在线问答服务只注入 VectorStoreRetriever,用接口约束防止误写。
- 写入必打组标:共享索引场景,每个 Document 写入时带上 group/tenant 等元数据,检索与删除一律带同款过滤器。
- 切块先实测:用真实语料跑几轮 TokenTextSplitter,对中文配置中文标点;必要时用 SummaryMetadataEnricher 补前后块摘要。
- 嵌入模型单点配置:ETL 与在线共用同一个 EmbeddingModel Bean;阈值在真实数据上校准后再定。
- 显式开启 initialize-schema:开发环境交给自动初始化,生产环境评估后决定用自动初始化还是手工 DDL,并留意 PgVector 的 HNSW 2000 维上限。
- 检索参数成对调:topK 与 similarityThreshold 一起动;空上下文的拒答行为按业务显式选择。
- 大批量写入走默认 batching:一般无需干预 TokenCountBatchingStrategy;文档普遍超长时优先改切块,而非调高批上限。
- 简单场景用 QuestionAnswerAdvisor,复杂链路再升级:需要查询改写、多路检索、重排序时再引入 RetrievalAugmentationAdvisor,别让架构过度设计先行。
总结
- RAG 的本质是「先检索、后生成」:Spring AI 用 ETL Pipeline 管数据入库、VectorStore 管存储检索、Advisor/Modular RAG 模块管在线增强,三层解耦,各自可独立演进。
- Document(文本 + 元数据)是全链路的通用货币,元数据是分区、过滤、版本管理的唯一抓手;向量库只存和查,生成嵌入永远是 EmbeddingModel 的职责。
- 检索质量的核心杠杆在切块(TokenTextSplitter 的 chunkSize 与中文标点配置)、检索参数(topK 默认 4、threshold 默认 0)与嵌入一致性三者,且阈值没有通用最优解。
- 接入层从简到繁两条路:QuestionAnswerAdvisor 开箱即用(注意依赖与 query / question_answer_context 两个模板占位符),RetrievalAugmentationAdvisor 提供查询变换、检索、后处理、查询增强四阶段可插拔模块。
- 工程纪律:initialize-schema 必须显式开启、读写接口分离注入、更新文档先删旧版、删除操作包 try-catch、共享索引写入必打组标。
- 选择向量库实现时,PgVector 适合与业务数据同库的场景(注意维度上限),Redis 适合低延迟与语义缓存场景(注意 prefix 与 HNSW 参数),SimpleVectorStore 只配用于测试。
参考资料
- Spring AI Reference — Retrieval Augmented Generation (RAG):https://docs.spring.io/spring-ai/reference/api/retrieval-augmented-generation.html
- Spring AI Reference — Vector Databases:https://docs.spring.io/spring-ai/reference/api/vectordbs.html
- Spring AI Reference — ETL Pipeline:https://docs.spring.io/spring-ai/reference/api/etl-pipeline.html
- Spring AI Reference — PGvector Vector Store:https://docs.spring.io/spring-ai/reference/api/vectordbs/pgvector.html
- Spring AI Reference — Redis Vector Store:https://docs.spring.io/spring-ai/reference/api/vectordbs/redis.html
(文中 API 名称、默认值、配置项均以写作时当前版本官方文档为准;个别实现相关数值如未在当前文档中明确给出,已标注「以当前版本官方文档为准」。)