--- title: "10-策略推荐与方案持久化" created: 2026-05-19 aliases: - 策略推荐与方案持久化 tags: - 项目 --- # 策略推荐与方案持久化 上一篇走完了解析结果统计和异步收尾工作,任务阶段已经推进到了 `STRATEGY_ROUTE`。现在进入 `handleParseRoute` 的最后一段:拿到解析结果和结构节点之后,系统要自动推荐一套切块策略,然后把方案写进数据库。 ## 策略推荐决策流程 先看一张决策流程图,理解整个推荐逻辑的走向: ![[FhjoMRimlQmWMa-jlxSkmAOGuvHi-b39a5711.png]] ## recommendStrategy:策略推荐核心方法 这个方法在 `DocumentStrategyServiceImpl` 里,它不执行切块,而是回答一个更上游的问题:**"对于这份文档,后续索引构建时应该采用怎样的 Parent/Child 切块流水线?"** ### 四个基础判断 ```java @Override public DocumentStrategyPlanDraft recommendStrategy(SuperAgentDocument document, DocumentAnalysisResult analysisResult) { List reasonList = new ArrayList<>(); // fileType 会参与结构切块判断,因为并不是所有文件格式都天然适合结构识别。 DocumentFileTypeEnum fileType = DocumentFileTypeEnum.getRc(document.getFileType()); // 这四个布尔判断是后续整套推荐的"基础事实层"。 // 它们并不直接生成步骤,而是回答"这份文档在切块上有哪些客观特征和风险"。 boolean structureRecommended = shouldUseStructure(fileType, analysisResult); boolean recursiveRecommended = shouldUseRecursive(analysisResult); boolean semanticRecommended = shouldUseSemantic(analysisResult); boolean llmRecommended = shouldUseLlm(analysisResult); ``` 我们逐个看这四个判断方法: ```java /** * 判断是否适合使用结构切块。 *

* 只有在文件类型本身适合结构识别,且结构等级或标题数量达到一定阈值时才推荐。 *

