Spring AI 实战:把内存换成 PgVector + Redis,让 RAG 服务重启不丢数据

Spring AI 实战:把内存换成 PgVector + Redis,让 RAG 服务重启不丢数据

系列第四篇。前三篇把功能做完了,但项目还只是个"每次重启都要重新养一遍"的 demo。这篇把它变成能一直跑着的服务。 全文代码可运行,仓库地址在文末。


前言:从"能跑"到"能一直跑"

前三篇结束时,我的物流智能客服系统已经能对话、能查知识库、能调工具、能记住上下文。功能上没什么缺的了。

但它有一个致命问题:重启就失忆。

bash 复制代码
向量库  → SimpleVectorStore(JVM 内存 Map)      → 重启后空
对话记忆 → InMemoryChatMemoryRepository(JVM 内存) → 重启后空

这意味着两件事:

  1. 每次启动都得手动 POST /rag/ingest 重新灌一遍知识库,忘了就静默失效------模型照样回答,只是完全没看你的文档

  2. 用户聊到一半你重启了一下,上一句说过的话它就忘了

"能跑"和"能一直跑"之间,差的不是功能,是状态。

这篇就做这一件事:把两个内存实现换成持久化的。听起来像"换个 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 就行:

bash 复制代码
docker 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 这个坑,我很想知道我不是一个人。

相关推荐
粥里有勺糖1 小时前
视野修炼-技术周刊第137期 | 编程已经面目全非
前端·github·ai编程
西安小哥1 小时前
2026 智能体全景 SWOT 分析:企业、团队与个人的选型地图
ai编程
郑鱼咚3 小时前
一个 Agent 能干的活,为什么非要组个团队?
agent·ai编程
王大王momo3 小时前
Kimi K3.1 Pro 正式发布:编程与 3D 能力升级,Highspeed 同步上线
人工智能·ai编程
anyup3 小时前
uView Pro X 1.0.0 发布:全面支持 uni-app x 蒸汽模式,80+ 组件
前端·uni-app·ai编程
mck354 小时前
别再把整个代码库塞进 Prompt 了!我做了一个按需拉取的 MCP 代码上下文引擎(附 59 任务实测)
ai编程
yz_aiks5 小时前
Obsidian安装使用教程:核心插件配置与图片自动上传、Claude Code插件实战
ai编程·开发工具·效率工具·笔记软件·obsidian
TimeFine5 小时前
AI 编码工作流的设计
android·ai编程
用户69371750013845 小时前
我买的电视,我还不能研究它吗?
前端·后端·ai编程