Skip to main content

知识库文档管理接口

前面几篇文章把文档的上传和分块处理讲完了:upload 接口负责把文件存到对象存储、元数据存到数据库,状态设为 PENDING;startChunk 接口通过 MQ 异步触发分块处理,把文档切分成 chunk 并写入向量库。这两个接口构成了文档从入库到可检索的完整链路。

文档入库之后,还需要一套管理接口来控制文档的生命周期:删除不需要的文档、更新文档配置、临时下线某个文档等。这篇文章聚焦三个事务型管理接口:delete(删除文档)、update(更新文档信息)、enable(启用/禁用文档)。

这三个接口看起来简单,但背后涉及分布式系统的数据一致性、事务边界设计、幂等性保障等核心问题。

三个接口概览

在深入每个接口之前,先用一张表格横向对比它们的关键特征:

维度deleteupdateenable
HTTP 方法DELETEPUTPATCH
接口路径/knowledge-base/docs/{doc-id}/knowledge-base/docs/{docId}/knowledge-base/docs/{docId}/enable
核心职责删除文档及所有关联数据更新文档信息和配置启用/禁用文档
破坏性高(逻辑删除)中(配置变更)低(状态切换)
事务范围DB + 向量库 + 文件DB + 调度任务DB + chunk + 调度
幂等性非严格幂等部分幂等严格幂等
关联清理chunk/schedule/log/vector/file
调度同步删除调度任务同步调度配置同步调度状态
状态校验禁止 RUNNING禁止 RUNNING禁止 RUNNING

1. 共同特征

三个接口有几个明显的共同点:

  • 都使用事务保护:数据库操作(document、chunk、schedule、chunk_log 表)在 Spring 事务中,保证 ACID 特性。多表操作要么全部成功,要么全部回滚。delete 和 update 使用 @Transactional 注解声明式事务,enable 使用编程式事务(TransactionOperations)以便把耗时的 embedding 调用移到事务外,缩短事务持有时间。

  • 都检查 status != RUNNING:三个接口在执行前都会检查文档是否处于 RUNNING 状态,如果是则直接抛异常。

  • 都涉及多表联动:不只是更新 document 表,还会联动更新 chunk、schedule、chunk_log 等关联表。

1.1 为什么要检查 RUNNING 状态

分块处理是异步的,用户点击执行分块后,startChunk 接口立即返回,实际的分块任务在 MQ 消费者中执行,可能需要几分钟。如果允许在分块运行时删除或修改文档,会导致:

  • 分块任务找不到文档记录:delete 接口把文档删了,分块任务执行到一半发现文档不存在,抛异常。

  • 分块任务使用旧配置:update 接口把 chunkStrategy 从 fixed_size 改成 structure_aware,但分块任务已经开始执行,用的还是旧配置,最终写入的 chunk 和配置不一致。

  • 向量库和数据库数据不一致:分块任务正在写向量库,delete 接口把数据库记录删了,向量库里留下孤儿向量。

所以三个接口都要先检查状态,确保文档不在分块中,才能安全地执行变更操作。

1.2 事务保护的范围

三个接口的事务边界基本一致:

  • 在事务中:document、chunk、schedule、chunk_log 表的增删改操作,以及向量库操作(项目使用 PgVector,向量存储在 PostgreSQL 的 t_knowledge_vector 表,JdbcTemplate 自动参与 Spring 事务)。这些操作在同一个事务中,要么全部成功,要么全部回滚。

  • 不在事务中:文件删除(deleteStoredFileQuietly),文件系统不支持事务,失败时静默处理。

需要特别说明 enable 接口:启用文档时需要调用外部 embedding 模型 API 重新计算向量,这个调用耗时较长,如果放在事务内会长时间占用数据库连接。所以 enable 接口用编程式事务,把 embedding 计算提前到事务外,只在事务内做 DB 写入和向量写入。

这就引出了一个核心问题:如何保证数据库、向量库、文件系统三者的数据一致性?后面会单独拆一个大节深入讨论。

2. 差异点分析

三个接口的差异主要体现在:

  • 破坏性程度:delete 最强(删除所有数据),update 中等(修改配置),enable 最弱(只改状态)。

  • 幂等性设计:enable 是严格幂等(先检查当前状态),update 是部分幂等(重复更新相同内容结果不变),delete 是非严格幂等(重复删除会抛异常,但符合业务语义)。

  • 关联数据清理:delete 需要清理 chunk、schedule、log、vector、file 五类关联数据,update 和 enable 不需要清理,只需要同步更新。

delete 接口详解

1. 接口定义

Controller 层的代码很简单:

@DeleteMapping("/knowledge-base/docs/{doc-id}")
public Result<Void> delete(@PathVariable(value = "doc-id") String docId) {
documentService.delete(docId);
return Results.success();
}

接口路径 DELETE /knowledge-base/docs/{doc-id},接收一个路径参数 docId,返回 Result<Void>。注意这里用的是 DELETE 动词,符合 RESTful 规范。

2. 核心实现流程

下面这张图展示了 delete 接口的完整执行流程和事务边界:

Service 层的 delete 方法完整代码如下:

