初步父子切块

我们接着上一篇“索引构建入口与 Kafka 消息投递”继续往下看。

上一篇的终点是:

Service 层做完四道校验
创建了 BUILD_INDEX 任务(NEW 状态)
strategySnapshot 已固化到任务上
文档 indexStatus 切到 BUILDING
Kafka 同步发送了一条三个 ID 的瘦消息
Consumer 在 -index 消费组接到消息
反序列化后调用 asyncProcessService.handleIndexBuild()

这一篇就是从 handleIndexBuild() 进来开始,看异步链路的前两个阶段

阶段一:初始化(把任务、文档、步骤都切到运行态)
阶段二:切块执行(Parent-Child 双层流水线产出父子块)

学完这一篇,你会理解:

1. 异步链路一进来为什么要重新查三张表
2. 为什么要用 planId 全量更新步骤状态而不是逐步推进
3. Parent-Child 双层切块的代码骨架
4. 结构节点为什么是父块切分的"天然边界"
5. 为什么要做三轮清洗去重
6. "结构优先 + 流水线兜底"的降级思想
7. 子块为什么必须保持在父块语义范围内

下一篇才会展开四种切块策略的具体实现(结构 / 递归 / 语义 / LLM)和它们的嵌套关系。


一、整体认知:这一节在做什么

可以一句话概括:

Consumer 接到消息后调用 handleIndexBuild(),第一阶段把 task、document、step 三层状态全部切到运行态,第二阶段从 MinIO 下载解析后的纯文本,调用 buildParentBlocks 用 Parent-Child 双层流水线把文本拆成"父块 → 子块列表"的候选结构。这一阶段只产出内存里的候选结果,还没有任何数据落库,等后续阶段做向量化和索引写入时才真正持久化。

整体流程图:

flowchart TD
    A[Consumer 收到消息] --> B[handleIndexBuild 入口]
    B --> C[查 document/task/plan]
    C --> D{任一缺失?}
    D -->|是| E[告警退出]
    D -->|否| F[加载并排序 step 列表]
    F --> G[task → RUNNING + CHUNK_EXECUTE]
    G --> H[document → BUILDING]
    H --> I[step 全量 → EXECUTING]
    I --> J[从 MinIO 下载 parsedText]
    J --> K[buildParentBlocks 双层切块]
    K --> L[拆父子流水线]
    L --> M[加载结构节点]
    M --> N[buildParentSeedList 父块种子]
    N --> O[遍历每个父块]
    O --> P[buildChildSeedList 子块种子]
    P --> Q[父子兜底 + 三轮清洗]
    Q --> R[step 全量 → EXECUTE_SUCCESS]
    R --> S[(候选父子块在内存中,等下一阶段)]

这一节的定位

它处在“消息消费”和“向量化/索引写入”之间,承担两个职责:

1. 把链路的"运行态"切起来——所有相关记录的状态字段同步推进
2. 完成最重的纯计算工作——文本切块,产出结构化候选结果

特别值得注意的是:这一节不写任何业务数据。父块、子块都还在内存的 ParentBlockCandidate 列表里,没有 insert 任何 chunk 表。这是有意为之的设计,下文会详细解释。


二、为什么要把切块做成独立阶段,而不是边切边写?

可以把切块和落库合成一步:每切一个 chunk 就 insert 一条记录。但这套代码刻意把切块单独抽出来作为一个阶段,原因很值得推敲。

1. 切块过程不稳定

切块涉及四种策略叠加:

结构切块:依赖结构节点,可能不准
递归切块:基本稳定
语义切块:依赖 embedding 模型,可能调用失败
LLM 切块:调用大模型,可能超时、限流、返回乱码

如果边切边写:

切了一半 LLM 超时
数据库里已经写了 50 个 chunk
任务标记 FAILED
重试时这 50 个 chunk 怎么办?清理?去重?

如果切完再落库:

切失败 → 内存数据丢弃,数据库零污染
切成功 → 一次性批量写入,要么全成功要么全回滚

先纯计算后写入,让“可能失败的过程”和“持久化”解耦,是经典的**"计算-提交"分离**模式。

2. 切块可以做去重和清洗

只有先把所有候选块都拿到手,才能做:

全局去重(同一段文本被两条策略切出两次)
父子映射调整
按路径排序
最终顺序号生成

如果边切边写,这些全局视图操作就做不了。

3. 后续阶段对"成块结构"有完整性要求

向量化阶段需要:

按父块批量处理
父块和子块的映射关系完整
所有 chunk 有稳定的顺序号

这些要求只有“全部切块完成”后才能保证。


三、阶段一:初始化

代码不长但每一步都有讲究。

1. 取出三类核心数据

SuperAgentDocument document = documentMapper.selectById(documentId);
SuperAgentDocumentTask task = taskMapper.selectById(taskId);
SuperAgentDocumentStrategyPlan plan = planMapper.selectById(planId);
if (document == null || task == null || plan == null) {
    log.warn("索引任务对应的数据不存在...");
    return;
}
为什么要重新查?消息里不是已经有 ID 了吗?

ID 只是指针,不是数据。Consumer 拿到 ID 后必须查数据库拿到完整记录,原因:

1. 消息从发送到消费有时间差,数据库才是数据的真实来源
2. 后续要更新这些记录,必须先 select 出来
3. 三类数据是后续所有逻辑的输入
为什么三个都查而不只查 task?

理论上从 task 也能反查到 document 和 plan,但显式分别查有几个好处:

减少 join,SQL 简单
任意一个缺失立刻发现
后续逻辑各自使用,代码清晰
缺失时为什么 return 而不抛异常?
if (document == null || task == null || plan == null) {
    log.warn(...);
    return;
}

这里只打 warn 日志然后退出,没有抛异常。这种设计的潜台词是:

消息可能是脏消息(测试遗留、消费延迟、记录被删)
脏消息再消费多少次都是脏的
return 让 Kafka 认为消息消费成功(commit offset)
不让脏消息无限重试占用资源

回顾上一篇 Consumer 的 catch Exception 不重抛——是同一思想:业务级"消费成功"和 Kafka 层"投递成功"解耦,业务失败由数据库的任务状态托管,Kafka 只保证消息不丢。

