RAG增量更新:如何避免旧知识污染?
2026/8/4 18:38:49 网站建设 项目流程

很多团队第一次做 RAG,流程都差不多:上传文档、解析文本、切 chunk、生成 embedding、写入向量库,然后在问答时做相似度检索。

Demo 跑通以后,真正的问题才出现:文档更新了怎么办?

比如一份《售后政策》从 v1 改到 v2,旧条款已经废弃,但向量库里还躺着旧 chunk。用户问“七天无理由规则是什么”,系统可能同时召回新旧两段内容,模型再把它们揉成一个看似合理、实际错误的答案。

RAG 的增量更新不是“把新文档再导入一次”。它本质上是一个索引一致性问题:业务系统里的知识变了,向量索引也要能删除旧版本、写入新版本,并且让检索链路知道当前哪些内容才是可信的。

旧知识污染比召回不到更危险

召回不到,用户通常能感知到系统“不知道”。但召回到过期内容,模型会很自然地生成一个确定语气的错误答案。

这类问题在企业知识库里很常见:

场景

如果只追加会发生什么

制度文档修订

新旧制度同时被召回

接口文档更新

Agent 仍然按旧参数调用

FAQ 答案调整

客服答案出现前后不一致

产品价格变更

模型引用过期价格

权限文档迁移

已废弃权限规则继续生效

向量库不是数据库的替代品。它擅长相似度检索,但不天然知道“哪条知识已经被业务废弃”。这个判断必须由索引设计来表达。

给每个 chunk 带上可重建的身份

在 RAG 里,一个原始文档通常会被拆成多个 chunk。真正进入向量库的不是“文档”,而是“文档片段”。所以增量更新的关键不是只记录文件名,而是让每个 chunk 都能追溯回原始文档和版本。

建议至少保留这些 metadata:

字段

作用

docId

原始文档稳定 ID,删除旧 chunk 时使用

contentHash

判断内容是否变化,避免重复 embedding

version

文档版本,便于排查和灰度

chunkIndex

chunk 顺序,便于定位和上下文拼接

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 展开。

      @Servicepublic 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()));List<Document> chunks = splitter.apply(List.of(raw));List<Document> indexedChunks = IntStream.range(0, chunks.size()).mapToObj(i -> {Document chunk = chunks.get(i);Map<String, 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 标记”的方式:

      1. 新版本 chunk 写入向量库,metadata 带上 version = v2。

      2. 业务表把当前 active version 从 v1 切到 v2。

      3. 检索时通过 filter expression 只查 active version。

      4. 后台异步删除旧版本 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();

        这里不要把 filter 只理解成“权限隔离”。它也是 RAG 工程化里控制知识有效性的手段。一个成熟的 RAG 系统,写入侧和检索侧必须共享同一套 metadata 约定。

        真实项目里还要补三件事

        第一,索引任务要可重试。文档解析、embedding、向量库写入都有可能失败,不要把它们塞进一个不可观察的同步接口里。更稳的做法是通过 MQ 或任务表异步处理,失败后记录错误原因和重试次数。

        第二,删除要分批。Spring AI 文档也提醒,大量删除可能给后端向量库带来压力,filter 删除的性能还和具体实现有关。大文档、批量导入、整库重建时,要控制批次大小。

        第三,答案要能溯源。每个 chunk 的 metadata 里保留 docId、sourceUri、version 和 chunkIndex,不仅是为了更新,也是为了在用户质疑答案时能定位来源。RAG 系统没有溯源能力,排查成本会非常高。

        把 RAG 做到能回答问题并不难,难的是当知识不断变化时,系统仍然能回答“当前正确的内容”。对 Java 后端开发者来说,向量库只是新型索引,真正决定系统稳定性的,还是那些熟悉的东西:ID 设计、状态表、异步任务、重试、审计和一致性边界。

        需要专业的网站建设服务?

        联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

        立即咨询