切块策略落库

我们接着上一篇“解析结果统计与异步收尾”往后学。上一篇的终点是:

任务阶段被推进到 STRATEGY_ROUTE
文档状态是 PARSING(还没切到 PARSE_SUCCESS)
画像、结构节点、导航索引、图谱投影都已就绪
parsed-text txt 已写到 MinIO

这一篇的起点就是 handleParseRoute 的最后一段:系统拿到解析结果和结构节点之后,要自动推荐一套切块策略,把方案写进数据库,然后把任务按成功态收尾。

学完这一篇,你就完整打通了“上传 → Kafka → 解析 → 结构 → 收尾 → 策略推荐”这条主线。下一篇就要进入用户确认方案后的索引构建异步链路。

一、先建立整体认知:这一节在做什么?

可以一句话概括:

系统根据解析阶段算出来的结构等级、内容质量、字符数、段落特征等指标,自动推荐一套 Parent/Child 双层切块流水线,把方案以 WAIT_CONFIRM 状态落库等待用户确认,然后把任务按成功态收尾。

整体可以画成两段流程:

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 字符切,或者统一按段落切。

不行。原因是文档世界差异巨大:

一份 100 字的 FAQ
一份 50 万字的产品手册
一份扫描版乱码 PDF
一份结构清晰的技术规范
一份纯散文式的会议纪要
一份带大段表格的财务报表

如果都用同一种切块策略:

FAQ 会被切得太碎,召回时找不到完整问答
长手册不切就直接超模型上下文
扫描乱码切完一堆噪声,向量化效果极差
结构清晰的文档强行按字符切,破坏章节边界
散文按结构切又找不到标题

所以系统必须因文施策:根据每份文档的“画像”,自动选择最合适的切块策略组合。

这就是策略推荐的存在意义。


三、recommendStrategy:策略推荐的核心方法

入口在 DocumentStrategyServiceImpl.recommendStrategy()

