统计打分收口打包同步落库

我们接着上一篇“结构节点提取的四阶段流水线”往后学。上一篇的终点是:

extract() 返回 DocumentStructureNodeCandidate 列表
parse() 方法继续往下执行

这一篇要做的事情是:把 parse() 后半段的统计分析跑完,然后回到 handleParseRoute,完成异步解析链路的收尾工作。

学完之后,你就完整打通了"上传 → Kafka → Tika 解析 → 结构提取 → 解析收尾"这条主线。下一篇才会进入策略推荐。


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

可以用一句话概括:

在拿到结构节点之后,parse() 方法继续做标题计数、段落切分、token 估算、结构等级和内容质量评估,最后打包成 DocumentAnalysisResult。然后 handleParseRoute 接着把解析文本上传 MinIO、结构节点落库、同步导航索引和图谱投影、生成文档画像、记录任务日志,最后把任务推进到策略推荐阶段。

整体可以画成两段流程:

flowchart TD
    A[已经拿到 structureNodes] --> B[countHeadings 标题计数]
    B --> C[extractParagraphs 段落切分]
    C --> D[estimateTokenCount token 估算]
    D --> E[evaluateStructureLevel 结构等级]
    E --> F[evaluateContentQuality 内容质量]
    F --> G[打包 DocumentAnalysisResult]
    G --> H[上传 parsed-text txt 到 MinIO]
    H --> I[replaceDocumentNodes 结构节点落库]
    I --> J[syncNavigationArtifacts 同步导航和图谱]
    J --> K[generateProfile 生成文档画像]
    K --> L[记录任务完成日志]
    L --> M[推进到 STRATEGY_ROUTE 阶段]

可以看出,这一节虽然不像结构提取那么复杂,但它是“收口”阶段,把解析结果落到各个存储里,为后面的策略推荐和索引构建准备数据。


二、回到 parse():还剩五个统计步骤

上一篇结束时,我们停在 structureNodeExtractor.extract() 返回结构节点。接下来的代码很短,但每一步都在为后续策略推荐准备“判断依据”。

List<DocumentStructureNodeCandidate> structureNodes =
    structureNodeExtractor.extract(originalFileName, cleanedText);

int headingCount = countHeadings(cleanedText, structureNodes);

List<String> paragraphList = extractParagraphs(cleanedText);

int maxParagraphLength = paragraphList.stream()
    .mapToInt(String::length)
    .max()
    .orElse(0);

int charCount = cleanedText.length();

int tokenCount = estimateTokenCount(cleanedText);

int structureLevel = evaluateStructureLevel(headingCount, paragraphList.size());

int contentQualityLevel = evaluateContentQuality(cleanedText, charCount);

return new DocumentAnalysisResult(...);

我们一步一步看。


三、countHeadings:标题计数为什么要双路径?

代码的核心是这样:

if (structureNodes != null && !structureNodes.isEmpty()) {
    long structuredHeadingCount = structureNodes.stream()
        .filter(node -> node != null
            && DocumentStructureNodeTypeEnum.SECTION.getCode().equals(node.getNodeType())
            && node.getDepth() != null
            && node.getDepth() > 0)
        .count();
    if (structuredHeadingCount > 0) {
        return (int) structuredHeadingCount;
    }
}

