RAG 知识库文档重复入库问题解决方案 - 最新spring ai 1.1.2

RAG 知识库文档重复入库问题解决方案

适用版本:Spring AI 1.1.2 / Spring Boot 3.5.3 关键词:增量更新、文档指纹、PgVectorStore、幂等入库


一、问题背景

1.1 现象

应用每次启动后,resources/document/ 下的 Markdown 文档会被全量重新读取、增强并写入 PostgreSQL pgvector 的 vector_store 表,导致:

  • 启动 N 次,表中出现 N 份内容完全相同的记录;
  • 相似度检索时 topK 结果被重复文档挤占,RAG 回答质量下降;
  • 每次启动都会对全量文档调用大模型生成关键词/摘要,造成无谓的 API 费用。

1.2 原实现(问题代码)

java
复制代码
@Configuration public class PgVectorVectorStoreConfig { @Resource private LoveAppDocumentLoader loveAppDocumentLoader; @Resource private MyDocumentEnricher myDocumentEnricher; @Bean public VectorStore pgVectorVectorStore(...) { PgVectorStore vectorStore = PgVectorStore.builder(...) .initializeSchema(true) // 仅建表,不清数据 .build(); // 每次启动都全量加载 + 增强 + 插入 List<Document> documents = loveAppDocumentLoader.loadDocuments(); List<Document> docsByKeyword = myDocumentEnricher.enrichDocumentsByKeyword(documents); List<Document> docsBySummary = myDocumentEnricher.enrichDocumentsBySummary(docsByKeyword); for (int i = 0; i < docsBySummary.size(); i += 10) { vectorStore.add(docsBySummary.subList(i, ...)); } return vectorStore; } }

1.3 根因分析

因素说明
入库时机错误文档加载/入库逻辑写在 @Bean 方法中,Spring 每次启动创建 Bean 时必然执行
无去重机制PgVectorStore.add() 是纯 INSERT,不检查内容是否已存在
表数据持久化initializeSchema(true) 只在表不存在时建表,不会清空旧数据
Document ID 随机未显式设置 ID 时每次生成新 UUID,插入永不冲突、只增不减

二、解决方案:指纹增量更新

2.1 设计思路

