切块策略落库
我们接着上一篇“解析结果统计与异步收尾”往后学。上一篇的终点是:
任务阶段被推进到 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-白话讲解
💬 评论