public DocumentStrategyPlanDraft recommendStrategy(
    SuperAgentDocument document,
    DocumentAnalysisResult analysisResult
) {
    List<String> 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 是一份“方案草稿”,由后续逻辑落库等待用户确认。

这种“判断与执行分离”的设计

可以画成:

recommendStrategy:只决定"用哪些策略"
buildDraftSteps:把策略类型转成可落库的步骤
持久化层:把步骤落到 plan + step 两张表
执行层:用户确认后,索引构建链路读取步骤逐一执行

每一层职责单一,互不耦合。这种分层有几个好处:

1. 推荐逻辑可以单独迭代,不影响执行
2. 用户可以在中间环节修改步骤
3. 步骤落库后任何时刻都能查看回放
4. 失败时可以基于已落库的步骤精确重试

四、四个基础判断:推荐的“事实层”

策略推荐的核心是四个布尔判断,它们不直接生成步骤,而是回答“这份文档在切块上有哪些客观特征和风险”。

1. shouldUseStructure:是否适合结构切块

boolean suitableType = fileType == PDF || fileType == DOC || fileType == DOCX
                    || fileType == MD || fileType == HTML;
return suitableType
    && (analysisResult.getStructureLevel() >= MEDIUM
        || analysisResult.getHeadingCount() >= 2);

两个条件同时满足才推荐:

条件一:文件类型本身适合结构识别
条件二:结构等级 MEDIUM 及以上,或标题数量 ≥ 2

为什么 TXT 不在适合列表里?

因为 TXT 没有任何格式信息,结构信号完全靠正文内容里的标题写法(比如 第一章1.1)。这些信号上一节四阶段流水线确实能识别,但相比 Markdown 的 ## 或 PDF/Word 内嵌的标题样式,可靠性低很多。

所以工程上做了一个明确取舍:

TXT → 不走结构切块,走递归分块
PDF/DOC/DOCX/MD/HTML → 满足信号阈值就走结构切块

为什么用 MEDIUM 或 标题 ≥ 2 这种“或”逻辑?

因为这两个条件代表两种不同的“结构证据”:

structureLevel:综合指标(标题数 + 段落数)
headingCount:单一指标(标题数)

任意一个达标就足够了,不需要双重门槛。

2. shouldUseRecursive:是否需要递归分块

return analysisResult.getCharCount() >= recursiveMaxChars
    || analysisResult.getMaxParagraphLength() >= recursiveMaxChars;

也是“或”逻辑:

全文够长 → 需要递归切
单段够长 → 也需要递归切

为什么单段长也要递归?

这是一个非常关键的细节。

考虑这种文档:

全文 5000 字
但中间有一段是 4500 字的连续大段落

如果只看全文长度,可能没超阈值;但那一段 4500 字的段落如果不切,就会变成一个单独超大块,向量化时被截断,检索召回时也会拖累相关性。

所以递归分块的判断必须看“最坏情况下的最大单段”,而不是平均值。

这就是上一篇为什么要算 maxParagraphLength 的原因。

3. shouldUseSemantic:是否适合语义分块

如果只看全文长度,可能没超阈值;但那一段 4500 字的段落如果不切,就会变成一个单独超大块,向量化时被截断,检索召回时也会拖累相关性。
所以递归分块的判断必须看“最坏情况下的最大单段”,而不是平均值。
这就是上一篇为什么要算 maxParagraphLength 的原因。
3. shouldUseSemantic:是否适合语义分块

三个条件同时满足:

够长(短文本没必要按主题切)
段落 ≥ 3(至少有几个主题块可识别)
质量 ≥ MEDIUM(乱码文本算不出主题边界)

为什么是“且”逻辑?

因为语义切块本身有一定开销(要算段落 embedding、计算相似度),如果文档太短或质量太差,硬上语义切块只会浪费算力还出不了好结果。

4. shouldUseLlm:是否需要 LLM 智能切块

return Boolean.TRUE.equals(recommendLlmWhenLowQuality)
    && contentQualityLevel.equals(LOW)
    && charCount >= semanticMinChars;

三个条件同时满足:

配置开关打开
质量等于 LOW
文本长度达到阈值

为什么 LLM 切块只在质量 LOW 时才推荐?

因为 LLM 调用是这四种里最贵、最慢的:

每个块都要经过模型推理
长文档可能上百次调用
单次延迟可能秒级

只在“规则切块明显切不好”的场景才值得上 LLM。也就是:

乱码多、结构混乱、段落分割不清晰
此时 LLM 能用语义理解能力做更智能的边界划分

而对于结构清晰的文档,结构切块就够了,没必要花钱上 LLM。

为什么短文本不用 LLM?

因为短文本切完只有几块,规则也能切得不错,性价比太低。

四个判断的组合表

判断 关键条件 性质
结构切块 文件类型适合 + 结构信号充足 优先利用文档自身结构
递归分块 全文长 或 单段长 长度兜底
语义分块 长 + 段落多 + 质量好 主题边界优化
LLM 切块 配置开 + 质量差 + 够长 低质量场景救火

注意这四个判断是独立的,可以同时为 true。后面的父块/子块决策才是基于这四个事实做组合。


五、为什么要分 Parent / Child 双层切块?

这是整篇文档最值得深入的设计点。

1. 召回和回答的目标矛盾

RAG 系统的两个核心动作是:

召回:从向量库中找到相关内容
回答:把相关内容喂给模型生成答案

这两个动作对“块大小”的要求是矛盾的:

召回:块越小越精准
    - 嵌入向量更聚焦
    - 相似度计算更准
    - 不容易被无关内容稀释

回答:块越大越完整
    - 模型能看到完整上下文
    - 不会答出"半截答案"
    - 章节信息保留

如果只用一套切块:

切大了 → 召回不准
切小了 → 回答缺上下文

2. Parent-Child 的解法

把这两个目标解耦:

Child(子块):专门服务召回
    - 切得小、边界精准
    - 用于向量化和检索

Parent(父块):专门服务回答
    - 保留章节级大上下文
    - 召回时通过子块"映射"找到对应父块
    - 把父块送给模型生成答案

也就是:

搜的是子块
读的是父块

3. 这种思想在业界的位置

这其实是 LangChain ParentDocumentRetriever 的核心设计,也叫“小块检索、大块回答”。

它的工程意义在于:

不必为了精准召回牺牲回答完整性
也不必为了完整回答牺牲召回精准度
两个目标分别用最合适的切块方式实现

4. 父子映射怎么做?

子块在切分时会记录它属于哪个父块(通常是父块 ID 或编号)。检索流程是:

1. 用户提问 → 生成查询向量
2. 在子块向量库里召回 top-k
3. 拿到子块 → 找到它的 parentId
4. 加载对应的父块文本
5. 父块文本拼成 prompt 送给模型

这一节虽然只讲推荐,但你要理解:父块和子块在策略上的差异,根源在于这两个目标的根本矛盾


六、父块流水线决策

代码非常简洁:

if (structureRecommended) {
    parentStrategyTypes.add(STRUCTURE);
} else {
    parentStrategyTypes.add(RECURSIVE);
}

逻辑就两条路:

有结构 → 结构切块
没结构 → 递归切块兜底

1. 为什么不像子块那样追加多步?

因为父块的目标是“稳定的大语义单元”:

理想情况:沿章节边界切
    每一块就是一章/一节
    回答时上下文完整
    用户看到引用时知道"这是第 X 章"

退化情况:递归按长度切
    至少保证有稳定的大块
    虽然边界不那么自然
    但仍能服务回答阶段

如果父块也叠加多步策略(比如结构 + 语义),会带来两个问题:

1. 复杂度上升,边界更不稳定
2. 父块本身就是给模型读的,不需要太精细

所以父块保持“一步到位”:要么结构,要么递归。

2. 为什么没结构信号时不用语义切块?

因为父块要保留“章节级的大上下文”,而语义切块的边界是按主题相似度,主题边界 ≠ 章节边界

举例:

一篇技术文章,第 1 章和第 2 章都在讨论"性能优化"
语义切块可能把这两章合并成一块
而读者期望看到"第 1 章是 X 优化,第 2 章是 Y 优化"

所以父块要的是“结构边界优先”,没结构就退化到“长度边界稳定”,而不是“主题边界”。

3. recommendReason 同步落进 reason 字典

所以父块要的是“结构边界优先”,没结构就退化到“长度边界稳定”,而不是“主题边界”。
3. recommendReason 同步落进 reason 字典

这里维护了两套理由:

parentReasonMap:per-step 的理由,落到每个步骤的 recommendReason 字段
reasonList:整体方案的理由,落到 plan 主记录的 recommendReason 字段

为什么要两套?

plan 级理由:用户在方案列表页快速看到"为什么推荐这套"
step 级理由:用户在方案详情页看每一步的具体动机

这是一种两级展示的产品设计。如果只有一份理由,要么用户列表页看不到关键信息,要么详情页冗长。


七、子块流水线决策:优先级链

子块的决策比父块复杂,是一个三段式优先级链:

if (llmRecommended) {
    childStrategyTypes.add(LLM);
}
else if (semanticRecommended) {
    childStrategyTypes.add(SEMANTIC);
}

if (recursiveRecommended || llmRecommended || childStrategyTypes.isEmpty()) {
    childStrategyTypes.add(RECURSIVE);
}

逻辑可以拆成两步。

第一段:选主策略(LLM 或 SEMANTIC)

注意第一段是 if-else if两者只能二选一

质量差(LLM 命中) → 用 LLM 智能切块
质量尚可 + 段落丰富 → 用语义切块
都不满足 → 这一段什么都不选,直接进入第二段

为什么 LLM 优先于语义?

因为 LLM 命中的前提是“质量 LOW”,而语义切块在低质量文本上效果很差(embedding 算不准、相似度噪声大)。所以一旦质量差就上 LLM,让它先做一次智能边界增强。

第二段:递归兜底

为什么 LLM 优先于语义?
因为 LLM 命中的前提是“质量 LOW”,而语义切块在低质量文本上效果很差(embedding 算不准、相似度噪声大)。所以一旦质量差就上 LLM,让它先做一次智能边界增强。
第二段:递归兜底

三个 OR 条件,任意一个成立都会追加 RECURSIVE:

1. recursiveRecommended:文本本来就长,需要长度控制
2. llmRecommended:LLM 切完后还要再过一遍递归保证长度
3. childStrategyTypes.isEmpty():前面什么都没选,至少保证有递归

特别要注意第二种情况:LLM 后还要追加递归

为什么?

因为 LLM 切块的边界是“语义合理”,但 LLM 不保证“块大小符合 token 预算”。模型可能切出一个 5000 字符的大块,对召回阶段来说还是太大。所以 LLM 之后必须有一道“长度兜底”,把超大块按字符再切一次。

这是一个非常成熟的工程兜底设计:

语义层:LLM 决定"应该在哪里切"
长度层:RECURSIVE 决定"切完后块的大小是否合规"

两层各司其职,任何一层不靠谱都不会导致最终块完全不可用。

子块流水线的可能组合

触发条件 子块步骤
质量 LOW + 长 LLM → RECURSIVE
质量 MEDIUM/HIGH + 长 + 段落多 SEMANTIC → RECURSIVE
质量 MEDIUM/HIGH + 短 或 段落少 RECURSIVE
文本短 RECURSIVE

也就是说子块流水线最少 1 步,最多 2 步。这种有限组合既能覆盖主要场景,又不会让步骤数失控。


八、buildDraftSteps:从“类型列表”到“可落库步骤”

策略类型只是 Integer 列表,要落库还需要补足很多信息:

private List<DocumentStrategyStepDraft> buildDraftSteps(
    DocumentStrategyPipelineTypeEnum pipelineType,
    List<Integer> strategyTypes,
    Map<Integer, String> reasonMap
) {
    List<DocumentStrategyStepDraft> 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;
}

每个步骤要补齐四类信息:

1. pipelineType:属于父块还是子块流水线
2. strategyType:具体策略(结构/递归/语义/LLM)
3. strategyRole:在流水线中的职责(PRIMARY/FALLBACK/OPTIMIZE/ENHANCE)
4. recommendReason:推荐原因,前端展示用

strategyRole 的动态判定

resolveRole(index, strategyType) 不是写死的,而是根据“步骤顺序 + 策略类型”联合决定:

第一步通常是 PRIMARY(主策略)
后续递归通常是 FALLBACK(兜底)
LLM 通常是 ENHANCE(增强)
语义通常是 OPTIMIZE(优化)

为什么要分这么细的角色?

因为前端展示和日志追踪都要清楚地告诉用户:

"这一步是核心切块"
"这一步是失败兜底"
"这一步是质量优化"
"这一步是智能增强"

不同的角色对应不同的 UI 视觉(图标、颜色、说明),用户一眼就能看出方案结构。

sourceType 固定为 SYSTEM_RECOMMEND

DocumentStrategySourceTypeEnum.SYSTEM_RECOMMEND.getCode()

为什么需要这个字段?

因为方案不仅可以是系统推荐的,还可以是用户手动添加或修改的。比如:

SYSTEM_RECOMMEND:系统自动推荐
USER_OVERRIDE:用户手动修改
USER_ADD:用户手动追加

将来在数据库里查“某文档当前方案有多少步是用户改的”,就靠这个字段。这是为后续“用户调整方案”留的扩展位。


九、strategySnapshot:紧凑快照的设计巧思

String strategySnapshot = buildCombinedStrategySnapshot(parentSteps, childSteps);

格式像 PARENT:1;CHILD:4,2,意思是:

PARENT 流水线第 1 步是 STRUCTURE(策略类型 1)
CHILD 流水线第 1 步是 LLM(策略类型 4),第 2 步是 RECURSIVE(策略类型 2)

为什么要造这么一个字符串?

因为完整方案信息分散在 plan 主表和 step 步骤表两张表里,每次要展示“当前方案是什么”都要 join 一次。如果列表页要批量展示几百份文档的方案,性能会很差。

strategySnapshot 是一个预计算的展示字段

落库时一次计算,后续读取免 join
日志记录时直接塞进 metadata
方案对比时字符串相等就能快速判断
版本对比时直接看快照差异

这是典型的“反范式优化”

数据库范式上 step 表才是事实来源,但为了高频读场景的性能,在 plan 表里冗余一份压缩表示。这种设计在工程上非常常见:

Redis 缓存:为了快
ES 文档冗余:为了搜
快照字符串:为了显示和对比

只要保证主数据更新时同步更新冗余字段,反范式就是合理的优化。


十、回到 handleParseRoute:方案持久化

拿到 DocumentStrategyPlanDraft 后,回到主流程做四件事。

第一步:创建 plan 主记录

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。这样可以做:

方案历史回溯:用户能看到曾经推荐过哪些方案
A/B 对比:同一文档不同推荐版本的效果
质量监控:某次升级后推荐质量是否变化
回滚:必要时恢复到旧方案
2. planSource = SYSTEM_RECOMMEND

明确这是系统自动推荐,不是用户手动创建的方案。和步骤的 sourceType 是同一思想:追溯数据来源

3. planStatus = WAIT_CONFIRM

这是整个设计最关键的状态。

为什么不直接执行切块?

因为:

1. 自动推荐可能不完美,用户应该有干预空间
2. 切块后向量化是高成本操作,执行错了浪费资源
3. 用户可能想试不同策略对比效果
4. 重要文档需要人工把关

所以方案必须由用户显式确认才能进入下一阶段。这是一种“人机协同”的设计——系统给建议,人做决定。

4. strategyCount:父子步骤数之和

冗余字段,方便列表页展示“这套方案有几步”,避免回表统计。

第二步:父块步骤批量入库

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 顺序逐步执行

stepNo=1 先执行
stepNo=2 用 stepNo=1 的产物作为输入
stepNo=3 用 stepNo=2 的产物作为输入
...

这种序号机制让多步流水线可以链式执行

2. executeStatus = WAIT_EXECUTE

每个步骤初始化为“等待执行”,配合 plan 的 WAIT_CONFIRM 状态:

plan 在 WAIT_CONFIRM:整套方案等待确认
step 在 WAIT_EXECUTE:每个步骤等待被调度执行

用户确认方案后,plan 切到 CONFIRMED,触发索引构建链路;索引构建逐步把每个 step 切到 RUNNING → SUCCESS / FAILED。

3. 每个 step 都带 documentId

虽然通过 planId 也能查到 documentId,但冗余存一份让 step 表可以独立查询。比如查“某文档所有步骤”不需要先查 plan。

第三步:子块步骤批量入库

逻辑和父块一样,只是数据来自 childSteps

为什么父子分两个循环写?

原因有几个:

1. 语义清晰,代码自解释
2. 父子的 stepNo 各自独立从 1 开始
3. 未来可能给父子添加不同的扩展字段
4. 失败时可以分别看是父块还是子块出问题

十一、文档主表更新:状态机推进

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. 状态切换

parseStatus: PARSING  PARSE_SUCCESS
strategyStatus: WAIT_RECOMMEND  RECOMMENDED

文档生命周期推进到“解析完成 + 策略已推荐”。

为什么要分两个状态字段?

parseStatus:回答"内容解析这一步成功了吗"
strategyStatus:回答"策略推荐这一步到哪了"

两件事独立追踪。比如:

parseStatus = PARSE_SUCCESS + strategyStatus = WAIT_RECOMMEND
    解析成功了,但还没生成策略

parseStatus = PARSE_SUCCESS + strategyStatus = RECOMMENDED
    都完成了,等用户确认

parseStatus = PARSE_SUCCESS + strategyStatus = CONFIRMED
    用户已确认,等索引构建

如果合并成一个状态字段,状态会爆炸。

2. 解析指标回填

charCount / tokenCount / structureLevel / contentQualityLevel

把解析阶段算出的关键指标存到文档主表,便于:

列表页展示
后台筛选
质量统计
策略调整时复用

这避免了每次需要这些指标都要去解析记录里翻。

3. 关键路径回填

parseTextPath:MinIO 中 .txt 的路径
currentPlanId:当前生效方案 ID
lastParseTaskId:最近一次解析任务 ID
structureNodeCount:结构节点数

currentPlanId 是这里最关键的字段。它表示“当前文档使用的是哪套方案”。

为什么要这个字段?

因为同一文档可以有多个方案版本(每次重新推荐都会新增一份)。currentPlanId 指向当前生效的那一份,相当于一个“指针”:

plan 表:多版本方案历史
document.currentPlanId:指向当前生效的 plan
切换方案 = 修改 currentPlanId 指针

这种“事实表 + 指针”模式让多版本管理变得非常优雅。

4. parseErrorMsg 清空

document.setParseErrorMsg(null);

如果之前解析失败过,错误信息会留在这个字段。这次成功了就清空,避免误导。

这是一个很容易被忽略但非常重要的细节。如果不清空:

用户看到 parseStatus=PARSE_SUCCESS
但 parseErrorMsg 还有上次的报错
会非常困惑

十二、finishTaskSuccess:任务成功收尾

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);
}

