异步索引构建:初始化与切块执行

上一篇我们看到,同步链路最后把消息丢进了 Kafka。这篇我们来看消费端拿到消息之后,到底做了什么——这才是索引构建的真正核心。

handleIndexBuild() 方法是整个异步链路的主控方法,它会串行推进多个阶段。这篇先讲前两个阶段:初始化和切块执行。先看一张全景图:

异步执行全景图

Fq0iAAMxE4ORhw_5OhNBeVbLVNra-27b20ee6

阶段一:初始化

方法一进来,先把三个核心数据拿到手:文档、任务、策略方案。任何一个缺失都直接退出。同时还会预先读取并排序策略步骤:

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 双层流水线 架构——先把文档切成大块(父块),再把每个大块切成小块(子块)。父块和子块各有一条独立的处理流水线,每条流水线可以串联多个切块策略。

FhTBi2XhL8K4qMrRULnYL4IKuzN8-923d2ea4

不是每种策略都会执行

流水线里具体执行哪些策略,取决于方案里配置了哪些步骤。比如方案里只配了"结构切块 + 递归切块",那语义切块和 LLM 切块就不会执行。

来看 buildParentBlocks 的源码:

DocumentStrategyServiceImpl.java — buildParentBlocks()

/**
 * 执行 Parent-Child 切块策略,生成父块结果集。
 * <p>
 * 这里不是简单把原文直接切成一组 chunk,而是分两层执行:
 * 1. 先根据父块流水线生成 parent seed;
 * 2. 再针对每个 parent seed 继续执行子块流水线;
 * 3. 对父子结果做去空、去重、兜底补齐,最终返回稳定的父子结构。
 * </p>
 * <p>
 * 整个过程允许混用结构切块、递归切块、语义切块和 LLM 切块,
 * 但输出口径始终统一成 ParentBlockCandidate -> List&lt;ChunkCandidate&gt;。
 * </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 方法的执行逻辑,我们用一张流程图来梳理清楚:

Fgd1rOAJgYYEdd72rXuME0t_RzpD-de2ea683

总结一下这个方法的核心逻辑:

  • 拆流水线:方法一进来,先把传入的策略步骤按 pipelineType 拆成两条独立的流水线——父块流水线和子块流水线。两条线各自排序、各自执行,互不干扰。如果任意一条流水线为空,直接抛异常终止,因为 Parent-Child 结构要求两层都必须存在
  • 加载结构节点:尝试从数据库加载文档解析阶段沉淀下来的结构节点(structureNodes)。这些节点记录了文档的章节层级关系,是结构切块的基础数据。如果文档没有经过结构化解析,这里拿到的就是空列表,后续会自动走非结构化的切块路径
  • 生成父块种子:调用 buildParentSeedList(),这一步决定了文档会被切成哪些大块。如果方案里配了结构步骤且文档有可用的结构节点,就优先从结构节点中提取父块(比如按章节边界切分);否则把整篇文本作为输入,丢进父块流水线走递归/语义/LLM 等普通切块策略
  • 逐个父块生成子块:遍历每个父块种子,调用 buildChildSeedList() 为它生成子块。子块生成的逻辑和父块类似——优先走结构切分(从父节点的直接子节点中提取),不行就走子块流水线。这里有个重要的兜底机制:如果子块流水线最终一个有效子块都没产出,就把父块本身当作唯一子块,保证不会出现"有父无子"的空壳结构
  • 多轮清洗去重:整个过程中做了三次清洗——父块种子生成后清洗一次,每个父块的子块种子生成后清洗一次,最终整个父块列表还要再做一次 cleanupParentBlockList()。每次清洗都会去掉空文本块和重复块,用 LinkedHashMap 按"路径 + 位置 + 文本"构造去重键,保证去重后仍然保持原始顺序

接下来我们按执行顺序,逐个看 buildParentSeedListbuildChildSeedListexecutePipeline 以及四种切块策略的具体实现。

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 对象,带上 sectionPathstructureNodeId 等元数据,供后续流水线继续处理。

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-异步索引构建:落库、向量化与收尾