Spring AI 实战:把内存换成 PgVector + Redis,让 RAG 服务重启不丢数据
系列第四篇。前三篇把功能做完了,但项目还只是个"每次重启都要重新养一遍"的 demo。这篇把它变成能一直跑着的服务。 全文代码可运行,仓库地址在文末。
前言:从"能跑"到"能一直跑"
前三篇结束时,我的物流智能客服系统已经能对话、能查知识库、能调工具、能记住上下文。功能上没什么缺的了。
但它有一个致命问题:重启就失忆。
bash
向量库 → SimpleVectorStore(JVM 内存 Map) → 重启后空
对话记忆 → InMemoryChatMemoryRepository(JVM 内存) → 重启后空
这意味着两件事:
-
每次启动都得手动
POST /rag/ingest重新灌一遍知识库,忘了就静默失效------模型照样回答,只是完全没看你的文档 -
用户聊到一半你重启了一下,上一句说过的话它就忘了
"能跑"和"能一直跑"之间,差的不是功能,是状态。
这篇就做这一件事:把两个内存实现换成持久化的。听起来像"换个 Bean"这么简单?实际上撞了三个坑,其中一个是 Spring AI 的参数名把我骗了。
一、目标:只换实现,不动接口
先说清楚改动的边界,这决定了工作量。
bash
接口层(不动) 实现层(换掉)
┌──────────────────┐ ┌─────────────────────┐
│ VectorStore │ ───► │ SimpleVectorStore │ ✗
│ │ │ PgVectorStore │ ✓
└──────────────────┘ └─────────────────────┘
┌──────────────────┐ ┌─────────────────────┐
│ ChatMemoryRepository │ ► │ InMemoryChatMemory │ ✗
│ │ │ RedisChatMemory │ ✓(自己写的)
└──────────────────┘ └─────────────────────┘
业务代码一行不用改。DocumentIngestService 只依赖 VectorStore 接口,CustomerServiceConfig 只依赖 ChatMemoryRepository 接口------所以换实现这件事被完全关在了配置层。
这就是 Spring 的老本行。Java 后端做 AI 应用的舒服之处就在这里:模型、向量库、缓存全是可替换的基础设施。
| 组件 | 之前 | 之后 |
|---|---|---|
| 向量库 | SimpleVectorStore(内存) |
PgVectorStore(PostgreSQL) |
| 对话记忆 | InMemoryChatMemoryRepository |
RedisChatMemoryRepository(自实现) |
二、PgVector 是什么,怎么起
一句话:PostgreSQL 的一个扩展,给 Postgres 加上"向量"数据类型和向量索引。
装上之后,同一个数据库既能存业务表,又能存 1024 维的 embedding,还能高效地做"找出最相似的 4 条"这种查询。
对比一下:
| SimpleVectorStore | PgVector | |
|---|---|---|
| 存哪 | JVM 内存 Map | 磁盘 |
| 重启后 | 清空 | 还在 |
| 检索方式 | 全量暴力算余弦 | 走 IVFFlat / HNSW 索引 |
| 适合 | 本地验证流程 | 真跑起来 |
用 Docker 起一个,就是十几行的事:
bash
services:
pgvector:
image: pgvector/pgvector:pg16
container_name: aidemo-pgvector
restart: unless-stopped
environment:
POSTGRES_DB: aidemo
POSTGRES_USER: aidemo
POSTGRES_PASSWORD: aidemo123
TZ: Asia/Shanghai
ports:
# 宿主机 5433 -> 容器 5432
# 用 5433 而不是 5432,避免和你本机可能已有的 PostgreSQL 冲突
- "5433:5432"
volumes:
- pgvector-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U aidemo -d aidemo"]
interval: 5s
timeout: 3s
retries: 20
volumes:
pgvector-data:
两个细节值得说:
-
端口用 5433。本机很可能已经装了 PostgreSQL 占着 5432,撞上就是一堆莫名的连接错误。
-
volumes挂载 。不挂卷的话,docker compose down一执行数据就没了------那这一整篇就白做了。
起完验一下扩展确实可用:
bash
docker exec aidemo-pgvector psql -U aidemo -d aidemo -tAc \
"SELECT name, default_version FROM pg_available_extensions WHERE name='vector'"
bash
vector|0.8.7
小插曲:国内直接
docker pull pgvector/pgvector:pg16大概率超时(context deadline exceeded)。换成镜像加速地址先拉下来再打 tag 就行:
bashdocker pull docker.m.daocloud.io/pgvector/pgvector:pg16 docker tag docker.m.daocloud.io/pgvector/pgvector:pg16 pgvector/pgvector:pg16
三、配置:维度必须对上,表结构让它自己建
bash
@Bean
public VectorStore vectorStore(JdbcTemplate jdbcTemplate,
EmbeddingModel embeddingModel,
BatchingStrategy embeddingBatchingStrategy,
@Value("${rag.pgvector.table-name}") String tableName,
@Value("${rag.pgvector.dimensions}") int dimensions) {
return PgVectorStore.builder(jdbcTemplate, embeddingModel)
.vectorTableName(tableName)
.dimensions(dimensions)
.batchingStrategy(embeddingBatchingStrategy) // 下一节重点讲这个
.initializeSchema(true)
.build();
}
bash
spring.datasource.url=jdbc:postgresql://${PGVECTOR_HOST:localhost}:${PGVECTOR_PORT:5433}/${PGVECTOR_DB:aidemo}
spring.datasource.username=${PGVECTOR_USER:aidemo}
spring.datasource.password=${PGVECTOR_PASSWORD:aidemo123}
rag.pgvector.table-name=vector_store
# 必须和 Embedding 模型维度一致:text-embedding-v4 默认 1024 维
rag.pgvector.dimensions=1024
这里最容易埋雷的是 dimensions。
它必须和 Embedding 模型的实际输出维度完全一致。text-embedding-v4 是 1024 维,配错了会怎样?------建表时不一定报错,检索时也不报错,只是结果不对。 这是最恶心的一类失败:静默错误。
所以换 Embedding 模型时,这三件事必须一起做:改 dimensions、清空向量表、重新入库。只改模型不重建索引,等于拿苹果去比对橘子的坐标。
启动后确认表建出来了:
bash
public | vector_store | table | aidemo
id | uuid
content | text
metadata | json
embedding | USER-DEFINED
initializeSchema(true) 会自动执行 CREATE EXTENSION IF NOT EXISTS vector 并建表建索引,前提是数据库用户有权限(docker-compose 里的 user 是库 owner,没问题)。
四、坑一:自动配置和你自己定义的 Bean 撞名了
配置写完,启动直接失败:
bash
The bean 'vectorStore', defined in
org.springframework.ai.vectorstore.pgvector.autoconfigure.PgVectorStoreAutoConfiguration,
could not be registered.
A bean with that name has already been defined in RagConfig
原因很简单:引入了 pgvector starter,它自己也要注册一个叫 vectorStore 的 Bean。 而我在 RagConfig 里手写了一个同名的。
两条路:
| 方案 | 做法 | 适合 |
|---|---|---|
| A. 用自动配置 | 删掉自己的 @Bean,全靠 properties 配 |
参数简单、全走默认 |
| B. 排除自动配置 | 保留自己的 @Bean,把 AutoConfiguration 排掉 |
参数多、需要显式控制 |
我选了 B,因为我需要挂 batchingStrategy(下一节会说为什么必须显式控制):
bash
spring.autoconfigure.exclude=org.springframework.ai.vectorstore.pgvector.autoconfigure.PgVectorStoreAutoConfiguration
教训:引入任何 starter 之前,先想清楚"我要不要自己定义这个 Bean"。 两套同时生效必然打架,而且报错信息只说"名字重复",不会告诉你该删哪个。
五、坑二:Spring AI 没有 Redis 版记忆实现,我自己写了一个
对话记忆这边,我本来想的是"换个 starter 就完事"。翻了一遍 Spring AI 1.1.2 的 BOM:
bash
Cassandra ✓
CosmosDB ✓
JDBC ✓
MongoDB ✓
Neo4j ✓
Redis ✗ ← 没有
官方没提供 Redis 实现。
那怎么办?看接口------只有四个方法:
bash
public interface ChatMemoryRepository {
List<String> findConversationIds();
List<Message> findByConversationId(String conversationId);
void saveAll(String conversationId, List<Message> messages);
void deleteByConversationId(String conversationId);
}
四个方法,一个 key 存一个会话的消息列表。自己实现大概 80 行:
bash
@Repository
public class RedisChatMemoryRepository implements ChatMemoryRepository {
private static final String KEY_PREFIX = "ai:chat:memory:";
private static final Duration TTL = Duration.ofDays(7);
private final StringRedisTemplate redis;
private final ObjectMapper objectMapper;
@Override
public List<Message> findByConversationId(String conversationId) {
String json = redis.opsForValue().get(key(conversationId));
if (json == null || json.isBlank()) {
return List.of();
}
try {
List<StoredMessage> stored = objectMapper.readValue(json, new TypeReference<>() {});
return stored.stream()
.map(StoredMessage::toMessage)
.filter(Objects::nonNull)
.toList();
} catch (Exception e) {
// 反序列化失败不要炸掉整轮对话------当作没有历史,比抛异常更安全
return List.of();
}
}
@Override
public void saveAll(String conversationId, List<Message> messages) {
List<StoredMessage> stored = messages.stream()
.map(StoredMessage::from)
.filter(Objects::nonNull)
.toList();
redis.opsForValue().set(key(conversationId),
objectMapper.writeValueAsString(stored), TTL);
}
// ... delete / findConversationIds / key() 略
}
两个设计决定
① 只存「角色 + 文本」,不序列化 Message 对象
Message 是接口,下面有 UserMessage / AssistantMessage / SystemMessage / ToolResponseMessage 一堆实现。直接 Jackson 序列化接口类型,反序列化时必须处理多态类型信息,容易出坑(字段丢失、类型认不出来)。
存最小信息、读的时候重新构造,反而更稳:
bash
private record StoredMessage(String type, String text) {
static StoredMessage from(Message message) {
MessageType messageType = message.getMessageType();
// 只持久化能还原的三种角色
if (messageType != MessageType.USER
&& messageType != MessageType.ASSISTANT
&& messageType != MessageType.SYSTEM) {
return null;
}
return new StoredMessage(messageType.getValue(), message.getText());
}
Message toMessage() {
return switch (MessageType.fromValue(type)) {
case USER -> new UserMessage(text);
case ASSISTANT -> new AssistantMessage(text);
case SYSTEM -> new SystemMessage(text);
default -> null;
};
}
}
Redis 里长这样:
bash
key = ai:chat:memory:user-001
value = [{"type":"USER","text":"羽绒大衣还有货吗"},
{"type":"ASSISTANT","text":"..."}]
② 反序列化失败时返回空列表,不抛异常
这个选择有争议,但我认为对:记忆是增强能力,不是核心链路。Redis 里一条脏数据不该让用户这一轮对话直接 500。当作没有历史继续聊,比报错好。
③ 一定设 TTL
bash
private static final Duration TTL = Duration.ofDays(7);
不设 TTL 的 Redis 就是个只会长大的垃圾场。会话长期不活跃自动清理,7 天是个合理值。
六、坑三:maxDocumentBatchSize 这个名字把我骗了
这个坑是全文最有价值的,因为它差点让我"改对了却没用"。
现象
迁完 PgVector,启动自动入库报 400:
bash
NonTransientAiException: 400 -
{"error":{"message":"InvalidParameter: Value error, batch size is invalid,
it should not be larger than 10.: input.contents"}}
意思很明确:你一次提交了超过 10 条文本,而接口上限是 10 条。
可我只入库了 14 个分块,按理说不该超------除非它们被打包成一个请求全发出去了。
第一直觉(错的)
PgVectorStore.builder() 上有个参数叫 maxDocumentBatchSize。看名字,这不就是"每次批量处理多少文档"吗?设成 10 不就完了?
如果不是随手反编译看了一眼,我就这么改了。
bash
// doAdd() 里的真实逻辑
embeddingModel.embed(documents, options, batchingStrategy); // ← 分批由 batchingStrategy 决定
batchDocuments(documents); // ← maxDocumentBatchSize 只管这里
maxDocumentBatchSize 只控制"写数据库时分几批",跟 embedding 请求完全无关。
如果我按第一直觉改,会出现什么?代码能编译、能启动、参数看起来也对------但 400 照旧。然后我就会陷入"我明明设了 10,怎么还是超了"的困惑,去怀疑分块数量、怀疑配置没生效、怀疑缓存......
真正的机制
bash
public interface BatchingStrategy {
List<List<Document>> process(List<Document> documents);
}
Spring AI 默认的批处理策略是按 token 数 分批的。14 个中文分块每个才 150~200 字,加起来远没到 token 上限------于是全被打包成一个请求。而百炼的限制是条数,不是 token。
维度不匹配:它按 token 算,供应商按条数卡。
修复
自定义一个按条数切的策略:
bash
@Bean
public BatchingStrategy embeddingBatchingStrategy(
@Value("${rag.embedding.batch-size}") int batchSize) {
return documents -> {
List<List<Document>> batches = new ArrayList<>();
for (int i = 0; i < documents.size(); i += batchSize) {
batches.add(documents.subList(i, Math.min(i + batchSize, documents.size())));
}
return batches;
};
}
挂上去:
bash
PgVectorStore.builder(jdbcTemplate, embeddingModel)
.dimensions(dimensions)
.batchingStrategy(embeddingBatchingStrategy) // ← 关键
.initializeSchema(true)
.build();
配置里写清楚这是供应商限制:
bash
# ⚠️ 阿里百炼 text-embedding-v4 单请求最多 10 条,超过会报:
# 400 InvalidParameter: batch size is invalid, it should not be larger than 10
rag.embedding.batch-size=10
验证
写个不依赖 API Key 的单测,直接验切片逻辑:
bash
@Test
void should_split_into_batches_by_size() {
List<Document> docs = IntStream.range(0, 14)
.mapToObj(i -> new Document("chunk-" + i))
.toList();
List<List<Document>> batches = strategy.process(docs);
assertThat(batches).hasSize(2);
assertThat(batches.get(0)).hasSize(10);
assertThat(batches.get(1)).hasSize(4);
}
bash
14 条 → [10, 4]
Tests run: 2, Failures: 0, Errors: 0
这条的通用教训
改框架行为之前,先确认参数的真实作用域。参数名很像,不代表用途一致。
不用每次都反编译。但当你遇到"改了配置却不生效"的情况,javap -c 看字节码比猜快得多。一行命令:
bash
javap -c -p -cp xxx.jar com.example.SomeClass | grep -A 20 "doAdd"
七、坑四:持久化之后,原来的"自动入库"变成了 bug
这个坑是我自己埋的,而且是持久化引入的新问题。
之前为了防止"重启忘了入库导致 RAG 静默失效",我加了启动自动入库。当时的实现是幂等的:先按 source 删旧分块,再写入新分块。
迁到 PgVector 之后,这段逻辑的问题暴露了:
bash
启动 → 查索引 → 发现要入库 → 删除旧的 14 条 → 调用 embedding ...
↑ 如果这里失败了呢?
旧的没了,新的没写 → 索引变空
先删后加不是原子操作。 embedding 接口只要抽风一次(超时、限流、欠费),索引就变空了------而且和之前一样是静默失效。
修复分两步:
第一步:幂等入库(保证重复入库不产生重复数据)
bash
// 写入前先删掉这批文档的旧分块
// 因为 PgVector 是持久化的,每次启动若直接 add,同样内容会被重复灌一遍,
// 索引越跑越脏(重复片段还会挤占 Top-K 名额)
deleteExistingChunks(fileNames);
vectorStore.add(allChunks);
bash
private void deleteExistingChunks(List<String> fileNames) {
vectorStore.delete(new FilterExpressionBuilder()
.in("source", new ArrayList<>(fileNames))
.build());
}
第二步:启动时索引非空就跳过(彻底避开先删后加)
bash
Integer existing = jdbcTemplate.queryForObject(
"SELECT count(*) FROM " + vectorTableName, Integer.class);
if (existing != null && existing > 0) {
log.info("[RAG] 索引已有 {} 条分块(PgVector 持久化),跳过启动入库。"
+ "文档有更新时请手动 POST /rag/ingest", existing);
return;
}
好处有三:避开先删后加的风险、省掉每次重启的重复 embedding 调用、启动更快。
代价是改了 docs/ 下的文档不会自动生效 ,得手动 POST /rag/ingest。这个取舍我认为值得------文档更新是低频操作,索引损坏是灾难。
/rag/status也同步改了:新增indexedChunks(库里实际行数),判断逻辑从"本次启动是否入库过"改成"库里有没有数据"。因为跳过入库时lastIngest是 null,但索引其实好好的------只看lastIngest会误判成"没入库"。
八、怎么证明持久化真的生效
这里有个坑:应用重启后能答出知识库内容,不能证明持久化生效------因为启动自动入库会重新灌一遍,结果看起来一样。
真正有效的验证是:起一个全新的 JVM 实例,让它不执行入库,直接查。
我另起了一个 8081 端口实例(不打扰 8080 那个):
bash
curl "http://localhost:8081/rag/status"
返回:
bash
{
"vectorStore": "PgVectorStore",
"vectorStorePersistent": true,
"indexedChunks": 14,
"topK": 4,
"similarityThreshold": 0.5,
"lastIngest": null,
"hint": "索引有数据。若仍检索不到,可调低 similarityThreshold,或确认问题与文档内容相关"
}
关键两点:
-
lastIngest: null------ 这个 JVM 没有入库 -
indexedChunks: 14------ 但它读到了 14 条分块
数据不是这个进程产生的,是从 Postgres 里读出来的。这才叫持久化。
再直接查库确认没有重复:
bash
docker exec aidemo-pgvector psql -U aidemo -d aidemo -tAc \
"SELECT metadata->>'source' AS src, count(*) FROM vector_store GROUP BY 1 ORDER BY 1"
bash
delivery-process.txt|4
return-policy.txt|5
warehouse-management.txt|5
14 条,5+4+5,和首次入库完全一致。 幂等生效了。
Redis 那边也是实测的------同一 sessionId 连续两问:
bash
问1:冷藏区温度超限多久会告警
答1:超过 15 分钟(引用《仓储管理规范(2026版)》)
问2:那这些温控异常记录要保留多少年?
答2:至少 3 年
第二问里的"这些",模型能正确指代第一问的"温控异常记录",说明历史消息确实从 Redis 读回来了。
九、更正:我之前对相似度阈值的判断是错的
这个必须写出来,因为我之前公开发过错误结论。
第三篇里我说过「中文 embedding 的相似度普遍偏低,0.5 的阈值可能太严」,建议降到 0.3。这是基于推测的想当然。
实际测出来的数据:
bash
相关分块 distance: 0.39 ~ 0.44
换算相似度: 0.56 ~ 0.61 (similarity = 1 - distance)
阈值: 0.50 ← 稳稳能过
而且有个 delivery-process.txt 的无关片段也被召回了,说明 0.5 甚至略松,不是太严。
所以别去调低它。
这条的教训比结论本身重要:调参要基于实测分布,不是基于"某类模型通常怎样"的印象。 不同供应商的 embedding 空间分布差异很大,同一家不同版本之间也会变。要调阈值,先把实际命中的 distance 打出来看。
十、踩坑清单
1. spring.autoconfigure.exclude 要写全限定类名
bash
org.springframework.ai.vectorstore.pgvector.autoconfigure.PgVectorStoreAutoConfiguration
写错了不会报"类不存在",只会静默不生效,然后你继续被 Bean 冲突卡住。
2. dimensions 和 Embedding 模型不一致时,是静默错误
建表可能成功、写入可能成功、检索也"成功"------只是结果毫无意义。换模型必须重建索引。
3. maxDocumentBatchSize 管写库,BatchingStrategy 管 embedding
两回事。名字很像,作用域完全不同。
4. 换 Embedding 供应商时,除了维度还要确认单请求上限
-
维度 → 影响表结构
-
单请求条数上限 → 影响批量入库
-
单条 token 上限 → 影响分块大小
三个都得对上,缺一个就在某个环节炸。
5. docker compose down -v 会删数据卷
持久化的意义全在 volumes 上。-v 一把梭就是回到解放前,重新入库。别在生产库上试。
6. 持久化之后,"启动重建索引"的逻辑要重新审一遍
如果入库是"先删后加",重启时就存在空索引窗口。要么做成原子替换,要么加"非空跳过"的守卫。
7. 密码走环境变量,不要进 Git
application.properties 里全是 ${REDIS_PASSWORD:} 这种占位。数据库密码、Redis 密码、API Key 一个都不写死。提交前扫一遍 diff:
bash
git diff --cached | grep -inE "password=|sk-[a-zA-Z0-9]{10}"
密码一旦进了 Git 历史,改密码才是唯一解------删文件没用,历史里还在。
总结
四篇下来,这个项目从"调通 API"走到了"能一直跑的服务":
| 阶段 | 能力 | 关键点 |
|---|---|---|
| 第一篇 | 对话 / 流式 / 记忆 / 工具 | ChatClient 基础装配 |
| 第二篇 | RAG 知识库 | DeepSeek 无 Embedding,需双供应商 |
| 第三篇 | 整合成客服系统 | Advisor 顺序 + 来源追溯 |
| 第四篇 | 持久化 | PgVector + Redis,换实现不动接口 |
回头看这四篇,真正花时间的从来不是"调用大模型"。调用 API 半小时就能跑通,剩下的时间都在处理这些:
-
Bean 冲突、Advisor 顺序、
mutate()的继承语义 -
参数名和实际作用域不一致
-
先删后加不是原子操作
-
静默失败(不报错,只是不对)
-
供应商的隐藏配额(条数上限、维度、token)
这些恰好都是 Java 后端工程师每天在做的事------依赖注入、AOP、事务边界、状态一致性。换了个领域,基本功还是那些基本功。
所以如果你也是 Java 后端在观望 AI 应用开发:难的不是 AI,是工程。而那块你已经有了。
项目已开源,持续更新中: gitee.com/yuanGe1206/...
系列还会继续。下一步是业务工具接 MySQL,把 OrderService 里的硬编码数据换成真实表结构------那部分反而是最"传统"的活儿。
如果这个系列对你有帮助,点个赞让更多 Java 同行看到。有问题欢迎评论区交流,尤其是如果你也踩过 maxDocumentBatchSize 这个坑,我很想知道我不是一个人。