这个方法把任务统一收尾:

状态切到 SUCCESS
当前阶段固定为 STRATEGY_ROUTE(传进来的)
记录完成时间
计算耗时(用于性能分析)
清空错误字段

为什么要单独抽方法?

因为成功收尾的逻辑会被多个地方调用:

解析+推荐成功
后续切块成功
后续向量化成功
后续索引构建成功

抽成方法保证“成功收尾”的语义一致:一定要清空错误、一定要计算耗时

costMillis:性能可观测的关键

task.setCostMillis(finishTime.getTime() - startTime.getTime());

这个字段看起来不起眼,但对运营和优化非常重要:

统计平均解析耗时
找出耗时异常的文档
评估系统瓶颈在哪个阶段
为容量规划提供数据

配合任务日志的 metadata,可以做出非常完整的全链路性能监控。


十三、记录 RECOMMEND_STRATEGY 任务日志

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 塞到日志?

因为日志是“那一刻的事实快照”。

如果某天用户来问:

"我这份文档为什么当时推荐的是 LLM + 递归?"

后台直接查任务日志,metadata 里就有完整的推荐结果和理由。不需要重新算、也不依赖 plan 表(用户可能已经修改过方案)。

这就是为什么任务日志要走结构化 metadata,而不是简单的文本日志。


