跳到主要内容

数据截至 (上游 commit c988e72ab728)

记忆、RAG 与向量存储:把企业数据接进模型

30 秒导读: 大模型是无状态的——它不记得你上一句说了什么,也不知道你公司内网的文档。想让它"记得"和"知道",唯一的办法是在每次请求前,把这些内容拼进 prompt。本章讲 Spring AI 怎么做这件拼装:ChatMemory 负责对话历史,spring-ai-rag 负责外部知识,VectorStore 那一层负责把知识变成可检索的向量。


1. 这是什么(零基础也能懂)

1.1 先看问题

大模型 API 是一次性的函数调用:你给它一段文本,它返回一段文本,调用结束后什么都不留。这带来两个缺口:

缺口表现补法
不记得上一轮你问"它多少钱?",模型不知道"它"指谁记忆:把历史消息重新塞回请求
不知道你的私有数据问"我们公司报销上限多少",模型胡编RAG:先去知识库检索,把命中的段落塞进 prompt

两件事的共同本质是一样的:往 prompt 里加料。区别只在料从哪来——记忆的料来自这个会话自己的历史,RAG 的料来自一个外部知识库。

1.2 一句话定义

  • ChatMemory:一个按 conversationId 存取消息列表的东西,外加一条"存多少、超了怎么砍"的策略。
  • RAG(Retrieval-Augmented Generation,检索增强生成):回答前先用用户的问题去检索一批相关文档,把文档正文写进 prompt,再让模型基于这些文档作答。
  • VectorStore(向量存储):RAG 的"检索"那一半——把文本切块、算成向量(embedding,一串浮点数,语义相近的文本向量也相近)存进库,查询时按向量距离找最像的几条。

1.3 用起来什么样

下面这段展示"加记忆"和"加 RAG"在 ChatClient 上分别是一行 advisor 的事(advisor 责任链本身见 Advisor 责任链):

// 示意,非源码
ChatClient client = ChatClient.builder(chatModel)
.defaultAdvisors(
// 记忆:自动带上这个会话最近 N 条消息
MessageChatMemoryAdvisor.builder(chatMemory).build(),
// RAG:先去向量库捞文档,再把文档写进用户消息
RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore).topK(5).build())
.build())
.build();

String answer = client.prompt()
.user("我们的报销上限是多少?")
// 会话身份:memory advisor 用它决定读写哪一条历史
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "user-42"))
.call().content();

重点看两处:记忆靠一个 conversationId 参数区分会话RAG 靠一个 DocumentRetriever 决定去哪捞数据。其余都是默认值。

1.4 心智模型

把上下文窗口当内存,把 ChatMemoryRepository 和 VectorStore 当磁盘

  • 记忆 = 每次请求前把磁盘上最近的那一页原样读回内存(消息还是消息)。
  • RAG = 每次请求前按语义检索磁盘,只把命中的几段读回内存(文档变成一段文本)。

2. 顶层全景(它大概怎么转)

怎么读这张图: 从左到右是一次请求的时间顺序。中间那一列是 advisor 的 before() 阶段——三条料(历史、检索到的文档、用户这句话)在这里汇成一个 prompt;返回时 after() 阶段再把新产生的助手消息写回去。

┌──────────────── before() 往 prompt 里加料 ─────────────┐
│ │
用户这句话 ─────────┼──► ① 记忆 advisor │
│ 读 ChatMemory.get(conversationId) │
│ → 历史消息拼在最前 │
│ │
│ ② RAG advisor ├──► ChatModel
│ 改写/扩展 query → 向量库检索 → 合并 → 后处理 │ │
│ → 文档正文写进用户消息 │ │
└────────────────────────────────────────────────────────┘ │

┌──────────────── after() 收尾 ──────────────────────────┐
│ 记忆 advisor:助手消息写回 ChatMemory │◄── 模型回复
│ RAG advisor:命中文档挂到 ChatResponse metadata │
└────────────────────────────────────────────────────────┘

三块的部件与所在模块:

部件干什么在哪个 Maven 模块
ChatMemory / MessageWindowChatMemory会话历史的读写与窗口裁剪spring-ai-model
ChatMemoryRepository 五种实现历史真正落到 JDBC / Cassandra / Mongo / Neo4j / Redismemory-repositories/
MessageChatMemoryAdvisor把历史作为消息插进 promptspring-ai-client-chat
VectorStoreChatMemoryAdvisor把历史存向量库、按相关性召回进 system 文本advisors/spring-ai-vector-store-advisor
RetrievalAugmentationAdvisor + 四个子包Modular RAG 的七步流水线spring-ai-rag
QuestionAnswerAdvisor轻量版 RAG(一次检索 + 模板)advisors/spring-ai-vector-store-advisor
Document / ETL / VectorStore / Filter DSL知识的入库、检索与可移植过滤spring-ai-commonsspring-ai-vector-store

3. 记忆:ChatMemory 这一层

3.1 接口小得只有四个方法

ChatMemory 是整个记忆体系的门面,接口里只有增、查、清三类操作,外加一个约定俗成的 context key:

String CONVERSATION_ID = "chat_memory_conversation_id";
void add(String conversationId, List<Message> messages);
List<Message> get(String conversationId);
void clear(String conversationId);

依据:spring-ai-model/src/main/java/org/springframework/ai/chat/memory/ChatMemory.java:31-61,常量 CONVERSATION_ID:36

CONVERSATION_ID 是一条跨层的暗线约定。 它不是方法参数,而是一个放在 advisor context 里的 key:调用方用 .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "user-42")) 塞进去,memory advisor 在 BaseChatMemoryAdvisor.getConversationId 里取出来并强制非空spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseChatMemoryAdvisor.java:38-43)。没设就抛 IllegalArgumentException——这是刻意的,避免所有用户的历史串成一条。

3.2 窗口裁剪:MessageWindowChatMemory.process 的三条规则