缺失场景下任务表怎么办?

注意这个分支没有把 task 标记为 FAILED。这是个潜在的设计缺陷

如果 document 没了,但 task 还在 → task 永远卡在 NEW
后台监控会看到一个"幽灵任务"

更稳健的写法可能是:

document 缺失 → task 标 FAILED + 错误信息
plan 缺失 → task 标 FAILED + 错误信息

但这是工程权衡:缺失场景极少出现,加这套逻辑收益有限。

2. 加载并排序 step 列表

private List<SuperAgentDocumentStrategyStep> listSteps(Long planId) {
    List<SuperAgentDocumentStrategyStep> stepList = stepMapper.selectList(
        new LambdaQueryWrapper<SuperAgentDocumentStrategyStep>()
            .eq(...planId)
            .eq(...status, YES));
    return stepList.stream()
        .sorted(Comparator
            .comparingInt(step -> pipelineOrder(step.getPipelineType()))
            .thenComparing(SuperAgentDocumentStrategyStep::getStepNo)
            .thenComparing(SuperAgentDocumentStrategyStep::getId))
        .toList();
}

三层排序的设计

第一层:pipelineType 排序(父块在前,子块在后)
第二层:stepNo 升序(同一流水线内按步骤号)
第三层:id 兜底(防止前两个值相同时顺序不稳定)

为什么要排到 id 这么严格?

因为切块是强顺序敏感操作:

父块流水线先于子块流水线
结构切块在递归切块之前
LLM 切块在递归切块之前

任何顺序错乱都会导致切块结果不稳定,特别是复现 bug 时——同一份文档同样的方案应该出同样的结果,否则没法定位问题。

为什么这里要排序而不是数据库 ORDER BY?

也可以在 SQL 里 ORDER BY pipeline_type, step_no, id。代码里用 stream 排序的好处:

排序逻辑可读,直接看 Comparator 链就知道意图
pipelineOrder 是自定义映射(可能不是简单的数值大小)
独立测试容易

不影响性能(步骤数量级是个位数到十位数)。

status = YES 的限制

只取未被逻辑删除的步骤。逻辑删除是企业系统的常规操作,让删除可恢复、有审计。

3. 任务和文档状态推进

task.setTaskStatus(RUNNING);
task.setCurrentStage(CHUNK_EXECUTE);
task.setStartTime(startTime);
taskMapper.updateById(task);

document.setIndexStatus(BUILDING);
documentMapper.updateById(document);
为什么 task 状态从 NEW 切到 RUNNING?

回想上一篇,同步链路里 task 创建时是 NEW 状态——表示“已预约但还没开始”。Consumer 真正接手后切成 RUNNING:

NEW:任务在 Kafka 队列里等待被消费
RUNNING:Consumer 已开始处理

这个状态切换让监控可以一眼看出:

NEW 堆积 → Consumer 消费跟不上
RUNNING 堆积 → 单个任务处理太慢
document.indexStatus 已经是 BUILDING 了为什么还更新一次?

回顾上一篇,同步链路已经把 indexStatus 切到 BUILDING 了。这里再写一次看似冗余,但其实是保险

异常场景:同步链路切了 BUILDING,但中间被外部接口改回 WAIT_BUILD
异常场景:数据库主从延迟,这里查到的还是旧值
异常场景:多个任务并发改这个字段

幂等更新代价小,能屏蔽掉一些边界场景。这是工程上的“防御性更新”。

startTime 字段
task.setStartTime(startTime);

这是 Consumer 真正开始处理的时间。配合后续的 finishTime,可以算出实际处理耗时

task.createTime:任务创建时间(同步链路)
task.startTime:Consumer 开始消费时间
task.finishTime:任务完成时间

队列等待时间 = startTime - createTime
实际处理时间 = finishTime - startTime
端到端时间 = finishTime - createTime

三个时间字段拆开记录,可以精确分析瓶颈在排队还是处理。

4. 全量更新步骤状态

private void updateStepExecuteStatus(Long planId, Integer executeStatus) {
    stepMapper.update(null, new LambdaUpdateWrapper<>()
        .eq(...planId)
        .eq(...status, YES)
        .set(...executeStatus, executeStatus));
}

调用:

updateStepExecuteStatus(planId, EXECUTING);

把当前 plan 下所有有效步骤executeStatus 一次性切到 EXECUTING。

为什么不逐步推进?

最直观的设计是:

执行结构切块时:STRUCTURE step → RUNNING
执行完成时:STRUCTURE step → SUCCESS
执行递归切块时:RECURSIVE step → RUNNING
执行完成时:RECURSIVE step → SUCCESS

这种“精细推进”的设计在工作流引擎里很常见。

但这套代码选了粗粒度推进:所有步骤一起 EXECUTING,全部成功后一起 EXECUTE_SUCCESS。注释里有说明:

这里按 planId 全量更新,是因为索引执行对整套策略同时生效,不区分单个 step 分别推进状态。

为什么这么设计?

因为切块流水线的本质是协同产出而不是串行接力

父块流水线和子块流水线在 buildParentBlocks 里嵌套调用
执行顺序不是线性的(父块切完一个 → 子块切这个 → 父块切下一个 → ...)
单个步骤的状态没办法精确划分

举例:

如果父块用 STRUCTURE,子块用 LLM + RECURSIVE
执行顺序大致是:
    父块 STRUCTURE 切出 5 个父块
    第 1 个父块 → 子块 LLM 切 → 子块 RECURSIVE 兜底
    第 2 个父块 → 子块 LLM 切 → 子块 RECURSIVE 兜底
    ...

子块的 LLM 步骤被调用了 5 次,每次状态怎么标?
RUNNING → SUCCESS → RUNNING → SUCCESS → ... 反复横跳?

粗粒度推进规避了这种复杂度。

代价和回报

代价:

看不到具体哪一步在跑
失败时不知道卡在哪一步(只能看日志)

回报:

逻辑简单
不需要改造嵌套调用
不会出现状态错乱

