--- title: "02-初步父子切块" created: 2026-05-20 aliases: - 初步父子切块 tags: - 项目 --- # 初步父子切块 我们接着上一篇“索引构建入口与 Kafka 消息投递”继续往下看。 上一篇的终点是: ```text Service 层做完四道校验 创建了 BUILD_INDEX 任务(NEW 状态) strategySnapshot 已固化到任务上 文档 indexStatus 切到 BUILDING Kafka 同步发送了一条三个 ID 的瘦消息 Consumer 在 -index 消费组接到消息 反序列化后调用 asyncProcessService.handleIndexBuild() ``` 这一篇就是从 `handleIndexBuild()` 进来开始,看异步链路的**前两个阶段**: ```text 阶段一:初始化(把任务、文档、步骤都切到运行态) 阶段二:切块执行(Parent-Child 双层流水线产出父子块) ``` 学完这一篇,你会理解: ```text 1. 异步链路一进来为什么要重新查三张表 2. 为什么要用 planId 全量更新步骤状态而不是逐步推进 3. Parent-Child 双层切块的代码骨架 4. 结构节点为什么是父块切分的"天然边界" 5. 为什么要做三轮清洗去重 6. "结构优先 + 流水线兜底"的降级思想 7. 子块为什么必须保持在父块语义范围内 ``` 下一篇才会展开四种切块策略的具体实现(结构 / 递归 / 语义 / LLM)和它们的嵌套关系。 --- ### **一、整体认知:这一节在做什么** 可以一句话概括: > Consumer 接到消息后调用 handleIndexBuild(),第一阶段把 task、document、step 三层状态全部切到运行态,第二阶段从 MinIO 下载解析后的纯文本,调用 buildParentBlocks 用 Parent-Child 双层流水线把文本拆成"父块 → 子块列表"的候选结构。这一阶段只产出内存里的候选结果,还没有任何数据落库,等后续阶段做向量化和索引写入时才真正持久化。 整体流程图: ```mermaid 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[(候选父子块在内存中,等下一阶段)] ``` #### **这一节的定位** 它处在“**消息消费**”和“**向量化/索引写入**”之间,承担两个职责: ```text 1. 把链路的"运行态"切起来——所有相关记录的状态字段同步推进 2. 完成最重的纯计算工作——文本切块,产出结构化候选结果 ``` 特别值得注意的是:**这一节不写任何业务数据**。父块、子块都还在内存的 `ParentBlockCandidate` 列表里,没有 insert 任何 chunk 表。这是有意为之的设计,下文会详细解释。 --- ### **二、为什么要把切块做成独立阶段,而不是边切边写?** 可以把切块和落库**合成一步**:每切一个 chunk 就 insert 一条记录。但这套代码刻意把切块单独抽出来作为一个阶段,原因很值得推敲。 #### **1. 切块过程不稳定** 切块涉及四种策略叠加: ```text 结构切块:依赖结构节点,可能不准 递归切块:基本稳定 语义切块:依赖 embedding 模型,可能调用失败 LLM 切块:调用大模型,可能超时、限流、返回乱码 ``` 如果边切边写: ```text 切了一半 LLM 超时 数据库里已经写了 50 个 chunk 任务标记 FAILED 重试时这 50 个 chunk 怎么办?清理?去重? ``` 如果切完再落库: ```text 切失败 → 内存数据丢弃,数据库零污染 切成功 → 一次性批量写入,要么全成功要么全回滚 ``` **先纯计算后写入**,让“可能失败的过程”和“持久化”解耦,是经典的**"计算-提交"分离**模式。 #### **2. 切块可以做去重和清洗** 只有先把所有候选块都拿到手,才能做: ```text 全局去重(同一段文本被两条策略切出两次) 父子映射调整 按路径排序 最终顺序号生成 ``` 如果边切边写,这些全局视图操作就做不了。 #### **3. 后续阶段对"成块结构"有完整性要求** 向量化阶段需要: ```text 按父块批量处理 父块和子块的映射关系完整 所有 chunk 有稳定的顺序号 ``` 这些要求只有“全部切块完成”后才能保证。 --- ### **三、阶段一:初始化** 代码不长但每一步都有讲究。 #### **1. 取出三类核心数据** ```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("索引任务对应的数据不存在..."); return; } ``` ##### **为什么要重新查?消息里不是已经有 ID 了吗?** ID 只是**指针**,不是**数据**。Consumer 拿到 ID 后必须查数据库拿到完整记录,原因: ```sql 1. 消息从发送到消费有时间差,数据库才是数据的真实来源 2. 后续要更新这些记录,必须先 select 出来 3. 三类数据是后续所有逻辑的输入 ``` ##### **为什么三个都查而不只查 task?** 理论上从 task 也能反查到 document 和 plan,但显式分别查有几个好处: ```text 减少 join,SQL 简单 任意一个缺失立刻发现 后续逻辑各自使用,代码清晰 ``` ##### **缺失时为什么 return 而不抛异常?** ```text if (document == null || task == null || plan == null) { log.warn(...); return; } ``` 这里只打 warn 日志然后退出,没有抛异常。这种设计的**潜台词**是: ```text 消息可能是脏消息(测试遗留、消费延迟、记录被删) 脏消息再消费多少次都是脏的 return 让 Kafka 认为消息消费成功(commit offset) 不让脏消息无限重试占用资源 ``` 回顾上一篇 Consumer 的 catch Exception 不重抛——是同一思想:**业务级"消费成功"和 Kafka 层"投递成功"解耦**,业务失败由数据库的任务状态托管,Kafka 只保证消息不丢。 ##### **缺失场景下任务表怎么办?** 注意这个分支没有把 task 标记为 FAILED。这是个**潜在的设计缺陷**: ```text 如果 document 没了,但 task 还在 → task 永远卡在 NEW 后台监控会看到一个"幽灵任务" ``` 更稳健的写法可能是: ```text document 缺失 → task 标 FAILED + 错误信息 plan 缺失 → task 标 FAILED + 错误信息 ``` 但这是工程权衡:缺失场景极少出现,加这套逻辑收益有限。 #### **2. 加载并排序 step 列表** ```typescript private List listSteps(Long planId) { List stepList = stepMapper.selectList( new LambdaQueryWrapper() .eq(...planId) .eq(...status, YES)); return stepList.stream() .sorted(Comparator .comparingInt(step -> pipelineOrder(step.getPipelineType())) .thenComparing(SuperAgentDocumentStrategyStep::getStepNo) .thenComparing(SuperAgentDocumentStrategyStep::getId)) .toList(); } ``` 三层排序的设计 ```text 第一层:pipelineType 排序(父块在前,子块在后) 第二层:stepNo 升序(同一流水线内按步骤号) 第三层:id 兜底(防止前两个值相同时顺序不稳定) ``` 为什么要排到 id 这么严格? 因为切块是**强顺序敏感**操作: ```text 父块流水线先于子块流水线 结构切块在递归切块之前 LLM 切块在递归切块之前 ``` 任何顺序错乱都会导致切块结果不稳定,特别是**复现 bug 时**——同一份文档同样的方案应该出同样的结果,否则没法定位问题。 ##### **为什么这里要排序而不是数据库 ORDER BY?** 也可以在 SQL 里 `ORDER BY pipeline_type, step_no, id`。代码里用 stream 排序的好处: ```text 排序逻辑可读,直接看 Comparator 链就知道意图 pipelineOrder 是自定义映射(可能不是简单的数值大小) 独立测试容易 ``` 不影响性能(步骤数量级是个位数到十位数)。 ##### **status = YES 的限制** 只取**未被逻辑删除**的步骤。逻辑删除是企业系统的常规操作,让删除可恢复、有审计。 #### **3. 任务和文档状态推进** ```java 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: ```text NEW:任务在 Kafka 队列里等待被消费 RUNNING:Consumer 已开始处理 ``` 这个状态切换让监控可以一眼看出: ```text NEW 堆积 → Consumer 消费跟不上 RUNNING 堆积 → 单个任务处理太慢 ``` ##### **document.indexStatus 已经是 BUILDING 了为什么还更新一次?** 回顾上一篇,同步链路已经把 indexStatus 切到 BUILDING 了。这里再写一次看似冗余,但其实是**保险**: ```text 异常场景:同步链路切了 BUILDING,但中间被外部接口改回 WAIT_BUILD 异常场景:数据库主从延迟,这里查到的还是旧值 异常场景:多个任务并发改这个字段 ``` 幂等更新代价小,能屏蔽掉一些边界场景。这是工程上的“**防御性更新**”。 ##### **startTime 字段** ```java task.setStartTime(startTime); ``` 这是 Consumer 真正开始处理的时间。配合后续的 `finishTime`,可以算出**实际处理耗时**: ```text task.createTime:任务创建时间(同步链路) task.startTime:Consumer 开始消费时间 task.finishTime:任务完成时间 队列等待时间 = startTime - createTime 实际处理时间 = finishTime - startTime 端到端时间 = finishTime - createTime ``` 三个时间字段拆开记录,可以**精确分析**瓶颈在排队还是处理。 #### **4. 全量更新步骤状态** ```java private void updateStepExecuteStatus(Long planId, Integer executeStatus) { stepMapper.update(null, new LambdaUpdateWrapper<>() .eq(...planId) .eq(...status, YES) .set(...executeStatus, executeStatus)); } ``` 调用: ```java updateStepExecuteStatus(planId, EXECUTING); ``` 把当前 plan 下**所有有效步骤**的 `executeStatus` 一次性切到 EXECUTING。 ##### **为什么不逐步推进?** 最直观的设计是: ```text 执行结构切块时:STRUCTURE step → RUNNING 执行完成时:STRUCTURE step → SUCCESS 执行递归切块时:RECURSIVE step → RUNNING 执行完成时:RECURSIVE step → SUCCESS ``` 这种“**精细推进**”的设计在工作流引擎里很常见。 但这套代码选了**粗粒度推进**:所有步骤一起 EXECUTING,全部成功后一起 EXECUTE\_SUCCESS。注释里有说明: > 这里按 planId 全量更新,是因为索引执行对整套策略同时生效,不区分单个 step 分别推进状态。 ##### **为什么这么设计?** 因为切块流水线的本质是**协同产出**而不是**串行接力**: ```text 父块流水线和子块流水线在 buildParentBlocks 里嵌套调用 执行顺序不是线性的(父块切完一个 → 子块切这个 → 父块切下一个 → ...) 单个步骤的状态没办法精确划分 ``` 举例: ```text 如果父块用 STRUCTURE,子块用 LLM + RECURSIVE 执行顺序大致是: 父块 STRUCTURE 切出 5 个父块 第 1 个父块 → 子块 LLM 切 → 子块 RECURSIVE 兜底 第 2 个父块 → 子块 LLM 切 → 子块 RECURSIVE 兜底 ... 子块的 LLM 步骤被调用了 5 次,每次状态怎么标? RUNNING → SUCCESS → RUNNING → SUCCESS → ... 反复横跳? ``` 粗粒度推进规避了这种复杂度。 ##### **代价和回报** 代价: ```text 看不到具体哪一步在跑 失败时不知道卡在哪一步(只能看日志) ``` 回报: ```text 逻辑简单 不需要改造嵌套调用 不会出现状态错乱 ``` 工程上选了简单可靠。如果后续需要精细监控,可以另开一张 step\_execution\_log 表,**事件流**式记录,而不污染 step 表本身。 --- ### **四、阶段二:切块执行** 阶段二是整段流程的**计算密集型核心**。 #### **1. 下载解析后的纯文本** ```java String parsedText = storageService.downloadText(document.getParseTextPath()); ``` ##### **为什么从 MinIO 下载而不是数据库读?** 回想之前几篇,解析阶段把纯文本(可能几十 KB 到几十 MB)写到 MinIO,数据库只存路径。原因: ```text 数据库不适合存大文本(影响主从复制、备份成本高) MinIO 是对象存储,天然适合大文件 路径存数据库,内容存对象存储,职责清晰 ``` ##### **为什么不重新解析原始文件?** 注释里写得很清楚: > 索引构建不再重复解析原始文件,而是基于解析后的标准文本继续切块。 理由: ```text 1. 解析阶段已经做过 Tika 提取、清洗、标准化,结果稳定 2. 重复解析浪费算力(Tika 不便宜) 3. 重复解析可能因模型/库版本差异产生不一致结果 ``` 把“解析”和“切块”解耦后,**任何一边的改动都不影响另一边**: ```text 切块策略升级 → 不用重新解析所有文档 解析逻辑改动 → 不影响已有 chunk 结构(除非显式重建) ``` #### **2. 调用 buildParentBlocks** ```java List parentBlockCandidateList = strategyService.buildParentBlocks(document, plan, stepList, parsedText); ``` 四个入参的角色: ```text document:提供 documentId、lastParseTaskId 用于查结构节点 plan:策略方案元信息 stepList:已排序的步骤列表(决定走哪些策略) parsedText:切块的输入数据 ``` 返回值是 `List`,每个父块带一组子块,是后续向量化和索引写入的输入。 #### **3. 步骤状态收尾** ```java updateStepExecuteStatus(planId, EXECUTE_SUCCESS); ``` 切块成功后,把所有步骤标记成功。同样是粗粒度推进。 注意这一行**只在切块成功的路径**执行。如果 `buildParentBlocks` 抛异常,这一行不会执行,外层会有 catch 把步骤标记 FAILED(这一篇没贴出来,应该在最外层)。 --- ### **五、buildParentBlocks:双层流水线的核心入口** 这是整个切块逻辑的“**总指挥**”,方法本身不大但每一步都关键。 #### **1. 拆流水线** ```java 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` 拆成两条独立的列表: ```text parentSteps:只有父块流水线的步骤 childSteps:只有子块流水线的步骤 ``` 后续两条流水线**各自独立调度**: ```text 父块流水线决定整篇文档怎么切成父块 子块流水线决定每个父块怎么切成子块 互不干扰 ``` ##### **为什么任一为空就抛异常?** Parent-Child 架构是**强约束**: ```text 没有父块 → 没有大上下文,回答阶段没东西读 没有子块 → 没有检索单元,召回阶段没东西搜 ``` 任意一层缺失整个 RAG 链路都跑不起来。这种情况下**早抛早死**,比让流程半途垮掉更好。 ##### **抛 IllegalStateException 而不是业务异常** ```java throw new IllegalStateException(...); ``` 这种选择有讲究。`IllegalStateException` 表示“**程序内部状态错误**”,意味着这是**不应该出现**的情况: ```text 正常情况下方案推荐阶段就保证了父子流水线都有 如果跑到这里发现一边为空,说明数据被外部改坏了 ``` 抛 IllegalStateException 让外层 catch 知道“**这不是用户错误,而是内部 bug**”,会被监控重点关注。 #### **2. 加载结构节点** ```text List<...> structureNodes = structureNodeService.listDocumentNodes( document == null ? null : document.getId(), document == null ? null : document.getLastParseTaskId() ); ``` 回想前几篇,解析阶段提取了文档的**结构骨架**:章节标题、层级关系、内容承载关系等。这些结构节点保存在 `document_structure_node` 表里,每条记录关联到 `documentId + parseTaskId`。 ##### **为什么需要 structureNodes?** 它们是**结构切块的天然边界**: ```yaml 普通切块:只能按字符长度或语义相似度切 结构切块:可以按"第一章"、"第二章"这样的天然章节边界切 结构切块的优势: 边界对齐用户认知 每块都对应明确的章节 检索结果引用时可以显示"第 X 章 Y 节" ``` ##### **为什么 documentId 和 parseTaskId 都要传?** 因为同一文档可能被解析多次(重新上传、策略调整后重新解析)。每次解析产生独立的结构节点集合。`lastParseTaskId` 锁定**当前生效的那一次解析**,避免拿到历史脏数据。 ##### **null 防御** ```text document == null ? null : document.getId() ``` 这里防御看起来多余(前面已经判断过 document 非空),但保留它有两个好处: ```text 方法签名解耦:即使 buildParentBlocks 被别处调用且 document 为空,也不会 NPE 代码 review 友好:一眼能看出考虑了边界 ``` 工程上**多一行防御 vs 少一行简洁**,前者优先。 #### **3. 第一层:产出父块种子** ```java List parentSeedList = buildParentSeedList(parsedText, parentSteps, structureNodes); ``` “**种子**”这个命名很值得品。 ##### **为什么叫"种子"而不是"父块"?** 因为这里产出的还**不是最终父块**: ```text parentSeedList:父块的"半成品",还要经过清洗和子块切分 ParentBlockCandidate:父块的"完整候选",带 finalChildren ``` **种子 → 候选 → 最终**的命名体系有清晰的语义层次,让代码读起来知道每个变量在生命周期的哪个阶段。 ##### **种子是 ChunkCandidate 类型** ```text ChunkCandidate:统一的内部数据结构 包含字段:sectionPath、structureNodeId、structureNodeType、canonicalPath、itemIndex、text、sourceType... ``` 为什么要有这么多元数据? ```text sectionPath:章节路径(如"第一章/1.1节"),用于展示和定位 structureNodeId:关联到结构节点表,用于父子映射 canonicalPath:规范化路径,用于去重 itemIndex:位置序号,用于稳定排序 sourceType:来源类型(原文 / 结构切块 / 递归切块 / 语义切块 / LLM 切块) ``` 这些元数据让每个 chunk **知道自己从哪来、在文档中的什么位置**,对后续展示、调试、问题追溯都至关重要。 #### **4. 父块种子第一轮清洗** ```text for (ChunkCandidate parentSeed : cleanupChunkList(parentSeedList)) { if (parentSeed == null || StrUtil.isBlank(parentSeed.getText())) { continue; } ... } ``` ##### **cleanupChunkList 做什么?** 虽然代码没贴,但根据后面 `cleanupParentBlockList` 的逻辑能推断出来: ```text 1. 去除 null 和空文本块 2. 按"路径 + 位置 + 文本"构造去重键(LinkedHashMap) 3. 保留出现顺序 ``` ##### **为什么 for 循环里还要再判一次空?** ```text if (parentSeed == null || StrUtil.isBlank(parentSeed.getText())) continue; ``` `cleanupChunkList` 应该已经过滤过空了,这里再判一次是**双保险**: ```text 1. cleanupChunkList 实现可能升级,保护边界 2. 即使清洗逻辑漏过空块,这里也能拦住 3. 防御性代码成本低,易读性高 ``` 工程上经常出现这种“**理论上不会发生但还是判一下**”的写法,叫**深度防御**。 #### **5. 第二层:每个父块产出子块种子** ```java List childSeedList = buildChildSeedList(parentSeed, childSteps, structureNodes); List finalChildren = cleanupChunkList(childSeedList); ``` 注意几个关键点: ##### **子块切分是"父块视角"的** 第一个参数 `parentSeed` 把当前父块作为子块切分的输入。这意味着: ```text 子块永远在父块的语义范围内 子块的文本 ⊆ 父块的文本 ``` 这个约束保证了 RAG 检索时的**父子映射正确性**: ```text 子块召回 → 找到 parentId 加载父块 → 父块文本完整覆盖子块内容 模型读父块 → 包含召回点的完整上下文 ``` 如果子块切分跨父块边界,会导致: ```text 检索的子块 → 加载的父块文本不完整 模型读到的上下文缺失关键内容 ``` ##### **第二轮清洗** ```java List finalChildren = cleanupChunkList(childSeedList); ``` 这是第二轮清洗:每个父块产出的子块种子都要单独清洗一次。 这一轮清洗的范围是**单个父块内**,不是全局: ```text 不同父块下可能有完全相同的子块文本 但它们语境不同(隶属不同章节) 所以全局去重反而错误,只在父块内去重 ``` #### **6. 子块兜底机制** ```java if (finalChildren.isEmpty()) { finalChildren = List.of(cloneChunkCandidate(parentSeed, parentSeed.getText().trim())); } ``` 这是这段代码**最值得讲的设计点之一**。 ##### **什么场景下子块会为空?** ```text 1. 父块本身很短(只有一两句),子块流水线切不出任何块 2. 子块流水线全是清洗后剩 0 块的边界情况 3. LLM 切块返回乱码被清洗光 4. 语义切块在低质量文本上失败 ``` ##### **为什么不能让子块为空?** 因为 RAG 检索是**基于子块的**: ```text 子块 → 向量化 → 写入向量库 → 检索时召回 没有子块 → 没有向量 → 这部分内容永远不会被召回 → 父块成"死内容" ``` 兜底策略:父块即唯一子块 ```java finalChildren = List.of(cloneChunkCandidate(parentSeed, parentSeed.getText().trim())); ``` 逻辑是: ```text clone 一份父块作为唯一子块 子块文本 = 父块文本(trim 后) 确保每个父块至少有一个子块 ``` 这种兜底意味着检索粒度变粗了(这一段父块整体被检索)但**至少能被召回**。 ##### **设计哲学:可用性优先** 这个兜底体现了一个核心原则: ```text "完美但不可用" < "次优但能跑" ``` 宁可子块切得不那么精细,也要保证 RAG 链路完整。这种思想贯穿这套代码的多处兜底设计: ```text 没结构信号 → 用递归切块兜底 LLM 切完没控制长度 → 追加递归切块兜底 子块为空 → 用父块本身兜底 ``` 每一层兜底的意义都是“**让最坏情况下系统仍能跑**”。 #### **7. 打包成 ParentBlockCandidate** ```text parentBlockList.add(new ParentBlockCandidate( parentSeed.getSectionPath(), parentSeed.getStructureNodeId(), parentSeed.getStructureNodeType(), parentSeed.getCanonicalPath(), parentSeed.getItemIndex(), parentSeed.getText().trim(), parentSeed.getSourceType(), finalChildren )); ``` ##### **ParentBlockCandidate 的结构** ```text sectionPath:章节路径 structureNodeId:结构节点 ID structureNodeType:结构节点类型(章/节/小节) canonicalPath:规范化路径 itemIndex:位置序号 text:父块文本 sourceType:来源类型 finalChildren:最终子块列表 ``` 注意 `finalChildren` 是 `ParentBlockCandidate` 的字段,体现了**父子的"组合"关系**而不是"两张平行表": ```text 父块和子块在数据结构上是嵌套的 父块"拥有"它的子块 落库时也按这种关系处理 ``` 这种**对象模型**比"父块表 + 子块表 + 关联字段"的扁平模型更接近业务语义。 ##### **为什么 text 要 trim?** ```text parentSeed.getText().trim() ``` trim 去掉前后空白字符。理由: ```text 切块边界可能落在空白处,导致 chunk 前后有空格/换行 向量化时空白不携带语义但占 token 检索时空白干扰相似度计算 ``` trim 是**廉价但必要**的归一化。 #### **8. 第三轮全局清洗** ```java return cleanupParentBlockList(parentBlockList); ``` 这是第三轮清洗,作用范围是**全部父块**。 ##### **三轮清洗各自的范围** ```text 第一轮(cleanupChunkList(parentSeedList)):清洗父块种子(父块层面) 第二轮(cleanupChunkList(childSeedList)):清洗子块种子(单个父块内的子块层面) 第三轮(cleanupParentBlockList):清洗最终父块列表(整个父块列表) ``` 为什么需要三轮? ```text 每一轮处理的对象不同 每一轮发现的问题也不同 单轮清洗解决不了所有问题 ``` 举例: ```text 第一轮清洗 → 父块种子层去重 第二轮清洗 → 子块种子层去重 但是:第一轮没发现的"重复父块"(因为元数据略有差异),在加完子块后变得明显重复 所以需要第三轮基于完整 ParentBlockCandidate 再做一次去重 ``` 这种**多轮清洗**是数据处理流水线的标准做法。 --- ### **六、buildParentSeedList:父块种子的三条路径** ```java private List buildParentSeedList(...) { if (containsStructureStep(parentSteps) && structureNodes != null && !structureNodes.isEmpty()) { List 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:结构优先 + 剩余步骤继续细分** ```java List<...> remainingSteps = stripStructureSteps(parentSteps); if (remainingSteps.isEmpty()) { return structureSeeds; } return executePipeline(structureSeeds, remainingSteps, PARENT); ``` ##### **为什么要 stripStructureSteps?** 因为结构切块**已经做过了**: ```text buildStructureParentSeeds:已经从结构节点切出种子 后续步骤如果还有 STRUCTURE,就重复切,毫无意义 ``` 剔除已执行的步骤再传给 executePipeline,**避免重复执行**。 ##### **剩余步骤的意义** 如果父块流水线是 `STRUCTURE + RECURSIVE`: ```text 结构步骤切出 5 个章节级父块 但其中某个章节有 1 万字,太长 RECURSIVE 步骤把这个长章节再按长度切小 最终产出 5 + N 个父块 ``` 这就是为什么剩余步骤要继续执行——**结构边界 + 长度兜底**。 #### **路径 B:结构降级** ```java if (structureSeeds.isEmpty()) { return executePipeline(...); } ``` 什么场景下结构筛不出种子? ```text 所有章节都是纯标题壳节点(下面还有子章节,自己不直接承载内容) 或者结构节点表里只有"文档根节点",没有可用的章节 ``` 这时候放弃结构路径,**整篇文本作为输入走完整流水线**: ```text 输入是一个超大块:[ChunkCandidate("", parsedText)] sourceType = ORIGINAL(标记是原文) 经过完整 parentSteps(包括 STRUCTURE 在内) 但 STRUCTURE 找不到节点会自动跳过(在 executePipeline 内部) 实际生效的是 RECURSIVE/SEMANTIC/LLM ``` ##### **降级而不是抛错** 如果筛不出来直接抛 IllegalStateException 也是一种选择。但这里选**优雅降级**: ```text 方案推荐时基于"标题 ≥ 2"就推荐了 STRUCTURE 但实际筛选时可能因为内容承载性判断更严格,过滤掉了 这种情况不应该让任务失败,应该自动用其他策略补救 ``` 降级后用户可能感知不到(也就是“**优雅**”的含义)。 #### **路径 C:无结构** 最简单的路径——文档没结构信号或方案没配结构步骤,直接走流水线。 ```text return executePipeline( List.of(new ChunkCandidate("", parsedText, ORIGINAL)), parentSteps, PARENT); ``` 注意这里把 `parsedText` 包装成一个 `ChunkCandidate` 列表。这是为了**统一接口**: ```text executePipeline 的入参永远是 List 不管最初输入是结构种子还是原文,都封装成同样的形状 内部逻辑不需要分两种情况处理 ``` 这是**输入归一化**的常见模式,让流水线引擎可以单一职责。 --- ### **七、buildStructureParentSeeds:从结构树筛叶子章节** ```java private List buildStructureParentSeeds(List<...> structureNodes) { Map parentHasChildSection = new LinkedHashMap<>(); for (...node : structureNodes) { if (node == null || node.getParentNodeId() == null) continue; if (SECTION.equals(node.getNodeType())) { parentHasChildSection.put(node.getParentNodeId(), true); } } List 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; } ``` #### **核心思想:只取"叶子章节"** 考虑一份文档的结构: ```text 第一章 1.1 节 1.1.1 小节 1.1.2 小节 1.2 节 第二章 2.1 节 ``` 如果父块按 `第一章 / 第二章` 切: ```text 第一章包含 1.1.1 + 1.1.2 + 1.2 全部内容 "第一章"作为父块,文本可能上万字 父块太大,语义太杂 ``` 如果父块按 `1.1.1 / 1.1.2 / 1.2 / 2.1` 切: ```text 每块大小适中 每块语义聚焦 检索引用时定位到具体小节 ``` 所以**真正适合做父块的是"内容承载的叶子章节"**,而不是中间层的"标题壳"。 #### **算法:两轮遍历** ##### **第一轮:建立"父子关系映射"** ```java for (node : structureNodes) { if (node.getParentNodeId() == null) continue; if (SECTION.equals(node.getNodeType())) { parentHasChildSection.put(node.getParentNodeId(), true); } } ``` 这一轮做的事: ```text 扫描所有节点 对每个 SECTION 节点,把它的 parentNodeId 标记为"有子章节" 最终 parentHasChildSection 包含所有"还有子章节的父节点 ID" ``` 为什么用 LinkedHashMap 而不是 HashSet? ```text LinkedHashMap 保留插入顺序 遍历时顺序稳定 方便调试和测试 HashSet 也行但顺序不保证 ``` ##### **第二轮:筛选叶子章节** ```java for (node : structureNodes) { if (!SECTION.equals(node.getNodeType())) continue; if (!isContentBearingSection(node, parentHasChildSection.getOrDefault(node.getId(), false))) { continue; } seeds.add(toChunkCandidate(node)); } ``` 这一轮的过滤: ```text 1. 只看 SECTION 类型(忽略文档根、段落等非章节节点) 2. 调用 isContentBearingSection 判断是否"内容承载" 入参:节点本身 + 是否还有子章节 返回:这个节点能不能作为父块种子 ``` #### **isContentBearingSection 的判断逻辑** 虽然代码没贴,但根据语义能推出: ```text 一个章节是"内容承载"的,通常需要满足: 1. 自己有正文文本(不只是标题) 2. 不是纯壳节点(下面没有子章节,或者下面有子章节但自己也有正文) ``` 边界情况: ```text "第一章"下面有 1.1 和 1.2,自己只有标题 → 非内容承载,过滤 "第一章"下面有 1.1 和 1.2,自己也有引言文字 → 内容承载,保留 "1.1 节"下面没有子章节,有正文 → 内容承载,保留(典型叶子) "1.1 节"下面没有子章节,只有标题没正文 → 非内容承载,过滤 ``` #### **为什么这种算法值得?** ```text 不做这个筛选 → 父块包含所有章节节点(中间层的"壳"也会成为父块) 做了这个筛选 → 父块只包含真正承载内容的叶子章节 ``` 效果差异: ```text 前者:大量空标题父块 + 内容父块,父子关系混乱 后者:每个父块都是聚焦的小章节,大小均衡,语义清晰 ``` 这种结构感知的切分,是普通递归切块完全做不到的。 --- ### **八、buildChildSeedList:子块种子的两条路径** ```java private List buildChildSeedList(...) { if (containsStructureStep(childSteps) && parentSeed != null && parentSeed.getStructureNodeId() != null && structureNodes != null && !structureNodes.isEmpty()) { List 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); } ``` 子块的设计思想和父块**类似但条件更严格**。 #### **结构路径的三个前置条件** ```text 1. 子块流水线配了 STRUCTURE 步骤 2. parentSeed.structureNodeId 不为空 3. 文档有结构节点 ``` ##### **第二个条件最关键** `parentSeed.getStructureNodeId() != null` 意味着: ```text 当前父块来自结构切块(buildStructureParentSeeds 产出) 带有结构树上的锚点 ``` 如果父块是普通流水线产出的(比如递归切块切出来的),它身上**没有 structureNodeId**: ```text 父块文本来自原文的某段 不知道对应结构树上的哪个节点 没法定位"这个父块下面有哪些子节点" ``` 这种情况下,即使方案配了 STRUCTURE 步骤,子块也走不了结构路径——**没锚点就找不到方向**。 #### **buildStructureChildSeeds 的语义** 虽然代码没贴,但能推断: ```text 入参:parentSeed(带 structureNodeId) + structureNodes 逻辑: 定位到 parentSeed 对应的节点 取这个节点的"直接子节点"(可能是更小的小节、段落等) 把每个子节点转成 ChunkCandidate 返回:子节点列表 ``` 这种设计让子块切分**沿着结构树继续往下**: ```text 父块是"1.1 节"(叶子章节,但下面可能还有更细的段落节点) 子块就是"1.1 节"下面的段落级节点 ``` 降级路径 ```text return executePipeline( List.of(cloneChunkCandidate(parentSeed, parentSeed.getText())), childSteps, CHILD); ``` cloneChunkCandidate 把父块复制一份,文本作为输入丢进子块流水线。 这就是为什么前面说**子块永远在父块语义范围内**——子块流水线的输入永远是父块的文本,不会跨边界。 #### **没有"结构降级到流水线"的中间路径** 注意子块和父块的差异: ```text 父块:结构筛不出来 → 降级到完整流水线(整篇文本走流水线) 子块:三个条件不全满足 → 直接走流水线(父块文本走流水线) ``` 子块没有“结构筛出空”的降级路径。原因: ```text 结构子块是从父节点的子节点取的 如果取出来是空,要么父节点没子节点(没法切),要么子节点都被过滤 这两种情况下重新走整篇流水线没意义(因为输入就是父块本身) 所以条件直接前置判断,空了就走流水线 ``` 逻辑上更紧凑。 --- ### **九、把整段流程串成一句话** > 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` 传给 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消息投递|01-索引构建入口与Kafka消息投递]] | 02-初步父子切块 | ➡️ [[03-四种切块策略详解|03-四种切块策略详解]]