RAG 知识库别只会追加:Java 项目里如何做增量更新
很多团队第一次做 RAG流程都差不多上传文档、解析文本、切 chunk、生成 embedding、写入向量库然后在问答时做相似度检索。Demo 跑通以后真正的问题才出现文档更新了怎么办比如一份《售后政策》从 v1 改到 v2旧条款已经废弃但向量库里还躺着旧 chunk。用户问“七天无理由规则是什么”系统可能同时召回新旧两段内容模型再把它们揉成一个看似合理、实际错误的答案。RAG 的增量更新不是“把新文档再导入一次”。它本质上是一个索引一致性问题业务系统里的知识变了向量索引也要能删除旧版本、写入新版本并且让检索链路知道当前哪些内容才是可信的。旧知识污染比召回不到更危险召回不到用户通常能感知到系统“不知道”。但召回到过期内容模型会很自然地生成一个确定语气的错误答案。这类问题在企业知识库里很常见场景如果只追加会发生什么制度文档修订新旧制度同时被召回接口文档更新Agent 仍然按旧参数调用FAQ 答案调整客服答案出现前后不一致产品价格变更模型引用过期价格权限文档迁移已废弃权限规则继续生效向量库不是数据库的替代品。它擅长相似度检索但不天然知道“哪条知识已经被业务废弃”。这个判断必须由索引设计来表达。给每个 chunk 带上可重建的身份在 RAG 里一个原始文档通常会被拆成多个 chunk。真正进入向量库的不是“文档”而是“文档片段”。所以增量更新的关键不是只记录文件名而是让每个 chunk 都能追溯回原始文档和版本。建议至少保留这些 metadata字段作用docId原始文档稳定 ID删除旧 chunk 时使用contentHash判断内容是否变化避免重复 embeddingversion文档版本便于排查和灰度chunkIndexchunk 顺序便于定位和上下文拼接sourceUri原始来源如文件地址、CMS 链接、数据库主键updatedAt索引更新时间便于审计tenantId多租户场景下的隔离字段Spring AI 的Document支持文本内容和 metadata。官方文档中也明确了VectorStore提供add、delete、similaritySearch等操作并支持基于 metadata 的 filter expression。也就是说增量更新不应该绕开框架能力而应该把“文档身份”设计进 metadata。一条更稳的更新链路可落地的流程可以这样设计这里有一个重要细节不要把向量库当作唯一状态来源。生产项目里最好有一张普通业务表记录索引状态例如knowledge_index_state - doc_id - content_hash - version - indexed_at - status它的作用不是参与相似度检索而是回答几个工程问题这份文档有没有被索引过当前索引对应哪个版本上次失败停在哪一步是否需要重建向量库负责“查相似内容”业务表负责“管理索引生命周期”。这两个职责分开系统会好维护很多。Spring AI 中的关键代码下面的代码只展示核心思路检测内容变化删除旧 chunk重新切分并写入。具体向量库可以是 PgVector、Milvus、Redis、Elasticsearch、Qdrant 等接口层面都围绕VectorStore展开。Service public class KnowledgeIndexService { private final VectorStore vectorStore; private final KnowledgeIndexStateRepository stateRepository; private final TokenTextSplitter splitter TokenTextSplitter.builder() .withChunkSize(800) .withMinChunkSizeChars(350) .withKeepSeparator(true) .build(); public KnowledgeIndexService(VectorStore vectorStore, KnowledgeIndexStateRepository stateRepository) { this.vectorStore vectorStore; this.stateRepository stateRepository; } public void reindex(KnowledgeFile file) { String docId file.docId(); String contentHash sha256(file.content()); KnowledgeIndexState state stateRepository.findByDocId(docId); if (state ! null contentHash.equals(state.contentHash())) { return; } // docId 应使用系统生成的稳定 ID例如 UUID/ULID避免拼接外部输入 vectorStore.delete(docId docId ); Document raw new Document(file.content(), Map.of( docId, docId, version, file.version(), contentHash, contentHash, sourceUri, file.sourceUri(), updatedAt, Instant.now().toString() )); ListDocument chunks splitter.apply(List.of(raw)); ListDocument indexedChunks IntStream.range(0, chunks.size()) .mapToObj(i - { Document chunk chunks.get(i); MapString, Object metadata new LinkedHashMap(chunk.getMetadata()); metadata.put(chunkIndex, i); return new Document(chunk.getContent(), metadata); }) .toList(); vectorStore.add(indexedChunks); stateRepository.save(new KnowledgeIndexState( docId, contentHash, file.version(), Instant.now(), INDEXED )); } private String sha256(String text) { // 生产代码建议封装异常并统一字符集 return DigestUtils.sha256Hex(text); } }这段代码背后的重点不是TokenTextSplitter而是“可重建”。只要知道docId就能删除这个文档历史产生的所有 chunk只要知道contentHash就能判断是否需要重新 embedding只要保留chunkIndex就能定位某个答案来自哪一段。Spring AI 2.0.0 文档中TokenTextSplitter.builder()是推荐创建方式旧构造器已经不适合继续作为示例。具体 API 可能会随版本变化实际项目中应以官方文档为准。先删后写不是唯一答案上面的“先删旧 chunk再写新 chunk”适合大多数后台重建任务但它有一个短暂窗口如果删除成功、写入失败检索会暂时查不到这份文档。如果知识库对一致性要求很高可以改成“双版本 active 标记”的方式新版本 chunk 写入向量库metadata 带上version v2。业务表把当前 active version 从v1切到v2。检索时通过 filter expression 只查 active version。后台异步删除旧版本 chunk。这种方式更接近数据库里的灰度发布。代价是检索时必须带版本过滤索引状态表也要设计得更严谨。如果只是内部知识库问答先删后写通常够用如果是客服、合同、风控、操作指令类场景建议用版本切换避免半更新状态影响线上回答。检索链路也要理解版本很多人只在写入侧做版本检索侧却不加限制结果旧版本仍然可能被召回。Spring AI 的QuestionAnswerAdvisor可以基于SearchRequest配置相似度阈值、topK也支持动态 filter expression。比如在多租户或版本场景下检索时可以限制只查当前租户、当前知识库范围String answer chatClient.prompt() .user(question) .advisors(a - a.param( QuestionAnswerAdvisor.FILTER_EXPRESSION, tenantId t_1001 status ACTIVE )) .call() .content();