它要解决的小问题: 历史会无限增长,但上下文窗口是有限的,得砍。问题是砍哪一段

MessageWindowChatMemory 的默认窗口是 20 条消息(spring-ai-model/src/main/java/org/springframework/ai/chat/memory/MessageWindowChatMemory.java:45DEFAULT_MAX_MESSAGES),真正的算法在私有方法 process:81-130)里,一共三条规则:

规则一:SystemMessage 永不被裁剪。

裁剪时先把所有非 system 消息的下标收集成 nonSystemIndices:101-106),后面所有删除只在这个下标集合里挑。system 消息因此天然免疫。

规则二:新的 system 消息覆盖掉所有旧的 system 消息。

boolean hasNewSystemMessage = newMessages.stream()
.filter(SystemMessage.class::isInstance)
.anyMatch(message -> !memoryMessagesSet.contains(message));

依据:MessageWindowChatMemory.java:85-91。只要新来的消息里有一条内容不同于已存SystemMessage,就把历史里所有 SystemMessage 全部丢弃,只留新的。这样多轮之后不会堆出三四份互相打架的系统提示。

规则三:切口向前吸附到 USER 消息,不切断一个完整轮次。

先算出"按条数硬砍要删几条":

int cutIndex = processedMessages.size() - this.maxMessages;

依据:MessageWindowChatMemory.java:111。注意 cutIndex要删除的非 system 消息个数,同时也是"保留窗口在 nonSystemIndices 里的起点下标"。

然后往后推,直到起点落在一条 USER 消息上:

while (cutIndex < nonSystemIndices.size()
&& processedMessages.get(nonSystemIndices.get(cutIndex)).getMessageType() != MessageType.USER) {
cutIndex++;
}

依据:MessageWindowChatMemory.java:116-119

为什么要这么做? 一个轮次是 USER → ASSISTANT(tool call) → TOOL → ASSISTANT 这样一串。硬砍很可能把开头的 USER 砍掉、只留下一个孤零零的工具结果或助手回复,模型看到会莫名其妙,某些供应商甚至直接拒收(工具调用与结果必须配对,见 工具调用)。向前吸附保证保留下来的窗口一定从一次完整发问开始

图示(maxMessages = 4,S=system,U=user,A=assistant,T=tool):

原始: S U1 A1 U2 A2 T2 A2'
│ │ │ │ │ │ │
硬切 cut=3 ────────────►│ 起点落在 A2 —— 半个轮次,不行

向前吸附 ─────────────┘ 不是 USER,cutIndex++ …… 一直推到下一个 U
(本例中后面已无 USER,cutIndex 推到末尾)

结果: S (只剩 system,其余非 system 全删)

这条规则的代价(重要边界):吸附只会多删、不会少删,所以最终条数 ≤ maxMessages;极端情况下(切口之后再没有 USER 消息)cutIndex 会一路推到 nonSystemIndices.size()所有非 system 消息被清空,只剩 system 消息。窗口设得太小、又碰上长工具链时会撞上这个行为。

3.3 存储分层:ChatMemory 管策略,Repository 管落地

Spring AI 把"记忆策略"和"记忆存哪"拆成两个接口,MessageWindowChatMemory 自己不碰存储,全部委托给注入的 ChatMemoryRepositoryMessageWindowChatMemory.java:59-79)。

ChatMemory(策略层:窗口怎么裁)
│ MessageWindowChatMemory

ChatMemoryRepository(存储层:四个 CRUD)

┌──────┬───┴────┬──────────┬────────┬────────┐
▼ ▼ ▼ ▼ ▼ ▼
InMemory Jdbc Cassandra MongoDB Neo4j Redis
(默认) ×8 方言

ChatMemoryRepository 只有四个方法(spring-ai-model/src/main/java/org/springframework/ai/chat/memory/ChatMemoryRepository.java:29-41),关键在 saveAll 的语义:

Replaces all the existing messages for the given conversation IDChatMemoryRepository.java:35-39

也就是说它是整条会话覆盖写,不是追加写。这个设计让上层的窗口裁剪极其简单——MessageWindowChatMemory.add 就是"读全量 → 裁剪 → 整条写回"三步(:64-66);代价是每轮对话都要重写整条历史。

各实现的落地方式:

实现模块路径落地手法
InMemoryChatMemoryRepositoryspring-ai-model/src/main/java/org/springframework/ai/chat/memory/InMemoryChatMemoryRepository.java:33ConcurrentHashMap<String, List<Message>>,进程内、重启即失
JdbcChatMemoryRepositorymemory-repositories/spring-ai-model-chat-memory-repository-jdbc/src/main/java/org/springframework/ai/chat/memory/repository/jdbc/JdbcChatMemoryRepository.java:59事务里先删后 batchUpdate 插入
CassandraChatMemoryRepositorymemory-repositories/spring-ai-model-chat-memory-repository-cassandra/src/main/java/org/springframework/ai/chat/memory/repository/cassandra/CassandraChatMemoryRepository.java:54CQL
MongoChatMemoryRepositorymemory-repositories/spring-ai-model-chat-memory-repository-mongodb/src/main/java/org/springframework/ai/chat/memory/repository/mongo/MongoChatMemoryRepository.java:45文档集合
Neo4jChatMemoryRepositorymemory-repositories/spring-ai-model-chat-memory-repository-neo4j/src/main/java/org/springframework/ai/chat/memory/repository/neo4j/Neo4jChatMemoryRepository.java:53Cypher
RedisChatMemoryRepositorymemory-repositories/spring-ai-model-chat-memory-repository-redis/src/main/java/org/springframework/ai/chat/memory/repository/redis/RedisChatMemoryRepository.java:78另外实现了 AdvancedRedisChatMemoryRepository 扩展接口