工程上选了简单可靠。如果后续需要精细监控,可以另开一张 step_execution_log 表,事件流式记录,而不污染 step 表本身。


四、阶段二:切块执行

阶段二是整段流程的计算密集型核心

1. 下载解析后的纯文本

String parsedText = storageService.downloadText(document.getParseTextPath());
为什么从 MinIO 下载而不是数据库读?

回想之前几篇,解析阶段把纯文本(可能几十 KB 到几十 MB)写到 MinIO,数据库只存路径。原因:

数据库不适合存大文本(影响主从复制、备份成本高)
MinIO 是对象存储,天然适合大文件
路径存数据库,内容存对象存储,职责清晰
为什么不重新解析原始文件?

注释里写得很清楚:

索引构建不再重复解析原始文件,而是基于解析后的标准文本继续切块。

理由:

1. 解析阶段已经做过 Tika 提取、清洗、标准化,结果稳定
2. 重复解析浪费算力(Tika 不便宜)
3. 重复解析可能因模型/库版本差异产生不一致结果

把“解析”和“切块”解耦后,任何一边的改动都不影响另一边

切块策略升级 → 不用重新解析所有文档
解析逻辑改动 → 不影响已有 chunk 结构(除非显式重建)

2. 调用 buildParentBlocks

List<ParentBlockCandidate> parentBlockCandidateList =
    strategyService.buildParentBlocks(document, plan, stepList, parsedText);

四个入参的角色:

document:提供 documentId、lastParseTaskId 用于查结构节点
plan:策略方案元信息
stepList:已排序的步骤列表(决定走哪些策略)
parsedText:切块的输入数据

返回值是 List<ParentBlockCandidate>,每个父块带一组子块,是后续向量化和索引写入的输入。

3. 步骤状态收尾

updateStepExecuteStatus(planId, EXECUTE_SUCCESS);

切块成功后,把所有步骤标记成功。同样是粗粒度推进。

注意这一行只在切块成功的路径执行。如果 buildParentBlocks 抛异常,这一行不会执行,外层会有 catch 把步骤标记 FAILED(这一篇没贴出来,应该在最外层)。


五、buildParentBlocks:双层流水线的核心入口

这是整个切块逻辑的“总指挥”,方法本身不大但每一步都关键。

1. 拆流水线

List<...> parentSteps = sortPipelineSteps(steps, PARENT);
List<...> childSteps = sortPipelineSteps(steps, CHILD);
if (parentSteps.isEmpty()) {
    throw new IllegalStateException("当前方案缺少父块流水线...");
}
if (childSteps.isEmpty()) {
    throw new IllegalStateException("当前方案缺少子块流水线...");
}
为什么要再次按 pipelineType 拆?

外层 listSteps 已经做过排序,但还是混在一个 list 里。这里按 pipelineType 拆成两条独立的列表:

parentSteps:只有父块流水线的步骤
childSteps:只有子块流水线的步骤

后续两条流水线各自独立调度

父块流水线决定整篇文档怎么切成父块
子块流水线决定每个父块怎么切成子块
互不干扰
为什么任一为空就抛异常?

Parent-Child 架构是强约束

没有父块 → 没有大上下文,回答阶段没东西读
没有子块 → 没有检索单元,召回阶段没东西搜

任意一层缺失整个 RAG 链路都跑不起来。这种情况下早抛早死,比让流程半途垮掉更好。

抛 IllegalStateException 而不是业务异常
throw new IllegalStateException(...);

这种选择有讲究。IllegalStateException 表示“程序内部状态错误”,意味着这是不应该出现的情况:

正常情况下方案推荐阶段就保证了父子流水线都有
如果跑到这里发现一边为空,说明数据被外部改坏了

抛 IllegalStateException 让外层 catch 知道“这不是用户错误,而是内部 bug”,会被监控重点关注。

2. 加载结构节点

List<...> structureNodes = structureNodeService.listDocumentNodes(
    document == null ? null : document.getId(),
    document == null ? null : document.getLastParseTaskId()
);

回想前几篇,解析阶段提取了文档的结构骨架:章节标题、层级关系、内容承载关系等。这些结构节点保存在 document_structure_node 表里,每条记录关联到 documentId + parseTaskId

为什么需要 structureNodes?

它们是结构切块的天然边界

普通切块:只能按字符长度或语义相似度切
结构切块:可以按"第一章"、"第二章"这样的天然章节边界切

结构切块的优势:
    边界对齐用户认知
    每块都对应明确的章节
    检索结果引用时可以显示"第 X  Y 节"
为什么 documentId 和 parseTaskId 都要传?

因为同一文档可能被解析多次(重新上传、策略调整后重新解析)。每次解析产生独立的结构节点集合。lastParseTaskId 锁定当前生效的那一次解析,避免拿到历史脏数据。

null 防御
document == null ? null : document.getId()

这里防御看起来多余(前面已经判断过 document 非空),但保留它有两个好处:

方法签名解耦:即使 buildParentBlocks 被别处调用且 document 为空,也不会 NPE
代码 review 友好:一眼能看出考虑了边界

工程上多一行防御 vs 少一行简洁,前者优先。

3. 第一层:产出父块种子

List<ChunkCandidate> parentSeedList = buildParentSeedList(parsedText, parentSteps, structureNodes);

种子”这个命名很值得品。

为什么叫"种子"而不是"父块"?

因为这里产出的还不是最终父块

parentSeedList:父块的"半成品",还要经过清洗和子块切分
ParentBlockCandidate:父块的"完整候选",带 finalChildren

种子 → 候选 → 最终的命名体系有清晰的语义层次,让代码读起来知道每个变量在生命周期的哪个阶段。

种子是 ChunkCandidate 类型
ChunkCandidate:统一的内部数据结构
包含字段:sectionPath、structureNodeId、structureNodeType、canonicalPath、itemIndex、text、sourceType...

为什么要有这么多元数据?

sectionPath:章节路径(如"第一章/1.1节"),用于展示和定位
structureNodeId:关联到结构节点表,用于父子映射
canonicalPath:规范化路径,用于去重
itemIndex:位置序号,用于稳定排序
sourceType:来源类型(原文 / 结构切块 / 递归切块 / 语义切块 / LLM 切块)

