RAG 知识库越用越脏?先把“增量更新”设计清楚

很多团队第一次做 RAG,最容易把精力放在"怎么把文档切块、向量化、写入向量库"上。Demo 很快能跑起来:上传 PDF,切成 chunk,调用 Embedding,存进 PGvector,然后用 Spring AI 的 QuestionAnswerAdvisor 做问答。

真正上线后,麻烦才开始。

业务文档会改,制度会废止,产品手册会补丁更新,合同模板会换版本。如果知识库只会"新增向量",不会识别"哪些内容已经变了、哪些内容应该删除",RAG 系统就会慢慢变脏:旧答案和新答案同时被召回,模型看起来很自信,实际引用的是过期内容。

RAG 的增量更新不是一个批处理脚本,而是知识库长期可维护的核心能力。

错误做法:每次都追加 chunk

最常见的做法是这样的:

  1. 用户上传文档。
  2. 后端解析文本。
  3. 文本分块。
  4. 每个 chunk 写入向量数据库。
  5. 问答时按相似度召回。

这个流程适合第一次导入,不适合持续更新。因为它没有回答三个问题:

  • 同一份文档重新上传时,旧 chunk 怎么处理?
  • 文档只改了一小段时,要不要整篇重新向量化?
  • 文档被删除或下线后,向量库里的内容是否同步删除?

如果这些问题不处理,向量库会变成"历史垃圾场"。相似度检索不会天然理解业务版本,它只会把语义接近的内容找出来。旧制度和新制度都和用户问题很接近时,模型很可能混着回答。

增量更新要先定义文档身份

在 Java 后端项目里,不要直接把"文件名"当作唯一标识。文件名会重复,用户会改名,同一个文件也可能来自不同租户、不同业务线。

更稳妥的做法是给知识库文档设计一组元数据:

字段 作用
tenant_id 租户或业务空间隔离
doc_id 业务文档唯一标识
source_hash 原始内容哈希,用于判断内容是否变化
doc_version 文档版本,便于审计和回滚
chunk_index 当前 chunk 在文档中的位置
enabled 是否参与检索
updated_at 最近更新时间

这里的关键不是字段多,而是 doc_id 和 source_hash。

doc_id 解决"这是谁"的问题,source_hash 解决"它变没变"的问题。没有这两个字段,后面很难做可靠的删除、替换和审计。

一种更靠谱的同步流程

RAG 知识库的增量同步可以拆成四种状态:

  • 新文档:向量库不存在,直接解析、分块、写入。
  • 未变化文档:source_hash 一致,跳过向量化。
  • 已变化文档:删除旧 chunk,再写入新 chunk。
  • 已删除文档:删除或禁用对应 chunk。

很多项目会纠结"文档改了一小段,能不能只更新局部 chunk"。理论上可以,但第一版不建议这么做。局部更新需要稳定分块策略、chunk 对齐、版本映射和失败补偿,复杂度很快上升。

工程上更务实的第一版是:以文档为单位替换 chunk。只要发现 source_hash 变化,就删除该 doc_id 下的旧 chunk,再写入新 chunk。对于大多数企业知识库,这个方案已经足够稳定。

Spring AI 里的关键实现思路

Spring AI 提供了文档、文本切分、VectorStore、metadata filter、RAG Advisor 等能力,可以把增量更新做进 Spring Boot 服务里。下面示例只保留关键逻辑,具体 API 可能会随版本变化,实际项目中应以官方文档为准。

XML 复制代码
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-vector-store-pgvector</artifactId>
</dependency>
java 复制代码
@Service
public class KnowledgeSyncService {

    private final VectorStore vectorStore;
    private final TokenTextSplitter textSplitter;
    private final DocumentRepository documentRepository;

    public KnowledgeSyncService(VectorStore vectorStore,
                                DocumentRepository documentRepository) {
        this.vectorStore = vectorStore;
        this.documentRepository = documentRepository;
        this.textSplitter = new TokenTextSplitter();
    }

    public void sync(String tenantId, String docId, String content) {
        String sourceHash = DigestUtils.sha256DigestAsHex(
                content.getBytes(StandardCharsets.UTF_8)
        );

        KnowledgeDocument existing = documentRepository.findByTenantIdAndDocId(tenantId, docId);

        if (existing != null && sourceHash.equals(existing.sourceHash())) {
            return;
        }

        if (existing != null) {
            deleteOldChunks(tenantId, docId);
        }

        Document source = new Document(content, Map.of(
                "tenant_id", tenantId,
                "doc_id", docId,
                "source_hash", sourceHash,
                "doc_version", UUID.randomUUID().toString(),
                "enabled", true
        ));

        List<Document> chunks = textSplitter.apply(List.of(source));

        for (int i = 0; i < chunks.size(); i++) {
            chunks.get(i).getMetadata().put("chunk_index", i);
        }

        vectorStore.add(chunks);
        documentRepository.upsert(tenantId, docId, sourceHash);
    }