十四、异常处理:解析失败的收尾

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()));
}

失败处理做三件事:

1. 文档主表 → PARSE_FAILED + 错误信息
2. 任务 → FAILED + 错误信息 + 耗时
3. 任务日志 → ERROR 级别

失败时为什么 currentStage 写 CONTENT_PARSE?

注意这里不是 STRATEGY_ROUTE,而是 CONTENT_PARSE

因为整个 try 块里失败可能发生在任意阶段:解析、结构提取、画像生成、策略推荐。统一标记为 CONTENT_PARSE 表示“内容解析整体阶段”失败。

具体失败在哪一步,要看错误信息和异常堆栈。

为什么没有自动重试?

策略一:Kafka 自动重试
策略二:写入死信队列后人工处理
策略三:依赖外部补偿
策略四:用户重新上传

这套设计选择了不在 catch 块里自动重试,原因是:

1. 解析失败大概率是文件本身问题(损坏、格式异常),自动重试无意义
2. 自动重试可能让同一个失败任务被重复消费
3. 失败后让任务停在 FAILED 状态,人工或定时任务来决定怎么处理更稳

所以重试要么:

依赖 Kafka 消费失败抛异常 → broker 自动重投
依赖外部补偿任务定时扫描 FAILED 任务
依赖前端给用户显示"重试"按钮

