文本转文档结构树

我们接着上一篇“Kafka 消费与文本内容解析”往下学。上一篇的终点是:

Tika 提取出原始文本
做了文本清洗
拿到 cleanedText
准备调用 structureNodeExtractor.extract()

这一篇就要重点拆解 extract() 内部到底做了什么。它是整个文档解析里最难、最复杂、也最能体现工程功力的一段。学完它,你才真正理解你简历里写的“基于 Neo4j 构建文档层级结构图谱”不是凭空来的,而是从一份扁平文本里一行一行抠出来的。

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

简单一句话概括:

把清洗后的纯文本,逐行扫描后转换成一棵带有父子关系、深度、路径、兄弟链接的文档结构树。

也就是说,输入是一段纯文本:

第一章 总则
1.1 项目背景
本项目旨在……
1.2 系统目标
- 提高效率
- 降低成本
第二章 系统设计
2.1 架构图

输出是一棵树:

image-87e1035f

这棵树后面会被用在几个非常重要的地方:

1. 写入 Neo4j,形成文档结构图谱
2. 同步到导航索引,支持目录浏览
3. 作为结构化切块策略的判断依据
4. 用于章节级 RAG 检索和定位
5. 用于"它的第三章讲了什么"这类问题的图谱导航

所以这一步的质量决定了:

目录导航是否准确
结构化切块是否可用
章节级问答能不能精确定位
图谱导航能不能展开子节点

二、为什么这一步这么难?

很多人第一反应是:不就是按章节标题切一切吗?写几个正则不就行了?

实际并不简单。

文档世界里的"结构"五花八门:

Markdown 标题:## 概述
中文章节:第一章 总则
多级编号:1.2.3 配置说明
中文大纲:一、项目背景
附录:附录A 术语表
显式步骤:第一步:安装
无序列表:- 项目一
有序列表:1、苹果 2、香蕉
复选框:[ ] 待办
表格行:| 列1 | 列2 |
引用:> 引用内容

如果只是单纯匹配正则,你会遇到一堆坑:

1、项目背景 → 是标题还是列表项?
一、概述 → 是标题还是大纲?
1.2 配置 → 它的父节点是哪个?
第一章 → 应该是几级标题?
有的章节没有子节点
有的文档第一行就是文档标题本身
PDF 提取后会有页眉、页脚、页码
有些行在一行内写了多个步骤
缩进可能表示嵌套也可能表示对齐
LLM 切块也可能给出错误结构

所以这一步不是写几个正则就能搞定的,它需要:

多种结构信号识别
对歧义信号做二次判定
扁平信号组装成树
树修复和路径重建

这四件事正好对应代码里的四阶段流水线


三、四阶段流水线总览

整个 extract() 方法可以画成一张流程图:

image-ca49a352

每个阶段的职责非常清晰:

阶段 输入 输出 核心思想
信号提取 纯文本 扁平信号列表 逐行识别结构信号,宁可多标不可漏标
歧义消解 扁平信号列表 修正后的信号列表 LLM 对低置信度行做二次判定
层级构建 修正后的信号列表 草稿树 drafts 把扁平信号组装成父子关系
树校验 草稿树 候选节点列表 修复非法关系、重算深度、重建路径

这种四阶段拆分是非常成熟的工程设计思路:

规则先做粗扫描
模型做边界修正
然后构建结构
最后做质量兜底

每个阶段只做一件事,可以独立调试、单独优化、单独换实现。


四、总入口:DocumentStructureNodeExtractor

入口类很短:

public List<DocumentStructureNodeCandidate> extract(String documentTitle, String parsedText) {
    String normalizedTitle = StrUtil.blankToDefault(documentTitle, "文档").trim();
    String normalizedText = StrUtil.blankToDefault(parsedText, "").trim();

    if (normalizedText.isBlank()) {
        return List.of(buildOnlyRootNode(normalizedTitle));
    }

    DocumentStructureSignalBatch batch = signalExtractor.extract(normalizedTitle, normalizedText);
    List<DocumentStructureSignal> rawSignals = batch.signals();
    List<String> allLines = batch.contextLines();

    List<DocumentStructureSignal> resolvedSignals =
        ambiguityResolver.resolve(normalizedTitle, allLines, rawSignals);

    List<DocumentStructureNodeDraft> drafts =
        hierarchyResolver.resolve(normalizedTitle, resolvedSignals);

    return treeValidator.validateAndBuild(normalizedTitle, drafts);
}