JDBC 那支还多一层方言抽象JdbcChatMemoryRepositoryDialect 用四个 default 方法给出标准 SQL,八个子类(Postgres / MySQL / Oracle / SQL Server / SQLite / H2 / HSQLDB / MariaDB 系)按需覆盖,并能从 DataSource 自动嗅探(JdbcChatMemoryRepositoryDialect.java:31-66 的静态方法 from)。写入路径是"事务内先 deleteByConversationId 再批量插入"(JdbcChatMemoryRepository.java:122-124),正好兑现 saveAll 的覆盖语义。

Spring Boot 侧的默认装配是 InMemoryChatMemoryRepository + MessageWindowChatMemoryauto-configurations/models/chat/memory/spring-ai-autoconfigure-model-chat-memory/src/main/java/org/springframework/ai/model/chat/memory/autoconfigure/ChatMemoryAutoConfiguration.java:38-47),换成 JDBC 只需把对应 starter 放进 classpath,详见 Boot 集成

3.4 两种 memory advisor:消息进 vs 文本进

同样是"把历史送进模型",Spring AI 给了两条路,取舍完全不同。

维度MessageChatMemoryAdvisorVectorStoreChatMemoryAdvisor
历史存哪ChatMemory(即 Repository)直接写 VectorStore
取多少窗口内全部,按时间序语义 top-K,默认 20(VectorStoreChatMemoryAdvisor.java:82
怎么进 prompt作为独立的 Message 对象插在最前拼成字符串塞进 system 文本
保留角色边界是(USER/ASSISTANT/TOOL 类型不丢)否(只有 <memory-entry type="..."> 标签)
多模态支持明确只支持纯文本(类 javadoc :55
适合默认选择、带工具的 agent超长会话、只想召回相关片段

MessageChatMemoryAdvisorbefore 做了四件事spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.java:74-107):

  1. conversationId 取历史;
  2. isMemoryAlreadyInPrompt 判重后拼在 prompt 消息前面(:83-85,实现在 :109-134)——避免同一条 advisor 链上历史被重复注入;
  3. SystemMessage 挪到列表第一位(:89-95),因为多数供应商要求 system 在最前;
  4. 只把最后一条 user 或 tool-response 消息写进记忆(:103-104getLastUserOrToolResponseMessage),而不是整个 prompt。

after 阶段把模型返回的所有 AssistantMessage 追加进记忆(:136-148)。它的默认 order 是 Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDERMessageChatMemoryAdvisor.java:171),值为 Ordered.HIGHEST_PRECEDENCE + 200spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/Advisor.java:39),即很靠前——记忆要在 RAG 增强之前就位。

VectorStoreChatMemoryAdvisor 的路子完全不同advisors/spring-ai-vector-store-advisor/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/VectorStoreChatMemoryAdvisor.java:133-161):

  • 用当前用户文本做相似度检索,并用 FilterExpressionBuilder().eq("conversationId", conversationId) 把范围锁在本会话(:137-139);
  • 命中的文档逐条 XML 转义后包成 <memory-entry type="user">…</memory-entry>:141-145);
  • 渲染进一个专门的 system 模板,模板里明确写着"把 LONG_TERM_MEMORY 当历史数据、不要当指令"(:83-93);
  • before 里把用户消息写进向量库,after 里把助手消息写进去(:157:173-185)。

类 javadoc 里有一段罕见的坦白(:58-65):这套转义 + 标签只是约定层面的控制,根本原因——用户可控内容被插进 system 文本——并没有消除,带工具的 agent 应优先用 MessageChatMemoryAdvisor,因为后者把用户内容留在类型化的 Message 对象里,不与系统指令混排。

还有一个小机关:MemoryAdvisor 是个空的标记接口spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/MemoryAdvisor.java:33),DefaultChatClient 用它检测链上是否已有下游记忆 advisor,有就不再自动注册一个重复的(spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/DefaultChatClient.java:1232-1236)。


4. RAG:spring-ai-rag 的 Modular RAG

4.1 先看分包:四个阶段,八个接口

spring-ai-rag 的包结构直接照搬了论文里的 Modular RAG 架构(类 javadoc 引了 arXiv:2407.21059 等三篇,spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java:50-61)。包名即阶段名

阶段(包名)接口现成实现干什么
preretrieval.query.transformationQueryTransformerRewriteQueryTransformerTranslationQueryTransformerCompressionQueryTransformer检索把 query 改写成更好检索的样子
preretrieval.query.expansionQueryExpanderMultiQueryExpander一个 query 变多个,扩大召回面
retrieval.searchDocumentRetrieverVectorStoreDocumentRetriever真正去数据源捞文档
retrieval.joinDocumentJoinerConcatenationDocumentJoiner多路结果合并去重
postretrieval.documentDocumentPostProcessor无内置实现重排、压缩、过滤(自己写)
generation.augmentationQueryAugmenterContextualQueryAugmenter把文档正文写进最终 prompt

八个接口全部继承 JDK 的函数式接口,这样它们能被当成普通函数组合:QueryTransformer extends Function<Query, Query>preretrieval/query/transformation/QueryTransformer.java:31)、DocumentRetriever extends Function<Query, List<Document>>retrieval/search/DocumentRetriever.java:33)、DocumentJoiner extends Function<Map<Query, List<List<Document>>>, List<Document>>retrieval/join/DocumentJoiner.java:34)、DocumentPostProcessor extends BiFunction<...>postretrieval/document/DocumentPostProcessor.java:37)。

流水线里流动的数据结构是一个 record:

public record Query(String text, List<Message> history, Map<String, Object> context)

依据:spring-ai-rag/src/main/java/org/springframework/ai/rag/Query.java:36。带 history 是为了让压缩改写器能看到上下文,带 context 是为了让检索器能读到运行期参数(比如动态过滤条件)。

4.2 RetrievalAugmentationAdvisor.before 的七步