这是有意为之的设计取舍:宁可让任务失败,也不在主链路里加自动重试

failTask 方法

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" 是个粗粒度的错误码,未来可以根据异常类型细化:

PARSE_FILE_BROKEN
TIKA_FAILED
STRUCTURE_EXTRACT_FAILED
LLM_DISAMBIGUATION_FAILED
STRATEGY_RECOMMEND_FAILED

便于后台分类统计。


十五、整个 handleParseRoute 的状态机视图

把上一篇和这一篇连起来,可以画出一张完整的状态流转图:

stateDiagram-v2
    [*] --> NEW: 任务创建
    NEW --> RUNNING: Consumer 消费
    RUNNING --> CONTENT_PARSE: 进入内容解析
    CONTENT_PARSE --> STRATEGY_ROUTE: 解析完成
    STRATEGY_ROUTE --> SUCCESS: 推荐策略并落库
    SUCCESS --> [*]

    CONTENT_PARSE --> FAILED: 解析异常
    STRATEGY_ROUTE --> FAILED: 推荐异常
    FAILED --> [*]

文档级状态流转:

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 --> [*]
    }

状态机思维的工程价值

这种显式状态机让系统具备:

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-结构节点提取的四阶段流水线 | 08-切块策略落库 | ➡️ 09-白话讲解