Spring AI RAG - 12 文档更新与全链路删除

文章目录

引言

知识库不是一次性建立就万事大吉的。文档会过期、会修订、会上传错误版本。如果不能优雅地处理"更新"和"删除",向量库很快就会变成一锅杂乱的"垃圾汤"------新旧内容混在一起,AI 回答时会从过期或错误的内容中检索信息。

本篇将解析项目中文档生命周期管理的设计:从 OSS、向量库到业务表的"全链路清洗"机制。

设计说明

两类典型场景

场景一:文档版本迭代(Update)

例如《公司报销制度》每年更新,需要把旧版本替换为新版本。

错误做法:原地修补

试图找出旧文档的几百个 chunk,逐一对比内容,只更新变动的部分。这条路在工程上是灾难性的:

  • 逻辑极其复杂(需要语义对比)
  • 容易产生数据碎片
  • 一旦更新错了无法回滚

正确做法:版本隔离

把每个文档当作一个独立的"知识库单元"。文档更新 = 删除旧版 + 上传新版。如果业务需要保留历史,可以让旧文件不删除,只是从用户的"可见列表"中下线。

场景二:传错文件(Delete)

例如运营人员误将一份涉密 Excel 上传到了公开知识库,必须立即彻底删除。

解决方案:三层联动清理

复制代码
[1] 业务 DB 删除文件记录
        ↓
[2] 向量库删除对应 chunk
        ↓
[3] OSS 删除原始文件

任何一层残留,都可能造成信息泄露或检索污染。

数据关联设计

整个删除链路能跑通的前提,是入库时就建立了完整的关联:

复制代码
ali_oss_file 表
    ├── file_name      # 业务可读
    ├── url            # OSS 物理位置
    └── vector_id      # JSON 数组,记录所有 chunk 的向量库 ID

vector_id 是关键。入库时把每个 chunk 在 Milvus 中的 ID 序列化为 JSON 数组存起来,删除时反序列化逐个删除。

为什么不直接按 source 元数据删除?

理论上可以这样:

java 复制代码
vectorStore.delete("source == 'xxx.pdf'");

但有几个问题:

  1. 不同向量库支持不同:Milvus 的 expression 语法和别家不一样,跨库迁移困难
  2. 元数据可能重复:同一文件名可能上传过多次,删除会误伤
  3. 删除时机不一致:业务表删除成功了,向量库可能因为表达式问题失败

显式记录 vector_id 是最稳的方式。

原理方案

入库时记录 vector_id

java 复制代码
List<Document> splitDocuments = tokenTextSplitter.apply(documents);
vectorStore.add(splitDocuments);

// 收集所有 chunk 的 ID
List<String> ids = splitDocuments.stream()
        .map(Document::getId)
        .collect(Collectors.toList());

aliOssFileService.save(AliOssFile.builder()
        .fileName(originalFilename)
        .vectorId(JSON.toJSONString(ids))
        .url(url)
        .createTime(new Date())
        .updateTime(new Date())
        .build());

vectorStore.add(documents) 内部会为每个 Document 分配 UUID 作为 ID(如果没有显式设置)。这些 ID 就是后续删除时的依据。

删除时的反序列化

java 复制代码
List<String> vectorIds = JSON.parseArray(aliOssFile.getVectorId(), String.class);
vectorStore.delete(vectorIds);

把 TEXT 字段中存的 JSON 数组反序列化为 List<String>,调用 vectorStore.delete(ids) 批量删除。

事务保证

业务表的删除应该用事务保证原子性:

java 复制代码
@Transactional(rollbackFor = Exception.class)
public BaseResponse deleteFiles(List<Long> ids) {
    // ...
}

但要注意:vectorStore.delete()aliOssUtil.deleteOss() 是外部资源调用,不在事务范围内。即使这两步失败,业务表的删除也会提交。

更稳的方案是"先备份后删除 + 失败补偿":

java 复制代码
// 1. 先记录待删除的资源
DeleteTask task = new DeleteTask();
task.setVectorIds(vectorIds);
task.setOssUrl(url);
deleteTaskService.save(task);

// 2. 删除业务表(事务内)
fileService.removeByIds(ids);

// 3. 异步执行外部删除
asyncExecutor.submit(() -> {
    vectorStore.delete(vectorIds);
    aliOssUtil.deleteOss(url);
    deleteTaskService.markDone(task.getId());
});

// 4. 定时任务扫描未完成的 DeleteTask 重试

源码解析

deleteFiles 完整实现

java 复制代码
@Override
@Transactional(rollbackFor = Exception.class)
public BaseResponse deleteFiles(List<Long> ids) {
    // 1. 校验
    if (ids.isEmpty()) {
        return ResultUtils.error(ErrorCode.PARAMS_ERROR, "请选择文件");
    }
    
    // 2. 查询待删除的文件记录
    List<AliOssFile> aliOssFiles = aliOssFileMapper.selectByIds(ids);

    // 3. 业务表删除
    int count = aliOssFileMapper.deleteBatchIds(ids);
    if (count == 0) {
        return ResultUtils.error(ErrorCode.OPERATION_ERROR, "删除失败");
    }
    
    // 4. 全链路清理
    for (AliOssFile aliOssFile : aliOssFiles) {
        // 4.1 删除向量库中的 chunk
        List<String> vectorIds = JSON.parseArray(aliOssFile.getVectorId(), String.class);
        vectorStore.delete(vectorIds);
        
        // 4.2 删除 OSS 原始文件
        aliOssUtil.deleteOss(aliOssFile.getUrl());
    }

    return ResultUtils.success("成功删除" + count + "个文件");
}