// 否则退化成逐行扫描
int count = 0;
for (String line : text.split("
")) {
    if (documentLineClassifier.classify(line).isHeading()) {
        count++;
    }
}
return count;

这里的设计有两个关键点。

1. 优先使用结构节点提取的结果

为什么?

因为上一阶段的四阶段流水线已经做了非常严谨的结构识别:

信号提取
歧义消解
层级构建
树校验

它得到的 SECTION 节点(depth > 0)质量比单纯逐行扫描高得多。

所以这里直接 stream 过滤:

节点类型 = SECTION
depth > 0(排除根节点)

得到的就是真正的“章节级标题数量”。

2. 没识别出 SECTION 才退化成逐行扫描

如果文档结构非常乱,四阶段流水线可能没有抽到任何 SECTION。这时候不能直接说“没标题”,而是要退化成 DocumentLineClassifier 的轻量识别。

这是一种降级策略:

高质量路径:用结构节点
高质量失败:用单行启发式
都失败:返回 0

3. 为什么标题数量这么重要?

因为它直接影响后面 evaluateStructureLevel:

标题数 >= 5 → 结构等级 HIGH
标题数 >= 2 → 结构等级 MEDIUM
没标题但有段落 → LOW
什么都没有 → UNKNOWN

而结构等级又是策略推荐的核心输入:

HIGH → 优先结构化切块
MEDIUM → 结构化 + 递归
LOW → 递归 + 语义
UNKNOWN → LLM 智能切块或最简单的递归

所以标题计数看似一个简单的 count++,实际是后面切块策略选择的关键信号。


四、extractParagraphs:段落切分

代码很短:

for (String paragraph : text.split("
\\s*
")) {
    String trimmed = paragraph.trim();
    if (StrUtil.isNotBlank(trimmed)) {
        paragraphList.add(trimmed);
    }
}

核心逻辑:

按"连续空行"切分
trim 每段
过滤空段

为什么用“连续空行”而不是单一换行?

因为单换行通常只是行内换行(比如 PDF 转换出来的硬换行),并不代表段落边界。

只有“两个换行之间没有内容”才是段落分隔符。

这也是为什么上一篇文本清洗时:

.replaceAll("
{3,}", "

")

只把超过 3 个换行的地方压缩成 2 个,保留段落边界。

段落统计的几个用途

1. 段落数 → 结构等级判断
2. 段落数 → 是否适合语义切块
3. 最长段落长度 → 切块策略选择
4. 段落数 → 文档画像、目录辅助判断

例如最长段落很长(几千字符),说明文档里可能有大段连续文本,递归切块需要严格控制块大小,否则单块超过预算。


五、estimateTokenCount:为什么是估算?

代码:

int englishWordCount = 0;
int chineseCharCount = 0;

for (String word : text.split("\\s+")) {
    if (word.matches(".*[A-Za-z].*")) {
        englishWordCount++;
    }
}
for (char current : text.toCharArray()) {
    if (String.valueOf(current).matches("[\\u4e00-\\u9fa5]")) {
        chineseCharCount++;
    }
}

return englishWordCount + chineseCharCount
    + Math.max(1, (text.length() - chineseCharCount) / 4);

公式可以简化为:

中文字符数 + 英文单词数 + 剩余字符数 / 4

1. 为什么不用真实 tokenizer?

理论上你可以用 OpenAI tiktoken、HuggingFace tokenizer 之类来精确算。

但这里做了一个非常工程化的取舍:

当前阶段只是粗粒度判断
不需要精确到每一 token
不想引入额外依赖
不想增加调用开销

因为这里 token 数的用途是:

判断文档大致体量
策略推荐里的粗略阈值判断
日志统计
前端展示

不是真正调用模型时的 token 控制。

2. 公式背后的近似逻辑

中文一个字 ≈ 一个 token
英文一个单词 ≈ 一个 token
其他字符按 4 个字符 ≈ 1 个 token

这个估算和主流 tokenizer 在中英文文本上的统计结果差异通常在 ±10~20% 之间,对“做策略判断”来说完全够用。

3. 这就是工程权衡的一个典型案例

如果未来某个阶段确实需要精确 token 数,例如调用模型前要严格控制 prompt 大小,可以单独引入精确 tokenizer。

但解析阶段没必要,“好用、稳定、便宜”就行。

面试时可以这样讲:

解析阶段的 token 估算只用于策略判断和粗粒度统计,采用“中文字符数 + 英文单词数 + 其他字符 / 4”的近似公式,在保证稳定性和零外部依赖的前提下,提供了足够的精度。如果未来需要精确 token,会在调用模型前单独引入 tokenizer,而不是在解析层。


六、evaluateStructureLevel:结构等级

代码:

if (headingCount >= 5) {
    return DocumentStructureLevelEnum.HIGH.getCode();
}
if (headingCount >= 2) {
    return DocumentStructureLevelEnum.MEDIUM.getCode();
}
if (paragraphCount >= 3) {
    return DocumentStructureLevelEnum.LOW.getCode();
}
return DocumentStructureLevelEnum.UNKNOWN.getCode();

这是一个非常简洁但意义重大的分类。

1. HIGH:有清晰章节结构

标题 >= 5

适合走结构化切块,沿章节切,块边界自然,语义独立。

2. MEDIUM:有部分结构

标题 >= 2

可能是一些有几级目录但不完整的文档。

适合“结构化 + 递归”混合切块。

3. LOW:几乎没结构,只是连续段落

没什么标题
但有 >= 3 段

适合递归切块或语义切块。

4. UNKNOWN:啥结构都没有

没标题
段落也很少

可能是非常短的文档、解析失败、扫描版 PDF 等。

需要走 LLM 智能切块或最简单的整文档作为一个块。

5. 这种粗粒度分类的工程意义

不是越细越好。

如果这里分得太细,后面策略推荐的逻辑就会爆炸。

四档分类(HIGH / MEDIUM / LOW / UNKNOWN)既能覆盖绝大多数情况,又能让策略推荐保持简单。


七、evaluateContentQuality:内容质量等级

代码:

if (StrUtil.isBlank(text) || charCount < 20) {
    return LOW;
}

long brokenCharCount = text.chars().filter(value -> value == '�').count();
double brokenRatio = charCount == 0 ? 1D : (double) brokenCharCount / charCount;

if (brokenRatio > 0.02D || charCount < 100) {
    return LOW;
}
if (brokenRatio > 0.005D || charCount < 500) {
    return MEDIUM;
}
return HIGH;

它判断的是“解析得到的文本质量好不好”。

1. 极短文本直接判 LOW

charCount < 20

这种文档基本无法用于问答,需要提示用户或人工处理。

2. 乱码率检测

brokenCharCount = 替换字符 �(U+FFFD)的数量

这个字符是 UTF-8 解码失败时的占位符。

如果一份文档解析出来 � 占比很高,说明:

原始文件编码有问题
PDF 嵌入字体不规范
解析器无法识别某些字符

这种情况要降级处理,例如:

不推荐自动切块
提示用户重新上传
切到 LLM 智能切块兜底
  1. 阈值设计
乱码 > 2% 或字符数 < 100 → LOW
乱码 > 0.5% 或字符数 < 500 → MEDIUM
否则 → HIGH

这种多维阈值是工程经验积累下来的。

它的核心思想是:

不能只看一个维度
要把"长度"和"乱码"组合起来判断

例如一份 1000 字符的文档,乱码占 1%,虽然乱码不多但比例不低,仍然只算 MEDIUM。

4. 这两个等级如何影响后续?

structureLevel:决定切块策略
contentQualityLevel:决定是否能自动处理

例如:

质量 HIGH + 结构 HIGH → 自动结构化切块
质量 MEDIUM + 结构 LOW → 推荐递归 + 语义
质量 LOW → 提示用户、可能拒绝继续处理

八、打包返回 DocumentAnalysisResult

最后一步:

return new DocumentAnalysisResult(
    cleanedText,
    charCount,
    tokenCount,
    structureLevel,
    contentQualityLevel,
    headingCount,
    paragraphList.size(),
    maxParagraphLength,
    structureNodes
);

这个对象是“解析阶段的标准输出”。

可以理解为一份文档解析报告:

原文(清洗后)
长度统计
结构指标
质量指标
结构节点

后续模块只对接这个对象,不再关心:

文档是 PDF 还是 DOCX
Tika 怎么解析
怎么换行清洗
怎么识别标题
怎么算 token

这就是抽象的力量:通过中间 DTO 把上下游解耦。

到这里 parse() 全部执行完成,控制流回到 handleParseRoute


九、回到 handleParseRoute:解析后的收尾工作

handleParseRoute 拿到 DocumentAnalysisResult 后,要做四件大事:

1. 上传清洗后纯文本到 MinIO
2. 把结构节点落库
3. 同步导航索引和图谱投影
4. 生成文档画像
5. 记录解析完成日志
6. 推进到策略推荐阶段

我们逐一展开。


十、上传解析文本 txt:为什么要再存一份?

代码:

String parseTextPath = storageService.uploadParsedText(
    documentId,
    analysisResult.getParsedText()
);

这一步把清洗后的纯文本以 .txt 形式上传到 MinIO,路径例如:

parsed-text/123456.txt

1. 为什么不直接每次重新解析原始文件?

因为后续阶段会多次用到这个文本:

切块阶段:把文本切成父子块
向量化阶段:把每个块送进 embedding 模型
索引阶段:写入 ES、向量库
图谱阶段:节点 contentText 已经写过,但全文也可能补充
摘要阶段:基于全文生成摘要
RAG 阶段:可能需要回查全文证据

每次都重新跑一次 Tika 是非常昂贵的:

PDF 解析慢
Word 解析慢
内存开销大
还要重新做清洗

而 .txt 文件:

读取很快
不需要解析
所有阶段都能直接用

2. 为什么不直接放数据库?

因为文本可能很大,几十万字符甚至几百万。

数据库适合存结构化字段,不适合存大块文本:

影响行存储
影响查询性能
备份成本高

对象存储 + 数据库存路径,是更典型的设计。

3. 为什么是 .txt 而不是 JSON?

因为这一步只关心“纯文本”。

结构化信息已经分别保存:

统计指标 → 数据库 document 字段
结构节点 → document_structure_node 表
画像 → document_profile 表

所以 .txt 只承担“最干净的全文”的职责,简单清晰。


十一、replaceDocumentNodes:结构节点整体替换

这是这一节最有“工程感”的一段代码,值得好好讲。

List<SuperAgentDocumentStructureNode> structureNodes =
    structureNodeService.replaceDocumentNodes(
        documentId,
        taskId,
        analysisResult.getStructureNodes()
    );

1. 为什么是“替换”而不是“增量”?

因为每次重新解析都意味着:

文档可能被重新上传
切块策略可能被调整
结构识别算法可能升级

如果做增量更新,会非常麻烦:

要识别哪些节点是同一个
要处理父子关系迁移
要保留旧 ID 还是分配新 ID

而“整树替换”非常清晰:

旧节点全删
新节点全建

代价是同一文档的旧节点 ID 不再保留,但这一般不是问题,因为:

节点 ID 主要用于内部关系
对外暴露的是 canonicalPath 和 sectionPath

2. 两轮遍历的设计

代码做了一个非常重要的设计:

第一轮:为每个 candidate 预生成数据库 ID
第二轮:把逻辑编号 nodeNo 翻译成数据库 ID,再插入

为什么要两轮?

因为节点之间存在前后引用关系:

parentNodeId 指向父节点的真实 ID
prevSiblingNodeId 指向前兄弟
nextSiblingNodeId 指向后兄弟

如果一边遍历一边插入:

插到节点 A 时,可能它的兄弟 B 还没插入
B 还没拿到真实 ID
A 的 nextSiblingNodeId 就只能留空或要后续修正

而如果先把所有 nodeNo → 真实 ID 的映射建好:

插入 A 时,直接根据 nextSiblingNodeNo 查映射表
拿到 B 的真实 ID
一次性完成所有引用关系

这是处理“有向图持久化”的经典做法:

先分配 ID
再 resolve 引用

3. 为什么不直接用 nodeNo 当主键?

因为 nodeNo 只是“当前这一次解析里”的逻辑编号:

只在当前文档解析任务中唯一
不同文档之间会重复
重新解析时也可能重新编号

数据库主键需要全局唯一,所以用 uidGenerator 生成。

而 candidate 里只保留 nodeNo,是为了让结构提取阶段不依赖具体存储实现。

这种“逻辑层用 nodeNo,持久层用真实 ID”的分层设计很常见。

4. 为什么先删旧数据?

deleteByDocumentId(documentId);

因为这个方法的语义就是“整树替换”。

先删旧,再插新,保证数据库里只有“当前最新的一棵结构树”。

如果不先删,可能会出现:

旧节点和新节点共存
父子关系混乱
导航索引和图谱投影查到错节点

5. 这部分可以怎么讲给面试官

可以这样表达:

结构节点采用整树替换策略,避免增量更新引发的关系迁移复杂度。落库时使用两轮遍历:第一轮基于候选节点的 nodeNo 预分配数据库主键,第二轮再把候选节点中的 parentNodeNo、prevSiblingNodeNo、nextSiblingNodeNo 翻译成真实主键 ID,保证父子关系和兄弟关系在一次插入中即可闭合。逻辑层使用 nodeNo,持久层使用全局唯一 ID,通过映射表完成解耦。


十二、syncNavigationArtifacts:导航索引和图谱投影

代码:

DocumentNavigationIndexService navigationIndexService =
    navigationIndexServiceProvider.getIfAvailable();

if (navigationIndexService != null) {
    navigationIndexService.reindexDocumentNodes(documentId, parseTaskId, structureNodes);
}

DocumentStructureGraphProjectionService graphProjectionService =
    graphProjectionServiceProvider.getIfAvailable();

if (graphProjectionService != null && graphProjectionService.enabled()) {
    graphProjectionService.projectToGraph(documentId, parseTaskId);
}

这一步做两件事:

1. 把结构节点写入 ES,用于导航搜索
2. 把结构节点写入 Neo4j,用于图谱导航

1. ObjectProvider 模式的妙用

代码用了 Spring 的 ObjectProvider:

navigationIndexServiceProvider.getIfAvailable();
graphProjectionServiceProvider.getIfAvailable();

它的作用是:

如果项目里没有这个 Bean,返回 null
如果有,就拿到实例

为什么要这么写?

因为这两个能力是可选组件:

有些部署环境可能不启用 Neo4j
有些环境只用向量检索,不做结构导航
开发环境可能完全跳过这两块

如果直接 @Autowired,Bean 不存在就启动失败。

ObjectProvider 的好处是:

不存在不抛异常
存在就用
不存在就跳过

这是非常优雅的“可选依赖”设计。

2. 导航索引:reindexDocumentNodes

它把结构节点写入 ES,例如:

索引名:document_structure_index
文档:每个 SECTION / LIST_ITEM 一条
字段:title、anchorText、sectionPath、contentText、documentId、nodeId

后续用户可以做:

按章节标题搜索
按章节路径定位
全文搜索章节内容

这就是项目里“章节级导航与上下文扩展”的基础。

3. 图投影:projectToGraph

它把结构节点写入 Neo4j,形成三层图谱:

Document 节点
    └── Section 节点
        └── Item 节点

通过四种关系连接:

HAS_SECTION
HAS_CHILD
HAS_ITEM
NEXT_SIBLING

这就是项目亮点里的“基于 Neo4j 图数据库构建文档层级结构图谱”。

支持五种查询能力:

章节编号定位
标题路径查找
邻接遍历(前后兄弟)
子节点展开
语义最佳匹配

4. 为什么要在解析阶段就同步,而不是等切块完?

因为图谱和导航索引依赖的是结构信息,而不是切块信息。

切块的产物是“父子文本块”,用于 RAG 检索; 图谱和导航索引用的是“章节标题、路径、层级”,和切块无关。

所以:

结构节点一旦落库,就立刻同步图谱和导航索引
不必等切块或向量化完成
用户上传后短时间内就能看到目录导航

这是一种把“快产物”和“慢产物”分开释放的设计。


十三、generateProfile:文档画像生成

文档画像是这一节最有意思的部分之一。

它的目标是:

不解析正文
不做切块
而是把解析结果再压缩成一份"画像快照"

可以理解为给文档做一张“身份证”,告诉系统:

这份文档大致讲什么
属于哪种类型
适不适合图谱导航
适不适合条目定位
属于哪个知识范围
属于哪个业务分类
有哪些标签
有哪些核心主题
有哪些示例问题

1. 画像 vs 文档主表

很多人会问:为什么不直接把这些字段放到 document 表?

因为画像和文档主表的职责不同:

document:文档的基础属性 + 状态字段
profile:文档的"能力摘要" + 自动推断结果

而且画像是“可重新生成”的:

重新解析时画像版本递增
不会覆盖用户手填字段

document 表里的字段则是“正式资产数据”,不能轻易自动覆盖。

这是一种典型的“正式数据 vs 派生数据”分表设计。

2. 画像版本号

代码里有一段:

boolean creating = profile == null;
if (creating) {
    profile.setProfileVersion(1);
}
else {
    profile.setProfileVersion(
        Optional.ofNullable(profile.getProfileVersion()).orElse(0) + 1
    );
}

每次重新生成画像,版本号 +1。

这有什么用?

后台运营可以看出文档画像被重建过几次
调试时可以看哪一次画像出问题
新旧画像对比有依据

如果未来要做画像质量监控、画像 A/B 测试,版本号是基础。

3. buildDraft:画像推断中心

buildDraft() 是画像生成的核心,它有非常清晰的依赖链:

flowchart TD
    A[structureNodes] --> B[sectionTitles]
    A --> C[supportsItemLookup]
    B --> D[supportsGraphOutline]
    C --> E[graphFriendly]
    D --> E
    A --> F[documentType]
    B --> F
    F --> G[coreTopics]
    G --> H[exampleQuestions]
    G --> I[summary]
    G --> J[knowledgeScopeCode]
    F --> K[businessCategory]
    J --> L[documentTags]

这种链式推断有一个核心思想:

所有画像字段使用同一批输入快照,避免“摘要按旧数据生成、标签按新数据生成”的不一致问题。

这是设计上的一个细节,也是工程师的成熟标志。

4. inferDocumentType:启发式关键词推断

代码里没用 LLM 来判断文档类型,而是用关键词规则:

if (combined.contains("faq") || combined.contains("常见问题")) {
    return "faq";
}
if (combined.contains("故障") || combined.contains("排查") ...) {
    return "troubleshooting";
}
if (combined.contains("规则") || combined.contains("制度")) {
    return "rule";
}
if (combined.contains("规格") || combined.contains("参数")) {
    return "spec";
}
if (supportsItemLookup || combined.contains("手册") ...) {
    return "manual";
}
return "intro";

为什么这里没用 LLM?

因为画像生成是一个高频操作:

每次上传都要做
每次重新解析都要做
不能让 LLM 成本和延迟拖累主链

而文档类型只是粗粒度归类,关键词规则:

稳定、可解释、零成本
出错也容易排查
随时可以加新关键词

这是和上一节歧义消解里 LLM 介入的鲜明对比:

歧义消解:边界场景,数量很少,适合 LLM
文档类型:高频粗分类,适合规则

工程系统中,“什么时候用 LLM,什么时候用规则”是非常重要的判断。

5. 文档类型的下游作用

documentType 会影响:

exampleQuestions 的生成模板
businessCategory 的归类
后续策略推荐里的判断逻辑

例如:

faq 类型 → 适合短块切分,问题导向召回
troubleshooting → 适合条目级精准定位
manual → 适合结构化切块 + 章节导航
rule → 适合段落级切块,严格证据匹配
spec → 适合表格识别,关键词匹配
intro → 适合短文档摘要召回

所以画像不是“为了画像而画像”,它是后续策略推荐和路由的输入。

6. backfillDocumentMetadata:补空不覆盖

这一步把画像推断的字段补到 document 主表:

if (StrUtil.isBlank(document.getKnowledgeScopeCode())
    && StrUtil.isNotBlank(draft.knowledgeScopeCode())) {
    document.setKnowledgeScopeCode(draft.knowledgeScopeCode());
    changed = true;
}

注意这里有一条非常重要的原则:

只在 document 主表为空时才补 用户已经填的值绝对不动
这就是“尊重用户输入”的设计原则。

举个例子:

用户上传文档时手动填了知识范围:"人力资源"
画像自动推断出"行政管理"

如果直接覆盖:

用户会非常困惑
甚至会觉得系统乱改数据

而“补空不覆盖”可以做到:

用户填了 → 用用户的
用户没填 → 用画像推断

这种细节非常体现产品意识。


十四、记录解析完成日志

代码:

taskLogService.saveLog(
    taskId,
    documentId,
    DocumentTaskStageEnum.CONTENT_PARSE.getCode(),
    DocumentTaskEventTypeEnum.COMPLETE.getCode(),
    DocumentLogLevelEnum.INFO.getCode(),
    DocumentOperatorTypeEnum.SYSTEM.getCode(),
    null,
    "文档解析完成。",
    Map.of(
        "charCount", analysisResult.getCharCount(),
        "tokenCount", analysisResult.getTokenCount(),
        "structureLevel", analysisResult.getStructureLevel(),
        "contentQualityLevel", analysisResult.getContentQualityLevel(),
        "structureNodeCount", structureNodeCount
    )
);

为什么要把这些数据塞到日志里?

1. 全链路可观测的数据基础

你的项目亮点里有“全链路可观测”。

任务日志不是简单的 info 文本,而是结构化的事件:

任务 ID
文档 ID
阶段
事件类型
日志级别
操作人类型
操作人
描述
metadata

后台可以基于 metadata 做:

不同结构等级的文档分布
平均字符数和 token 数
解析成功率
画像版本号
失败模式分类

2. 事后排查问题的关键信息

如果用户说:

我上传的文档为什么切块策略选错了?

后台可以查日志:

charCount = 12000
tokenCount = 8000
structureLevel = LOW
contentQualityLevel = MEDIUM
structureNodeCount = 3

立刻就能知道:

文档结构识别少
质量一般
所以策略推荐选了递归 + 语义,而不是结构化

整个排查链路非常清晰。


十五、推进任务阶段:CONTENT_PARSE → STRATEGY_ROUTE

最后一步:

task.setCurrentStage(DocumentTaskStageEnum.STRATEGY_ROUTE.getCode());
taskMapper.updateById(task);

这一步只更新一个字段,但意义重大。

它表示:

内容解析阶段完成
任务进入策略推荐阶段

整个文档生命周期可以画成:

stateDiagram-v2
    [*] --> FILE_UPLOAD
    FILE_UPLOAD --> CONTENT_PARSE
    CONTENT_PARSE --> STRATEGY_ROUTE
    STRATEGY_ROUTE --> WAIT_USER_CONFIRM
    WAIT_USER_CONFIRM --> CHUNK_BUILD
    CHUNK_BUILD --> EMBEDDING
    EMBEDDING --> INDEX_BUILD
    INDEX_BUILD --> [*]

每一次状态切换都会落库,任务表里始终能反映最新进度。

为什么不直接进入切块阶段,而要中间过策略推荐?

因为切块策略是“可由用户调整”的:

系统先推荐
用户确认或修改
再触发切块

这就是项目里设计五种切块策略的原因:

结构化、递归、语义、LLM、自动选择

每一种都有适用场景,系统给推荐,但用户可以决定。


十六、把 parse() 后半段和 handleParseRoute 收尾完整串起来

可以总结成一段话:

在结构节点提取完成后,parse() 方法继续做标题计数、段落切分、token 估算、结构等级评估和内容质量评估,把所有结果打包成 DocumentAnalysisResult。控制流回到 handleParseRoute 后,系统先把清洗后的纯文本以 .txt 形式上传到 MinIO,供后续切块和索引复用;再调用 DocumentStructureNodeService.replaceDocumentNodes 用最新候选节点整体替换文档结构树,通过两轮遍历实现 nodeNo 到真实主键的引用闭合;接着通过 ObjectProvider 模式可选地同步导航 ES 索引和 Neo4j 图谱投影;然后调用 DocumentProfileService.generateProfile 生成或递增更新文档画像,并将画像推断出的元数据"补空不覆盖"地回填到文档主表;最后记录解析完成日志,把任务阶段从 CONTENT_PARSE 推进到 STRATEGY_ROUTE,等待策略推荐流程接管。


十七、这一节的核心技术点

1. 双路径标题计数

优先用结构节点,降级用单行启发式,保证标题计数始终有结果。

2. token 估算的工程权衡

策略判断阶段不需要精确 token,公式估算更便宜更稳定。

3. 多维阈值评估

结构等级和内容质量都用“数量阈值 + 比例阈值”组合判断,鲁棒性更好。

4. DTO 抽象 DocumentAnalysisResult

把多格式文档的解析结果统一成一个对象,下游模块只对接 DTO,不关心源格式。

5. 解析文本独立存储

避免重复解析原始文件,以 .txt 形式存到 MinIO,索引和切块阶段直接复用。

6. 结构节点整树替换

避免增量更新的复杂度,用先删后插的方式保证一致性。

7. 两轮遍历闭合引用

先分配 ID,再翻译引用,解决节点之间的前后向引用问题。

8. ObjectProvider 处理可选依赖

导航索引和图投影都做成可插拔组件,缺失不报错。

9. 画像与主表分离

画像是派生数据,主表是正式资产数据,职责清晰,互不干扰。

10. 画像版本号

每次重新生成画像递增版本,便于审计、对比和质量监控。

11. 启发式关键词分类

文档类型用规则推断,避免高频 LLM 调用。LLM 只用在歧义边界场景。

12. 补空不覆盖原则

自动推断不能覆盖用户显式输入,尊重用户意图。

13. 结构化任务日志

不是文本日志,而是带 metadata 的事件日志,支撑全链路可观测。

14. 阶段状态机

任务在 FILE_UPLOAD → CONTENT_PARSE → STRATEGY_ROUTE 之间显式流转,每一步都更新数据库。


十八、面试官可能会问的问题

问题 1:为什么 token 数用估算公式,不用精确 tokenizer?

可以回答:

解析阶段的 token 数主要用于策略判断和粗粒度统计,不直接参与模型 prompt 控制。所以采用“中文字符数 + 英文单词数 + 其他字符 / 4”的近似公式,在保证精度足够的前提下,避免引入额外依赖和开销。如果未来需要精确 token 数,会在调用模型前单独引入 tokenizer,而不是在解析层。

问题 2:为什么结构节点要整树替换,不做增量更新?

可以回答:

增量更新需要识别同一节点、迁移父子关系、保留历史 ID,对树形结构来说复杂度很高。而文档每次重新解析后结构可能完全变化,与其追求最小变更,不如直接整树替换。先删旧节点再插入新节点,保证数据库始终只有“当前最新结构”。代价是节点 ID 变更,但对外暴露的是 canonicalPath 和 sectionPath,这两者保持一致即可。

问题 3:为什么用两轮遍历来插入结构节点?

可以回答:

因为节点之间存在 parent、prevSibling、nextSibling 等前后向引用,如果一边遍历一边插入,可能在插入当前节点时其引用对象还没拿到真实主键。所以先做一轮遍历预生成 nodeNo → 真实 ID 的映射表,再做第二轮遍历,把候选节点中的逻辑编号翻译成真实主键 ID,最后插入。这样可以一次性闭合所有引用关系,不需要后续修补。

问题 4:为什么导航索引和图投影用 ObjectProvider?

可以回答:

因为这两个能力是可选组件。有些部署环境只用向量检索不启用 Neo4j,有些开发环境为了简化部署连 ES 都不启用。如果直接 @Autowired,Bean 不存在会启动失败。ObjectProvider 的 getIfAvailable() 模式可以做到“存在就用,不存在就跳过”,实现真正的可插拔。

问题 5:画像为什么要单独建一张表,而不放到 document 主表?

可以回答:

因为它们的职责不同。document 主表存的是文档基础属性和正式状态字段,这些字段一般由用户显式维护或系统状态机推进。画像是派生数据,通过解析结果自动推断而来,可以重新生成、可以版本递增。两者分离可以避免“自动推断覆盖用户输入”的风险,同时方便画像质量监控、版本审计和迭代升级。

问题 6:画像里为什么不用 LLM 来推断文档类型?

可以回答:

画像生成是高频操作,每次上传或重新解析都会执行一次。如果用 LLM 推断文档类型,会带来明显的成本和延迟。而文档类型只是粗粒度归类(faq、manual、rule、spec、troubleshooting、intro),用启发式关键词规则就能稳定覆盖大多数场景。规则的好处是稳定、可解释、零成本、出错好排查、随时可扩展。LLM 的能力会留给真正“规则解决不了”的边界场景,例如歧义消解。

问题 7:为什么画像回填只补空不覆盖?

可以回答:

因为用户在知识管理界面手动填写的元数据优先级应该高于系统自动推断结果。如果直接覆盖,用户可能困惑甚至觉得系统乱改数据。所以策略是:用户填了就保留用户值,用户没填才用画像推断作为默认值补上。这种“尊重用户输入”的设计在产品上非常重要。

问题 8:任务日志为什么要带 metadata?

可以回答:

因为日志不只是记录“发生了什么”,还要记录“当时的关键状态”。每个阶段把 charCount、tokenCount、structureLevel、contentQualityLevel、structureNodeCount 等关键指标作为 metadata 写入日志,后续排查问题、做质量分析、生成统计报表时都能直接基于结构化字段查询,而不用回去重新算。这是全链路可观测的基础。


十九、这块可以怎么写进简历

可以这样表达:

负责文档解析阶段的统计分析与异步收尾流程。在结构节点提取完成后,完成标题计数、段落切分、token 估算、结构等级和内容质量评估,统一打包为 DocumentAnalysisResult,作为后续策略推荐的标准输入。解析收尾环节,将清洗后的纯文本以 .txt 上传至 MinIO 供索引和切块复用;通过两轮遍历的"先分配 ID,再 resolve 引用"模式,将结构节点整树替换写入数据库;基于 ObjectProvider 模式可选同步 Elasticsearch 导航索引和 Neo4j 图谱投影;并调用画像服务生成文档摘要、文档类型、核心主题、示例问题、知识范围、业务分类和标签等画像信息,以"补空不覆盖"策略回填文档主表元数据。最后记录结构化任务日志并推进任务阶段至 STRATEGY_ROUTE,为后续切块策略推荐提供完整上下文。

如果要再强调架构性,可以加一句:

通过"统一 DTO 抽象 + 整树替换 + 可插拔派生产物 + 画像版本化"的设计,实现了多格式文档解析结果到 RAG 知识资产的标准化转换,是文档处理流水线中承上启下的关键阶段。


企业级项目导航:⬅️ 10-策略推荐与方案持久化 | 11-统计打分收口打包同步落库 | ➡️ 12-解析结果统计与异步收尾