这些元数据让每个 chunk 知道自己从哪来、在文档中的什么位置,对后续展示、调试、问题追溯都至关重要。

4. 父块种子第一轮清洗

for (ChunkCandidate parentSeed : cleanupChunkList(parentSeedList)) {
    if (parentSeed == null || StrUtil.isBlank(parentSeed.getText())) {
        continue;
    }
    ...
}
cleanupChunkList 做什么?

虽然代码没贴,但根据后面 cleanupParentBlockList 的逻辑能推断出来:

1. 去除 null 和空文本块
2. 按"路径 + 位置 + 文本"构造去重键(LinkedHashMap)
3. 保留出现顺序
为什么 for 循环里还要再判一次空?
if (parentSeed == null || StrUtil.isBlank(parentSeed.getText())) continue;

cleanupChunkList 应该已经过滤过空了,这里再判一次是双保险

1. cleanupChunkList 实现可能升级,保护边界
2. 即使清洗逻辑漏过空块,这里也能拦住
3. 防御性代码成本低,易读性高

工程上经常出现这种“理论上不会发生但还是判一下”的写法,叫深度防御

5. 第二层:每个父块产出子块种子

List<ChunkCandidate> childSeedList = buildChildSeedList(parentSeed, childSteps, structureNodes);
List<ChunkCandidate> finalChildren = cleanupChunkList(childSeedList);

注意几个关键点:

子块切分是"父块视角"的

第一个参数 parentSeed 把当前父块作为子块切分的输入。这意味着:

子块永远在父块的语义范围内
子块的文本 ⊆ 父块的文本

这个约束保证了 RAG 检索时的父子映射正确性

子块召回 → 找到 parentId
加载父块 → 父块文本完整覆盖子块内容
模型读父块 → 包含召回点的完整上下文

如果子块切分跨父块边界,会导致:

检索的子块 → 加载的父块文本不完整
模型读到的上下文缺失关键内容
第二轮清洗
List<ChunkCandidate> finalChildren = cleanupChunkList(childSeedList);

这是第二轮清洗:每个父块产出的子块种子都要单独清洗一次。

这一轮清洗的范围是单个父块内,不是全局:

不同父块下可能有完全相同的子块文本
但它们语境不同(隶属不同章节)
所以全局去重反而错误,只在父块内去重

6. 子块兜底机制

if (finalChildren.isEmpty()) {
    finalChildren = List.of(cloneChunkCandidate(parentSeed, parentSeed.getText().trim()));
}

这是这段代码最值得讲的设计点之一

什么场景下子块会为空?
1. 父块本身很短(只有一两句),子块流水线切不出任何块
2. 子块流水线全是清洗后剩 0 块的边界情况
3. LLM 切块返回乱码被清洗光
4. 语义切块在低质量文本上失败
为什么不能让子块为空?

因为 RAG 检索是基于子块的

子块 → 向量化 → 写入向量库 → 检索时召回
没有子块 → 没有向量 → 这部分内容永远不会被召回 → 父块成"死内容"

兜底策略:父块即唯一子块

finalChildren = List.of(cloneChunkCandidate(parentSeed, parentSeed.getText().trim()));

逻辑是:

clone 一份父块作为唯一子块
子块文本 = 父块文本(trim 后)
确保每个父块至少有一个子块

这种兜底意味着检索粒度变粗了(这一段父块整体被检索)但至少能被召回

设计哲学:可用性优先

这个兜底体现了一个核心原则:

"完美但不可用" < "次优但能跑"

宁可子块切得不那么精细,也要保证 RAG 链路完整。这种思想贯穿这套代码的多处兜底设计:

没结构信号 → 用递归切块兜底
LLM 切完没控制长度 → 追加递归切块兜底
子块为空 → 用父块本身兜底

每一层兜底的意义都是“让最坏情况下系统仍能跑”。

7. 打包成 ParentBlockCandidate

parentBlockList.add(new ParentBlockCandidate(
    parentSeed.getSectionPath(),
    parentSeed.getStructureNodeId(),
    parentSeed.getStructureNodeType(),
    parentSeed.getCanonicalPath(),
    parentSeed.getItemIndex(),
    parentSeed.getText().trim(),
    parentSeed.getSourceType(),
    finalChildren
));
ParentBlockCandidate 的结构
sectionPath:章节路径
structureNodeId:结构节点 ID
structureNodeType:结构节点类型(章/节/小节)
canonicalPath:规范化路径
itemIndex:位置序号
text:父块文本
sourceType:来源类型
finalChildren:最终子块列表

注意 finalChildrenParentBlockCandidate 的字段,体现了父子的"组合"关系而不是"两张平行表":

父块和子块在数据结构上是嵌套的
父块"拥有"它的子块
落库时也按这种关系处理

这种对象模型比"父块表 + 子块表 + 关联字段"的扁平模型更接近业务语义。

为什么 text 要 trim?
parentSeed.getText().trim()

trim 去掉前后空白字符。理由:

切块边界可能落在空白处,导致 chunk 前后有空格/换行
向量化时空白不携带语义但占 token
检索时空白干扰相似度计算

trim 是廉价但必要的归一化。

8. 第三轮全局清洗

return cleanupParentBlockList(parentBlockList);

这是第三轮清洗,作用范围是全部父块

三轮清洗各自的范围
第一轮(cleanupChunkList(parentSeedList)):清洗父块种子(父块层面)
第二轮(cleanupChunkList(childSeedList)):清洗子块种子(单个父块内的子块层面)
第三轮(cleanupParentBlockList):清洗最终父块列表(整个父块列表)

为什么需要三轮?

每一轮处理的对象不同
每一轮发现的问题也不同
单轮清洗解决不了所有问题

举例:

第一轮清洗 → 父块种子层去重
第二轮清洗 → 子块种子层去重
但是:第一轮没发现的"重复父块"(因为元数据略有差异),在加完子块后变得明显重复
所以需要第三轮基于完整 ParentBlockCandidate 再做一次去重

这种多轮清洗是数据处理流水线的标准做法。


六、buildParentSeedList:父块种子的三条路径