这里有几个设计点要重点理解。

1. 这个类只做编排,不做规则判断

它不直接写任何正则,也不直接写任何 LLM 调用代码。它的职责就是:

把四个阶段串起来
保证上一阶段输出是下一阶段输入

这种"编排器"思想非常常见,例如 Spring 的责任链、流水线、Pipeline 模式。

2. 空文本特殊处理

if (normalizedText.isBlank()) {
    return List.of(buildOnlyRootNode(normalizedTitle));
}

如果文档解析后正文为空(比如扫描版 PDF 没有 OCR、文件损坏、内容全是图片),不会让流水线跑空,而是直接返回一个根节点。

这是一个非常稳的设计。它保证下游永远拿到的是一棵合法的树,而不是空列表。

否则 Neo4j 写入、目录导航、切块策略推荐可能都会因为空列表崩掉。

3. 标题为空时兜底为"文档"

StrUtil.blankToDefault(documentTitle, "文档").trim();

这样无论用户传不传 documentName,根节点至少有一个名字。


五、阶段一:信号提取(SignalExtractor)

信号提取是这条流水线代码量最多的阶段。

它的核心思想是:

逐行扫描文档纯文本,把每一行打上一个"结构信号类型"标签。

可以理解为给每行做"语义分类":

这一行是标题
这一行是列表项
这一行是步骤
这一行是表格
这一行是引用
这一行是噪声
这一行是普通正文
这一行是空行
这一行像标题但不确定

输出是一个扁平的 DocumentStructureSignal 列表,每个信号包含:

lineNo:行号
rawText:原始文本
normalizedText:规范化文本
kind:信号类型
nodeCode:编号(如 1.2.3)
title:标题文本
levelHint:层级提示
numericPath:数字路径
confidence:置信度

1. 它的设计原则是"宁可多标,不可漏标"

信号提取阶段不追求 100% 准确,而是追求高召回。

为什么?

因为后面还有歧义消解阶段。

如果信号提取漏掉了,后面再也没机会补救;但如果信号提取多标了,后面可以由 LLM 修正。

例如:

1、项目背景

不能直接判定它是列表项,而要标成 HEADING_CANDIDATE,把判断交给下一个阶段。

2. 几个关键正则模式

代码里定义了一堆正则,可以分成几类。

强标题信号(高置信度):

Markdown 标题:## 概述
多级数字编号:1.2.3 配置
中文章节:第一章 总则
附录:附录A 术语表

这些一旦匹配,基本可以确定是标题。

有歧义的信号:

单级数字编号:1、概述
中文大纲:一、项目背景

这些可能是标题,也可能是列表项,需要上下文配合判断。

列表类信号:

无序列表:- 项目一
显式步骤:第一步:安装
复选框:[ ] 待办

这些通常是列表项,但有些"步骤"也可能算结构节点。

噪声信号:

页码:第 3 页 / Page 5 / 3 / 10
版权:版权所有 / Copyright
重复出现的页眉页脚

这些不是文档结构,应当被过滤掉。


六、逻辑行 vs 物理行

信号提取里有一个非常细的点:

物理行:按  切出来的原始行
逻辑行:可能从一条物理行里再拆出多条

比如有些文档会写:

步骤1:下载 步骤2:安装 步骤3:配置

这一条物理行里其实包含三个步骤。

如果不拆开,后续会把它当成一条信号处理,导致步骤识别和层级解析都出问题。

所以代码里有 splitInlineSegments():

按"行内显式步骤边界"拆分
拆完每个片段单独算逻辑行
每个片段单独算缩进级别

这个细节看起来不起眼,但它直接影响结构解析的准确率。

逻辑行还会保留:

逻辑行号
物理行号
物理行内的片段序号
缩进级别
原始文本
规范化文本

这些字段后面会被上下文构建、列表层级、兄弟排序复用。


七、行频次:用于检测噪声

代码里有一段:

Map<String, Integer> lineFrequency = buildLineFrequency(logicalLines);

它统计每个规范化文本在文档里出现的次数。

为什么?

因为页眉、页脚、版权声明这些噪声往往会在多个页面重复出现。

例如:

第 1 页
机密内部使用
版权所有 © 公司

这些行在每一页都出现一次。

如果只看单行,你很难判定它是噪声还是结构;但配合行频次:

出现 3 次以上
长度不超过 120 字符

大概率就是噪声。

这是一个非常工程化的设计:用全文统计特征辅助单行判断


八、上下文 LineContext:标题判定的关键

代码里有:

LineContext context = buildContext(logicalLines, index);

它的作用是:

向前找到最近一个非空行
向后找到最近一个非空行
记录前面是否有空行
记录后面是否有空行

为什么要这些信息?

因为标题的判定离不开上下文。

例如:

这是上一段正文。

部署手册

接下来是部署步骤。

中间这一行"部署手册":

前面有空行
后面有空行
后面是有内容的句子
本身是名词短语

这种行更像标题,而不是普通正文。

反过来:

我们的项目目标
1、提高效率
2、降低成本

1、提高效率 前一行是"我们的项目目标",但前面没有空行,后一行 2、降低成本 又是连续编号,所以这是列表项,不是标题。

这就是为什么 LineContext 是判断标题的核心特征。

九、classify:十五条规则的优先级匹配

classify() 是这个阶段最核心的方法,它按优先级从高到低尝试 15 种匹配:

1. 空行
2. 重复噪声(页眉页脚)
3. 页码噪声
4. Markdown 标题
5. 显式步骤
6. 中文章节
7. 附录
8. 多级数字编号
9. 表格行
10. 引用
11. 复选框
12. 无序列表
13. 单级数字编号(可能是标题或列表)
14. 中文大纲(可能是标题或列表)
15. 兜底分类(交给 DocumentLineClassifier)

匹配到一个就返回,不再继续匹配。

这种"优先级链"的设计有几个好处:

第一,规则之间互不冲突。

如果 ## 配置 同时匹配了 Markdown 标题和单级数字编号,优先级高的 Markdown 标题先返回,不会混乱。

第二,可读性强。

按业务理解的顺序排列规则,而不是按代码长度。

第三,扩展性强。

要新增一种结构,例如检测 Word 自动编号,只需要插入一条规则即可,不影响其它。

这个就是责任链模式 + 优先级匹配的典型应用。


十、最难的两条规则:歧义编号

整个 classify 里最有意思的是这两段:

单级阿拉伯数字编号:1、概述
中文大纲:一、项目背景

它们天然有歧义。

举几个例子:

1、项目背景
2、技术选型
3、实施方案

看起来像目录标题,但也可能是列表项。

项目使用以下技术:
1、Spring Boot
2、Redis
3、Kafka

这里就明显是列表项。

代码里用了三个组合判断:

1. 邻居序列检测 isNeighborSequence

当前是 2,前面是 1 或后面是 3 → 列表项

连续编号意味着这是一组并列条目,通常是列表。

2. 引导列表检测 previousIntroducesList

上一行以":" 或 "::" 结尾 → 当前是列表项

引导句后面必然是列表,例如"包含以下内容:"。

3. 启发式标题判断 looksLikePlainHeading

文本短
前后有空行
后面有内容
不含中文标点

满足这些才像标题。

最终决策:

如果连续序列 或 引导列表 → 列表项
否则如果像标题 → HEADING_CANDIDATE (低置信度)
否则 → 列表项

也就是说,即使判定为像标题,也不直接标为 HEADING,而是 HEADING_CANDIDATE,留给下一阶段裁定。

这就是"宁可多标,不可漏标"的体现。


十一、为什么不直接全部丢给 LLM 判断?

很多人看到这里会想:既然规则这么麻烦,为什么不直接让 LLM 来分类每一行?

原因有几个。

1. 成本太高

一个文档可能几千行,每行都调一次 LLM,成本爆炸。

2. 速度太慢

每行调一次 LLM,文档解析可能要几分钟到几十分钟。

3. 不可控

LLM 的输出不稳定,无法保证 100% 返回合法分类。

4. 大部分行其实很明确

Markdown 标题、多级数字编号、表格行这些非常确定,根本不需要 LLM。

所以这里采用的是"规则 + LLM"混合策略:

高确定性的规则做粗扫描
低置信度的歧义行才交给 LLM 判断

这样既保证速度和成本,又能修正最容易出错的边界。

