--- title: "05-异步索引构建:初始化与切块执行" created: 2026-05-20 aliases: - 异步索引构建:初始化与切块执行 tags: - 项目 --- # 异步索引构建:初始化与切块执行 上一篇我们看到,同步链路最后把消息丢进了 Kafka。这篇我们来看消费端拿到消息之后,到底做了什么——这才是索引构建的真正核心。 `handleIndexBuild()` 方法是整个异步链路的主控方法,它会串行推进多个阶段。这篇先讲前两个阶段:初始化和切块执行。先看一张全景图: ## 异步执行全景图 ![[Fq0iAAMxE4ORhw_5OhNBeVbLVNra-27b20ee6.png]] ## 阶段一:初始化 方法一进来,先把三个核心数据拿到手:文档、任务、策略方案。任何一个缺失都直接退出。同时还会预先读取并排序策略步骤: **DocumentAsyncProcessServiceImpl.java — handleIndexBuild()** ```java // 先取出索引构建链路必需的三类核心数据:文档、任务、策略方案。 // 任意一个缺失都说明这条异步消息已经失去执行基础,直接记录告警并退出。 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()** ```typescript private List listSteps(Long planId) { List stepList = stepMapper.selectList( new LambdaQueryWrapper() .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()** ```java // 任务正式进入运行态,并把当前阶段推进到"切块执行"。 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()** ```java private void updateStepExecuteStatus(Long planId, Integer executeStatus) { // 这里按 planId 全量更新,是因为索引执行对整套策略同时生效, // 不区分单个 step 分别推进状态。 stepMapper.update(null, new LambdaUpdateWrapper() .eq(SuperAgentDocumentStrategyStep::getPlanId, planId) .eq(SuperAgentDocumentStrategyStep::getStatus, BusinessStatus.YES.getCode()) .set(SuperAgentDocumentStrategyStep::getExecuteStatus, executeStatus)); } ``` ## 阶段二:切块执行 这是整个流程最核心的一步——读取解析好的文本,然后按策略方案切成父块和子块。 **DocumentAsyncProcessServiceImpl.java — handleIndexBuild()** ```java // 读取解析阶段已经落盘的纯文本内容; // 索引构建不再重复解析原始文件,而是基于解析后的标准文本继续切块。 String parsedText = storageService.downloadText(document.getParseTextPath()); // 根据文档、方案和步骤列表,生成父块候选集。 // 这个阶段只得到"候选结果",还没落库,也还没做空内容过滤。 List parentBlockCandidateList = strategyService.buildParentBlocks(document, plan, stepList, parsedText); // 切块成功后,将策略步骤整体标记为"执行成功" updateStepExecuteStatus(planId, DocumentStrategyExecuteStatusEnum.EXECUTE_SUCCESS.getCode()); ``` 这里的关键就是 `buildParentBlocks()` 这个方法,它是切块策略的核心入口。我们接着往里看它到底做了什么。 ### buildParentBlocks:Parent-Child 双层切块入口 整个切块采用的是 **Parent-Child 双层流水线** 架构——先把文档切成大块(父块),再把每个大块切成小块(子块)。父块和子块各有一条独立的处理流水线,每条流水线可以串联多个切块策略。 ![[FhTBi2XhL8K4qMrRULnYL4IKuzN8-923d2ea4.png]] > 不是每种策略都会执行 > > 流水线里具体执行哪些策略,取决于方案里配置了哪些步骤。比如方案里只配了"结构切块 + 递归切块",那语义切块和 LLM 切块就不会执行。 来看 `buildParentBlocks` 的源码: **DocumentStrategyServiceImpl.java — buildParentBlocks()** ```java /** * 执行 Parent-Child 切块策略,生成父块结果集。 *

* 这里不是简单把原文直接切成一组 chunk,而是分两层执行: * 1. 先根据父块流水线生成 parent seed; * 2. 再针对每个 parent seed 继续执行子块流水线; * 3. 对父子结果做去空、去重、兜底补齐,最终返回稳定的父子结构。 *

*

* 整个过程允许混用结构切块、递归切块、语义切块和 LLM 切块, * 但输出口径始终统一成 ParentBlockCandidate -> List<ChunkCandidate>。 *