逐步解析:

  1. 先查后删 :必须先 selectByIds 拿到完整记录(含 vector_id 和 url),否则删除后这些信息就丢了
  2. 业务表先删:先删 ali_oss_file 表的记录,确认删除成功后再清理外部资源
  3. 批量循环:每个文件分别清理向量和 OSS
  4. JSON 反序列化JSON.parseArray(vectorId, String.class) 把 TEXT 字段还原为 List

Controller 层接口

java 复制代码
@Operation(summary = "delete", description = "文件删除")
@DeleteMapping("/delete")
public BaseResponse deleteFiles(@RequestParam List<Long> ids) {
    return aliOssFileService.deleteFiles(ids);
}

支持批量删除,前端可以勾选多个文件一次性清理。

AliOssUtil.deleteOss 实现

java 复制代码
public boolean deleteOss(String objectName) {
    OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
    try {
        URL url = new URL(objectName);
        // 从 URL 中提取 OSS 的 objectName(去掉前导斜杠)
        String fileName = url.getPath().replaceFirst("/", "");
        ossClient.deleteObject(bucketName, fileName);
    } catch (MalformedURLException e) {
        throw new RuntimeException(e);
    } finally {
        if (ossClient != null) {
            ossClient.shutdown();
        }
    }
    return true;
}

