前面我们通过文档的上传,以及文档的转换处理,已经可以拿到一份markdown文档了,接着,我们就要针对这份文档做切分了,切片成多个chunk,用于embedding。
markdown标题切分
我们这里选择的切块方式,针对markdown的话,选择的是基于标题的切分方式。因为前面我们讲过,mineru默认会把所有标题都解析成1级标题,但是其实实测下来大多数影响不太大。
如以下文档,会被切分成3段。
上面的第一个分段,因为只有标题,其实这个分段并没什么太大的用,我们可以在工程中把他过滤掉,当然,最好的办法就是像我们之前在RAG章节讲的那样,能够做到分块的合并,但是需要有标题的层级关系才行。如果没有层级关系,只有这种一级标题的话,是没办法做合并分块的。
所以可以根据文档的实际情况来判断下,如果这个标题真的很重要,重要到影响检索了,那么就需要在转换的时候指定llm_aided_title,让他基于大模型来做多级标题的识别。
但是实测下来,这种其实影响并不大。因为一般标题中的内容和正文内容都是有关联的,而只要有关联,语义相似度就可以做比较好的检索。如果效果不好,那就上其他优化手段,比如重写、多路检索、重排序等方式做优化。
切分代码逻辑
因为我们之前在上传后,已经在KnowledgeDocument表中存了一条记录了,那么就可以针对这个Document做切分,切分后的多个分段,分别保存在KnowledgeSegment中,并且记录顺序,方便我们在后台能够看到一个文档对应的分段的具体情况。就像很多智能体平台上的分段预览一样。
@PostMapping("/split/{documentId}")
@Transactional
public Integer splitDocument(@PathVariable Long documentId) {
// 1. 查询文档
KnowledgeDocument document = knowledgeDocumentService.getById(documentId);
Assert.notNull(document, "文档不存在");
Assert.notNull(document.getConvertedDocUrl(), "文档未转换完成");
if (document.getStatus() == DocumentStatus.CHUNKED) {
// 返回已切分的分段数量
Long chunkedCount = knowledgeSegmentService.count(new QueryWrapper<KnowledgeSegment>()
.eq("document_id", documentId)
.eq("skipEmbedding", 0));
return chunkedCount.intValue();
}
if(document.getStatus() != DocumentStatus.CONVERTED){
throw new RuntimeException("文档状态不为CONVERTED,无法完成切分");
}
// 2. 从MinIO下载文件内容
String convertedDocUrl = document.getConvertedDocUrl();
String objectName = extractObjectNameFromUrl(convertedDocUrl);
Assert.notNull(objectName, "无法解析文档URL");
String content;
try (InputStream inputStream = fileStorageService.downloadFile(objectName)) {
content = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("下载文档失败: " + e.getMessage(), e);
}
// 3. 使用 MarkdownHeaderParentTextSplitter 进行切分
MarkdownHeaderParentTextSplitter splitter = new MarkdownHeaderParentTextSplitter(1000, 100);
Document doc = Document.from(content);
List<TextSegment> segments = splitter.split(doc);
// 4. 转换为 KnowledgeSegment 并保存
List<KnowledgeSegment> knowledgeSegments = new ArrayList<>();
for (int i = 0; i < segments.size(); i++) {
TextSegment segment = segments.get(i);
KnowledgeSegment knowledgeSegment = new KnowledgeSegment();
knowledgeSegment.setText(segment.text());
knowledgeSegment.setChunkId(segment.metadata().getString("chunkId"));
knowledgeSegment.setMetadata(JSON.toJSONString(segment.metadata().toMap()));
knowledgeSegment.setDocumentId(documentId);
knowledgeSegment.setChunkOrder(i);
knowledgeSegment.setStatus(SegmentStatus.INIT);
// 检查是否需要跳过嵌入
Integer skipEmbedding = segment.metadata().getInteger("skipEmbedding");
if (skipEmbedding != null && skipEmbedding == 1) {
knowledgeSegment.setSkipEmbedding(1);
} else {
knowledgeSegment.setSkipEmbedding(0);
}
knowledgeSegments.add(knowledgeSegment);
}
// 5. 批量保存片段
boolean saveResult = knowledgeSegmentService.saveBatch(knowledgeSegments);
Assert.isTrue(saveResult, "保存知识片段失败");
// 6. 更新文档状态为 CHUNKED
document.setStatus(DocumentStatus.CHUNKED);
boolean updateResult = knowledgeDocumentService.updateById(document);
Assert.isTrue(updateResult, "更新文档状态失败");
return knowledgeSegments.size();
}
这里面用到了一个MarkdownHeaderParentTextSplitter,是我们给大家实现的一个父子分段的分段器。我们在下面的文档中专门讲:
✅表格&图片被拆分到多个分块如何解决?
在RAG智能客服中,比较常见的问题就是如果文档中包含了表格、或者图片,如何在分段的时候避免同一个图片、或者表格被切分到不同的分块中。 一旦表格被切分到不同分块,那么后面的分段就会丢失表头的信息,不仅会影响检索的效果,还会影响输出的结果,因为 LLMentor 这个方法执行完之后,knowledgeDocument的状态会推进到CHUNKED,并且在knowledgeSocument表中保存下来各个分段的内容和元数据的信息。