  1. 职责分离@Bean 只负责构建 PgVectorStore;文档加载/增强/入库独立到 ApplicationRunner 中执行;
  2. 指纹去重:以「文件名 + 内容」的确定性 UUID 作为 Document 唯一 ID(内容一致 → ID 稳定;内容变更 → 新 ID);
  3. 增量比对:启动时查询库中已有 ID 集合,仅对「新增/变更」文档执行大模型增强与入库;
  4. 幂等写入:Spring AI PgVectorStore 对相同 ID 执行 upsert(ON CONFLICT (id) DO UPDATE),天然幂等;
  5. 可选清理:开启 app.rag.remove-orphans 后,删除知识库中已不存在文档的残留向量。

2.2 架构对比

mermaid
复制代码
graph TB subgraph 改造前 A1[启动] --> B1[pgVectorVectorStore Bean 方法] B1 --> C1[全量加载文档] C1 --> D1[全量大模型增强] D1 --> E1[全量 INSERT → 冗余累积] end subgraph 改造后 A2[启动] --> B2[pgVectorVectorStore Bean 仅构建存储] A2 --> C2[DocumentIngestionRunner 增量入库] C2 --> D2[加载文档 + 计算指纹 ID] D2 --> E2[比对库中已有 ID] E2 -->|新增/变更| F2[仅对增量调用大模型增强] F2 --> G2[分批 add → upsert 幂等写入] E2 -->|未变更| H2[直接跳过] E2 -->|remove-orphans| I2[清理残留向量] end

2.3 改动文件清单

文件改动
PgVectorVectorStoreConfig.java@Bean 移除加载/增强/入库逻辑,只构建存储
DocumentIngestionRunner.java新增ApplicationRunner 实现指纹增量入库
LoveAppVectorStoreConfig.java内存向量存储 Bean 加 @Lazy,避免启动时白调大模型
application.yaml新增 app.rag.ingest-on-startup / app.rag.remove-orphans 开关

三、核心代码

3.1 配置类(只构建存储)

java
复制代码
@Bean public VectorStore pgVectorVectorStore(@Qualifier("pgJdbcTemplate") JdbcTemplate pgJdbcTemplate, EmbeddingModel dashscopeEmbeddingModel) { // 构建向量存储:1024 维(对齐 text-embedding-v3)、余弦距离、HNSW 索引、自动建表 return PgVectorStore.builder(pgJdbcTemplate, dashscopeEmbeddingModel) .dimensions(1024) .distanceType(PgVectorStore.PgDistanceType.COSINE_DISTANCE) .indexType(PgVectorStore.PgIndexType.HNSW) .initializeSchema(true) .schemaName("public") .vectorTableName("vector_store") .maxDocumentBatchSize(10000) .build(); }

3.2 增量入库启动器(核心逻辑)

java
复制代码
@Override public void run(ApplicationArguments args) { if (!ingestOnStartup) { log.info("文档自动入库已关闭(app.rag.ingest-on-startup=false),跳过增量同步"); return; } try { // 1. 加载并切分知识库文档 List<Document> documents = loveAppDocumentLoader.loadDocuments(); if (documents.isEmpty()) { log.info("未读取到任何知识库文档,跳过增量同步"); return; } // 2. 为每篇文档计算指纹 ID(文件名 + 内容哈希),内容一旦变更即生成新 ID documents = documents.stream() .map(doc -> new Document(computeFingerprint(doc), doc.getText(), doc.getMetadata())) .toList(); // 3. 查询向量库中已存在的文档指纹 ID Set<String> existingIds = new HashSet<>( pgJdbcTemplate.queryForList("SELECT id FROM public.vector_store", String.class)); // 4. 筛选出新增或内容变更的文档,仅对这部分执行增强与入库 List<Document> toAdd = documents.stream() .filter(doc -> !existingIds.contains(doc.getId())) .toList(); if (!toAdd.isEmpty()) { // 5. 仅对新增文档调用大模型补充关键词与摘要元数据 List<Document> enriched = myDocumentEnricher.enrichDocumentsByKeyword(toAdd); enriched = myDocumentEnricher.enrichDocumentsBySummary(enriched); // 6. 分批写入向量库:相同 ID 走 upsert,重复启动不会产生冗余数据 for (int i = 0; i < enriched.size(); i += BATCH_SIZE) { int end = Math.min(i + BATCH_SIZE, enriched.size()); pgVectorVectorStore.add(enriched.subList(i, end)); } log.info("文档增量同步完成:加载 {} 篇,新增/更新 {} 篇", documents.size(), enriched.size()); } else { log.info("文档增量同步完成:加载 {} 篇,无新增文档", documents.size()); } // 7. 按配置清理已从知识库移除文档的残留向量 if (removeOrphans) { removeOrphanDocuments(existingIds, documents); } } catch (Exception e) { log.error("文档增量入库失败:{}", e.getMessage(), e); } }

3.3 指纹生成(关键)

java
复制代码
/** * 计算文档指纹 ID:基于文件名与文本内容的确定性 UUID(nameUUIDFromBytes 内部使用 MD5)。 * 内容一致时指纹稳定,内容变更时生成新指纹,从而支撑增量幂等入库。 * 指纹需为标准 UUID 格式,以满足 PgVectorStore 对文档 ID 的 UUID 解析要求。 */ private String computeFingerprint(Document document) { String filename = String.valueOf(document.getMetadata().getOrDefault("filename", "")); String raw = filename + "|" + document.getText(); return UUID.nameUUIDFromBytes(raw.getBytes(StandardCharsets.UTF_8)).toString(); }

3.4 孤儿向量清理

java
复制代码
private void removeOrphanDocuments(Set<String> existingIds, List<Document> documents) { Set<String> localIds = new HashSet<>(); documents.forEach(doc -> localIds.add(doc.getId())); List<String> orphans = new ArrayList<>(); existingIds.forEach(id -> { if (!localIds.contains(id)) { orphans.add(id); } }); if (!orphans.isEmpty()) { pgVectorVectorStore.delete(orphans); log.info("已清理知识库中已不存在文档的残留向量 {} 条", orphans.size()); } }

四、关键坑:Document ID 必须是标准 UUID 格式

4.1 现象

启动时报错:

text
复制代码
文档增量入库失败:Invalid UUID string: 5bf225c4953dc43ce27e07c433da68ef java.lang.IllegalArgumentException: Invalid UUID string at java.util.UUID.fromString(UUID.java:260) at org.springframework.ai.vectorstore.pgvector.PgVectorStore.convertIdToPgType(PgVectorStore.java:318)

4.2 原因

Spring AI 1.1.2 的 PgVectorStore 在写入时会调用 UUID.fromString(documentId) 将文档 ID 强制解析为 UUID。若直接使用 32 位无连字符的 MD5 hex 字符串作为 ID,解析失败抛异常。

4.3 结论

  • 指纹必须为标准 UUID 格式(xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx);
  • 推荐 UUID.nameUUIDFromBytes(...):内部基于 MD5 的确定性 UUID(v3),既保证幂等,又满足格式要求;
  • 若必须自定义 ID 格式,需确认所用 PgVectorStore 版本的 convertIdToPgType 实现是否带格式回退。

五、配置说明(application.yaml)

yaml
复制代码
# 知识库文档增量入库配置 app: rag: # 启动时是否执行知识库文档增量入库(按文档指纹比对,仅入库新增/变更的文档) ingest-on-startup: true # 是否清理已从知识库移除文档的残留向量 remove-orphans: false
配置项默认值说明
app.rag.ingest-on-startuptrue关闭后启动不再执行任何文档入库
app.rag.remove-orphansfalse开启后删除库中存在但知识库中已不存在的向量;默认关闭防止加载异常时误删数据

六、行为对比与验证

6.1 行为对比

场景改造前改造后
重复启动 N 次全量重插,冗余 N 份仅首次入库,后续零写入
修改知识库文档再插一份旧版残留新指纹入库,旧版按配置可清理
删除知识库文档旧向量永久残留remove-orphans: true 时自动清理
大模型 API 消耗每次启动全量增强仅对新增/变更文档增强

6.2 验证方式

sql
复制代码
-- 统计各文档重复条数(改造前可观测到 N 份相同内容) SELECT id, count(*) FROM public.vector_store GROUP BY id HAVING count(*) > 1; -- 清理历史冗余后重启应用,观察日志 -- 首次启动:文档增量同步完成:加载 37 篇,新增/更新 37 篇 -- 二次启动:文档增量同步完成:加载 37 篇,无新增文档

6.3 首次上线注意事项

数据库中存在历史重复数据时,先手动清空一次,再进入增量模式:

sql
复制代码
DELETE FROM public.vector_store;

七、相关文件

文件职责
DocumentIngestionRunner.java增量入库启动器(指纹比对、增量增强、分批 upsert、孤儿清理)
PgVectorVectorStoreConfig.java仅构建 PgVectorStore Bean
LoveAppDocumentLoader.java加载 classpath:document/*.md 并按水平线切分(保留)
MyDocumentEnricher.java大模型生成关键词与摘要元数据(保留)
LoveAppVectorStoreConfig.java内存向量存储,@Lazy 延迟初始化
application.yamlapp.rag.* 增量入库开关
0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
Null
下载 APP