--- title: "08-切块策略落库" created: 2026-05-19 aliases: - 切块策略落库 tags: - 项目 --- # 切块策略落库 我们接着上一篇“解析结果统计与异步收尾”往后学。上一篇的终点是: ```text 任务阶段被推进到 STRATEGY_ROUTE 文档状态是 PARSING(还没切到 PARSE_SUCCESS) 画像、结构节点、导航索引、图谱投影都已就绪 parsed-text txt 已写到 MinIO ``` 这一篇的起点就是 `handleParseRoute` 的最后一段:**系统拿到解析结果和结构节点之后,要自动推荐一套切块策略,把方案写进数据库,然后把任务按成功态收尾。** 学完这一篇,你就完整打通了“上传 → Kafka → 解析 → 结构 → 收尾 → 策略推荐”这条主线。下一篇就要进入用户确认方案后的索引构建异步链路。 ### **一、先建立整体认知:这一节在做什么?** 可以一句话概括: > 系统根据解析阶段算出来的结构等级、内容质量、字符数、段落特征等指标,自动推荐一套 Parent/Child 双层切块流水线,把方案以 WAIT\_CONFIRM 状态落库等待用户确认,然后把任务按成功态收尾。 整体可以画成两段流程: ```mermaid flowchart TD A[拿到 DocumentAnalysisResult] --> B[四个基础判断 structure/recursive/semantic/llm] B --> C[父块流水线决策] B --> D[子块流水线决策] C --> E[buildDraftSteps 转步骤草案] D --> E E --> F[打包 DocumentStrategyPlanDraft] F --> G[创建 plan 主记录 WAIT_CONFIRM] G --> H[父块步骤批量入库 WAIT_EXECUTE] H --> I[子块步骤批量入库 WAIT_EXECUTE] I --> J[更新文档主表 PARSE_SUCCESS + RECOMMENDED] J --> K[finishTaskSuccess 任务成功收尾] K --> L[记录 RECOMMEND_STRATEGY 任务日志] ``` 这一节虽然代码量不算最多,但**它是“自动化能力”最集中的一段**——系统在没有任何人工介入的情况下,要替用户决定“这份文档应该怎么切”。这个决策的好坏直接影响后续 RAG 检索的精度。 --- ### **二、为什么需要“策略推荐”这一步?** 很多人会问:直接给所有文档用同一种切块方式不行吗?比如统一按 500 字符切,或者统一按段落切。 不行。原因是文档世界差异巨大: ```text 一份 100 字的 FAQ 一份 50 万字的产品手册 一份扫描版乱码 PDF 一份结构清晰的技术规范 一份纯散文式的会议纪要 一份带大段表格的财务报表 ``` 如果都用同一种切块策略: ```text FAQ 会被切得太碎,召回时找不到完整问答 长手册不切就直接超模型上下文 扫描乱码切完一堆噪声,向量化效果极差 结构清晰的文档强行按字符切,破坏章节边界 散文按结构切又找不到标题 ``` 所以系统必须**因文施策**:根据每份文档的“画像”,自动选择最合适的切块策略组合。 这就是策略推荐的存在意义。 --- ### **三、recommendStrategy:策略推荐的核心方法** 入口在 `DocumentStrategyServiceImpl.recommendStrategy()`: ```java public DocumentStrategyPlanDraft recommendStrategy( SuperAgentDocument document, DocumentAnalysisResult analysisResult ) { List reasonList = new ArrayList<>(); DocumentFileTypeEnum fileType = DocumentFileTypeEnum.getRc(document.getFileType()); boolean structureRecommended = shouldUseStructure(fileType, analysisResult); boolean recursiveRecommended = shouldUseRecursive(analysisResult); boolean semanticRecommended = shouldUseSemantic(analysisResult); boolean llmRecommended = shouldUseLlm(analysisResult); // ... 父块决策 // ... 子块决策 // ... 打包草案 } ``` 这个方法本身**不执行切块**,它只回答一个上游问题: > 这份文档在后续索引构建时,应该采用怎样的 Parent/Child 切块流水线? 返回的 `DocumentStrategyPlanDraft` 是一份“方案草稿”,由后续逻辑落库等待用户确认。 #### **这种“判断与执行分离”的设计** 可以画成: ```text recommendStrategy:只决定"用哪些策略" buildDraftSteps:把策略类型转成可落库的步骤 持久化层:把步骤落到 plan + step 两张表 执行层:用户确认后,索引构建链路读取步骤逐一执行 ``` 每一层职责单一,互不耦合。这种分层有几个好处: ```text 1. 推荐逻辑可以单独迭代,不影响执行 2. 用户可以在中间环节修改步骤 3. 步骤落库后任何时刻都能查看回放 4. 失败时可以基于已落库的步骤精确重试 ``` ### **四、四个基础判断:推荐的“事实层”** 策略推荐的核心是四个布尔判断,它们不直接生成步骤,而是回答“这份文档在切块上有哪些客观特征和风险”。 #### **1. shouldUseStructure:是否适合结构切块** ```java boolean suitableType = fileType == PDF || fileType == DOC || fileType == DOCX || fileType == MD || fileType == HTML; return suitableType && (analysisResult.getStructureLevel() >= MEDIUM || analysisResult.getHeadingCount() >= 2); ``` 两个条件**同时**满足才推荐: ```text 条件一:文件类型本身适合结构识别 条件二:结构等级 MEDIUM 及以上,或标题数量 ≥ 2 ``` 为什么 TXT 不在适合列表里? 因为 TXT 没有任何格式信息,结构信号完全靠正文内容里的标题写法(比如 `第一章` 或 `1.1`)。这些信号上一节四阶段流水线确实能识别,但相比 Markdown 的 `##` 或 PDF/Word 内嵌的标题样式,可靠性低很多。 所以工程上做了一个明确取舍: ```text TXT → 不走结构切块,走递归分块 PDF/DOC/DOCX/MD/HTML → 满足信号阈值就走结构切块 ``` 为什么用 `MEDIUM 或 标题 ≥ 2` 这种“或”逻辑? 因为这两个条件代表两种不同的“结构证据”: ```text structureLevel:综合指标(标题数 + 段落数) headingCount:单一指标(标题数) ``` 任意一个达标就足够了,不需要双重门槛。 #### **2. shouldUseRecursive:是否需要递归分块** ```text return analysisResult.getCharCount() >= recursiveMaxChars || analysisResult.getMaxParagraphLength() >= recursiveMaxChars; ``` 也是“或”逻辑: ```text 全文够长 → 需要递归切 单段够长 → 也需要递归切 ``` 为什么单段长也要递归? 这是一个非常关键的细节。 考虑这种文档: ```text 全文 5000 字 但中间有一段是 4500 字的连续大段落 ``` 如果只看全文长度,可能没超阈值;但那一段 4500 字的段落如果不切,就会变成一个单独超大块,向量化时被截断,检索召回时也会拖累相关性。 所以递归分块的判断必须看“最坏情况下的最大单段”,而不是平均值。 这就是上一篇为什么要算 `maxParagraphLength` 的原因。 #### **3. shouldUseSemantic:是否适合语义分块** ```text 如果只看全文长度,可能没超阈值;但那一段 4500 字的段落如果不切,就会变成一个单独超大块,向量化时被截断,检索召回时也会拖累相关性。 所以递归分块的判断必须看“最坏情况下的最大单段”,而不是平均值。 这就是上一篇为什么要算 maxParagraphLength 的原因。 3. shouldUseSemantic:是否适合语义分块 ``` 三个条件**同时**满足: ```text 够长(短文本没必要按主题切) 段落 ≥ 3(至少有几个主题块可识别) 质量 ≥ MEDIUM(乱码文本算不出主题边界) ``` 为什么是“且”逻辑? 因为语义切块本身有一定开销(要算段落 embedding、计算相似度),如果文档太短或质量太差,硬上语义切块只会浪费算力还出不了好结果。 #### **4. shouldUseLlm:是否需要 LLM 智能切块** ```text return Boolean.TRUE.equals(recommendLlmWhenLowQuality) && contentQualityLevel.equals(LOW) && charCount >= semanticMinChars; ``` 三个条件**同时**满足: ```text 配置开关打开 质量等于 LOW 文本长度达到阈值 ``` 为什么 LLM 切块只在质量 LOW 时才推荐? 因为 LLM 调用是这四种里**最贵、最慢**的: ```text 每个块都要经过模型推理 长文档可能上百次调用 单次延迟可能秒级 ``` 只在“规则切块明显切不好”的场景才值得上 LLM。也就是: ```text 乱码多、结构混乱、段落分割不清晰 此时 LLM 能用语义理解能力做更智能的边界划分 ``` 而对于结构清晰的文档,结构切块就够了,没必要花钱上 LLM。 为什么短文本不用 LLM? 因为短文本切完只有几块,规则也能切得不错,性价比太低。 #### **四个判断的组合表** | 判断 | 关键条件 | 性质 | | --- | --- | --- | | 结构切块 | 文件类型适合 + 结构信号充足 | 优先利用文档自身结构 | | 递归分块 | 全文长 或 单段长 | 长度兜底 | | 语义分块 | 长 + 段落多 + 质量好 | 主题边界优化 | | LLM 切块 | 配置开 + 质量差 + 够长 | 低质量场景救火 | 注意这四个判断是**独立的**,可以同时为 true。后面的父块/子块决策才是基于这四个事实做组合。 --- ### **五、为什么要分 Parent / Child 双层切块?** 这是整篇文档最值得深入的设计点。 #### **1. 召回和回答的目标矛盾** RAG 系统的两个核心动作是: ```text 召回:从向量库中找到相关内容 回答:把相关内容喂给模型生成答案 ``` 这两个动作对“块大小”的要求是**矛盾**的: ```text 召回:块越小越精准 - 嵌入向量更聚焦 - 相似度计算更准 - 不容易被无关内容稀释 回答:块越大越完整 - 模型能看到完整上下文 - 不会答出"半截答案" - 章节信息保留 ``` 如果只用一套切块: ```text 切大了 → 召回不准 切小了 → 回答缺上下文 ``` #### **2. Parent-Child 的解法** 把这两个目标解耦: ```text Child(子块):专门服务召回 - 切得小、边界精准 - 用于向量化和检索 Parent(父块):专门服务回答 - 保留章节级大上下文 - 召回时通过子块"映射"找到对应父块 - 把父块送给模型生成答案 ``` 也就是: ```text 搜的是子块 读的是父块 ``` #### **3. 这种思想在业界的位置** 这其实是 LangChain `ParentDocumentRetriever` 的核心设计,也叫“小块检索、大块回答”。 它的工程意义在于: ```text 不必为了精准召回牺牲回答完整性 也不必为了完整回答牺牲召回精准度 两个目标分别用最合适的切块方式实现 ``` #### **4. 父子映射怎么做?** 子块在切分时会记录它属于哪个父块(通常是父块 ID 或编号)。检索流程是: ```text 1. 用户提问 → 生成查询向量 2. 在子块向量库里召回 top-k 3. 拿到子块 → 找到它的 parentId 4. 加载对应的父块文本 5. 父块文本拼成 prompt 送给模型 ``` 这一节虽然只讲推荐,但你要理解:**父块和子块在策略上的差异,根源在于这两个目标的根本矛盾**。 --- ### **六、父块流水线决策** 代码非常简洁: ```java if (structureRecommended) { parentStrategyTypes.add(STRUCTURE); } else { parentStrategyTypes.add(RECURSIVE); } ``` 逻辑就两条路: ```text 有结构 → 结构切块 没结构 → 递归切块兜底 ``` #### **1. 为什么不像子块那样追加多步?** 因为父块的目标是“稳定的大语义单元”: ```text 理想情况:沿章节边界切 每一块就是一章/一节 回答时上下文完整 用户看到引用时知道"这是第 X 章" 退化情况:递归按长度切 至少保证有稳定的大块 虽然边界不那么自然 但仍能服务回答阶段 ``` 如果父块也叠加多步策略(比如结构 + 语义),会带来两个问题: ```text 1. 复杂度上升,边界更不稳定 2. 父块本身就是给模型读的,不需要太精细 ``` 所以父块保持“**一步到位**”:要么结构,要么递归。 #### **2. 为什么没结构信号时不用语义切块?** 因为父块要保留“章节级的大上下文”,而语义切块的边界是按主题相似度,**主题边界 ≠ 章节边界**。 举例: ```text 一篇技术文章,第 1 章和第 2 章都在讨论"性能优化" 语义切块可能把这两章合并成一块 而读者期望看到"第 1 章是 X 优化,第 2 章是 Y 优化" ``` 所以父块要的是“**结构边界优先**”,没结构就退化到“**长度边界稳定**”,而不是“**主题边界**”。 #### **3. recommendReason 同步落进 reason 字典** ```text 所以父块要的是“结构边界优先”,没结构就退化到“长度边界稳定”,而不是“主题边界”。 3. recommendReason 同步落进 reason 字典 ``` 这里维护了两套理由: ```text parentReasonMap:per-step 的理由,落到每个步骤的 recommendReason 字段 reasonList:整体方案的理由,落到 plan 主记录的 recommendReason 字段 ``` 为什么要两套? ```text plan 级理由:用户在方案列表页快速看到"为什么推荐这套" step 级理由:用户在方案详情页看每一步的具体动机 ``` 这是一种**两级展示**的产品设计。如果只有一份理由,要么用户列表页看不到关键信息,要么详情页冗长。 --- ### **七、子块流水线决策:优先级链** 子块的决策比父块复杂,是一个三段式优先级链: ```java if (llmRecommended) { childStrategyTypes.add(LLM); } else if (semanticRecommended) { childStrategyTypes.add(SEMANTIC); } if (recursiveRecommended || llmRecommended || childStrategyTypes.isEmpty()) { childStrategyTypes.add(RECURSIVE); } ``` 逻辑可以拆成两步。 #### **第一段:选主策略(LLM 或 SEMANTIC)** 注意第一段是 `if-else if`,**两者只能二选一**: ```text 质量差(LLM 命中) → 用 LLM 智能切块 质量尚可 + 段落丰富 → 用语义切块 都不满足 → 这一段什么都不选,直接进入第二段 ``` 为什么 LLM 优先于语义? 因为 LLM 命中的前提是“质量 LOW”,而语义切块在低质量文本上效果很差(embedding 算不准、相似度噪声大)。所以一旦质量差就上 LLM,让它先做一次智能边界增强。 #### **第二段:递归兜底** ```text 为什么 LLM 优先于语义? 因为 LLM 命中的前提是“质量 LOW”,而语义切块在低质量文本上效果很差(embedding 算不准、相似度噪声大)。所以一旦质量差就上 LLM,让它先做一次智能边界增强。 第二段:递归兜底 ``` 三个 OR 条件,任意一个成立都会追加 RECURSIVE: ```text 1. recursiveRecommended:文本本来就长,需要长度控制 2. llmRecommended:LLM 切完后还要再过一遍递归保证长度 3. childStrategyTypes.isEmpty():前面什么都没选,至少保证有递归 ``` 特别要注意第二种情况:**LLM 后还要追加递归**。 为什么? 因为 LLM 切块的边界是“语义合理”,但 LLM 不保证“块大小符合 token 预算”。模型可能切出一个 5000 字符的大块,对召回阶段来说还是太大。所以 LLM 之后必须有一道“长度兜底”,把超大块按字符再切一次。 这是一个非常成熟的工程兜底设计: ```text 语义层:LLM 决定"应该在哪里切" 长度层:RECURSIVE 决定"切完后块的大小是否合规" ``` 两层各司其职,任何一层不靠谱都不会导致最终块完全不可用。 #### **子块流水线的可能组合** | 触发条件 | 子块步骤 | | --- | --- | | 质量 LOW + 长 | LLM → RECURSIVE | | 质量 MEDIUM/HIGH + 长 + 段落多 | SEMANTIC → RECURSIVE | | 质量 MEDIUM/HIGH + 短 或 段落少 | RECURSIVE | | 文本短 | RECURSIVE | 也就是说子块流水线**最少 1 步,最多 2 步**。这种有限组合既能覆盖主要场景,又不会让步骤数失控。 --- ### **八、buildDraftSteps:从“类型列表”到“可落库步骤”** 策略类型只是 `Integer` 列表,要落库还需要补足很多信息: ```java private List buildDraftSteps( DocumentStrategyPipelineTypeEnum pipelineType, List strategyTypes, Map reasonMap ) { List draftList = new ArrayList<>(); for (int index = 0; index < strategyTypes.size(); index++) { Integer strategyType = strategyTypes.get(index); draftList.add(new DocumentStrategyStepDraft( pipelineType.getCode(), strategyType, resolveRole(index, strategyType), DocumentStrategySourceTypeEnum.SYSTEM_RECOMMEND.getCode(), reasonMap.getOrDefault(strategyType, "系统为当前流水线生成的推荐步骤。") )); } return draftList; } ``` 每个步骤要补齐四类信息: ```text 1. pipelineType:属于父块还是子块流水线 2. strategyType:具体策略(结构/递归/语义/LLM) 3. strategyRole:在流水线中的职责(PRIMARY/FALLBACK/OPTIMIZE/ENHANCE) 4. recommendReason:推荐原因,前端展示用 ``` #### **strategyRole 的动态判定** `resolveRole(index, strategyType)` 不是写死的,而是根据“**步骤顺序 + 策略类型**”联合决定: ```text 第一步通常是 PRIMARY(主策略) 后续递归通常是 FALLBACK(兜底) LLM 通常是 ENHANCE(增强) 语义通常是 OPTIMIZE(优化) ``` 为什么要分这么细的角色? 因为前端展示和日志追踪都要清楚地告诉用户: ```text "这一步是核心切块" "这一步是失败兜底" "这一步是质量优化" "这一步是智能增强" ``` 不同的角色对应不同的 UI 视觉(图标、颜色、说明),用户一眼就能看出方案结构。 #### **sourceType 固定为 SYSTEM\_RECOMMEND** ```text DocumentStrategySourceTypeEnum.SYSTEM_RECOMMEND.getCode() ``` 为什么需要这个字段? 因为方案不仅可以是系统推荐的,还可以是用户手动添加或修改的。比如: ```text SYSTEM_RECOMMEND:系统自动推荐 USER_OVERRIDE:用户手动修改 USER_ADD:用户手动追加 ``` 将来在数据库里查“某文档当前方案有多少步是用户改的”,就靠这个字段。这是为后续“用户调整方案”留的扩展位。 --- ### **九、strategySnapshot:紧凑快照的设计巧思** ```java String strategySnapshot = buildCombinedStrategySnapshot(parentSteps, childSteps); ``` 格式像 `PARENT:1;CHILD:4,2`,意思是: ```text PARENT 流水线第 1 步是 STRUCTURE(策略类型 1) CHILD 流水线第 1 步是 LLM(策略类型 4),第 2 步是 RECURSIVE(策略类型 2) ``` #### **为什么要造这么一个字符串?** 因为完整方案信息分散在 plan 主表和 step 步骤表两张表里,每次要展示“当前方案是什么”都要 join 一次。如果列表页要批量展示几百份文档的方案,性能会很差。 `strategySnapshot` 是一个**预计算的展示字段**: ```text 落库时一次计算,后续读取免 join 日志记录时直接塞进 metadata 方案对比时字符串相等就能快速判断 版本对比时直接看快照差异 ``` #### **这是典型的“反范式优化”** 数据库范式上 step 表才是事实来源,但为了高频读场景的性能,在 plan 表里冗余一份压缩表示。这种设计在工程上非常常见: ```text Redis 缓存:为了快 ES 文档冗余:为了搜 快照字符串:为了显示和对比 ``` 只要保证**主数据更新时同步更新冗余字段**,反范式就是合理的优化。 --- ### **十、回到 handleParseRoute:方案持久化** 拿到 `DocumentStrategyPlanDraft` 后,回到主流程做四件事。 #### **第一步:创建 plan 主记录** ```java Long planId = uidGenerator.getUid(); int planVersion = getNextPlanVersion(documentId); SuperAgentDocumentStrategyPlan plan = new SuperAgentDocumentStrategyPlan(); plan.setId(planId); plan.setDocumentId(documentId); plan.setPlanVersion(planVersion); plan.setPlanSource(SYSTEM_RECOMMEND); plan.setPlanStatus(WAIT_CONFIRM); plan.setStrategyCount(parentSteps.size() + childSteps.size()); plan.setStrategySnapshot(planDraft.getStrategySnapshot()); plan.setRecommendReason(planDraft.getRecommendReason()); planMapper.insert(plan); ``` 几个关键字段: ##### **1. planVersion:版本递增** 每次重新推荐方案,版本号 +1。这样可以做: ```text 方案历史回溯:用户能看到曾经推荐过哪些方案 A/B 对比:同一文档不同推荐版本的效果 质量监控:某次升级后推荐质量是否变化 回滚:必要时恢复到旧方案 ``` ##### **2. planSource = SYSTEM\_RECOMMEND** 明确这是系统自动推荐,不是用户手动创建的方案。和步骤的 sourceType 是同一思想:**追溯数据来源**。 ##### **3. planStatus = WAIT\_CONFIRM** 这是整个设计最关键的状态。 为什么不直接执行切块? 因为: ```text 1. 自动推荐可能不完美,用户应该有干预空间 2. 切块后向量化是高成本操作,执行错了浪费资源 3. 用户可能想试不同策略对比效果 4. 重要文档需要人工把关 ``` 所以方案必须由用户**显式确认**才能进入下一阶段。这是一种“**人机协同**”的设计——系统给建议,人做决定。 ##### **4. strategyCount:父子步骤数之和** 冗余字段,方便列表页展示“这套方案有几步”,避免回表统计。 #### **第二步:父块步骤批量入库** ```java for (int index = 0; index < planDraft.getParentSteps().size(); index++) { DocumentStrategyStepDraft draft = planDraft.getParentSteps().get(index); SuperAgentDocumentStrategyStep step = new SuperAgentDocumentStrategyStep(); step.setId(uidGenerator.getUid()); step.setPlanId(planId); step.setDocumentId(documentId); step.setPipelineType(draft.getPipelineType()); step.setStepNo(index + 1); step.setStrategyType(draft.getStrategyType()); step.setStrategyRole(draft.getStrategyRole()); step.setSourceType(draft.getSourceType()); step.setExecuteStatus(WAIT_EXECUTE); step.setRecommendReason(draft.getRecommendReason()); stepMapper.insert(step); } ``` 几个细节: ##### **1. stepNo = index + 1** 步骤序号从 1 开始,决定执行顺序。后续索引构建会按 stepNo 顺序逐步执行 ```properties stepNo=1 先执行 stepNo=2 用 stepNo=1 的产物作为输入 stepNo=3 用 stepNo=2 的产物作为输入 ... ``` 这种序号机制让多步流水线可以**链式执行**。 ##### **2. executeStatus = WAIT\_EXECUTE** 每个步骤初始化为“等待执行”,配合 plan 的 WAIT\_CONFIRM 状态: ```text plan 在 WAIT_CONFIRM:整套方案等待确认 step 在 WAIT_EXECUTE:每个步骤等待被调度执行 ``` 用户确认方案后,plan 切到 CONFIRMED,触发索引构建链路;索引构建逐步把每个 step 切到 RUNNING → SUCCESS / FAILED。 ##### **3. 每个 step 都带 documentId** 虽然通过 planId 也能查到 documentId,但冗余存一份让 step 表可以**独立查询**。比如查“某文档所有步骤”不需要先查 plan。 #### **第三步:子块步骤批量入库** 逻辑和父块一样,只是数据来自 `childSteps`。 #### **为什么父子分两个循环写?** 原因有几个: ```text 1. 语义清晰,代码自解释 2. 父子的 stepNo 各自独立从 1 开始 3. 未来可能给父子添加不同的扩展字段 4. 失败时可以分别看是父块还是子块出问题 ``` ### 十一、文档主表更新:状态机推进 ```java document.setParseStatus(PARSE_SUCCESS); document.setStrategyStatus(RECOMMENDED); document.setCharCount(analysisResult.getCharCount()); document.setTokenCount(analysisResult.getTokenCount()); document.setStructureLevel(analysisResult.getStructureLevel()); document.setContentQualityLevel(analysisResult.getContentQualityLevel()); document.setParseTextPath(parseTextPath); document.setParseErrorMsg(null); document.setCurrentPlanId(planId); document.setLastParseTaskId(taskId); document.setStructureNodeCount(structureNodeCount); documentMapper.updateById(document); ``` 这一段干了好几件事,要分类理解。 #### **1. 状态切换** ```yaml parseStatus: PARSING → PARSE_SUCCESS strategyStatus: WAIT_RECOMMEND → RECOMMENDED ``` 文档生命周期推进到“解析完成 + 策略已推荐”。 为什么要分两个状态字段? ```text parseStatus:回答"内容解析这一步成功了吗" strategyStatus:回答"策略推荐这一步到哪了" ``` 两件事独立追踪。比如: ```text parseStatus = PARSE_SUCCESS + strategyStatus = WAIT_RECOMMEND 解析成功了,但还没生成策略 parseStatus = PARSE_SUCCESS + strategyStatus = RECOMMENDED 都完成了,等用户确认 parseStatus = PARSE_SUCCESS + strategyStatus = CONFIRMED 用户已确认,等索引构建 ``` 如果合并成一个状态字段,状态会爆炸。 #### **2. 解析指标回填** ```text charCount / tokenCount / structureLevel / contentQualityLevel ``` 把解析阶段算出的关键指标存到文档主表,便于: ```text 列表页展示 后台筛选 质量统计 策略调整时复用 ``` 这避免了每次需要这些指标都要去解析记录里翻。 #### **3. 关键路径回填** ```text parseTextPath:MinIO 中 .txt 的路径 currentPlanId:当前生效方案 ID lastParseTaskId:最近一次解析任务 ID structureNodeCount:结构节点数 ``` `currentPlanId` 是这里最关键的字段。它表示“**当前文档使用的是哪套方案**”。 为什么要这个字段? 因为同一文档可以有多个方案版本(每次重新推荐都会新增一份)。`currentPlanId` 指向当前生效的那一份,相当于一个“**指针**”: ```text plan 表:多版本方案历史 document.currentPlanId:指向当前生效的 plan 切换方案 = 修改 currentPlanId 指针 ``` 这种“**事实表 + 指针**”模式让多版本管理变得非常优雅。 #### **4. parseErrorMsg 清空** ```java document.setParseErrorMsg(null); ``` 如果之前解析失败过,错误信息会留在这个字段。这次成功了就清空,避免误导。 这是一个很容易被忽略但非常重要的细节。如果不清空: ```text 用户看到 parseStatus=PARSE_SUCCESS 但 parseErrorMsg 还有上次的报错 会非常困惑 ``` ### 十二、finishTaskSuccess:任务成功收尾 ```java private void finishTaskSuccess(SuperAgentDocumentTask task, Integer stage, Date startTime) { Date finishTime = new Date(); task.setTaskStatus(SUCCESS); task.setCurrentStage(stage); task.setFinishTime(finishTime); task.setCostMillis(finishTime.getTime() - startTime.getTime()); task.setErrorCode(null); task.setErrorMsg(null); taskMapper.updateById(task); } ``` 这个方法把任务统一收尾: ```text 状态切到 SUCCESS 当前阶段固定为 STRATEGY_ROUTE(传进来的) 记录完成时间 计算耗时(用于性能分析) 清空错误字段 ``` #### **为什么要单独抽方法?** 因为成功收尾的逻辑会被多个地方调用: ```text 解析+推荐成功 后续切块成功 后续向量化成功 后续索引构建成功 ``` 抽成方法保证“成功收尾”的语义一致:**一定要清空错误、一定要计算耗时**。 #### **costMillis:性能可观测的关键** ```java task.setCostMillis(finishTime.getTime() - startTime.getTime()); ``` 这个字段看起来不起眼,但对运营和优化非常重要: ```text 统计平均解析耗时 找出耗时异常的文档 评估系统瓶颈在哪个阶段 为容量规划提供数据 ``` 配合任务日志的 metadata,可以做出非常完整的全链路性能监控。 --- ### **十三、记录 RECOMMEND\_STRATEGY 任务日志** ```text taskLogService.saveLog(taskId, documentId, STRATEGY_ROUTE, RECOMMEND_STRATEGY, INFO, SYSTEM, null, "系统已生成推荐策略。", detail("planId", planId, "strategySnapshot", planDraft.getStrategySnapshot(), "parentStepCount", planDraft.getParentSteps().size(), "childStepCount", planDraft.getChildSteps().size(), "structureNodeCount", structureNodeCount, "recommendReason", planDraft.getRecommendReason())); ``` 这条日志和上一节的解析完成日志结构一致,但 metadata 内容不同。 #### **为什么要把 strategySnapshot 塞到日志?** 因为日志是“**那一刻的事实快照**”。 如果某天用户来问: ```text "我这份文档为什么当时推荐的是 LLM + 递归?" ``` 后台直接查任务日志,metadata 里就有完整的推荐结果和理由。不需要重新算、也不依赖 plan 表(用户可能已经修改过方案)。 这就是为什么任务日志要走结构化 metadata,而不是简单的文本日志。 --- ### **十四、异常处理:解析失败的收尾** ```java catch (Exception exception) { log.error("异步解析文档失败..."); document.setParseStatus(PARSE_FAILED); document.setParseErrorMsg(exception.getMessage()); documentMapper.updateById(document); failTask(task, startTime, exception, CONTENT_PARSE); taskLogService.saveLog(taskId, documentId, CONTENT_PARSE, FAILED, ERROR, SYSTEM, null, "文档解析失败。", detail("error", exception.getMessage())); } ``` 失败处理做三件事: ```text 1. 文档主表 → PARSE_FAILED + 错误信息 2. 任务 → FAILED + 错误信息 + 耗时 3. 任务日志 → ERROR 级别 ``` #### **失败时为什么 currentStage 写 CONTENT\_PARSE?** 注意这里不是 `STRATEGY_ROUTE`,而是 `CONTENT_PARSE`。 因为整个 `try` 块里失败可能发生在任意阶段:解析、结构提取、画像生成、策略推荐。统一标记为 `CONTENT_PARSE` 表示“**内容解析整体阶段**”失败。 具体失败在哪一步,要看错误信息和异常堆栈。 #### **为什么没有自动重试?** ```text 策略一:Kafka 自动重试 策略二:写入死信队列后人工处理 策略三:依赖外部补偿 策略四:用户重新上传 ``` 这套设计选择了**不在 catch 块里自动重试**,原因是: ```text 1. 解析失败大概率是文件本身问题(损坏、格式异常),自动重试无意义 2. 自动重试可能让同一个失败任务被重复消费 3. 失败后让任务停在 FAILED 状态,人工或定时任务来决定怎么处理更稳 ``` 所以重试要么: ```text 依赖 Kafka 消费失败抛异常 → broker 自动重投 依赖外部补偿任务定时扫描 FAILED 任务 依赖前端给用户显示"重试"按钮 ``` 这是有意为之的设计取舍:**宁可让任务失败,也不在主链路里加自动重试**。 #### **failTask 方法** ```java private void failTask(SuperAgentDocumentTask task, Date startTime, Exception exception, Integer currentStage) { Date finishTime = new Date(); task.setTaskStatus(FAILED); task.setCurrentStage(currentStage); task.setFinishTime(finishTime); task.setCostMillis(finishTime.getTime() - startTime.getTime()); task.setErrorCode("TASK_FAILED"); task.setErrorMsg(exception.getMessage()); taskMapper.updateById(task); } ``` 和 finishTaskSuccess 对称:一个把成功填好,一个把失败填好。 注意 `errorCode = "TASK_FAILED"` 是个粗粒度的错误码,未来可以根据异常类型细化: ```text PARSE_FILE_BROKEN TIKA_FAILED STRUCTURE_EXTRACT_FAILED LLM_DISAMBIGUATION_FAILED STRATEGY_RECOMMEND_FAILED ``` 便于后台分类统计。 --- ### **十五、整个 handleParseRoute 的状态机视图** 把上一篇和这一篇连起来,可以画出一张完整的状态流转图: ```mermaid stateDiagram-v2 [*] --> NEW: 任务创建 NEW --> RUNNING: Consumer 消费 RUNNING --> CONTENT_PARSE: 进入内容解析 CONTENT_PARSE --> STRATEGY_ROUTE: 解析完成 STRATEGY_ROUTE --> SUCCESS: 推荐策略并落库 SUCCESS --> [*] CONTENT_PARSE --> FAILED: 解析异常 STRATEGY_ROUTE --> FAILED: 推荐异常 FAILED --> [*] ``` 文档级状态流转: ```mermaid stateDiagram-v2 [*] --> WAIT_PARSE WAIT_PARSE --> PARSING: Consumer 开始处理 PARSING --> PARSE_SUCCESS: 解析成功 PARSING --> PARSE_FAILED: 解析失败 PARSE_SUCCESS --> [*]: 进入策略阶段 state PARSE_SUCCESS { [*] --> WAIT_RECOMMEND WAIT_RECOMMEND --> RECOMMENDED: 策略推荐完成 RECOMMENDED --> CONFIRMED: 用户确认 CONFIRMED --> [*] } ``` #### **状态机思维的工程价值** 这种显式状态机让系统具备: ```text 1. 可追溯性:任意时刻知道任务和文档处在什么状态 2. 可恢复性:失败后能从特定状态恢复,不必从头开始 3. 可观测性:监控可以基于状态做告警 4. 可测试性:每个状态转移都可以单独测试 ``` ### **十六、把整段流程串成一句话** 可以这样总结: > 在文档解析、结构节点落库、画像生成完成之后,handleParseRoute 调用 DocumentStrategyService 基于 DocumentAnalysisResult 进行四个基础判断(结构 / 递归 / 语义 / LLM),然后按"父块结构优先递归兜底,子块 LLM/语义优先递归兜底"的优先级链组装出 Parent/Child 双层切块流水线,再通过 buildDraftSteps 把策略类型转换为带 pipelineType、strategyType、strategyRole、recommendReason 的步骤草案,最后将策略草案落成一条 plan 主记录(状态 WAIT\_CONFIRM、版本递增、带 strategySnapshot)和若干 strategy\_step 步骤记录(状态 WAIT\_EXECUTE),同时把文档主表的 parseStatus 切到 PARSE\_SUCCESS、strategyStatus 切到 RECOMMENDED、回填 currentPlanId 和解析指标,调用 finishTaskSuccess 把任务收尾为 SUCCESS 并记录耗时,最后写一条带 strategySnapshot 和推荐理由的结构化任务日志。任意环节异常则统一进入 catch 块,把文档标记 PARSE\_FAILED、任务标记 FAILED 并记录 ERROR 日志,不做自动重试。 --- ### **十七、核心技术点提炼** #### **1. Parent-Child 双层切块的根本动机** 召回精度和回答完整性的目标矛盾,不能用一套切块规则解决,必须分层。子块负责召回,父块负责回答,通过父子映射连接。 #### **2. 四个布尔判断作为事实层** 把"客观特征评估"和"具体策略组装"分离,让推荐逻辑可读、可改、可测。 #### **3. 父块的"二选一"和子块的"优先级链"** 父块要稳定的大语义单元,所以策略简单(结构 or 递归);子块兼顾质量增强和长度兜底,所以是多步组合。 #### **4. LLM 后必加 RECURSIVE 的兜底设计** LLM 决定"哪里切",递归决定"块多大"。两层各司其职,不让任何单一策略决定最终块质量。 #### **5. 推荐结果不直接执行** plan 状态 WAIT\_CONFIRM、step 状态 WAIT\_EXECUTE,必须用户确认才推进。这是"人机协同"的关键。 #### **6. 双层 reason 设计** plan 级理由用于列表页展示,step 级理由用于详情页展示,前端体验更好。 #### **7. strategySnapshot 反范式优化** 冗余存一份压缩字符串,让方案展示、对比、日志记录都不用回表。 #### **8. planVersion + currentPlanId 多版本管理** plan 表存全历史,document 用 currentPlanId 指针选当前生效版本,切换方案 = 改指针。 #### **9. sourceType 区分 SYSTEM/USER** 为后续"用户调整方案"留扩展位,能追溯每个步骤来自系统还是用户。 #### **10. costMillis 全链路性能可观测** 每个任务都记录耗时,配合任务日志 metadata,做出端到端性能监控。 #### **11. 失败不自动重试** 显式选择,避免坏文件无限重试。失败后由外部机制(Kafka 重投、定时补偿、人工触发)处理。 #### **12. 错误信息清空** 成功收尾时主动清空 parseErrorMsg 和 errorMsg,避免历史错误信息误导。 --- ### **十八、面试官可能会问的问题** #### **问题 1:为什么需要 Parent-Child 双层切块?** 可以回答: > 因为 RAG 系统中召回和回答有矛盾的目标。召回阶段块越小越精准,向量更聚焦,相似度更准;回答阶段块越大越完整,模型能看到完整章节上下文。如果用一套切块,要么召回不准,要么回答缺上下文。所以子块负责召回(切小、边界精准),父块负责回答(保留章节级大上下文),检索时用子块召回再映射到对应父块,把父块文本送给模型生成答案。这是"小块检索、大块回答"的工程实践。 #### **问题 2:为什么父块流水线只有一步,子块可能有两步?** 可以回答: > 父块的目标是稳定的大语义单元,要么沿章节边界切,要么按长度兜底,多走一步只会让边界更不稳定。子块要兼顾两件事:质量增强(LLM 或语义切块决定哪里切)和长度兜底(递归保证块大小符合 token 预算),这两件事单一策略做不好,所以分两层。LLM 知道哪里切语义合理但不保证块大小,递归知道怎么控制长度但不懂语义,组合起来兜底最稳。 #### **问题 3:为什么 LLM 切块后还要追加递归切块?** 可以回答: > 因为 LLM 切块的边界判断是基于"语义合理",不保证"块大小符合 token 预算"。模型可能切出一个 5000 字符的大块,对召回阶段来说还是太大。所以 LLM 之后必须加一道长度兜底,把超大块按字符再切一次。这样语义层和长度层各司其职,任何一层不靠谱都不会让最终块完全不可用。 #### **问题 4:为什么推荐方案不直接执行,要等用户确认?** 可以回答: > 三个原因。第一,自动推荐基于规则和阈值,不可能 100% 完美,重要文档需要人工把关。第二,切块和向量化是高成本操作,执行错了浪费算力和向量库空间。第三,用户可能想试不同策略对比效果,或者根据业务理解手动调整。所以方案落库后状态是 WAIT\_CONFIRM,前端展示给用户,用户确认或修改后再推进到索引构建。这是人机协同的设计。 #### **问题 5:strategySnapshot 这种压缩字符串有什么用?** 可以回答: > 它是一种反范式优化。完整方案信息分散在 plan 主表和 step 步骤表里,每次展示都要 join 两表。如果列表页要批量展示几百份文档的方案,性能会很差。strategySnapshot 在落库时一次预计算,格式像 PARENT:1;CHILD:4,2,后续展示、对比、日志记录都不用回表。同时它也方便方案版本对比,字符串相等就能快速判断方案是否变化。代价是冗余存储,但只要保证主数据更新时同步更新冗余字段就没问题。 #### **问题 6:为什么解析失败不自动重试?** 可以回答: > 这是有意为之的设计。一是解析失败大概率是文件本身问题(损坏、格式异常),自动重试也救不回来;二是自动重试可能让同一个失败任务被反复消费,浪费资源;三是显式失败更利于追溯,让任务停在 FAILED 状态等待人工或外部补偿。重试逻辑放在外部更稳:可以是 Kafka broker 的重投机制、定时扫描 FAILED 任务的补偿任务、或者前端给用户显示"重试"按钮。主链路保持简单可靠。 --- **企业级项目导航**:⬅️ [[07-结构节点提取的四阶段流水线|07-结构节点提取的四阶段流水线]] | 08-切块策略落库 | ➡️ [[09-白话讲解|09-白话讲解]]