这是一种很典型的工程权衡。


十二、阶段二:歧义消解(AmbiguityResolver)

经过信号提取后,大部分行都有明确分类,但还有一批 HEADING_CANDIDATE(可能是标题也可能是列表项)。

这一阶段就是用 LLM 帮忙判定这些边界。

1. 设计上的强约束

配置开关:llmDisambiguationEnabled
模型可用:chatModelProvider.getIfAvailable()
置信度区间:只挑置信度在 [0.5, 0.7] 之间的信号
数量上限:每次最多处理 N 个候选
失败回退:LLM 出错时静默回退到规则结果,不阻断主链

这些约束看似保守,实际非常重要。

它体现了一个原则:LLM 是辅助,不是主流程。

如果 LLM 服务挂了,文档解析必须还能正常完成,只是结构准确率略降。

2. prompt 设计的重点

prompt 只给 LLM:

文档标题
若干候选行的局部上下文窗口
初始判断结果

不会发整篇文档。

为什么?

减少 token 消耗
避免无关内容干扰模型
让模型专注于"判边界"

prompt 里还会指定输出格式:

严格返回 JSON 数组
不要附加解释
只返回 line_no、resolved_kind、level_hint

这种"强格式化输出"非常重要,避免 LLM 自由发挥导致解析失败。

3. prompt 抽到外部模板文件

代码里有一段重要设计:

提示词不写在 Java 代码里
而是放在 .st 模板文件中
通过 PromptTemplateService 渲染

为什么这样做?

第一,修改提示词不用改代码。

调整措辞、增删规则只要改 .st 文件,不用重新编译。

第二,提示词和业务逻辑解耦。

Java 代码只关心"传什么变量",模板文件只关心"怎么组织 prompt"。

第三,统一管理。

所有提示词模板放在 resources/prompt/ 目录,通过 PromptTemplateNames 常量类引用。

这其实是 AI 工程化里非常成熟的做法。


十三、阶段三:层级构建(HierarchyResolver)

经过前两个阶段,你已经拿到一份"每行都有明确信号类型"的扁平列表。

这一阶段要把它变成一棵带父子关系的草稿树。

1. 线性扫描 + 多维上下文

整个方法是一次线性扫描,但同时维护多个"当前上下文":

currentSection:当前所在的 section 节点
currentListItem:当前所在的列表项节点
listStack:列表嵌套的缩进栈
latestHeadingByDepth:按深度索引最新标题
latestHeadingByNumericPath:按数字编号索引最新标题

为什么需要这么多上下文?

因为不同类型的信号要挂到不同的"当前节点":

正文 → 附着到当前 section 或当前列表项
列表项 → 根据缩进找父节点
标题 → 切换 currentSection,清空列表
空行 → 打断列表上下文

2. 状态机式扫描

可以画成状态机:

image-af8e42e0

这种线性扫描 + 状态维护是处理树形结构的经典方式。

3. 标题深度推断

这一段是最有意思的:

Markdown:# 数量就是层级
中文章节、附录:固定为 1 级
多级数字编号:
    1.先找直接上级编号(1.2.3 的父级是 1.2)
    2.找不到就退到同章节点(1.2.3 退到 1)
    3.再找不到就用编号段数

这种"先找上级、找不到退化、最后兜底"的设计非常稳。

举个例子:

1.2.3 数据库设计

正常情况下,系统应该已经见过 1.2,直接挂上去。

但如果文档跳着写,直接出现 1.2.3,而没有 1.2,系统会退到挂在 1 下面。

再不行就按编号段数判断,认为它是 3 级。

这种"多层兜底"思维体现了工程化的成熟度。

4. 正文附着

正文不会新建节点,而是附着到当前上下文。

当前在列表项内 → 附到列表项
否则附到 section
没有 section → 附到根节点

这样保证每个节点都有完整的内容文本。

后续切块策略可以基于 contentText 做切分。


十四、阶段四:树校验(TreeValidator)

经过层级构建,你已经有了一棵草稿树,但它可能存在一些问题:

正文里出现一次文档标题,导致多了一层假 section
1.2.3 没有挂在 1.2 下面
父节点根本不存在
section 被错误挂到列表项下面
深度算错
路径没建立
兄弟关系没建

第四阶段就是做最终质量收口。

它执行六个步骤,顺序非常重要:

