知识库数据分块管理接口
上一篇讲完了文档的生命周期管理——删除、更新、启用/禁用,都是针对文档整体的操作。但文档粒度太粗了,自动分块之后,实际参与检索的是 Chunk。用户有时候需要针对某一段具体内容做调整,文档级操作够不到这个层面。
自动分块用一套固定参数处理整份文档,大多数内容效果还行,但总有些段落切出来不对劲。常见的就两类问题:
- 一是截断,切割位置踩到一段完整描述的中间,前半截在一个 Chunk 里,后半截在下一个,各自语义残缺
- 二是稀释,某条关键但简短的条款被合并进前后的大段文字里,检索时语义被淹没,很难精准命中。这两种情况靠调参数解决不了——参数是全局的,顾了大段顾不了短句,顾了整体顾不了边角
最直接的方式是针对有问题的 Chunk 做手动调整:把截断的两段合并编辑成完整的 Chunk,把混入噪音的段落拆出来单独处理,或者把质量明显差的 Chunk 直接禁用。这比 重新调整分块策略、重跑整个文档的代价小得多。更重要的是,即便调整策略重跑,边界情况永远存在——手动干预是兜底能力,不是临时替代方案。
这就是 Chunk 管理接口存在的原因:不是让用户从头手写知识库,而是给自动分块的结果提供精准干预的入口。
KnowledgeChunkController 共有 6 个接口,覆盖了 Chunk 的完整 CRUD 加启用/禁用控制。
六个接口概览
先整体看一下这 6 个接口的关键维度:
| 接口 | HTTP 方法 | 路径 | 向量操作 | 事务方式 |
|---|---|---|---|---|
| 分页查询 | GET | /knowledge-base/docs/{doc-id}/chunks | 无 | 无事务 |
| 新增 Chunk | POST | /knowledge-base/docs/{doc-id}/chunks | 写入向量库 | @Transactional |
| 更新内容 | PUT | /knowledge-base/docs/{doc-id}/chunks/{chunk-id} | 删旧插新 | @Transactional |
| 删除 Chunk | DELETE | /knowledge-base/docs/{doc-id}/chunks/{chunk-id} | 精准删除单条 | @Transactional |
| 启用/禁用单条 | PATCH | /knowledge-base/docs/{doc-id}/chunks/{chunk-id}/enable | 按需写入或删除 | @Transactional |
| 批量启用/禁用 | PATCH | /knowledge-base/docs/{doc-id}/chunks/batch-enable | 精准写入或精准删除 | 编程式事务 |
pageQuery 接口详解
1. 接口与实现
@GetMapping("/knowledge-base/docs/{doc-id}/chunks")
public Result<IPage<KnowledgeChunkVO>> pageQuery(@PathVariable("doc-id") String docId,
@Validated KnowledgeChunkPageRequest requestParam) {
return Results.success(knowledgeChunkService.pageQuery(docId, requestParam));
}
KnowledgeChunkPageRequest 继承了 MyBatis Plus 的 Page(带 current、size),扩展了一个 enabled 字段用于状态过滤。
Service 层实现:
@Override
public IPage<KnowledgeChunkVO> pageQuery(String docId, KnowledgeChunkPageRequest requestParam) {
KnowledgeDocumentDO documentDO = documentMapper.selectById(docId);
Assert.notNull(documentDO, () -> new ClientException("文档不存在"));
LambdaQueryWrapper<KnowledgeChunkDO> queryWrapper = new LambdaQueryWrapper<KnowledgeChunkDO>()
.eq(KnowledgeChunkDO::getDocId, docId)
.eq(requestParam.getEnabled() != null, KnowledgeChunkDO::getEnabled, requestParam.getEnabled())
.orderByAsc(KnowledgeChunkDO::getChunkIndex);
Page<KnowledgeChunkDO> page = new Page<>(requestParam.getCurrent(), requestParam.getSize());
IPage<KnowledgeChunkDO> result = chunkMapper.selectPage(page, queryWrapper);
return result.convert(each -> BeanUtil.toBean(each, KnowledgeChunkVO.class));
}
2. 设计要点
2.1 按 chunkIndex 排序
chunkIndex 记录的是 Chunk 在原文档中的顺序(从 0 开始)。分块处理时按文档内容顺序生成,用户在管理界面看 Chunk 列表时,需要按这个顺序展示,才能直观判断某段内容是文档的哪个部分、前后文是什么。
如果按 id 或 createTime 排序,手动新增的 Chunk 会插到末尾,即使它在逻辑上应该排在中间,视觉上会很混乱。
2.2 enabled 过滤的实际用途
enabled 传 null 时查全部,传 1 查已启用,传 0 查已禁用。
这个过滤在实际使用中有几个场景:看某文档里有哪些 Chunk 当前参与检索(传 1);审查被禁用的内容、确认是否需要恢复(传 0);查看全貌时不传,看完整的分块结构。
create 接口详解
1. 接口定义
@PostMapping("/knowledge-base/docs/{doc-id}/chunks")
public Result<KnowledgeChunkVO> create(@PathVariable("doc-id") String docId,
@RequestBody KnowledgeChunkCreateRequest request) {
return Results.success(knowledgeChunkService.create(docId, request));
}
KnowledgeChunkCreateRequest 有三个字段:content(必填)、index(可选,指定 Chunk 序号,不传则自动追加到末尾)、chunkId(前端不会传,文档操作接口里会用到)。
2. 核心实现流程
@Override
@Transactional(rollbackFor = Exception.class)
public KnowledgeChunkVO create(String docId, KnowledgeChunkCreateRequest requestParam) {
KnowledgeDocumentDO documentDO = documentMapper.selectById(docId);
Assert.notNull(documentDO, () -> new ClientException("文档不存在"));
if (DocumentStatus.RUNNING.getCode().equals(documentDO.getStatus())) {
throw new ClientException("文档正在分块处理中,暂不支持新增 Chunk");
}
if (!Integer.valueOf(1).equals(documentDO.getEnabled())) {
throw new ClientException("文档未启用,暂不支持新增 Chunk");
}
String content = requestParam.getContent();
Assert.notBlank(content, () -> new ClientException("Chunk 内容不能为空"));
// 查当前最大 chunkIndex,用于自动追加
KnowledgeChunkDO latest = chunkMapper.selectOne(
Wrappers.lambdaQuery(KnowledgeChunkDO.class)
.eq(KnowledgeChunkDO::getDocId, docId)
.orderByDesc(KnowledgeChunkDO::getChunkIndex)
.last("LIMIT 1")
);
// 优先使用请求中指定的 index,未指定则自动追加到末尾
int chunkIndex = requestParam.getIndex() != null
? requestParam.getIndex()
: (latest != null ? latest.getChunkIndex() + 1 : 0);
String contentHash = SecureUtil.sha256(content);
int charCount = content.length();
KnowledgeBaseDO kbDO = knowledgeBaseMapper.selectById(documentDO.getKbId());
String embeddingModel = kbDO.getEmbeddingModel();
String collectionName = kbDO.getCollectionName();
Integer tokenCount = resolveTokenCount(content);
KnowledgeChunkDO chunkDO = KnowledgeChunkDO.builder()
.id(requestParam.getChunkId())
.kbId(documentDO.getKbId())
.docId(docId)
.chunkIndex(chunkIndex)
.content(content)
.contentHash(contentHash)
.charCount(charCount)
.tokenCount(tokenCount)
.enabled(1)
.createdBy(UserContext.getUsername())
.updatedBy(UserContext.getUsername())
.build();
chunkMapper.insert(chunkDO);
// chunk_count 自增
documentMapper.update(Wrappers.lambdaUpdate(KnowledgeDocumentDO.class)
.eq(KnowledgeDocumentDO::getId, docId)
.setSql("chunk_count = chunk_count + 1"));
// 同步写入向量库
syncChunkToVector(collectionName, docId, chunkDO, embeddingModel);
return BeanUtil.toBean(chunkDO, KnowledgeChunkVO.class);
}
2.1 为什么要求文档启用才能新增 Chunk
前置校验有三个:文档存在 → status != RUNNING → enabled = 1。
前两个好理解,第三个值得说一下。禁用文档的语义是这份文档暂时下线,不参与检索。如果允许在禁用文档上新增 Chunk,新增的 Chunk 会立即写入向量库(create 方法末尾调用 syncChunkToVector),这个 Chunk 就进了向量库参与检索,但它的父文档已经被标记为禁用。数据库说不参与检索,向量库说可以被检索到,这就矛盾了。
更直接的说:禁用文档往往是因为这份文档的内容有问题,需要整体下线处理,这种时候往里面加内容没有意义。