异步索引构建:初始化与切块执行
上一篇我们看到,同步链路最后把消息丢进了 Kafka。这篇我们来看消费端拿到消息之后,到底做了什么——这才是索引构建的真正核心。
handleIndexBuild() 方法是整个异步链路的主控方法,它会串行推进多个阶段。这篇先讲前两个阶段:初始化和切块执行。先看一张全景图:
异步执行全景图
阶段一:初始化
方法一进来,先把三个核心数据拿到手:文档、任务、策略方案。任何一个缺失都直接退出。同时还会预先读取并排序策略步骤:
DocumentAsyncProcessServiceImpl.java — handleIndexBuild()
// 先取出索引构建链路必需的三类核心数据:文档、任务、策略方案。
// 任意一个缺失都说明这条异步消息已经失去执行基础,直接记录告警并退出。
SuperAgentDocument document = documentMapper.selectById(documentId);
SuperAgentDocumentTask task = taskMapper.selectById(taskId);
SuperAgentDocumentStrategyPlan plan = planMapper.selectById(planId);
if (document == null || task == null || plan == null) {
log.warn("索引任务对应的数据不存在,documentId={}, taskId={}, planId={}",
documentId, taskId, planId);
return;
}
然后把任务推进到运行态,同时更新文档和策略步骤的状态:
DocumentAsyncProcessServiceImpl.java — listSteps()
private List<SuperAgentDocumentStrategyStep> listSteps(Long planId) {
List<SuperAgentDocumentStrategyStep> stepList = stepMapper.selectList(
new LambdaQueryWrapper<SuperAgentDocumentStrategyStep>()
.eq(SuperAgentDocumentStrategyStep::getPlanId, planId)
.eq(SuperAgentDocumentStrategyStep::getStatus, BusinessStatus.YES.getCode()));
return stepList.stream()
.sorted(Comparator
.comparingInt((SuperAgentDocumentStrategyStep step) -> pipelineOrder(step.getPipelineType()))
.thenComparing(SuperAgentDocumentStrategyStep::getStepNo)
.thenComparing(SuperAgentDocumentStrategyStep::getId))
.toList();
}
排序规则是:先父流水线、再子流水线;同一流水线内按 stepNo 升序,最后再按主键兜底。这样保证后续切块执行时步骤顺序是稳定的。
DocumentAsyncProcessServiceImpl.java — handleIndexBuild()
// 任务正式进入运行态,并把当前阶段推进到"切块执行"。
task.setTaskStatus(DocumentTaskStatusEnum.RUNNING.getCode());
task.setCurrentStage(DocumentTaskStageEnum.CHUNK_EXECUTE.getCode());
task.setStartTime(startTime);
taskMapper.updateById(task);
// 文档维度同步标记为"构建中",方便列表页/详情页立刻展示当前状态。
document.setIndexStatus(DocumentIndexStatusEnum.BUILDING.getCode());
documentMapper.updateById(document);
// 将当前方案下所有策略步骤统一置为"执行中"
updateStepExecuteStatus(planId, DocumentStrategyExecuteStatusEnum.EXECUTING.getCode());
updateStepExecuteStatus 是个工具方法,按 planId 全量更新所有步骤的执行状态:
DocumentAsyncProcessServiceImpl.java — updateStepExecuteStatus()
private void updateStepExecuteStatus(Long planId, Integer executeStatus) {
// 这里按 planId 全量更新,是因为索引执行对整套策略同时生效,
// 不区分单个 step 分别推进状态。
stepMapper.update(null, new LambdaUpdateWrapper<SuperAgentDocumentStrategyStep>()
.eq(SuperAgentDocumentStrategyStep::getPlanId, planId)
.eq(SuperAgentDocumentStrategyStep::getStatus, BusinessStatus.YES.getCode())
.set(SuperAgentDocumentStrategyStep::getExecuteStatus, executeStatus));
}
阶段二:切块执行
这是整个流程最核心的一步——读取解析好的文本,然后按策略方案切成父块和子块。
DocumentAsyncProcessServiceImpl.java — handleIndexBuild()
// 读取解析阶段已经落盘的纯文本内容;
// 索引构建不再重复解析原始文件,而是基于解析后的标准文本继续切块。
String parsedText = storageService.downloadText(document.getParseTextPath());
// 根据文档、方案和步骤列表,生成父块候选集。
// 这个阶段只得到"候选结果",还没落库,也还没做空内容过滤。
List<ParentBlockCandidate> parentBlockCandidateList =
strategyService.buildParentBlocks(document, plan, stepList, parsedText);
// 切块成功后,将策略步骤整体标记为"执行成功"
updateStepExecuteStatus(planId, DocumentStrategyExecuteStatusEnum.EXECUTE_SUCCESS.getCode());
这里的关键就是 buildParentBlocks() 这个方法,它是切块策略的核心入口。我们接着往里看它到底做了什么。
buildParentBlocks:Parent-Child 双层切块入口
整个切块采用的是 Parent-Child 双层流水线 架构——先把文档切成大块(父块),再把每个大块切成小块(子块)。父块和子块各有一条独立的处理流水线,每条流水线可以串联多个切块策略。
不是每种策略都会执行
流水线里具体执行哪些策略,取决于方案里配置了哪些步骤。比如方案里只配了"结构切块 + 递归切块",那语义切块和 LLM 切块就不会执行。
来看 buildParentBlocks 的源码:
DocumentStrategyServiceImpl.java — buildParentBlocks()
/**
* 执行 Parent-Child 切块策略,生成父块结果集。
* <p>
* 这里不是简单把原文直接切成一组 chunk,而是分两层执行:
* 1. 先根据父块流水线生成 parent seed;
* 2. 再针对每个 parent seed 继续执行子块流水线;
* 3. 对父子结果做去空、去重、兜底补齐,最终返回稳定的父子结构。
* </p>
* <p>
* 整个过程允许混用结构切块、递归切块、语义切块和 LLM 切块,
* 但输出口径始终统一成 ParentBlockCandidate -> List<ChunkCandidate>。
* </p>
*/
@Override
public List<ParentBlockCandidate> buildParentBlocks(SuperAgentDocument document,
SuperAgentDocumentStrategyPlan plan,
List<SuperAgentDocumentStrategyStep> steps,
String parsedText) {
// 先把整套步骤拆成父块流水线和子块流水线,保证两条链路各自独立排序执行。
List<SuperAgentDocumentStrategyStep> parentSteps = sortPipelineSteps(steps, DocumentStrategyPipelineTypeEnum.PARENT);
List<SuperAgentDocumentStrategyStep> childSteps = sortPipelineSteps(steps, DocumentStrategyPipelineTypeEnum.CHILD);
// Parent-Child 结构要求父块和子块两层都必须存在,否则无法形成完整索引单元。
if (parentSteps.isEmpty()) {
throw new IllegalStateException("当前方案缺少父块流水线,无法生成 Parent-Child 结构。");
}
if (childSteps.isEmpty()) {
throw new IllegalStateException("当前方案缺少子块流水线,无法生成 Parent-Child 结构。");
}
// 尝试拿到解析阶段沉淀的结构节点;
// 如果方案里含有 STRUCTURE 步骤,这些节点会成为非常重要的天然切块边界。
List<SuperAgentDocumentStructureNode> structureNodes = structureNodeService.listDocumentNodes(
document == null ? null : document.getId(),
document == null ? null : document.getLastParseTaskId()
);
// 第一层先产出父块种子,后续每个父块都会再进入子块流水线。
List<ChunkCandidate> parentSeedList = buildParentSeedList(parsedText, parentSteps, structureNodes);
List<ParentBlockCandidate> parentBlockList = new ArrayList<>();
// 先对父块种子做一次清洗,避免空文本和重复块继续污染后续子块切分。
for (ChunkCandidate parentSeed : cleanupChunkList(parentSeedList)) {
if (parentSeed == null || StrUtil.isBlank(parentSeed.getText())) {
continue;
}
// 针对每个父块独立生成 child seed,保证子块始终在父块语义范围内继续细分。
List<ChunkCandidate> childSeedList = buildChildSeedList(parentSeed, childSteps, structureNodes);
List<ChunkCandidate> finalChildren = cleanupChunkList(childSeedList);
// 如果子块流水线最终没有产出任何有效 child,就退回到“父块本身就是唯一 child”的兜底策略,
// 避免出现父块存在但 child 为空的不可用结构。
if (finalChildren.isEmpty()) {
finalChildren = List.of(cloneChunkCandidate(parentSeed, parentSeed.getText().trim()));
}
// 将父块元数据与最终 child 列表打包成 ParentBlockCandidate,供异步索引链落库。
parentBlockList.add(new ParentBlockCandidate(
parentSeed.getSectionPath(),
parentSeed.getStructureNodeId(),
parentSeed.getStructureNodeType(),
parentSeed.getCanonicalPath(),
parentSeed.getItemIndex(),
parentSeed.getText().trim(),
parentSeed.getSourceType(),
finalChildren
));
}
// 父块列表最后再做一次去重和规范化,保证输出结果稳定。
return cleanupParentBlockList(parentBlockList);
}
整个 buildParentBlocks 方法的执行逻辑,我们用一张流程图来梳理清楚:
总结一下这个方法的核心逻辑:
- 拆流水线:方法一进来,先把传入的策略步骤按
pipelineType拆成两条独立的流水线——父块流水线和子块流水线。两条线各自排序、各自执行,互不干扰。如果任意一条流水线为空,直接抛异常终止,因为 Parent-Child 结构要求两层都必须存在 - 加载结构节点:尝试从数据库加载文档解析阶段沉淀下来的结构节点(
structureNodes)。这些节点记录了文档的章节层级关系,是结构切块的基础数据。如果文档没有经过结构化解析,这里拿到的就是空列表,后续会自动走非结构化的切块路径 - 生成父块种子:调用
buildParentSeedList(),这一步决定了文档会被切成哪些大块。如果方案里配了结构步骤且文档有可用的结构节点,就优先从结构节点中提取父块(比如按章节边界切分);否则把整篇文本作为输入,丢进父块流水线走递归/语义/LLM 等普通切块策略 - 逐个父块生成子块:遍历每个父块种子,调用
buildChildSeedList()为它生成子块。子块生成的逻辑和父块类似——优先走结构切分(从父节点的直接子节点中提取),不行就走子块流水线。这里有个重要的兜底机制:如果子块流水线最终一个有效子块都没产出,就把父块本身当作唯一子块,保证不会出现"有父无子"的空壳结构 - 多轮清洗去重:整个过程中做了三次清洗——父块种子生成后清洗一次,每个父块的子块种子生成后清洗一次,最终整个父块列表还要再做一次
cleanupParentBlockList()。每次清洗都会去掉空文本块和重复块,用LinkedHashMap按"路径 + 位置 + 文本"构造去重键,保证去重后仍然保持原始顺序
接下来我们按执行顺序,逐个看 buildParentSeedList、buildChildSeedList、executePipeline 以及四种切块策略的具体实现。
buildParentSeedList:生成父块种子
DocumentStrategyServiceImpl.java — buildParentSeedList()
private List<ChunkCandidate> buildParentSeedList(String parsedText,
List<SuperAgentDocumentStrategyStep> parentSteps,
List<SuperAgentDocumentStructureNode> structureNodes) {
if (containsStructureStep(parentSteps) && structureNodes != null && !structureNodes.isEmpty()) {
// 结构化父块优先从章节节点中直接提取
List<ChunkCandidate> structureSeeds = buildStructureParentSeeds(structureNodes);
if (structureSeeds.isEmpty()) {
// 结构节点虽然存在,但没筛出可用父块时,回退到整文流水线切块
return executePipeline(
List.of(new ChunkCandidate("", parsedText, DocumentChunkSourceTypeEnum.ORIGINAL.getCode())),
parentSteps, DocumentStrategyPipelineTypeEnum.PARENT);
}
// 结构步骤已经消化完成,后续只需要执行剩余非结构步骤
List<SuperAgentDocumentStrategyStep> remainingSteps = stripStructureSteps(parentSteps);
if (remainingSteps.isEmpty()) { return structureSeeds; }
return executePipeline(structureSeeds, remainingSteps, DocumentStrategyPipelineTypeEnum.PARENT);
}
// 没有结构步骤或没有结构节点时,直接从整篇文本开始执行父块流水线
return executePipeline(
List.of(new ChunkCandidate("", parsedText, DocumentChunkSourceTypeEnum.ORIGINAL.getCode())),
parentSteps, DocumentStrategyPipelineTypeEnum.PARENT);
}
这里的策略是:有结构节点就优先用结构节点,没有就退回到普通流水线。具体来说,方法内部走了三条分支:
- 结构优先路径:如果父块流水线里配了结构步骤(
containsStructureStep),并且文档确实有可用的结构节点,就先调用buildStructureParentSeeds()从结构节点中提取父块种子。提取成功后,再用stripStructureSteps()把结构步骤从流水线里剔除,剩余的非结构步骤(比如递归、语义)继续通过executePipeline()对这些种子做进一步细分 - 结构降级路径:如果结构节点虽然存在,但
buildStructureParentSeeds()没筛出任何可用的父块(比如所有章节节点都是纯标题壳),就放弃结构路径,把整篇文本包装成一个ChunkCandidate,丢进完整的父块流水线从头执行 - 无结构路径:如果方案里压根没配结构步骤,或者文档没有结构节点,直接走这条路——整篇文本作为输入,交给父块流水线处理
这样设计的好处是:既能充分利用文档的天然层级结构来获得更精准的切块边界,又不会因为结构信息缺失或质量不够而卡住整个流程。
buildStructureParentSeeds:从结构节点提取父块
DocumentStrategyServiceImpl.java — buildStructureParentSeeds()
private List<ChunkCandidate> buildStructureParentSeeds(
List<SuperAgentDocumentStructureNode> structureNodes) {
Map<Long, Boolean> parentHasChildSection = new LinkedHashMap<>();
for (SuperAgentDocumentStructureNode node : structureNodes) {
if (node == null || node.getParentNodeId() == null) { continue; }
// 先标记"哪些节点下面还有 SECTION 子节点"
if (DocumentStructureNodeTypeEnum.SECTION.getCode().equals(node.getNodeType())) {
parentHasChildSection.put(node.getParentNodeId(), true);
}
}
List<ChunkCandidate> seeds = new ArrayList<>();
for (SuperAgentDocumentStructureNode node : structureNodes) {
if (node == null || !DocumentStructureNodeTypeEnum.SECTION.getCode().equals(node.getNodeType())) {
continue;
}
// 过滤掉只有标题、没有正文承载能力的章节节点
if (!isContentBearingSection(node, parentHasChildSection.getOrDefault(node.getId(), false))) {
continue;
}
seeds.add(toChunkCandidate(node));
}
return seeds;
}
这个方法做了两轮遍历,目的是从结构节点中筛选出真正适合作为父块的章节:
- 第一轮遍历:建立一个
parentHasChildSection映射表,记录"哪些节点 ID 下面还有 SECTION 类型的子节点"。这个信息后面用来判断一个章节是"内容承载节点"还是"纯标题壳节点" - 第二轮遍历:只保留
nodeType为 SECTION 的节点,然后用isContentBearingSection()做进一步过滤。过滤逻辑是:如果一个章节节点下面还嵌套了子章节(在第一轮映射表里能查到),说明它自己只是个"壳"——比如"第一章"下面有"1.1 节"和"1.2 节",那"第一章"本身并不直接承载正文内容,真正的内容在子章节里。这种壳节点会被过滤掉,只留下叶子级别的、真正包含正文的章节作为父块种子
最终通过 toChunkCandidate() 把筛选出的结构节点转换成统一的 ChunkCandidate 对象,带上 sectionPath、structureNodeId 等元数据,供后续流水线继续处理。
buildChildSeedList:为每个父块生成子块种子
DocumentStrategyServiceImpl.java — buildChildSeedList()
private List<ChunkCandidate> buildChildSeedList(ChunkCandidate parentSeed,
List<SuperAgentDocumentStrategyStep> childSteps,
List<SuperAgentDocumentStructureNode> structureNodes) {
if (containsStructureStep(childSteps)
&& parentSeed != null && parentSeed.getStructureNodeId() != null
&& structureNodes != null && !structureNodes.isEmpty()) {
// 子块结构切分要求 parentSeed 必须能定位回结构树上的父节点
List<ChunkCandidate> structureSeeds = buildStructureChildSeeds(parentSeed, structureNodes);
List<SuperAgentDocumentStrategyStep> remainingSteps = stripStructureSteps(childSteps);
if (remainingSteps.isEmpty()) { return structureSeeds; }
return executePipeline(structureSeeds, remainingSteps, DocumentStrategyPipelineTypeEnum.CHILD);
}
// 结构切分不可用时,就从父块文本本身出发继续做递归/语义/LLM 等细分
return executePipeline(
List.of(cloneChunkCandidate(parentSeed, parentSeed.getText())),
childSteps, DocumentStrategyPipelineTypeEnum.CHILD);
}
这个方法的思路和 buildParentSeedList 很像,也是"结构优先、流水线兜底",但多了几个前置条件的判断:
- 结构切分路径:要走这条路,需要同时满足三个条件——子块流水线里配了结构步骤、当前父块种子绑定了
structureNodeId(说明它来自结构节点而不是普通文本切块)、文档有可用的结构节点列表。三个条件都满足时,调用buildStructureChildSeeds()从父节点的直接子节点中提取子块种子。提取完成后,同样剔除结构步骤,剩余步骤继续通过executePipeline()细分 - 普通流水线路径:如果上面三个条件任意一个不满足,就走这条路——用
cloneChunkCandidate()把父块文本复制一份作为输入,丢进子块流水线从头执行递归/语义/LLM 等切块策略
这里有个细节值得注意:parentSeed.getStructureNodeId() != null 这个判断很关键。如果父块种子是通过普通流水线(比如递归切块)产出的,它身上不会带 structureNodeId,这时候即使子块流水线配了结构步骤也不会走结构路径,因为没有结构树上的锚点可以定位子节点。
到这里,buildParentBlocks() 内部的父块种子生成和子块种子生成逻辑就讲完了。不管是父块还是子块,最终都会走到 executePipeline() 这个流水线执行引擎,由它来调度结构切块、递归切块、语义切块、LLM 切块这四种策略。这四种策略的内部实现、嵌套关系和降级机制比较复杂,我们在下一篇单独展开。
企业级项目导航:⬅️ 04-四级切块策略 | 05-异步索引构建:初始化与切块执行 | ➡️ 06-异步索引构建:落库、向量化与收尾
💬 评论