1. 折叠重复标题
2. 修复数字编号父链
3. 修复非法父节点
4. 重算深度
5. 重建路径
6. 重建兄弟关系

为什么顺序重要?

因为:

深度依赖父子关系
路径依赖深度和父节点
兄弟关系依赖父子关系

必须先把父子关系修复好,后面才能算深度和路径。


十五、步骤一:折叠重复标题

很多 PDF 解析出来的第一行就是文档标题本身。

例如:

部署手册

部署手册

1. 环境准备
2. 安装步骤

如果不处理,文档结构里会出现:

根节点:部署手册
└── section:部署手册(重复)
    ├── 1. 环境准备
    └── 2. 安装步骤

这一层"重复标题 section"是冗余的,会让目录显示出多余的一层。

折叠的逻辑是:

找到与文档标题相同的一级 section
把它的子节点全部提升到根节点下
删除这个重复 section

结果变成:

根节点:部署手册
├── 1. 环境准备
└── 2. 安装步骤

这种细节看起来不重要,但实际它直接决定目录是否好看、是否清爽。


十六、步骤二:修复

数字编号父链

文档解析的时候,标题可能不是按顺序出现的,或者层级构建阶段出了一些误差。

例如最终草稿里:

1.2.3 数据库设计 → 父节点是根节点(错误)

应该是:

1.2.3 数据库设计 → 父节点是 1.2 系统设计
1.2 系统设计 → 父节点是 1 系统概述

修复逻辑是:

建立 numericPath 索引表
对每个数字编号标题:
    1.找直接上级编号(1.2.3 → 1.2)
    2.找不到就退到同章节点(1.2.3 → 1)
    3.找不到就挂到根节点

这是一种重映射操作。它不是修改信号识别的结果,而是重排父子关系。


十七、步骤三:修复非法父节点

可能存在两种非法情况:

父节点编号在树里找不到 → 直接挂到根节点
section 被挂到列表项下面 → 提升到列表项的父节点

为什么 section 不能挂到列表项下面?

因为 section 是结构性节点,语义上不应该是某个列表项的子节点。

错误:
列表项:产品介绍
    section:第一章 总则

正确:
section:第一章 总则
列表项:产品介绍

这种修复体现了一种类型安全思想:

section 只能挂在 document 或更高层 section 下面
列表项可以挂在 section 或其他列表项下面
正文可以挂在任何容器节点下面

十八、步骤四:重算深度

父子关系修好后,深度必须重新计算:

根节点深度 = 0
其他节点深度 = 父节点深度 + 1

按节点编号顺序遍历,保证每个节点的父节点已经先算过深度。

这一步看着简单,但前面没修父子关系,这里就会算错。


十九、步骤五:重建路径

这一步要给每个节点重建两种路径:

canonicalPath:机器定位用
    例如 /document/1/1.2/1.2.3

sectionPath:人类可读路径
    例如 第一章 总则 > 1.1 项目背景 > 1.1.1 业务场景

为什么要两种?

canonicalPath 用于程序检索、URL 生成、唯一定位
sectionPath 用于前端展示、引用来源、对话回答

例如 RAG 回答里说:

根据《部署手册》第一章 总则 > 1.1 项目背景 中的描述……

这就是用 sectionPath 渲染的。

机器内部跳转、章节定位则用 canonicalPath。


二十、步骤六:重建兄弟关系

最后一步是给同一父节点下的子节点建立前后兄弟链接:

prevSiblingNodeNo
nextSiblingNodeNo

为什么需要兄弟关系?

因为你的项目里有 Neo4j 图谱五种查询能力,其中之一是:

邻接遍历(前后兄弟)

用户问"上一章讲了什么",系统就需要找到当前章节的 prevSiblingNodeNo,然后定位到那个节点。

兄弟关系是图谱导航能力的基础。


二十一、最终输出 DocumentStructureNodeCandidate

树校验完成后,把每个 draft 转成最终的候选节点:

nodeNo:节点编号
nodeType:DOCUMENT / SECTION / LIST_ITEM / STEP
parentNodeNo / prevSiblingNodeNo / nextSiblingNodeNo
depth:深度
nodeCode:编号
title / anchorText:标题和锚点文本
canonicalPath / sectionPath:两种路径
contentText:该节点下的正文内容
itemIndex:列表项序号