private List<ChunkCandidate> buildParentSeedList(...) {
    if (containsStructureStep(parentSteps) && structureNodes != null && !structureNodes.isEmpty()) {
        List<ChunkCandidate> structureSeeds = buildStructureParentSeeds(structureNodes);
        if (structureSeeds.isEmpty()) {
            // 路径 B:结构降级
            return executePipeline(...);
        }
        List<...> remainingSteps = stripStructureSteps(parentSteps);
        if (remainingSteps.isEmpty()) { return structureSeeds; }
        // 路径 A:结构 + 后续细分
        return executePipeline(structureSeeds, remainingSteps, PARENT);
    }
    // 路径 C:无结构
    return executePipeline(...);
}

三条路径的决策表:

条件 走哪条
有结构步骤 + 有结构节点 + 结构能筛出种子 A:结构 + 后续细分
有结构步骤 + 有结构节点 + 结构筛不出种子 B:结构降级到完整流水线
没结构步骤 或 没结构节点 C:完整流水线

路径 A:结构优先 + 剩余步骤继续细分

List<...> remainingSteps = stripStructureSteps(parentSteps);
if (remainingSteps.isEmpty()) { return structureSeeds; }
return executePipeline(structureSeeds, remainingSteps, PARENT);
为什么要 stripStructureSteps?

因为结构切块已经做过了

buildStructureParentSeeds:已经从结构节点切出种子
后续步骤如果还有 STRUCTURE,就重复切,毫无意义

剔除已执行的步骤再传给 executePipeline,避免重复执行

剩余步骤的意义

如果父块流水线是 STRUCTURE + RECURSIVE

结构步骤切出 5 个章节级父块
但其中某个章节有 1 万字,太长
RECURSIVE 步骤把这个长章节再按长度切小
最终产出 5 + N 个父块

这就是为什么剩余步骤要继续执行——结构边界 + 长度兜底

路径 B:结构降级

if (structureSeeds.isEmpty()) {
    return executePipeline(...);
}

什么场景下结构筛不出种子?

所有章节都是纯标题壳节点(下面还有子章节,自己不直接承载内容)
或者结构节点表里只有"文档根节点",没有可用的章节

这时候放弃结构路径,整篇文本作为输入走完整流水线

输入是一个超大块:[ChunkCandidate("", parsedText)]
sourceType = ORIGINAL(标记是原文)
经过完整 parentSteps(包括 STRUCTURE 在内)
但 STRUCTURE 找不到节点会自动跳过(在 executePipeline 内部)
实际生效的是 RECURSIVE/SEMANTIC/LLM
降级而不是抛错

如果筛不出来直接抛 IllegalStateException 也是一种选择。但这里选优雅降级

方案推荐时基于"标题 ≥ 2"就推荐了 STRUCTURE
但实际筛选时可能因为内容承载性判断更严格,过滤掉了
这种情况不应该让任务失败,应该自动用其他策略补救

降级后用户可能感知不到(也就是“优雅”的含义)。

路径 C:无结构

最简单的路径——文档没结构信号或方案没配结构步骤,直接走流水线。

return executePipeline(
    List.of(new ChunkCandidate("", parsedText, ORIGINAL)),
    parentSteps,
    PARENT);

注意这里把 parsedText 包装成一个 ChunkCandidate 列表。这是为了统一接口

executePipeline 的入参永远是 List<ChunkCandidate>
不管最初输入是结构种子还是原文,都封装成同样的形状
内部逻辑不需要分两种情况处理

这是输入归一化的常见模式,让流水线引擎可以单一职责。


七、buildStructureParentSeeds:从结构树筛叶子章节

private List<ChunkCandidate> buildStructureParentSeeds(List<...> structureNodes) {
    Map<Long, Boolean> parentHasChildSection = new LinkedHashMap<>();
    for (...node : structureNodes) {
        if (node == null || node.getParentNodeId() == null) continue;
        if (SECTION.equals(node.getNodeType())) {
            parentHasChildSection.put(node.getParentNodeId(), true);
        }
    }
    List<ChunkCandidate> seeds = new ArrayList<>();
    for (...node : structureNodes) {
        if (node == null || !SECTION.equals(node.getNodeType())) continue;
        if (!isContentBearingSection(node, parentHasChildSection.getOrDefault(node.getId(), false))) {
            continue;
        }
        seeds.add(toChunkCandidate(node));
    }
    return seeds;
}

核心思想:只取"叶子章节"

考虑一份文档的结构:

第一章
    1.1 节
        1.1.1 小节
        1.1.2 小节
    1.2 节
第二章
    2.1 节

如果父块按 第一章 / 第二章 切:

第一章包含 1.1.1 + 1.1.2 + 1.2 全部内容
"第一章"作为父块,文本可能上万字
父块太大,语义太杂

如果父块按 1.1.1 / 1.1.2 / 1.2 / 2.1 切:

每块大小适中
每块语义聚焦
检索引用时定位到具体小节

所以真正适合做父块的是"内容承载的叶子章节",而不是中间层的"标题壳"。

算法:两轮遍历

第一轮:建立"父子关系映射"
for (node : structureNodes) {
    if (node.getParentNodeId() == null) continue;
    if (SECTION.equals(node.getNodeType())) {
        parentHasChildSection.put(node.getParentNodeId(), true);
    }
}

这一轮做的事:

扫描所有节点
对每个 SECTION 节点,把它的 parentNodeId 标记为"有子章节"
最终 parentHasChildSection 包含所有"还有子章节的父节点 ID"

为什么用 LinkedHashMap 而不是 HashSet?

LinkedHashMap 保留插入顺序
遍历时顺序稳定
方便调试和测试
HashSet 也行但顺序不保证
第二轮:筛选叶子章节
for (node : structureNodes) {
    if (!SECTION.equals(node.getNodeType())) continue;
    if (!isContentBearingSection(node, parentHasChildSection.getOrDefault(node.getId(), false))) {
        continue;
    }
    seeds.add(toChunkCandidate(node));
}

这一轮的过滤:

1. 只看 SECTION 类型(忽略文档根、段落等非章节节点)
2. 调用 isContentBearingSection 判断是否"内容承载"
   入参:节点本身 + 是否还有子章节
   返回:这个节点能不能作为父块种子

isContentBearingSection 的判断逻辑

虽然代码没贴,但根据语义能推出:

一个章节是"内容承载"的,通常需要满足:
1. 自己有正文文本(不只是标题)
2. 不是纯壳节点(下面没有子章节,或者下面有子章节但自己也有正文)

边界情况:

"第一章"下面有 1.1 和 1.2,自己只有标题 → 非内容承载,过滤
"第一章"下面有 1.1 和 1.2,自己也有引言文字 → 内容承载,保留
"1.1 节"下面没有子章节,有正文 → 内容承载,保留(典型叶子)
"1.1 节"下面没有子章节,只有标题没正文 → 非内容承载,过滤

为什么这种算法值得?

不做这个筛选 → 父块包含所有章节节点(中间层的"壳"也会成为父块)
做了这个筛选 → 父块只包含真正承载内容的叶子章节

效果差异:

前者:大量空标题父块 + 内容父块,父子关系混乱
后者:每个父块都是聚焦的小章节,大小均衡,语义清晰

这种结构感知的切分,是普通递归切块完全做不到的。


八、buildChildSeedList:子块种子的两条路径

private List<ChunkCandidate> buildChildSeedList(...) {
    if (containsStructureStep(childSteps)
        && parentSeed != null && parentSeed.getStructureNodeId() != null
        && structureNodes != null && !structureNodes.isEmpty()) {
        List<ChunkCandidate> structureSeeds = buildStructureChildSeeds(parentSeed, structureNodes);
        List<...> remainingSteps = stripStructureSteps(childSteps);
        if (remainingSteps.isEmpty()) return structureSeeds;
        return executePipeline(structureSeeds, remainingSteps, CHILD);
    }
    return executePipeline(
        List.of(cloneChunkCandidate(parentSeed, parentSeed.getText())),
        childSteps,
        CHILD);
}

子块的设计思想和父块类似但条件更严格

结构路径的三个前置条件

1. 子块流水线配了 STRUCTURE 步骤
2. parentSeed.structureNodeId 不为空
3. 文档有结构节点
第二个条件最关键

parentSeed.getStructureNodeId() != null 意味着:

当前父块来自结构切块(buildStructureParentSeeds 产出)
带有结构树上的锚点

如果父块是普通流水线产出的(比如递归切块切出来的),它身上没有 structureNodeId

父块文本来自原文的某段
不知道对应结构树上的哪个节点
没法定位"这个父块下面有哪些子节点"

这种情况下,即使方案配了 STRUCTURE 步骤,子块也走不了结构路径——没锚点就找不到方向

buildStructureChildSeeds 的语义

虽然代码没贴,但能推断:

入参:parentSeed(带 structureNodeId) + structureNodes
逻辑:
    定位到 parentSeed 对应的节点
    取这个节点的"直接子节点"(可能是更小的小节、段落等)
    把每个子节点转成 ChunkCandidate
返回:子节点列表

这种设计让子块切分沿着结构树继续往下

父块是"1.1 节"(叶子章节,但下面可能还有更细的段落节点)
子块就是"1.1 节"下面的段落级节点

降级路径

return executePipeline(
    List.of(cloneChunkCandidate(parentSeed, parentSeed.getText())),
    childSteps,
    CHILD);

cloneChunkCandidate 把父块复制一份,文本作为输入丢进子块流水线。

这就是为什么前面说子块永远在父块语义范围内——子块流水线的输入永远是父块的文本,不会跨边界。

没有"结构降级到流水线"的中间路径

注意子块和父块的差异:

父块:结构筛不出来 → 降级到完整流水线(整篇文本走流水线)
子块:三个条件不全满足 → 直接走流水线(父块文本走流水线)

子块没有“结构筛出空”的降级路径。原因:

结构子块是从父节点的子节点取的
如果取出来是空,要么父节点没子节点(没法切),要么子节点都被过滤
这两种情况下重新走整篇流水线没意义(因为输入就是父块本身)
所以条件直接前置判断,空了就走流水线

逻辑上更紧凑。


九、把整段流程串成一句话

Consumer 调用 handleIndexBuild 后第一阶段先重新查 document/task/plan 三类核心数据并加载已排序的 step 列表,把 task 切到 RUNNING + CHUNK_EXECUTE、document 的 indexStatus 更新为 BUILDING、所有 step 全量切到 EXECUTING;第二阶段从 MinIO 下载解析阶段沉淀的 parsedText,调用 buildParentBlocks 启动 Parent-Child 双层切块——先把 stepList 拆成父子两条流水线(任一为空就抛 IllegalStateException),再加载文档的结构节点;接着 buildParentSeedList 按"有结构步骤且有结构节点 → 先 buildStructureParentSeeds 从叶子章节筛种子(用两轮遍历过滤掉只有标题没内容的壳节点)→ 剩余步骤继续执行 / 结构筛空 → 降级到完整流水线 / 否则直接走完整流水线"三条路径生成父块种子;之后对每个父块种子调用 buildChildSeedList 生成子块种子(子块流水线必须 parentSeed 带 structureNodeId 才能走结构路径,否则用父块文本走完整流水线),保证子块永远在父块语义范围内;中间做三轮清洗去除空块和重复块,子块为空时用父块本身兜底以保证不出现"有父无子"的空壳;最后把每个父块的元数据 + finalChildren 打包成 ParentBlockCandidate 返回。整个阶段不写任何业务数据,所有候选块都在内存里等下一阶段做向量化和落库;切块成功后把所有 step 切到 EXECUTE_SUCCESS。


十、核心技术点提炼

1. 计算-提交分离

切块阶段只产出内存中的候选结果,不写任何业务数据。让"可能失败的过程"和"持久化"解耦,避免半成品污染数据库。

2. 三时间字段精确分析瓶颈

createTime / startTime / finishTime 拆开记录,能精确分析"队列等待"和"实际处理"哪个是瓶颈。

3. 状态粗粒度推进