怎么读这张图: 从上到下是执行顺序,每一步左边是编号、右边是负责的接口。只有第 3 步是并行的。

用户消息 + 对话历史

[0] 组装 Query(text + history + context)

[1] QueryTransformer 链 ← 逐个串行 apply,可为空

[2] QueryExpander ← 1 个 query → N 个 query,可为空

[3] DocumentRetriever × N ← ★ 并行:每个 query 一个 CompletableFuture

[4] DocumentJoiner ← N 组结果 → 一个去重列表

[5] DocumentPostProcessor 链 ← 重排/压缩,可为空
│ (同时把文档放进 context[DOCUMENT_CONTEXT])

[6] QueryAugmenter ← 文档正文 + 原始 query → 增强后的文本

[7] augmentUserMessage(...) ← 写回 ChatClientRequest

交给下游 advisor / ChatModel

依据:spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java:107-154,源码里的 // 0. ~ // 7. 注释与上图一一对应。

这里有个容易看漏的关键细节:第 6 步用的是 originalQuery,不是 transformedQuery

Query augmentedQuery = this.queryAugmenter.augment(originalQuery, documents);

依据:RetrievalAugmentationAdvisor.java:147(第 5 步的后处理同样用 originalQuery,见 :142)。含义是:改写/翻译/压缩只服务于"检索得准",模型最终看到的仍是用户原话。这避免了改写器把语义带偏后模型答非所问。

4.3 并行检索:CompletableFuture + 上下文传播

第 3 步是整条流水线唯一的并发点:

Map<Query, List<List<Document>>> documentsForQuery = expandedQueries.stream()
.map(query -> CompletableFuture.supplyAsync(() -> getDocumentsForQuery(query), this.taskExecutor))
.toList() // ← 先全部提交
.stream()
.map(CompletableFuture::join) // ← 再逐个等待
.collect(Collectors.toMap(...));

依据:RetrievalAugmentationAdvisor.java:129-134

中间那个 .toList() 是必须的,不是随手写的。 Java Stream 是惰性的:如果直接 .map(supplyAsync).map(join),每个元素会走完整条链才处理下一个——变成提交一个、等一个、再提交下一个,完全串行。先 .toList() 强制把所有任务都提交出去,才拿到真并行。

线程池是自己建的(:194-202):

taskExecutor.setThreadNamePrefix("ai-advisor-");
taskExecutor.setCorePoolSize(4);
taskExecutor.setMaxPoolSize(16);
taskExecutor.setTaskDecorator(new ContextPropagatingTaskDecorator());

ContextPropagatingTaskDecorator 是这里的点睛之笔:它把当前线程的上下文(Micrometer 的 observation、MDC、Security 上下文等)复制到工作线程。没有它,检索操作的 span 会从调用链上掉出去、日志里的 traceId 会断掉——可观测性细节见 Boot 集成与可观测性

一个边界: Collectors.toMapQuery 为 key,而 Query 是 record(值相等)。若 QueryExpander 生成了两个文本完全相同的变体,就会触发 toMap 的重复键异常。(inferred,源码未见去重保护)

4.4 after:把命中文档挂回响应

public static final String DOCUMENT_CONTEXT = "rag_document_context";

依据:RetrievalAugmentationAdvisor.java:64。第 5 步末尾把文档列表放进 advisor context(:144),after 再把它从 context 搬进 ChatResponse 的 metadata(:165-182)。

为什么要搬这一道? context 是 advisor 链内部的东西,调用方拿不到;metadata 挂在最终 ChatResponse 上,调用方可以读出来做引用标注(citation)——告诉用户"这个答案基于哪三份文档"。这是做可信问答的必需品。

4.5 各构件的实现要点

三个 QueryTransformer(都是"拿个小模型把 query 重写一遍",区别在 prompt):

干什么默认 prompt 要点失败兜底
RewriteQueryTransformer把啰嗦/含糊的问题改写成检索友好的短语{target} 占位符,默认 "vector store"RewriteQueryTransformer.java:45-55返回空则原样返回:89-92
TranslationQueryTransformer翻译成向量库语料的语言已是目标语言/语言未知则原样返回(TranslationQueryTransformer.java:54-62同上
CompressionQueryTransformer把"对话历史 + 追问"压成一句独立问题只取历史里的 USER/ASSISTANT 消息拼接(CompressionQueryTransformer.java:97同上

三者构造时都会调用 PromptAssert.templateHasRequiredPlaceholders(...) 在启动期校验自定义模板有没有缺占位符(例如 RewriteQueryTransformer.java:71),把"模板写错"从运行时错误提前成构造时错误。

MultiQueryExpanderpreretrieval/query/expansion/MultiQueryExpander.java:52)让模型一次生成 N 个(默认 3,:74)不同角度的变体,按换行切分。它有一条严格的自检:

if (CollectionUtils.isEmpty(queryVariants) || this.numberOfQueries != queryVariants.size()) {
// 数量对不上就整个放弃,返回原 query
return List.of(query);
}

依据:MultiQueryExpander.java:118-124。模型多输出一行解释就会让数量对不上,此时宁可退回单 query 也不要脏数据

VectorStoreDocumentRetrieverretrieval/search/VectorStoreDocumentRetriever.java:57)本身很薄——组装 SearchRequestsimilaritySearch:86-96)。有价值的是它的过滤表达式支持三种来源,优先级从高到低:

  1. Query.context 里放了 Filter.Expression 对象 → 直接用;
  2. Query.context 里放了字符串 → 用 FilterExpressionTextParser 现场解析;
  3. 都没有 → 用构造时注入的 Supplier<Filter.Expression>

依据::123-140。第 3 条用 Supplier 而非固定值是刻意的——字段注释写明是为了惰性求值,好按当前用户身份或租户 ID 动态生成过滤条件(:67-70)。这是多租户 RAG 的关键钩子。