@Override
@Transactional(rollbackFor = Exception.class)
public void delete(String docId) {
// 1. 查询文档记录,校验存在性
KnowledgeDocumentDO documentDO = documentMapper.selectById(docId);
Assert.notNull(documentDO, () -> new ClientException("文档不存在"));

// 2. 检查文档状态,禁止删除正在分块的文档
if (DocumentStatus.RUNNING.getCode().equals(documentDO.getStatus())) {
throw new ClientException("文档正在分块中,无法删除");
}

// 3. 删除关联数据:chunk 记录、调度任务、分块日志
knowledgeChunkService.deleteByDocId(docId);
scheduleService.deleteByDocId(docId);
chunkLogMapper.delete(Wrappers.lambdaQuery(KnowledgeDocumentChunkLogDO.class)
.eq(KnowledgeDocumentChunkLogDO::getDocId, docId));

// 4. 逻辑删除文档记录:设置 deleted=1
documentDO.setDeleted(1);
documentDO.setUpdatedBy(UserContext.getUsername());
documentMapper.deleteById(documentDO);

// 5. 删除向量库中的向量
String collectionName = resolveCollectionName(documentDO.getKbId());
vectorStoreService.deleteDocumentVectors(collectionName, docId);

// 6. 删除存储的文件
deleteStoredFileQuietly(documentDO);
}

整个流程可以分为六个步骤,下面逐个拆解。

2.1 状态校验

KnowledgeDocumentDO documentDO = documentMapper.selectById(docId);
Assert.notNull(documentDO, () -> new ClientException("文档不存在"));

if (DocumentStatus.RUNNING.getCode().equals(documentDO.getStatus())) {
throw new ClientException("文档正在分块中,无法删除");
}

和前面介绍的共同特征一样,先查文档是否存在,再检查是否 RUNNING,状态校验的原因参见 §1.1,这里不再重复。

2.2 删除关联数据

knowledgeChunkService.deleteByDocId(docId);
scheduleService.deleteByDocId(docId);
chunkLogMapper.delete(Wrappers.lambdaQuery(KnowledgeDocumentChunkLogDO.class)
.eq(KnowledgeDocumentChunkLogDO::getDocId, docId));

按顺序删除三类关联数据:

  • chunk 记录:调用 knowledgeChunkService.deleteByDocId(docId),删除 t_knowledge_chunk 表中该文档的所有分块记录。

  • 调度任务:调用 scheduleService.deleteByDocId(docId),删除 t_knowledge_document_schedule 表中该文档的定时同步任务(如果有)。

  • 分块日志:直接用 MyBatis Plus 的 delete 方法,删除 t_knowledge_document_chunk_log 表中该文档的所有分块日志。

2.3 逻辑删除文档

documentDO.setDeleted(1);
documentDO.setUpdatedBy(UserContext.getUsername());
documentMapper.deleteById(documentDO);

这里用的是 MyBatis Plus 的逻辑删除机制:设置 deleted=1,然后调用 deleteById。MyBatis Plus 会自动把这个操作转换成 UPDATE t_knowledge_document SET deleted=1 WHERE id=?,而不是真正的 DELETE

为什么是逻辑删除而不是物理删除?后面会单独讨论。

2.4 删除向量库数据

String collectionName = resolveCollectionName(documentDO.getKbId());
vectorStoreService.deleteDocumentVectors(collectionName, docId);

调用 vectorStoreService.deleteDocumentVectors 删除向量库中该文档的所有向量。项目使用 PgVector,底层是 JdbcTemplate 操作 t_knowledge_vector 表,和业务表共享同一个 Spring 事务,这个操作和前面的数据库操作是原子的。

resolveCollectionName 方法根据知识库 ID 查询 collection 名称,因为向量库是按 collection 组织的,不同知识库的向量存在不同的 collection 中。

2.5 删除存储文件

deleteStoredFileQuietly(documentDO);

调用 deleteStoredFileQuietly 删除对象存储中的文件。方法名里的 Quietly 表示静默失败:如果文件删除失败(比如文件已经不存在、网络超时),不抛异常,只记录日志。

为什么要静默失败?因为文件删除失败不影响核心业务逻辑,数据库记录已经删了,向量也删了,文件留着最多占点存储空间,可以通过定时任务清理。如果文件删除失败就回滚整个事务,代价太大。

3. 设计要点

项目中用的是逻辑删除而不是物理删除,主要有四个原因:

  • 数据审计:企业级系统需要保留操作记录,谁在什么时候删除了哪个文档,这些信息对于审计和问题排查很重要。物理删除后数据彻底消失,无法追溯。

  • 误删恢复:用户可能手抖点错了,或者删除后发现还需要这个文档。逻辑删除可以提供恢复功能,只需要把 deleted 字段改回 0。物理删除后数据无法恢复。

  • 关联数据追溯:文档删除后,chunk、log 等关联数据也删了,但如果需要排查历史问题(比如某个文档为什么分块失败了 10 次),逻辑删除可以保留这些记录。

  • 合规要求:某些行业(金融、医疗)有数据保留期的合规要求,必须保留一定时间的历史数据,不能物理删除。

当然,逻辑删除也有缺点:数据库会越来越大,查询时需要加 deleted=0 条件。项目中可以通过定时任务归档或物理删除超过保留期的数据。

其实这里也可以用编程式事务来写,将最后一步 deleteStoredFileQuietly 放到事务外,不过呢考虑到删除本身没那么耗时,我也就使用了声明式事务注解做了。包括下面的 update 也是一样的逻辑。

解锁付费内容,👉 戳