*/ @Override public List buildParentBlocks(SuperAgentDocument document, SuperAgentDocumentStrategyPlan plan, List steps, String parsedText) { // 先把整套步骤拆成父块流水线和子块流水线,保证两条链路各自独立排序执行。 List parentSteps = sortPipelineSteps(steps, DocumentStrategyPipelineTypeEnum.PARENT); List 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 structureNodes = structureNodeService.listDocumentNodes( document == null ? null : document.getId(), document == null ? null : document.getLastParseTaskId() ); // 第一层先产出父块种子,后续每个父块都会再进入子块流水线。 List parentSeedList = buildParentSeedList(parsedText, parentSteps, structureNodes); List parentBlockList = new ArrayList<>(); // 先对父块种子做一次清洗,避免空文本和重复块继续污染后续子块切分。 for (ChunkCandidate parentSeed : cleanupChunkList(parentSeedList)) { if (parentSeed == null || StrUtil.isBlank(parentSeed.getText())) { continue; } // 针对每个父块独立生成 child seed,保证子块始终在父块语义范围内继续细分。 List childSeedList = buildChildSeedList(parentSeed, childSteps, structureNodes); List 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.png]] 总结一下这个方法的核心逻辑: - **拆流水线**:方法一进来,先把传入的策略步骤按 `pipelineType` 拆成两条独立的流水线——父块流水线和子块流水线。两条线各自排序、各自执行,互不干扰。如果任意一条流水线为空,直接抛异常终止,因为 Parent-Child 结构要求两层都必须存在 - **加载结构节点**:尝试从数据库加载文档解析阶段沉淀下来的结构节点(`structureNodes`)。这些节点记录了文档的章节层级关系,是结构切块的基础数据。如果文档没有经过结构化解析,这里拿到的就是空列表,后续会自动走非结构化的切块路径 - **生成父块种子**:调用 `buildParentSeedList()`,这一步决定了文档会被切成哪些大块。如果方案里配了结构步骤且文档有可用的结构节点,就优先从结构节点中提取父块(比如按章节边界切分);否则把整篇文本作为输入,丢进父块流水线走递归/语义/LLM 等普通切块策略 - **逐个父块生成子块**:遍历每个父块种子,调用 `buildChildSeedList()` 为它生成子块。子块生成的逻辑和父块类似——优先走结构切分(从父节点的直接子节点中提取),不行就走子块流水线。这里有个重要的兜底机制:如果子块流水线最终一个有效子块都没产出,就把父块本身当作唯一子块,保证不会出现"有父无子"的空壳结构 - **多轮清洗去重**:整个过程中做了三次清洗——父块种子生成后清洗一次,每个父块的子块种子生成后清洗一次,最终整个父块列表还要再做一次 `cleanupParentBlockList()`。每次清洗都会去掉空文本块和重复块,用 `LinkedHashMap` 按"路径 + 位置 + 文本"构造去重键,保证去重后仍然保持原始顺序 接下来我们按执行顺序,逐个看 `buildParentSeedList`、`buildChildSeedList`、`executePipeline` 以及四种切块策略的具体实现。 ### buildParentSeedList:生成父块种子 **DocumentStrategyServiceImpl.java — buildParentSeedList()** ```java private List buildParentSeedList(String parsedText, List parentSteps, List structureNodes) { if (containsStructureStep(parentSteps) && structureNodes != null && !structureNodes.isEmpty()) { // 结构化父块优先从章节节点中直接提取 List structureSeeds = buildStructureParentSeeds(structureNodes); if (structureSeeds.isEmpty()) { // 结构节点虽然存在,但没筛出可用父块时,回退到整文流水线切块 return executePipeline( List.of(new ChunkCandidate("", parsedText, DocumentChunkSourceTypeEnum.ORIGINAL.getCode())), parentSteps, DocumentStrategyPipelineTypeEnum.PARENT); } // 结构步骤已经消化完成,后续只需要执行剩余非结构步骤 List 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()** ```java private List buildStructureParentSeeds( List structureNodes) { Map 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 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()** ```java private List buildChildSeedList(ChunkCandidate parentSeed, List childSteps, List structureNodes) { if (containsStructureStep(childSteps) && parentSeed != null && parentSeed.getStructureNodeId() != null && structureNodes != null && !structureNodes.isEmpty()) { // 子块结构切分要求 parentSeed 必须能定位回结构树上的父节点 List structureSeeds = buildStructureChildSeeds(parentSeed, structureNodes); List 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-四级切块策略|04-四级切块策略]] | 05-异步索引构建:初始化与切块执行 | ➡️ [[06-异步索引构建:落库、向量化与收尾|06-异步索引构建:落库、向量化与收尾]]