这个对象是后续所有结构化能力的基础数据:

Neo4j 三层节点写入靠它
导航索引靠它
图投影靠它
结构化切块策略判断靠它
RAG 引用来源靠它
"它的第三章讲了什么"图谱导航靠它

也就是说,这一节虽然复杂,但它输出的数据是后面好几个亮点功能的基石。


二十二、完整流程串成一句话

可以背成一句话:

结构节点提取分为四阶段流水线:第一阶段信号提取用正则和启发式规则对每行做粗分类,产出标题、列表项、步骤、表格、引用、噪声等扁平信号;第二阶段歧义消解针对 HEADING_CANDIDATE 这类低置信度信号,通过 LLM 在局部上下文窗口内做二次判定,失败时回退到规则结果;第三阶段层级构建以线性扫描配合 section、列表、缩进栈等多维上下文,把扁平信号组装成草稿树;第四阶段树校验执行折叠重复标题、修复数字父链、修复非法父节点、重算深度、重建路径、重建兄弟关系六步操作,输出稳定可落库的 DocumentStructureNodeCandidate 列表,作为 Neo4j 图谱、导航索引、结构化切块和章节级 RAG 检索的统一基础数据。


二十三、核心技术点提炼

这一节你要重点掌握下面这些技术点。

1. 编排器思想

总入口只串流程,不做规则,方便单独调试和扩展。

2. 信号提取的高召回设计

宁可多标不可漏标,把判断难度后置给歧义消解阶段。

3. 优先级链匹配

15 条规则按优先级排列,匹配到就返回,清晰且可扩展。

4. 逻辑行 vs 物理行

行内步骤要拆开,缩进、上下文要保留。

5. 行频次辅助噪声识别

全文统计特征 + 单行特征,组合判断更准。

6. LineContext 是标题判定核心

孤立程度 + 名词性 + 后续内容是判断标题的关键。

7. 规则 + LLM 混合策略

规则做高确定性识别,LLM 只判边界,降本提速。

8. LLM 调用的强约束

配置开关、置信度区间、数量上限、失败回退,LLM 是辅助不是主流程。

9. prompt 模板化

提示词放 .st 文件,Java 代码只传变量,降低修改成本。

10. 线性扫描 + 状态机

层级构建用一次扫描配合多种当前上下文,模拟树形构造。

11. 多层兜底的深度推断

找直接上级 → 退到同章 → 用段数,层层兜底。

12. 树校验六步顺序不可调换

折叠 → 修父链 → 修非法父 → 重算深度 → 重建路径 → 重建兄弟。

13. 双路径设计

canonicalPath 给机器用,sectionPath 给人看。

14. 兄弟关系支撑图谱导航

前后兄弟是 Neo4j 邻接遍历的基础。

15. 空文本和异常情况都要兜底

保证下游永远拿到合法树,不让 Neo4j 或切块崩掉。


二十四、这段可以怎么写进简历

可以提炼成这样:

负责文档结构节点提取的四阶段流水线设计与实现,涵盖信号提取、歧义消解、层级构建、树校验。信号提取阶段通过 15 种正则规则和启发式判断,结合逻辑行拆分、行频次统计和上下文窗口,把清洗后的纯文本逐行分类为标题、列表项、步骤、表格、引用、噪声等信号;歧义消解阶段在 LLM 可用时,对低置信度的 HEADING_CANDIDATE 信号在局部上下文窗口内进行二次判定,并将提示词抽到外部模板文件,通过 PromptTemplateService 渲染;层级构建阶段以线性扫描配合 section、列表、缩进栈等多维上下文,把扁平信号组装成草稿树;树校验阶段执行折叠重复标题、修复数字父链、修复非法父节点、重算深度、重建路径和重建兄弟关系六步操作,输出可落库的 DocumentStructureNodeCandidate,用于 Neo4j 文档图谱、导航索引、结构化切块策略和章节级 RAG 检索。

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

通过"高召回规则 + LLM 修边界 + 状态机构树 + 多步质量收口"的设计,实现了对 Markdown、PDF、Word、HTML 等多种格式文档的统一结构识别,兼顾性能、成本和准确率,是上游 Neo4j 图谱、章节级问答和影子路由观测能力的核心基础。


二十五、面试官可能会问的问题

