知识库文档管理接口
前面几篇文章把文档的上传和分块处理讲完了:upload 接口负责把文件存到对象存储、元数据存到数据库,状态设为 PENDING;startChunk 接口通过 MQ 异步触发分块处理,把文档切分成 chunk 并写入向量库。这两个接口构成了文档从入库到可检索的完整链路。
文档入库之后,还需要一套管理接口来控制文档的生命周期:删除不需要的文档、更新文档配置、临时下线某个文档等。这篇文章聚焦三个事务型管理接口:delete(删除文档)、update(更新文档信息)、enable(启用/禁用文档)。
这三个接口看起来简单,但背后涉及分布式系统的数据一致性、事务边界设计、幂等性保障等核心问题。
三个接口概览
在深入每个接口之前,先用一张表格横向对比它们的关键特征:
| 维度 | delete | update | enable |
|---|---|---|---|
| HTTP 方法 | DELETE | PUT | PATCH |
| 接口路径 | /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 也是一样的逻辑。