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 设计思路
- 职责分离:
@Bean只负责构建PgVectorStore;文档加载/增强/入库独立到ApplicationRunner中执行; - 指纹去重:以「文件名 + 内容」的确定性 UUID 作为
Document唯一 ID(内容一致 → ID 稳定;内容变更 → 新 ID); - 增量比对:启动时查询库中已有 ID 集合,仅对「新增/变更」文档执行大模型增强与入库;
- 幂等写入:Spring AI
PgVectorStore对相同 ID 执行 upsert(ON CONFLICT (id) DO UPDATE),天然幂等; - 可选清理:开启
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-startup | true | 关闭后启动不再执行任何文档入库 |
app.rag.remove-orphans | false | 开启后删除库中存在但知识库中已不存在的向量;默认关闭防止加载异常时误删数据 |
六、行为对比与验证
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.yaml | app.rag.* 增量入库开关 |
评论
问答助学
相关内容
0个评论
全部评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
内容推荐