问题 1:为什么要拆成四阶段?能不能合在一起?

可以回答:

四阶段拆分是为了职责单一和可调试性。信号提取追求高召回,可以快速扫描;歧义消解可以独立开关,LLM 出问题不影响主流程;层级构建只关心如何挂父子,不关心信号怎么来;树校验做最终质量收口。如果合在一起,正则、LLM 调用、树构造、校验混在一起,出问题不好排查,扩展新结构类型也会很难。

问题 2:为什么不全部交给 LLM 来分类?

可以回答:

一是成本和延迟问题,文档可能有几千行,每行调用 LLM 不现实;二是稳定性问题,LLM 返回不一定符合预期格式;三是大部分行其实非常确定,例如 Markdown 标题、多级编号、表格行,规则完全能识别。所以采用规则做粗扫描、LLM 只判边界的混合策略。

问题 3:HEADING_CANDIDATE 和 HEADING 有什么区别?

可以回答:

HEADING 是高置信度信号,例如 Markdown 标题、多级数字编号、中文章节,基本可以确定;HEADING_CANDIDATE 是低置信度信号,主要是单级编号和中文大纲在不确定上下文中的情况,可能是标题也可能是列表项,需要后续歧义消解阶段做二次判定。

问题 4:为什么要保留逻辑行而不是直接用物理行?

可以回答:

因为有些文档会在同一行内写多个步骤,例如"步骤1:下载 步骤2:安装"。如果当成一个信号处理,步骤识别和层级构建都会出错。所以必须先按行内步骤边界拆成逻辑行,每个片段单独算缩进、单独打信号,后续构树才稳定。

问题 5:树校验为什么要六步?能不能省一两步?

可以回答:

六步缺一不可。折叠重复标题是因为 PDF 解析常常会把文档标题在正文里再出现一次;修复数字父链是因为层级构建可能跳跃;修复非法父节点是因为信号有边界 case;深度、路径、兄弟关系必须在父子关系修复后重新计算,否则下游 Neo4j 写入、目录展示、邻接遍历都会出问题。少任何一步,树都不稳定。

问题 6:为什么要 canonicalPath 和 sectionPath 两套路径?

可以回答:

canonicalPath 是机器用的标识路径,格式稳定,适合做唯一定位、URL 拼接、节点检索;sectionPath 是给人看的章节链,使用真实标题文本拼接,便于在 RAG 引用来源和前端目录中展示。两者职责不同,所以分开维护。

问题 7:这套结构提取怎么和 Neo4j 图谱衔接?

可以回答:

结构提取输出的 DocumentStructureNodeCandidate 会被 DocumentStructureNodeService 写入数据库,同时同步到 Neo4j 形成三层节点结构:文档节点、章节节点、条目节点。节点之间通过包含章节、包含子节点、包含条目、下一兄弟四种关系连接。校验阶段输出的 parentNodeNo、prevSiblingNodeNo、nextSiblingNodeNo 直接对应图谱中的边,canonicalPath 用于章节编号定位,sectionPath 用于标题路径查找,depth 用于子节点展开层级控制。


二十六、这一节和后续模块的衔接

到这里,系统已经完整拿到了:

cleanedText:清洗后的纯文本
DocumentAnalysisResult:文档画像
DocumentStructureNodeCandidate 列表:结构节点候选

接下来的链路是:

1. 把纯文本重新上传为 txt,便于索引构建复用
2. 调用 DocumentStructureNodeService.replaceDocumentNodes() 写入数据库
3. 同步导航索引(供前端目录展示)
4. 同步 Neo4j 图投影(三层节点结构)
5. 生成文档画像(主题、关键词、摘要)
6. 进入切块策略推荐阶段

下一篇就要进入策略推荐了:

系统是怎么根据 DocumentAnalysisResult 和结构节点判断该用哪种切块策略的?
什么时候选结构化切块?
什么时候用语义切块?
什么时候用 LLM 智能切块?
什么时候递归切块兜底?
推荐结果怎么落库?
怎么等待用户确认?

到这里,你已经把"上传 → 异步消费 → 文本解析 → 结构提取"这条主线完整打通。下一篇我们继续往后,一起拆策略推荐。


企业级项目导航:⬅️ 04-异步解析入口 | 05-文本转文档结构树 | ➡️ 06-知识库系统的入口工程