整套 step 一起切 EXECUTING → EXECUTE_SUCCESS,避免在嵌套调用中精细推进单步状态导致的状态错乱。

4. 三层排序保证步骤稳定性

pipelineType + stepNo + id 三层兜底,让同一份方案的执行顺序完全可复现,便于调试。

5. 解析与切块解耦

切块直接读 parsedText 不重新解析原始文件,让解析逻辑改动和切块策略升级互不影响,节省算力。

6. Parent-Child 强约束

任一流水线为空直接抛 IllegalStateException。Parent 提供回答上下文,Child 提供检索单元,缺一不可。

7. 结构节点作为天然边界

通过 lastParseTaskId 锁定当前生效的结构骨架,让切块沿章节边界走而不是按字符长度硬切。

8. "种子 → 候选 → 最终"三层命名

清晰的语义层次让代码读起来知道每个变量在生命周期的哪一步。

9. "结构优先 + 流水线兜底"降级链

三条路径覆盖所有场景:结构成功用结构、结构筛空降级、无结构走流水线。任何文档都能跑通。

10. 子块永远在父块范围内

通过 cloneChunkCandidate 把父块文本作为子块流水线的输入,保证父子映射在文本上严格包含,避免检索时上下文缺失。

11. 子块为空兜底

finalChildren.isEmpty() 时用父块本身作为唯一子块,保证不出现"有父无子"的空壳结构。检索粒度变粗但 RAG 链路完整,体现"可用性优先"原则。

12. 三轮清洗多层去重

父块种子层、单父块内子块层、最终父块列表层各一轮,每轮处理范围不同。用 LinkedHashMap 按"路径 + 位置 + 文本"构造去重键,保持原始顺序。

13. 输入归一化让流水线引擎单一职责

不管是结构种子还是原文,都封装成 List<ChunkCandidate> 传给 executePipeline,内部不需要分两种情况处理。

14. stripStructureSteps 避免重复执行

结构步骤已经在 buildStructureParentSeeds/buildStructureChildSeeds 里执行过了,传给 executePipeline 前必须剔除,否则会重复切。

15. structureNodeId 作为父子结构路径的"通行证"

只有带 structureNodeId 的父块才能走子块结构路径,因为递归切块产出的父块没有结构树锚点,无法定位子节点。

16. 缺失数据 return 而非抛异常

document/task/plan 任一缺失时只打 warn 然后 return。让 Kafka commit offset 避免脏消息无限重试,呼应上一篇"业务级消费成功 ≠ Kafka 层投递成功"的设计。

17. 防御性 null 判断

document == null ? null : document.getId() 这种已经判过空还再判一次的写法,是深度防御的常见做法,工程上多一行防御 vs 少一行简洁,前者优先。

18. 防御性二次状态更新

document.indexStatus 在同步链路已经切到 BUILDING,异步链路还会再切一次。幂等更新代价小,能屏蔽主从延迟、并发覆盖等边界场景。


十一、面试官可能会问的问题

问题 1:为什么切块阶段不立刻把 chunk 落库,而是产出内存中的候选结果?

可以回答:

这是"计算-提交分离"的设计。切块涉及 LLM 调用、embedding 调用、外部模型服务,过程中任何一步都可能失败。如果边切边写,切到一半失败就会在数据库留下半成品 chunk,重试时还要先清理脏数据非常麻烦。先在内存里把所有候选块算出来,全部成功再统一落库,要么全成功要么全回滚。而且只有先拿到所有候选块才能做全局去重、父子映射调整、统一顺序号生成这些操作,边切边写做不到。这是经典的两阶段模式:第一阶段纯计算可失败,第二阶段写入要原子。

问题 2:为什么 step 状态用 planId 全量更新而不是逐步精细推进?

可以回答:

因为 Parent-Child 双层切块是嵌套调用而不是线性接力。比如父块用 STRUCTURE,子块用 LLM + RECURSIVE,执行顺序是"父块切出 5 个 → 第 1 个父块走子块 LLM → 走子块 RECURSIVE → 第 2 个父块走子块 LLM → ..."。子块的 LLM 步骤会被反复进入,单步状态根本没法精确划分,强行精细推进会出现 RUNNING/SUCCESS 反复横跳的状态错乱。粗粒度推进虽然看不到单步进度,但逻辑简单可靠。如果后续需要精细监控,可以另开一张事件流日志表来记录而不污染 step 表本身。这是简单可靠优先的工程权衡。

问题 3:buildStructureParentSeeds 为什么要过滤"壳节点"?

可以回答:

因为"壳节点"是中间层标题节点——比如"第一章"下面还有"1.1 节"和"1.2 节",第一章自己只是个标题,真正的内容在子节点里。如果不过滤,父块会同时包含"第一章"和"1.1 节"、"1.2 节",三者文本重叠:第一章包含子节点全部内容,1.1 和 1.2 又重复出现。这会导致父块大小爆炸、内容重复、检索时同一段文本反复召回。算法用两轮遍历解决:第一轮建立"哪些节点下面还有子章节"的映射,第二轮筛掉这些壳节点,只留下真正承载内容的叶子章节。这样每个父块都是聚焦的小章节,大小均衡,语义清晰,是普通递归切块完全做不到的。

问题 4:子块为什么必须保持在父块语义范围内?

可以回答:

因为 RAG 检索的工作流是"子块召回 → 找到 parentId → 加载对应父块 → 父块文本送给模型生成答案"。如果子块文本跨越父块边界,检索到的子块对应的父块文本就不能完整覆盖召回点,模型读到的上下文会缺失关键内容,回答质量直接崩。这套代码通过 cloneChunkCandidate 把父块文本作为子块流水线的输入,从源头保证子块文本是父块文本的子集。父子映射在文本上严格包含,召回的子块对应的父块永远能完整覆盖召回内容。这是 Parent-Child 架构能成立的核心约束。

问题 5:子块为空时为什么要用父块兜底?直接放弃这部分内容不行吗?

可以回答:

不行。RAG 检索是基于子块向量的,没有子块就没有向量就永远不会被召回,对应的父块就成了"死内容"——存在数据库里但永远不会被用户查到。这是个严重的数据丢失。子块兜底用 cloneChunkCandidate 把父块本身作为唯一子块,代价是这部分内容的检索粒度变粗(整段父块作为一个向量),但至少能被召回。这体现了"可用性优先"原则——宁可子块切得不那么精细,也要保证 RAG 链路完整。整套代码多处兜底(没结构信号用递归兜底、LLM 切完追加递归兜底、子块为空用父块兜底)背后都是同一思想:让最坏情况下系统仍能跑。

问题 6:为什么要做三轮清洗?一轮全局清洗不行吗?

可以回答:

一轮不够,因为三轮处理的对象和范围完全不同。第一轮在父块种子层清洗,去掉空种子避免污染后续子块切分;第二轮在单个父块内的子块层清洗,注意是父块内而不是全局——不同父块下可能有完全相同的子块文本但语境不同(隶属不同章节),全局去重反而错误;第三轮在最终父块列表层清洗,因为第一轮没发现的"重复父块"(元数据略有差异)在加完子块后变得明显重复,需要基于完整 ParentBlockCandidate 再做一次去重。每一轮都用 LinkedHashMap 按"路径 + 位置 + 文本"构造去重键,保持原始顺序。多轮清洗是数据处理流水线的标准做法,把不同层次的问题分开解决。

问题 7:handleIndexBuild 一进来发现 document/task/plan 缺失为什么 return 而不抛异常?

可以回答:

这是延续上一篇"业务级消费成功 ≠ Kafka 层投递成功"的设计。三类核心数据缺失意味着这是脏消息——可能是测试遗留、记录被外部删除、消费严重延迟等。脏消息再消费多少次都还是脏的,抛异常让 Kafka 自动重试只会浪费资源。直接 return 让 Kafka commit offset,避免脏消息无限重试占用消费线程。代价是这个分支没有把 task 标记 FAILED,可能留下幽灵任务,更稳健的写法是显式失败收尾,但缺失场景极少出现,加复杂逻辑收益有限。这是工程上"足够好 vs 完美"的取舍。

问题 8:buildParentSeedList 有三条路径,为什么不简化成两条(有结构 vs 没结构)?

可以回答:

因为"有结构步骤 + 有结构节点"和"结构能真正筛出种子"是两回事。方案推荐阶段可能基于"标题数 ≥ 2"就推荐了 STRUCTURE 步骤,但实际筛选时 isContentBearingSection 的判断更严格,可能过滤掉所有节点最终得到空种子。这种情况下抛错让任务失败用户体验差,正确做法是优雅降级——放弃结构路径,把整篇文本走完整流水线。这就是路径 B(结构降级)存在的意义。三条路径分别对应"结构有效"、"结构失效但配了"、"压根没配结构",覆盖所有真实场景。如果只分两条,要么处理不了降级,要么把降级硬塞进其中一条让逻辑变复杂。三条路径反而比两条更清晰。

问题 9:buildChildSeedList 为什么没有"结构降级到完整流水线"的路径?

可以回答:

因为子块的结构切分逻辑和父块不一样。父块结构切分的输入是整个 structureNodes 列表,需要从所有节点里筛叶子章节,可能因为筛选条件严格全部被过滤;这时降级到"整篇文本走流水线"是合理的备选。子块结构切分的输入是"父节点的直接子节点",要么父节点有子节点要么没有,没有子节点就根本走不了结构路径。所以子块把三个前置条件(配了结构步骤 + 父块带 structureNodeId + 有结构节点列表)直接前置判断,不满足就走流水线,满足就走结构。即使结构筛出空,也没必要降级回"父块文本走流水线"——因为输入是一样的,结果也一样。逻辑上更紧凑。

问题 10:为什么 sortPipelineSteps 已经在 listSteps 排过序了,这里还要再拆一次?

可以回答:

listSteps 是把所有步骤排好序后返回一个混合列表,pipelineType 排在最前面让父块步骤聚在前面、子块步骤聚在后面。但 buildParentBlocks 需要两条独立的流水线列表分别调度——父块流水线在 buildParentSeedList 里独立执行,子块流水线在 buildChildSeedList 里被每个父块反复调用。如果不拆开,每次调用都要传整个混合列表然后内部 filter,效率低且代码冗余。拆成 parentSteps 和 childSteps 两个独立列表后,两条流水线各自调度互不干扰,调用 executePipeline 时直接传对应列表就行。这是"按职责拆分数据结构"的常见做法。

问题 11:为什么 task 和 document 各自有 status 字段而不是合并管理?

可以回答:

因为 task 和 document 描述的是不同层次的状态。task 是"这次任务执行得怎么样",是行为视角;document 是"这份文档现在处于什么阶段",是实体视角。一份文档生命周期里可能有多个任务(解析任务、索引任务、重建任务),每个任务有自己独立的状态机,但文档的整体状态需要综合所有任务来反映。比如索引重建时,task.taskStatus 是 RUNNING(这次执行进行中),但 document.indexStatus 也是 BUILDING(文档正在重建索引)。两个字段一起更新冗余但语义清晰:前端列表页查 document 字段,运维监控查 task 字段,各取所需互不干扰。如果合并成单一状态,会出现"行为状态和实体状态打架"的问题。

问题 12:父块为什么要 trim 而子块不需要?

可以回答:

实际上子块也会被处理(在 cleanupChunkList 内部应该有类似处理)。这里在打包 ParentBlockCandidate 时显式调用 parentSeed.getText().trim() 是因为父块文本是直接从 parentSeed 拷贝过来作为 ParentBlockCandidate 的 text 字段,是最终输出结果,trim 一次保证落库的文本干净。trim 去掉前后空白字符的原因有三个:切块边界可能落在空白处导致 chunk 前后有多余空格或换行;向量化时空白不携带语义但占 token 浪费 embedding 维度容量;检索时空白干扰相似度计算让本来相关的内容算出来差距变大。trim 是廉价但必要的归一化操作,所有进入持久化或向量化的文本都应该归一化处理。


企业级项目导航:⬅️ 01-索引构建入口与Kafka消息投递 | 02-初步父子切块 | ➡️ 03-四种切块策略详解