*/ private boolean shouldUseStructure(DocumentFileTypeEnum fileType, DocumentAnalysisResult analysisResult) { boolean suitableType = fileType == DocumentFileTypeEnum.PDF || fileType == DocumentFileTypeEnum.DOC || fileType == DocumentFileTypeEnum.DOCX || fileType == DocumentFileTypeEnum.MD || fileType == DocumentFileTypeEnum.HTML; return suitableType && (analysisResult.getStructureLevel() >= DocumentStructureLevelEnum.MEDIUM.getCode() || analysisResult.getHeadingCount() >= 2); } ``` 结构切块需要同时满足两个条件:文件类型适合(PDF/DOC/DOCX/MD/HTML,TXT 不行)+ 结构信号足够(结构等级 >= MEDIUM 或标题 >= 2 个)。 ```java /** * 判断是否需要递归分块。 *

* 当全文或单段过长时,需要用递归分块控制 chunk 长度。 *

*/ private boolean shouldUseRecursive(DocumentAnalysisResult analysisResult) { return analysisResult.getCharCount() >= properties.getChunk().getRecursiveMaxChars() || analysisResult.getMaxParagraphLength() >= properties.getChunk().getRecursiveMaxChars(); } /** * 判断是否适合使用语义分块。 */ private boolean shouldUseSemantic(DocumentAnalysisResult analysisResult) { return analysisResult.getCharCount() >= properties.getChunk().getSemanticMinChars() && analysisResult.getParagraphCount() >= 3 && analysisResult.getContentQualityLevel() >= DocumentContentQualityLevelEnum.MEDIUM.getCode(); } /** * 判断是否需要启用 LLM 智能切块。 *

* 这里只在配置允许、文本质量偏低且长度达到一定规模时才推荐, * 避免把 LLM 用在收益不明显的小文本上。 *

*/ private boolean shouldUseLlm(DocumentAnalysisResult analysisResult) { return Boolean.TRUE.equals(properties.getChunk().getRecommendLlmWhenLowQuality()) && analysisResult.getContentQualityLevel() .equals(DocumentContentQualityLevelEnum.LOW.getCode()) && analysisResult.getCharCount() >= properties.getChunk().getSemanticMinChars(); } ``` 四个判断的总结: | 判断 | 条件 | 含义 | | --- | --- | --- | | **结构切块** | 文件类型适合 + 标题 >= 2 | 文档有清晰的章节结构 | | **递归分块** | 全文或单段超过阈值 | 文本太长,需要控制 chunk 大小 | | **语义分块** | 文本够长 + 段落 >= 3 + 质量 >= MEDIUM | 主题边界明确,适合按语义拆分 | | **LLM 切块** | 配置允许 + 质量 = LOW + 文本够长 | 质量差,需要大模型智能增强 | ### 父块流水线决策 ```java List parentStrategyTypes = new ArrayList<>(); Map parentReasonMap = new LinkedHashMap<>(); if (structureRecommended) { // 父块更偏向保留"章节级别"的大语义单元,所以只要结构信号明显,就优先保留天然章节边界。 parentStrategyTypes.add(DocumentStrategyTypeEnum.STRUCTURE.getCode()); parentReasonMap.put(DocumentStrategyTypeEnum.STRUCTURE.getCode(), "检测到文档具有较明显的标题或章节结构,父块优先保留天然章节边界。"); reasonList.add("父块流水线优先采用基于文档结构切块,保留回答阶段需要的大语义单元。"); } else { // 如果结构信号不足,继续强行做结构切块往往只会切出不稳定的边界, // 因此父块退回到大粒度递归分块,用长度控制来保证"至少有稳定的大块可用"。 parentStrategyTypes.add(DocumentStrategyTypeEnum.RECURSIVE.getCode()); parentReasonMap.put(DocumentStrategyTypeEnum.RECURSIVE.getCode(), "未识别出稳定结构时,父块先使用较大粒度的递归分块作为稳定回答单元。"); reasonList.add("父块流水线未命中明显结构信号,默认使用较大粒度递归分块作为回答单元。"); } ``` > 为什么要区分 Parent 和 Child? > > Parent-Child 是一种双层切块架构: > > - **父块(Parent)**:大语义单元,保留完整的章节上下文,用于回答阶段——当模型需要生成答案时,读的是父块 > - **子块(Child)**:小检索单元,边界更精确,用于召回阶段——当系统需要从向量库里找相关内容时,搜的是子块 > > 两者目标不同,所以不能用同一套切块规则。 父块的决策很简单:有结构就用结构切块,没结构就用递归分块兜底。 ### 子块流水线决策 ```java List childStrategyTypes = new ArrayList<>(); Map childReasonMap = new LinkedHashMap<>(); if (llmRecommended) { // 子块更偏向召回单元,文本质量差时最怕把噪声、断句错误、结构混乱直接带进向量库; // 因此如果满足 LLM 条件,优先用 LLM 先做一次更智能的边界增强。 childStrategyTypes.add(DocumentStrategyTypeEnum.LLM.getCode()); childReasonMap.put(DocumentStrategyTypeEnum.LLM.getCode(), "文档质量偏低或结构识别不稳定,子块先使用大模型智能切块增强复杂场景。"); reasonList.add("子块流水线追加大模型智能切块,处理低质量或结构不稳定文本。"); } else if (semanticRecommended) { // 如果文本质量尚可、段落足够丰富,就让子块优先按主题边界拆分, // 这样做出的检索块通常比纯长度切分更适合召回。 childStrategyTypes.add(DocumentStrategyTypeEnum.SEMANTIC.getCode()); childReasonMap.put(DocumentStrategyTypeEnum.SEMANTIC.getCode(), "文本主题边界相对明确,子块先使用语义分块优化召回边界。"); reasonList.add("子块流水线优先采用语义分块,优化召回边界和主题完整性。"); } if (recursiveRecommended || llmRecommended || childStrategyTypes.isEmpty()) { // 递归分块在子块流水线里承担"长度约束兜底"的角色: // 1. 文本本来就很长,需要控制 chunk 大小; // 2. 前面加了 LLM 后,仍然需要一个长度兜底步骤; // 3. 如果语义/LLM 都没命中,递归分块至少能保证产出稳定的检索块。 childStrategyTypes.add(DocumentStrategyTypeEnum.RECURSIVE.getCode()); childReasonMap.put(DocumentStrategyTypeEnum.RECURSIVE.getCode(), "文档整体较长、存在超长段落,或需要在增强切块后追加长度兜底。"); reasonList.add("子块流水线追加递归分块,控制召回单元长度并作为兜底。"); } ``` 子块的决策稍微复杂一些,是一个优先级链: - 质量差 → 先上 LLM 智能切块 - 质量还行、段落丰富 → 用语义切块 - 不管前面选了什么,只要文本够长、或者用了 LLM、或者前面啥都没选 → 追加递归分块做长度兜底 所以子块流水线可能是单步的(只有 RECURSIVE),也可能是两步的(LLM + RECURSIVE 或 SEMANTIC + RECURSIVE)。 ### 打包成策略草案 ```java // 这里把"推荐出的策略类型"转成真正可落库展示的步骤草案, // 包含 pipelineType、role、reason 等信息。 List parentSteps = buildDraftSteps( DocumentStrategyPipelineTypeEnum.PARENT, parentStrategyTypes, parentReasonMap); List childSteps = buildDraftSteps( DocumentStrategyPipelineTypeEnum.CHILD, childStrategyTypes, childReasonMap); // strategySnapshot 是后续任务日志、方案展示和异步索引构建都会复用的一份紧凑快照表示。 String strategySnapshot = buildCombinedStrategySnapshot(parentSteps, childSteps); return new DocumentStrategyPlanDraft(strategySnapshot, String.join(";", reasonList), parentSteps, childSteps); ``` `buildDraftSteps()` 把策略类型列表转成步骤草案: ```java /** * 按顺序构造某条流水线的步骤草案。 *

* 这个方法不是简单地把“策略类型整数列表”复制出来,而是把推荐结果提升成 * 一组后续可展示、可落库、可执行的 {@link DocumentStrategyStepDraft}。 *

*

* 每个 step draft 至少要补齐四类信息: * 1. pipelineType:说明该步骤属于父块流水线还是子块流水线; * 2. strategyType:说明执行的是结构切块、递归分块、语义分块还是 LLM 分块; * 3. strategyRole:说明该步骤在整条流水线中的职责,例如 PRIMARY、FALLBACK、OPTIMIZE、ENHANCE; * 4. recommendReason:说明系统为什么把这个步骤放进当前流水线,供前端展示和日志追踪使用。 *

*

* 之所以单独抽成一个 helper,是因为 {@link #recommendStrategy(SuperAgentDocument, DocumentAnalysisResult)} * 在确定“父块应该有哪些策略类型、子块应该有哪些策略类型”之后, * 还需要把这种“类型级结论”转换成真正能落成方案步骤的结构化对象。 * 这一步正是由这里完成的。 *

*/ 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); // index 不只是循环下标,它还决定了这个步骤在流水线中的顺序, // 同时也会影响 resolveRole 的结果,例如第一个步骤通常是 PRIMARY, // 而后续的递归步骤更可能被标为 FALLBACK。 draftList.add(new DocumentStrategyStepDraft( pipelineType.getCode(), strategyType, // role 不是固定写死的,而是由“步骤所在顺序 + 策略类型”共同决定。 resolveRole(index, strategyType), // 这里生成的是系统自动推荐出来的方案草稿,因此 sourceType 固定为 SYSTEM_RECOMMEND。 DocumentStrategySourceTypeEnum.SYSTEM_RECOMMEND.getCode(), // 推荐原因优先使用调用方已经为当前策略准备好的解释文本; // 如果没有命中,则退回到一个通用说明,避免前端展示空理由。 reasonMap.getOrDefault(strategyType, "系统为当前流水线生成的推荐步骤。") )); } return draftList; } ``` 最终返回的 `DocumentStrategyPlanDraft` 包含四个东西: - `strategySnapshot`:紧凑的快照字符串,格式如 `PARENT:1;CHILD:4,2` - `recommendReason`:所有推荐理由拼接成的文本 - `parentSteps`:父块流水线的步骤草案列表 - `childSteps`:子块流水线的步骤草案列表 ## 回到 handleParseRoute:方案持久化 拿到策略草案之后,`handleParseRoute` 要把它写进数据库。 ### 创建策略方案主记录 ```java // 根据文档类型、结构质量、正文长度、段落特征等信息生成推荐策略草案。 DocumentStrategyPlanDraft planDraft = strategyService.recommendStrategy( document, analysisResult); Long planId = uidGenerator.getUid(); int planVersion = getNextPlanVersion(documentId); // 推荐策略先落成一条 plan 主记录,表示"系统给当前文档建议采用这套切块方案"。 SuperAgentDocumentStrategyPlan plan = new SuperAgentDocumentStrategyPlan(); plan.setId(planId); plan.setDocumentId(documentId); plan.setPlanVersion(planVersion); plan.setPlanSource(DocumentPlanSourceEnum.SYSTEM_RECOMMEND.getCode()); plan.setPlanStatus(DocumentPlanStatusEnum.WAIT_CONFIRM.getCode()); plan.setStrategyCount(planDraft.getParentSteps().size() + planDraft.getChildSteps().size()); plan.setStrategySnapshot(planDraft.getStrategySnapshot()); plan.setRecommendReason(planDraft.getRecommendReason()); plan.setStatus(BusinessStatus.YES.getCode()); planMapper.insert(plan); ``` 方案主记录的关键字段: - `planSource = SYSTEM_RECOMMEND`:标记这是系统自动推荐的,不是用户手动调整的 - `planStatus = WAIT_CONFIRM`:方案还需要用户确认才能执行,不会自动进入索引构建 - `strategySnapshot`:快照字符串,方便后续快速展示和比对 ### 写入策略步骤 ```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( DocumentStrategyExecuteStatusEnum.WAIT_EXECUTE.getCode()); step.setRecommendReason(draft.getRecommendReason()); step.setStatus(BusinessStatus.YES.getCode()); stepMapper.insert(step); } for (int index = 0; index < planDraft.getChildSteps().size(); index++) { DocumentStrategyStepDraft draft = planDraft.getChildSteps().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( DocumentStrategyExecuteStatusEnum.WAIT_EXECUTE.getCode()); step.setRecommendReason(draft.getRecommendReason()); step.setStatus(BusinessStatus.YES.getCode()); stepMapper.insert(step); } ``` 父块步骤和子块步骤分别循环写入,每个步骤的 `executeStatus` 都初始化为 `WAIT_EXECUTE`,等后续索引构建时才会真正执行。 ### 更新文档主表 + 任务成功收尾 ```java // 到这里,文档已经完成内容解析并拿到了推荐策略, // 因此可以把主表状态切成"解析成功 + 已推荐策略"。 document.setParseStatus(DocumentParseStatusEnum.PARSE_SUCCESS.getCode()); document.setStrategyStatus(DocumentStrategyStatusEnum.RECOMMENDED.getCode()); 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); // 最后把任务按成功态收尾,并记录"系统已生成推荐策略"的阶段日志。 finishTaskSuccess(task, DocumentTaskStageEnum.STRATEGY_ROUTE.getCode(), startTime); taskLogService.saveLog(taskId, documentId, DocumentTaskStageEnum.STRATEGY_ROUTE.getCode(), DocumentTaskEventTypeEnum.RECOMMEND_STRATEGY.getCode(), DocumentLogLevelEnum.INFO.getCode(), DocumentOperatorTypeEnum.SYSTEM.getCode(), null, "系统已生成推荐策略。", detail("planId", planId, "strategySnapshot", planDraft.getStrategySnapshot(), "parentStepCount", planDraft.getParentSteps().size(), "childStepCount", planDraft.getChildSteps().size(), "structureNodeCount", structureNodeCount, "recommendReason", planDraft.getRecommendReason())); ``` 这里更新了文档主表的一大堆字段: - **parseStatus → PARSE\_SUCCESS**:解析成功 - **strategyStatus → RECOMMENDED**:策略已推荐,等待用户确认 - `charCount` / `tokenCount`:从解析结果回填 - `parseTextPath`:解析后纯文本在 MinIO 的路径 - `currentPlanId`:当前生效的推荐方案 ID - `parseErrorMsg → null`:清空错误信息(如果之前有失败记录的话) `finishTaskSuccess()` 是一个通用的任务成功收尾方法: ```java /** * 把任务按成功态收尾,统一写入完成时间、耗时,并清空错误字段。 */ private void finishTaskSuccess(SuperAgentDocumentTask task, Integer stage, Date startTime) { Date finishTime = new Date(); task.setTaskStatus(DocumentTaskStatusEnum.SUCCESS.getCode()); task.setCurrentStage(stage); task.setFinishTime(finishTime); task.setCostMillis(finishTime.getTime() - startTime.getTime()); task.setErrorCode(null); task.setErrorMsg(null); taskMapper.updateById(task); } ``` ## 异常处理:解析失败的收尾 如果上面任何一步抛了异常,就会进入 catch 块: ```java catch (Exception exception) { log.error("异步解析文档失败,documentId={}, taskId={}", documentId, taskId, exception); // 一旦异步解析链失败,文档主表要明确标记为解析失败,并保留错误信息供前端和日志查看。 document.setParseStatus(DocumentParseStatusEnum.PARSE_FAILED.getCode()); document.setParseErrorMsg(exception.getMessage()); documentMapper.updateById(document); // 任务本身也同步转为失败态,并追加一条错误日志,把失败停留在哪个阶段明确记录下来。 failTask(task, startTime, exception, DocumentTaskStageEnum.CONTENT_PARSE.getCode()); taskLogService.saveLog(taskId, documentId, DocumentTaskStageEnum.CONTENT_PARSE.getCode(), DocumentTaskEventTypeEnum.FAILED.getCode(), DocumentLogLevelEnum.ERROR.getCode(), DocumentOperatorTypeEnum.SYSTEM.getCode(), null, "文档解析失败。", detail("error", exception.getMessage())); } ``` 失败时做三件事: - 文档主表标记为 `PARSE_FAILED`,并把错误信息写进 `parseErrorMsg` - 任务标记为 `FAILED`,记录耗时和错误信息 - 写一条 ERROR 级别的任务日志 `failTask()` 方法: ```java /** * 把任务按失败态收尾,统一写入完成时间、耗时和错误信息。 */ private void failTask(SuperAgentDocumentTask task, Date startTime, Exception exception, Integer currentStage) { Date finishTime = new Date(); task.setTaskStatus(DocumentTaskStatusEnum.FAILED.getCode()); task.setCurrentStage(currentStage); task.setFinishTime(finishTime); task.setCostMillis(finishTime.getTime() - startTime.getTime()); task.setErrorCode("TASK_FAILED"); task.setErrorMsg(exception.getMessage()); taskMapper.updateById(task); } ``` > 关于重试 > > 注意这里没有自动重试机制。如果解析失败了,任务就直接标记为 FAILED,不会自动重新消费。重试依赖外部的 Kafka 重试机制或者人工触发重新上传。 ## 任务状态流转总结 用一张图来总结整个 `handleParseRoute` 中任务和文档状态的变化: ![[FrwPsKaj9P8C6joBZ9hqSVA2_Qtb-a6fd3b48.png]] ## 小结 整个 `handleParseRoute` 的后半段可以概括为: **调用策略服务 → 四个基础判断 → 决定 Parent/Child 流水线 → 方案主记录入库 → 步骤入库 → 更新文档状态 → 任务成功收尾** 到这里,一份文档就从"刚上传"走到了"解析完成 + 拿到推荐策略"的状态。接下来用户可以在前端查看推荐方案、调整策略,确认之后再触发索引构建——那就是另一条异步链路的事情了。 --- **企业级项目导航**:⬅️ [[09-白话讲解|09-白话讲解]] | 10-策略推荐与方案持久化 | ➡️ [[11-统计打分收口打包同步落库|11-统计打分收口打包同步落库]]