    private void deleteOldChunks(String tenantId, String docId) {
        FilterExpressionBuilder b = new FilterExpressionBuilder();

        var filter = b.and(
                b.eq("tenant_id", tenantId),
                b.eq("doc_id", docId)
        ).build();

        vectorStore.delete(filter);
    }
}

这个代码的重点不是 TokenTextSplitter,而是更新顺序:先判断内容是否变化,再按元数据删除旧 chunk,最后写入新 chunk 并更新业务表。

如果担心"删除成功但新增失败",不要把同步逻辑写成无补偿的一次性流程。生产环境里可以把文档状态拆成 SYNCING、READY、FAILED,失败后重试。更严格的场景还可以先写入新版本 chunk,再把旧版本标记为不可检索,最后异步清理。

查询时也要带上过滤条件

增量更新只解决"库里有什么",查询过滤解决"本次能看什么"。如果用户属于某个租户、部门或知识空间,检索时必须把这些条件传给向量库。

java 复制代码
String answer = ChatClient.builder(chatModel)
        .build()
        .prompt()
        .user(question)
        .advisors(QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder()
                        .filterExpression("tenant_id == '" + tenantId + "' && enabled == true")
                        .topK(5)
                        .build())
                .build())
        .call()
        .content();

真实项目里不要随手拼接复杂过滤表达式,尤其是过滤条件来自用户输入时。更好的方式是把租户、知识空间、权限范围这些信息从登录态或服务端上下文生成,避免让前端决定检索边界。

另外,topK 不是越大越好。旧 chunk 没删干净时,topK 调大只会让更多脏数据进入上下文。先把数据生命周期管住,再谈召回优化。

还有几个容易忽略的坑

第一,分块策略变化也应该触发重建。比如你把 chunk size 从 800 改到 500,旧 chunk 和新 chunk 混在一起,召回效果会变得难以评估。可以给 metadata 加一个 chunk_strategy_version。

第二,Embedding 模型变化也应该触发重建。不同模型生成的向量空间不一定兼容,把两种 embedding 混在一个集合里检索,结果可能很怪。

第三,删除策略要可审计。很多企业文档不是物理删除,而是下线、失效、权限变更。向量库里可以物理删,但业务库里最好保留同步记录,方便排查"为什么这份文档搜不到"。

第四,知识库更新不一定要同步阻塞用户请求。上传文档后先落业务库和对象存储,再通过消息队列或异步任务做解析和向量化,用户看到的状态是"处理中"。这比让上传接口卡在 Embedding 调用上稳定得多。

RAG 系统的质量,不只取决于模型和向量数据库。很多时候,决定它能不能长期使用的,是后端工程里这些朴素的设计:文档身份、版本、删除、过滤、重试和审计。先把知识库维护干净,再去优化 rerank、query rewrite 和更复杂的 Agent 编排,顺序会更稳。

相关推荐
江畔柳前堤19 小时前
roLabelImg 详细安装教程
开发语言·人工智能·后端·云原生
阿里云大数据AI技术19 小时前
分链路差异化设计的DSP准实时数仓|钛动科技基于阿里云实时计算 Flink 版 + DLF Paimon + EMR Serverless StarRocks 的实践
人工智能·flink
陕西企来客19 小时前
2026年7月AI智能搜索曝光趋势研判
大数据·人工智能·机器学习·ai智能搜索曝光
白鸽(二般)20 小时前
MinIO Java Client API
java·开发语言
阿里云大数据AI技术20 小时前
从算力到智能体,面向 Agentic AI 的基础设施演进
人工智能·agent
hangyuekejiGEO20 小时前
GEO技术服务选型指南
大数据·人工智能·python
阿里云大数据AI技术20 小时前
EMR Serverless Spark AI Function 的双维降本实践
人工智能·sql·spark
软萌萌的120 小时前
Java Spring Boot 修改yml配置&加载顺序规则
java·spring boot·python
维基框架21 小时前
GitHub源码处理提速 一趟扫描反而更慢
人工智能·github
冬奇Lab21 小时前
代码库知识库系列(05):向量检索 vs 知识图谱——加了调用图并没有变更好
人工智能