ConcatenationDocumentJoinerretrieval/join/ConcatenationDocumentJoiner.java:42):把所有结果摊平,用 toMap(Document::getId, identity(), (existing, duplicate) -> existing) 按 ID 去重、保留先到者,再按 score 降序排(:47-63)。注意它不做分数归一化——不同 query 检索出的分数直接放在一起比,跨 query 的分数可比性由使用者自负。

ContextualQueryAugmentergeneration/augmentation/ContextualQueryAugmenter.java:49)默认模板的三条硬约束:正文夹在 --------------------- 之间、"仅凭上下文、不用先验知识作答"、"答案不在上下文里就直说不知道"(:53-70)。

它最需要注意的是空上下文行为DEFAULT_ALLOW_EMPTY_CONTEXT = false:77),一旦检索为空,augmentQueryWhenEmptyContext 会把用户的问题整个丢掉,换成一句"这个问题超出你的知识库,礼貌地告诉用户你答不了"(:72-75:126-133)。想让模型在检索不到时退回通用能力,必须显式 .allowEmptyContext(true)

另一个细节:augment 返回的是 new Query(渲染后的文本):123),丢掉了 history 和 context——因为这是流水线最后一步,返回值只有 .text() 会被用到(RetrievalAugmentationAdvisor.java:151)。

4.6 对照:什么时候用轻量的 QuestionAnswerAdvisor

QuestionAnswerAdvisoradvisors/spring-ai-vector-store-advisor/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/QuestionAnswerAdvisor.java:55)是 RAG 的"精简版":没有改写、没有扩展、没有合并、没有后处理,before 就四步——检索、拼文本、渲染模板、写回请求(:109-141)。

维度QuestionAnswerAdvisorRetrievalAugmentationAdvisor
模块advisors/spring-ai-vector-store-advisorspring-ai-rag
步骤4 步,无并发7 步,检索并行
数据源只能是一个 VectorStore任意 DocumentRetriever
可插拔点只有 prompt 模板和 SearchRequest六类构件全可换
检索为空模板里让模型说"答不了"allowEmptyContext 决定
回传文档的 keyqa_retrieved_documents:57rag_document_context
动态过滤 keyqa_filter_expression:59vector_store_filter_expression

选择建议: 单一向量库、问答型场景选前者,代码量和延迟都更低(少了一到两次 LLM 往返);需要多数据源、多路召回、重排的正经知识库选后者。


5. 向量存储:从文档到可检索

5.1 Document:一个能自己决定"怎么被读"的载体

Documentspring-ai-commons/src/main/java/org/springframework/ai/document/Document.java:120)本身是 id + text/media + metadata + score 的容器,特别之处在于它带了一个 ContentFormatter

public static final ContentFormatter DEFAULT_CONTENT_FORMATTER = DefaultContentFormatter.defaultConfig();
public String getFormattedContent(ContentFormatter formatter, MetadataMode metadataMode)

依据:Document.java:122:308

为什么需要这层? 同一份文档,在不同环节该"长得不一样":算 embedding 时可能想带上标题和标签好让语义更准;喂给模型时不想让一堆元数据占 token;写文件时又想全带上。MetadataMode 就是这个开关,只有四个值(spring-ai-commons/src/main/java/org/springframework/ai/document/MetadataMode.java:19-21):

含义
ALL全部元数据都拼进正文
EMBED排除 excludedEmbedMetadataKeys 里的键——给 embedding 用
INFERENCE排除 excludedInferenceMetadataKeys 里的键——给模型推理用
NONE只要正文

真正的拼装在 DefaultContentFormatter.formatspring-ai-commons/src/main/java/org/springframework/ai/document/DefaultContentFormatter.java:104-116):按 {key}: {value} 渲染每条元数据,再套进 {metadata_string}\n\n{content} 模板;两个排除列表按 mode 分别生效(:136-139)。

5.2 ETL:读 → 切 → 富化 → 写

Spring AI 把知识入库定义成三个函数式接口的组合——DocumentReader extends Supplier<List<Document>>DocumentTransformer extends Function<...>DocumentWriter extends Consumer<...>,于是整条管道可以写成一行链式调用。

Reader ────► Transformer ────► Transformer ────► Writer
│ │ │ │
TextReader TokenTextSplitter KeywordMetadata VectorStore
JsonReader (按 token 切块) SummaryMetadata FileDocumentWriter
(调 LLM 富化)
// 示意,非源码
new TokenTextSplitter() // 800 token 一块
.apply(new TextReader("classpath:/handbook.txt").get())
.stream().toList()
.forEach(vectorStore::add); // VectorStore 本身就是 DocumentWriter

各构件的关键参数:

构件位置要点
TextReaderspring-ai-commons/src/main/java/org/springframework/ai/reader/TextReader.java:41自动写入 charsetsource 两条元数据(:43-45:92-93
JsonReaderspring-ai-commons/src/main/java/org/springframework/ai/reader/JsonReader.java:43jsonKeysToUse 挑字段拼正文;get(String pointer) 支持 JSON Pointer 定位子树(:122
TokenTextSplitterspring-ai-commons/src/main/java/org/springframework/ai/transformer/splitter/TokenTextSplitter.java:38见下
KeywordMetadataEnricherspring-ai-model/src/main/java/org/springframework/ai/model/transformer/KeywordMetadataEnricher.java:41调 LLM 抽关键词,写进 excerpt_keywords:51:102
SummaryMetadataEnricherspring-ai-model/src/main/java/org/springframework/ai/model/transformer/SummaryMetadataEnricher.java:42见下
FileDocumentWriterspring-ai-commons/src/main/java/org/springframework/ai/writer/FileDocumentWriter.java:32调试用,把文档按指定 MetadataMode 写文本文件(:74-85

TokenTextSplitter 为什么按 token 而不是字符切? 因为 embedding 模型的限制是 token 数。它的默认参数(TokenTextSplitter.java:40-46):

参数默认值作用
chunkSize800每块目标 token 数
minChunkSizeChars350在这个字符数之后才允许在标点处断句
minChunkLengthToEmbed5太短的块直接丢弃
maxNumChunks10000单文档切块上限,防失控

切分逻辑在 splitText:143-207):编码成 token 序列,每次取 chunkSize 个解码回文本,然后回头找最后一个标点符号. ? ! \n)作为真正的切点,把剩余 token 退回队列。这样块与块之间在句子边界断开,而不是把一个句子劈成两半。

父类 TextSplitter 负责元数据继承:切出来的每一块复制原文档的元数据,并按 copyContentFormatter 开关继承 formatterspring-ai-commons/src/main/java/org/springframework/ai/transformer/splitter/TextSplitter.java:41:121),子类只需实现 protected abstract List<String> splitText(String text):133)。

