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 编排,顺序会更稳。

相关推荐
Soari1 小时前
GitHub 开源项目解析:supermemoryai/supermemory —— AI 时代的持久记忆引擎
人工智能·github·开源项目·mcp·ai记忆引擎·下文搜索
实在智能RPA1 小时前
2026 金融信创替代转型:Agent如何赋能业务平稳过渡?
大数据·人工智能·ai·金融
装不满的克莱因瓶1 小时前
实现矩阵的点积:从数学原理到 NumPy 实战
人工智能·线性代数·算法·机器学习·矩阵·numpy
HyperAI超神经1 小时前
在线教程丨英伟达开源LocateAnything,3B模型可实现图像+视频的目标指向/开放词汇目标检测/指代表达定位/OCR文本定位等功能
人工智能·目标检测·计算机视觉·ocr·目标文本定位
库拉大叔1 小时前
KULAAI 一站式 AI 编程与模型聚合平台效果实测
人工智能
飞翔中文网1 小时前
Java学习笔记之注解
java·笔记·学习
诸葛务农1 小时前
溶液纳米颗粒净化技术及其在光刻胶纳过滤和提纯中的应用(上)
人工智能
oort1231 小时前
VLStream 全开源决策式 AI 视频平台 技术视角完整说明
大数据·开发语言·人工智能·经验分享·python·开源·音视频
Cloud_Shy6181 小时前
解读《Effective Python 3rd Edition》:从练气到老魔(第二章 Item 10 - 12)
c语言·开发语言·网络·人工智能·windows·python·编辑器