传统固定长度切分会把一个完整的语义单元(如一段说明、一个步骤)切成两半,而我们为了解决这个问题,采用了标题分段+父子分段的方式。 先按 Markdown 标题层级切分,保证每个分段边界在标题处。超长分段保留完整父分段,用于展示时替换,检索时用小分片定位,返回时用父分段展示完整内容。
传统方式: 本设计:
├─ 分段1(前500字) ├─ 分段1-完整内容(父分段)
├─ 分段2(后500字) │ ├─ 分段1a(子分片,用于检索)
│ └─ 分段1b(子分片,用于检索)
└─ 分段2-完整内容
检索阶段:小分片做向量匹配,精度高,能定位到具体知识点 展示阶段:用父分段替换子分片,内容完整,用户看到完整上下文
切分逻辑
在MarkdownHeaderParentTextSplitter中,我们实现基于markdown标题层级进行文档分段,并建立负载关系 Step1 , 标题检测与元数据维护 - 使用 headerStack 维护当前标题层级 - 遇到新标题时,将之前累积的内容保存为一个分段 - 每个分段生成唯一的 chunkId Step2,聚合相同元数据的行 - 相同元数据(相同标题路径)的行合并为一个分块 - 标题与其下第一段内容合并 Step3,chunkSize 二次切割 - 超出 chunkSize 的分段 - 保留完整分片,标记 skipEmbedding=1(不用于向量检索) - 生成多个子分片,每个子分片携带 parentChunkId
// splitByChunkSize 方法, 超出 chunkSize 的分片处理
if
result
}
fullMetadata
fullMetadata
result
subMetadata
subMetadata
result
}
需要用到的metadata的定义:
检索逻辑
在检索相关代码(KnowEngineElasticsearchHnswContentRetriever\KnowEngineElasticsearchKnnContentRetriever)中,针对父子分段做了特殊处理。 在检索到相应的chunk之后,做父子分段的处理: - 检查检索结果是否携带 parentChunkId - 若有,读取父分段的完整文本 - 用父分段内容替换子分段,获得更完整的语义
// 父分段替换
String parentChunkId = content.textSegment().metadata().getString(PARENT_CHUNK_ID);
if (parentChunkId != null) {
// 读取 parentChunk 的完整文本
String segmentText = knowledgeSegmentService.getTextByChunkId(parentChunkId);
if (segmentText != null) {
TextSegment parentSegment = TextSegment.from(segmentText, content.textSegment().metadata());
finalContents.remove(content); // 移除子分段
finalContents.addAll(parentDocs); // 加入父分段
}
}