SummaryMetadataEnricher 的巧思:它不只给当前块写摘要,还会把前一块和后一块的摘要也写进当前块的元数据——三个键 section_summary / prev_section_summary / next_section_summarySummaryMetadataEnricher.java:52-56:118-124),由 SummaryType 枚举控制生成哪几种(:129)。这样单块被召回时,模型也能感知到它在文档中的上下文位置。

5.3 Embedding 与批处理:10% 预留是怎么回事

EmbeddingModelspring-ai-model/src/main/java/org/springframework/ai/embedding/EmbeddingModel.java:39)的核心是 embed(List<Document>, EmbeddingOptions, BatchingStrategy):102-118):先让策略把文档分批,再逐批调 API,最后断言"向量数 == 文档数"。

TokenCountBatchingStrategyspring-ai-model/src/main/java/org/springframework/ai/embedding/TokenCountBatchingStrategy.java:54)是默认策略,两个数字定义了它:

private static final int MAX_INPUT_TOKEN_COUNT = 8191; // :59 OpenAI 上限
private static final double DEFAULT_TOKEN_COUNT_RESERVE_PERCENTAGE = 0.1; // :65
...
this.maxInputTokenCount = (int) Math.round(maxInputTokenCount * (1 - reservePercentage)); // :108

为什么要砍掉 10%? 因为客户端估的 token 数和服务端真实算的几乎不可能一模一样——分词器版本、特殊字符、API 自己加的包装都会带来偏差。留 10% 缓冲,避免一批发过去刚好超限被拒。按公式算实际上限是 Math.round(8191 × 0.9) = 7372(类 javadoc :141-142 写的是 7371,与 Math.round 的结果差 1,属文档笔误)。

batch() 的实现(:137-169)是贪心累加:

  • 先算每篇文档的 token 数,单篇就超限直接抛异常:148-150)——因为切分不是它的职责,那是 TokenTextSplitter 的活;
  • LinkedHashMap 存计数,注释明说是为了保序:143-145)——顺序不能乱,因为上层靠下标把向量对回文档。

一个容易踩的坑: SimpleVectorStore.doAdd走批处理,它一篇一篇 embeddingModel.embed(document)spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SimpleVectorStore.java:115-129);真正用上 batchingStrategy 的是生产级实现,比如 PgVectorStore.doAddvector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorStore.java:260-262)。

5.4 VectorStoreSearchRequest

VectorStorespring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/VectorStore.java:40)接口刻意小:add / 三个 delete 重载 / similaritySearch,并且 extends DocumentWriter——所以它能直接作为 ETL 管道的终点(:53-56accept 默认实现委托给 add)。三个 delete 重载里,接字符串的那个(:79-84)会先把字符串解析成 Filter.Expression 再转调,是纯语法糖。

SearchRequestspring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java:37)的两个默认值很关键:DEFAULT_TOP_K = 4:49)、SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0:44,即默认不按分数过滤)。filterExpression 同样有对象和字符串两个重载(:244:283)。

SimpleVectorStorespring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SimpleVectorStore.java:90)是零依赖的内存实现:ConcurrentHashMap 存内容,检索时对全量数据算余弦相似度再排序取 top-K(:150-160,相似度函数在内部类 EmbeddingMath.cosineSimilarity:264),元数据过滤靠 SimpleVectorStoreFilterExpressionEvaluator 在 JVM 里现场求值(spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SimpleVectorStoreFilterExpressionEvaluator.java:76)。它还能 save(File) / load(File) 把整个库序列化成 JSON(:174:218)——适合演示、测试和小规模只读知识库,不适合生产(全表扫描)。

5.5 可移植过滤器 DSL:三个入口、一棵树、N 种方言

它要解决的小问题: 二十多家向量库(vector-stores/ 下 22 个模块)的元数据过滤语法没有一个相同——Pinecone 用 JSON、PgVector 用 SQL、Qdrant 用自己的 protobuf、Weaviate 用 GraphQL。用户不该为了换库重写过滤条件。

思路: 中间加一棵与厂商无关的语法树,前面接多种写法,后面接多种翻译器。

写法一:Java 流式 API 写法二:类 SQL 文本 写法三:手搓
FilterExpressionBuilder "country == 'BG' new Expression(EQ,
.and(b.eq("country","BG"), && year >= 2020" new Key("country"),
b.gte("year",2020)) │ new Value("BG"))
│ │ │
│ FilterExpressionTextParser │
│ (ANTLR4 生成的词法/语法分析器) │
└──────────────┬───────────────────┴────────────────────────────┘

┌───────────────────────────────────┐
│ Filter.Expression 语法树(中立) │
│ type + left(Key) + right(Value) │
└───────────────────────────────────┘

AbstractFilterExpressionConverter(模板方法:遍历树 + NOT 消解)

┌──────────┬───────┴────────┬──────────┬─────────────┐
▼ ▼ ▼ ▼ ▼
Pinecone PgVector Milvus Qdrant …共 20+ 种方言