注意点:

  • 业务表存的是完整 URL(如 https://bucket.endpoint/xxx.pdf),需要先解析出 path
  • replaceFirst("/", "") 去掉前导斜杠,得到 OSS 的 objectName
  • 删除后释放 OSSClient,避免连接泄漏

文件分页查询接口

java 复制代码
@Override
public BaseResponse queryPage(QueryFileDTO request) {
    Page<AliOssFile> page = new Page<>(request.getPage(), request.getPageSize());
    IPage<AliOssFile> fileList = aliOssFileMapper.findByFileNameContaining(page, request.getFileName());
    return ResultUtils.success(fileList);
}

支持按文件名模糊搜索,前端展示已上传的所有文档。

文档更新的实现思路

项目代码中没有显式的"更新"接口,但可以基于"删除 + 重新上传"实现:

java 复制代码
@PostMapping("/update")
@Transactional(rollbackFor = Exception.class)
public BaseResponse updateFile(
        @RequestParam Long oldFileId,
        @RequestParam("file") MultipartFile newFile) {
    
    // 1. 删除旧文件(业务表 + 向量库 + OSS)
    aliOssFileService.deleteFiles(Collections.singletonList(oldFileId));
    
    // 2. 上传新文件(走原有 upload 流程)
    return uploadSingle(newFile);
}

或者保留旧文件,新文件作为新的版本:

java 复制代码
// 旧文件状态置为 ARCHIVED,从用户列表中下线
aliOssFile.setStatus("ARCHIVED");
aliOssFileService.updateById(aliOssFile);

// 新文件正常入库
uploadSingle(newFile);

验证结果

单文件删除

请求:

复制代码
DELETE /api/v1/knowledge/delete?ids=1

响应:

json 复制代码
{
    "code": 0,
    "data": "成功删除1个文件"
}

验证:

  1. 业务表

    sql 复制代码
    SELECT * FROM ali_oss_file WHERE id = 1;
    -- (返回空)
  2. 向量库:通过 Attu 查询 vector_store collection,确认对应 chunk 已删除

  3. OSS:登录阿里云控制台,确认对应文件已不存在

批量删除

请求:

复制代码
DELETE /api/v1/knowledge/delete?ids=1,2,3

响应:

json 复制代码
{ "code": 0, "data": "成功删除3个文件" }

容错测试

场景:向量库连接断开时删除

预期:业务表删除成功,向量库删除抛出异常,导致整个事务回滚(rollbackFor = Exception.class)。

实际:因为 vectorStore.delete 在事务内调用,异常会触发回滚。但这里有个隐患------OSS 已经被删除(OSS 操作不可回滚)。

更稳的实现需要"补偿队列"机制,前面已经讨论过。

优化方向

软删除策略

物理删除不可恢复,运营误操作会造成大麻烦。可以引入软删除:

sql 复制代码
ALTER TABLE ali_oss_file ADD COLUMN deleted TINYINT DEFAULT 0;
ALTER TABLE ali_oss_file ADD COLUMN delete_time TIMESTAMP NULL;
java 复制代码
public BaseResponse softDelete(List<Long> ids) {
    UpdateWrapper<AliOssFile> uw = new UpdateWrapper<>();
    uw.set("deleted", 1)
      .set("delete_time", new Date())
      .in("id", ids);
    aliOssFileService.update(uw);
    
    // 同时把向量库的对应 chunk 加上 metadata 标记
    // 检索时 filter expression 排除 deleted=1
    
    return ResultUtils.success("已删除");
}

定时任务清理"已删除超过 30 天"的记录,真正释放资源。

异步删除 + 重试

业务接口快速返回,外部资源清理放到队列:

java 复制代码
@DeleteMapping("/delete")
public BaseResponse deleteFiles(@RequestParam List<Long> ids) {
    // 1. 业务表标记为待删除
    aliOssFileService.markPendingDelete(ids);
    
    // 2. 推送到 RabbitMQ
    rabbitTemplate.convertAndSend("delete.queue", ids);
    
    // 3. 立即返回
    return ResultUtils.success("删除请求已提交");
}

@RabbitListener(queues = "delete.queue")
public void handleDelete(List<Long> ids) {
    // 真正执行删除,失败时 reject 重新入队
}

删除前置确认

对涉密文件做二次确认:

java 复制代码
// 1. 调用 /delete/preview,返回将要删除的资源详情
// 2. 用户确认
// 3. 调用 /delete/confirm,传入 token(来自 preview 接口)

防止误删。

删除审计日志

java 复制代码
@Loggable
@DeleteMapping("/delete")
public BaseResponse deleteFiles(@RequestParam List<Long> ids) {
    log.warn("用户 {} 删除文件: {}", BaseContext.getCurrentId(), ids);
    return aliOssFileService.deleteFiles(ids);
}

任何敏感数据操作都应留痕,便于事后审计。

全量重建工具

如果向量库数据出现严重不一致,可以基于 ali_oss_file 表 + OSS 中的原始文件重建:

java 复制代码
public void rebuildVectorStore() {
    // 1. 清空 Milvus collection
    vectorStore.delete(allIds);
    
    // 2. 遍历 ali_oss_file
    for (AliOssFile file : aliOssFileService.list()) {
        // 3. 从 OSS 下载
        byte[] bytes = aliOssUtil.download(file.getUrl());
        
        // 4. 重新解析 + 向量化 + 入库
        List<Document> docs = parse(bytes);
        vectorStore.add(docs);
        
        // 5. 更新 vector_id
        file.setVectorId(JSON.toJSONString(docs.stream().map(Document::getId).toList()));
        aliOssFileService.updateById(file);
    }
}

OSS 中的原始文件是最可靠的"真相源",永远保留。

知识库版本快照

每次大规模更新前打个快照:

sql 复制代码
CREATE TABLE knowledge_snapshot AS SELECT * FROM ali_oss_file;

或者把 vector_id 做成版本化的:

复制代码
v1.vector_id_for_file_1
v2.vector_id_for_file_1

回滚就是切换到旧版本。

失败资源补偿表

专门一张表记录"删除失败待重试"的资源:

sql 复制代码
CREATE TABLE delete_compensation (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    resource_type VARCHAR(20),  -- 'vector' / 'oss'
    resource_id TEXT,
    retry_count INT DEFAULT 0,
    last_error TEXT,
    created_at TIMESTAMP,
    completed BOOLEAN DEFAULT FALSE
);

定时任务扫描未完成的,按指数退避策略重试。

小结

本篇梳理了文档生命周期管理:

  • 入库时记录 vector_id,是后续删除的关键
  • "业务表 → 向量库 → OSS"三层联动清理
  • 事务能保证业务表的原子性,但外部资源需要补偿机制
  • 文档更新 = 删除旧版 + 上传新版,避免原地修补

进阶方向:软删除、异步队列、版本快照、补偿重试。

下一篇将聚焦"防幻觉与召回率优化"------如何让 RAG 系统更可靠、更准确。

相关推荐
薛定猫AI2 小时前
【深度解析】终端原生 AI 编程代理如何重塑开发工作流:从 Mistral Vibe 看 CLI 自动化实战
运维·人工智能·自动化
Magic-Yuan2 小时前
致命的耳语 - 提示词注入
人工智能·安全
武雄(小星Ai)2 小时前
GitHub Copilot Desktop 多 Agent 实测
人工智能·aigc·agent
盈达科技2 小时前
谷歌 officially 定性:GEO 不是新赛道,而是 SEO 的延伸 —— 但其他 AI 平台同意吗?
人工智能
东方佑2 小时前
生成即测度坍缩:深度学习生成模型必须构建连续波函数并从采样获取离散输出
人工智能·深度学习
财经资讯数据_灵砚智能2 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年5月17日
大数据·人工智能·python·信息可视化·自然语言处理
架构源启2 小时前
Spring AI 进阶系列- Agent 智能体开发:ReAct模式、多步推理与自主Agent实战
人工智能·spring·react·ai agent·智能体·springai
萤萤七悬2 小时前
【AI帮玩游戏】一、搭建Claude+vscode环境,先看看异环ok-nte项目
人工智能·vscode·玩游戏
深度学习lover2 小时前
<数据集>yolo 瓜果蔬菜识别<目标检测>
人工智能·python·yolo·目标检测·计算机视觉·瓜果蔬菜识别