树的定义只有五个类型spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/Filter.java):ExpressionType 枚举 13 个操作符 AND, OR, EQ, NE, GT, GTE, LT, LTE, IN, NIN, NOT, ISNULL, ISNOTNULL:81-83),操作数是 Key / Value / Expression / Group 四个 record(:101:111:127:141)。就这么点东西,构成了所有向量库过滤能力的最大公约数。

入口一:FilterExpressionBuilderspring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionBuilder.java:58)——十几个方法 eq/ne/gt/gte/lt/lte/in/nin/and/or/not/group/isNull/isNotNull:60-120),末尾 .build() 出树(:126)。类型安全,适合代码里写死的条件。

入口二:FilterExpressionTextParserspring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionTextParser.java:127)——把 "genre == 'drama' && year >= 2020" 这样的字符串解析成树。语法由 ANTLR4 文法定义(spring-ai-vector-store/src/main/antlr4/org/springframework/ai/vectorstore/filter/antlr4/Filters.g4),生成的 FiltersLexer / FiltersParser / FiltersBaseVisitor 落在 filter/antlr4/ 包里(文件头明写"auto-generated code, do not modify")。解析结果进 ConcurrentHashMap 缓存(:116:127-160)——同一条过滤串在高 QPS 下只解析一次。

这个入口的意义在于:过滤条件可以来自配置文件、HTTP 参数或 LLM 生成的文本,而不必是编译期常量。

出口:AbstractFilterExpressionConverterspring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/converter/AbstractFilterExpressionConverter.java:47)用模板方法遍历树,把稳定的部分做掉、把方言相关的部分留成抽象方法:

层次方法谁实现
骨架convertOperand(按类型分派,:95-118基类,final 逻辑
骨架doValue(区分单值/列表,:153-166基类,含日期归一化
可选覆盖doStartGroup / doEndGroup / doStartValueRange基类给默认括号写法
必须实现doExpression:139)、doKey:146)、doSingleValue:201每家方言

最妙的是 NOT 的处理:126-131):基类不要求方言实现 NOT,而是默认调 FilterHelper.negate(expression) 把 NOT 下推消解掉——NOT(a AND b)NOT a OR NOT bNOT(x == 1)x != 1,靠一张 TYPE_NEGATION_MAP 映射表递归改写(spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/FilterHelper.java:35negate:70)。于是语法树在交给方言之前就不含 NOT 了,二十多个转换器谁都不用操心否定语义——这是本章最值得抄的一手。

一个具体方言的样子:PgVectorFilterExpressionConvertervector-stores/spring-ai-pgvector-store/src/main/java/org/springframework/ai/vectorstore/pgvector/PgVectorFilterExpressionConverter.java:41)实现 doExpression:57)和 doKey:137),产出 PostgreSQL 的 JSONB 查询片段。

5.6 AbstractObservationVectorStore:所有实现共享的埋点

最后一层是可观测性。生产级 VectorStore 实现都继承 AbstractObservationVectorStorespring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/AbstractObservationVectorStore.java:40),它把三个公开方法全部包成 Micrometer observation:

public List<Document> similaritySearch(SearchRequest request) {
...createObservationContextBuilder(Operation.QUERY.value()).queryRequest(request).build();
return ...AI_VECTOR_STORE.observation(...).observe(() -> {
var documents = this.doSimilaritySearch(request); // ← 子类只实现这个
searchObservationContext.setQueryResponse(documents);
return documents;
});
}

依据:AbstractObservationVectorStore.java:128-143add / delete 同构(:74-85:99-122)。

这是标准的模板方法: add / delete / similaritySearch 由基类实现并埋点,子类只实现 doAdd / doDelete / doSimilaritySearch:149-161)。结果是二十多家向量库自动获得统一的指标和 trace,一行埋点代码都不用写。基类还顺手做了统一校验——非文本文档直接拒绝(:87-97)。


6. 巧妙之处(可借鉴的技术)

其一:裁剪切口向前吸附到 USER。 大多数框架砍历史都是"留最后 N 条",Spring AI 多走一步:把切口推到下一条 USER 消息,保证窗口从完整一轮开始,工具调用不会被腰斩。代价是可能留得比 maxMessages 更少。依据:MessageWindowChatMemory.java:116-119

其二:改写只服务检索,模型看到的仍是原话。 RAG 流水线第 1、2 步改写和扩展 query,但第 6 步增强用的是 originalQuery。检索的准确性和用户意图的保真度被解耦。依据:RetrievalAugmentationAdvisor.java:147

其三:NOT 在到达方言前就被消解掉。 基类默认把 NOT 递归下推成等价的取反操作符,二十多个转换器全部省掉了否定逻辑。依据:AbstractFilterExpressionConverter.java:126-131 + FilterHelper.java:70

其四:.toList() 换来真并行。 Stream 惰性求值会让 map(supplyAsync).map(join) 退化成串行;中间强制 .toList() 把所有任务先提交出去。一行代码的差别,是 N 倍延迟的差别。依据:RetrievalAugmentationAdvisor.java:129-134

其五:批处理预留 10% token。 承认"客户端 token 估算必然有偏差",用固定比例的缓冲换取不被 API 拒收。依据:TokenCountBatchingStrategy.java:65:108

其六:Supplier<Filter.Expression> 而非固定值。 检索器的默认过滤条件是惰性求值的,因而能在请求期读取当前用户/租户——多租户 RAG 的隔离靠这一个类型选择就落地了。依据:VectorStoreDocumentRetriever.java:67-70

其七:模板占位符在构造期校验。 PromptAssert.templateHasRequiredPlaceholders 让"自定义 prompt 模板漏写占位符"在应用启动时就炸,而不是上线后某次请求才炸。依据:RewriteQueryTransformer.java:71ContextualQueryAugmenter.java:102


7. 边界与局限

记忆

  • ChatMemoryRepository.saveAll整条会话覆盖写,每轮对话都要重写全部历史;会话很长时写放大明显(ChatMemoryRepository.java:35-39)。
  • 内置只有"消息条数窗口"一种策略,没有按 token 数裁剪、没有摘要式压缩。要按 token 控就得自己实现 ChatMemory
  • 窗口过小 + 长工具链时,吸附规则可能把非 system 消息全部清空(§3.2)。
  • VectorStoreChatMemoryAdvisor 把历史注入 system 文本,源码 javadoc 自己承认这只是约定级防护,不能消除注入风险(VectorStoreChatMemoryAdvisor.java:59-67)。

RAG

  • postretrieval只有接口没有实现——重排(rerank)、上下文压缩都要自己写。
  • ConcatenationDocumentJoiner 不做分数归一化,多路召回的分数跨 query 直接比大小。
  • MultiQueryExpander 对模型输出行数要求严格,多一行解释就整体回退到单 query(:118-124)。
  • 并行检索的 Collectors.toMap 对重复 query 无保护 (inferred)。
  • 每加一个 transformer / expander 就多一次 LLM 往返,延迟是叠加的。

向量存储

  • SimpleVectorStore 是全量扫描,只适合演示与小数据集。
  • 过滤 DSL 只覆盖 13 个操作符,各家独有的能力(如全文混合检索、地理距离)用不上——这是"最大公约数"抽象的固有代价。
  • AbstractObservationVectorStore 只支持纯文本文档,非文本直接抛异常(:87-97)。

8. 代码地图(导航索引)

主题文件路径关键符号
记忆接口与会话 ID 约定spring-ai-model/src/main/java/org/springframework/ai/chat/memory/ChatMemory.javaChatMemoryCONVERSATION_ID
窗口裁剪算法spring-ai-model/src/main/java/org/springframework/ai/chat/memory/MessageWindowChatMemory.javaprocesscutIndexDEFAULT_MAX_MESSAGES
存储抽象spring-ai-model/src/main/java/org/springframework/ai/chat/memory/ChatMemoryRepository.javaChatMemoryRepositorysaveAll
内存实现spring-ai-model/src/main/java/org/springframework/ai/chat/memory/InMemoryChatMemoryRepository.javaInMemoryChatMemoryRepository
JDBC 实现与方言memory-repositories/spring-ai-model-chat-memory-repository-jdbc/src/main/java/org/springframework/ai/chat/memory/repository/jdbc/JdbcChatMemoryRepositoryJdbcChatMemoryRepositoryDialect.from
消息式记忆 advisorspring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/MessageChatMemoryAdvisor.javabeforeisMemoryAlreadyInPromptafter
会话 ID 取值 / 标记接口spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/BaseChatMemoryAdvisor.getConversationIdMemoryAdvisor
向量式记忆 advisoradvisors/spring-ai-vector-store-advisor/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/VectorStoreChatMemoryAdvisor.javaTOP_KDEFAULT_SYSTEM_PROMPT_TEMPLATE
RAG 七步流水线spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.javabeforeDOCUMENT_CONTEXTbuildDefaultTaskExecutor
RAG 数据载体spring-ai-rag/src/main/java/org/springframework/ai/rag/Query.javaQuery
查询改写三兄弟spring-ai-rag/src/main/java/org/springframework/ai/rag/preretrieval/query/transformation/RewriteQueryTransformerTranslationQueryTransformerCompressionQueryTransformer
查询扩展spring-ai-rag/src/main/java/org/springframework/ai/rag/preretrieval/query/expansion/MultiQueryExpander.javaexpand
检索与合并spring-ai-rag/src/main/java/org/springframework/ai/rag/retrieval/VectorStoreDocumentRetriever.FILTER_EXPRESSIONConcatenationDocumentJoiner.join
Prompt 增强spring-ai-rag/src/main/java/org/springframework/ai/rag/generation/augmentation/ContextualQueryAugmenter.javaaugmentaugmentQueryWhenEmptyContext
轻量 RAGadvisors/spring-ai-vector-store-advisor/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/QuestionAnswerAdvisor.javaRETRIEVED_DOCUMENTSFILTER_EXPRESSION
文档与格式化spring-ai-commons/src/main/java/org/springframework/ai/document/Document.getFormattedContentMetadataModeDefaultContentFormatter.format
ETL 切分spring-ai-commons/src/main/java/org/springframework/ai/transformer/splitter/TokenTextSplitter.splitTextTextSplitter.doSplitDocuments
ETL 富化spring-ai-model/src/main/java/org/springframework/ai/model/transformer/KeywordMetadataEnricherSummaryMetadataEnricher.SummaryType
嵌入与批处理spring-ai-model/src/main/java/org/springframework/ai/embedding/EmbeddingModel.embedTokenCountBatchingStrategy.batch
向量库接口spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/VectorStoreSearchRequest.DEFAULT_TOP_KSimpleVectorStore.EmbeddingMath
过滤器语法树spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/Filter.javaFilter.ExpressionTypeFilter.ExpressionFilter.Group
过滤器三入口spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/FilterExpressionBuilderFilterExpressionTextParser.parseFilterHelper.negate
ANTLR4 文法spring-ai-vector-store/src/main/antlr4/org/springframework/ai/vectorstore/filter/antlr4/Filters.g4booleanExpressionconstantArray
方言转换基类spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/filter/converter/AbstractFilterExpressionConverter.javaconvertOperanddoNotdoExpression
统一埋点spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/observation/AbstractObservationVectorStore.javasimilaritySearchdoSimilaritySearch

相邻章节: advisor 链的执行模型见 Advisor 责任链ChatClient 如何组装请求见 ChatClient;工具调用的多轮循环见 工具调用Document/VectorStore 的 Boot 自动装配与可观测性见 Boot 集成