--- title: "05-文本转文档结构树" created: 2026-05-18 aliases: - 文本转文档结构树 tags: - 项目 --- # 文本转文档结构树 我们接着上一篇“Kafka 消费与文本内容解析”往下学。上一篇的终点是: ```text Tika 提取出原始文本 做了文本清洗 拿到 cleanedText 准备调用 structureNodeExtractor.extract() ``` 这一篇就要重点拆解 `extract()` 内部到底做了什么。它是整个文档解析里**最难、最复杂、也最能体现工程功力**的一段。学完它,你才真正理解你简历里写的“基于 Neo4j 构建文档层级结构图谱”不是凭空来的,而是从一份扁平文本里一行一行抠出来的。 ### **一、先建立整体认知:这一步在做什么?** 简单一句话概括: > 把清洗后的纯文本,逐行扫描后转换成一棵带有父子关系、深度、路径、兄弟链接的文档结构树。 也就是说,输入是一段纯文本: ```text 第一章 总则 1.1 项目背景 本项目旨在…… 1.2 系统目标 - 提高效率 - 降低成本 第二章 系统设计 2.1 架构图 ``` 输出是一棵树: ![[image-87e1035f.png]] 这棵树后面会被用在几个非常重要的地方: ```text 1. 写入 Neo4j,形成文档结构图谱 2. 同步到导航索引,支持目录浏览 3. 作为结构化切块策略的判断依据 4. 用于章节级 RAG 检索和定位 5. 用于"它的第三章讲了什么"这类问题的图谱导航 ``` 所以这一步的质量决定了: ```text 目录导航是否准确 结构化切块是否可用 章节级问答能不能精确定位 图谱导航能不能展开子节点 ``` ### **二、为什么这一步这么难?** 很多人第一反应是:不就是按章节标题切一切吗?写几个正则不就行了? 实际并不简单。 文档世界里的"结构"五花八门: ```text Markdown 标题:## 概述 中文章节:第一章 总则 多级编号:1.2.3 配置说明 中文大纲:一、项目背景 附录:附录A 术语表 显式步骤:第一步:安装 无序列表:- 项目一 有序列表:1、苹果 2、香蕉 复选框:[ ] 待办 表格行:| 列1 | 列2 | 引用:> 引用内容 ``` 如果只是单纯匹配正则,你会遇到一堆坑: ```text 1、项目背景 → 是标题还是列表项? 一、概述 → 是标题还是大纲? 1.2 配置 → 它的父节点是哪个? 第一章 → 应该是几级标题? 有的章节没有子节点 有的文档第一行就是文档标题本身 PDF 提取后会有页眉、页脚、页码 有些行在一行内写了多个步骤 缩进可能表示嵌套也可能表示对齐 LLM 切块也可能给出错误结构 ``` 所以这一步不是写几个正则就能搞定的,它需要: ```text 多种结构信号识别 对歧义信号做二次判定 扁平信号组装成树 树修复和路径重建 ``` 这四件事正好对应代码里的**四阶段流水线**。 --- ### **三、四阶段流水线总览** 整个 `extract()` 方法可以画成一张流程图: ![[image-ca49a352.png]] 每个阶段的职责非常清晰: | 阶段 | 输入 | 输出 | 核心思想 | | --- | --- | --- | --- | | 信号提取 | 纯文本 | 扁平信号列表 | 逐行识别结构信号,宁可多标不可漏标 | | 歧义消解 | 扁平信号列表 | 修正后的信号列表 | LLM 对低置信度行做二次判定 | | 层级构建 | 修正后的信号列表 | 草稿树 drafts | 把扁平信号组装成父子关系 | | 树校验 | 草稿树 | 候选节点列表 | 修复非法关系、重算深度、重建路径 | 这种四阶段拆分是非常成熟的工程设计思路: ```text 规则先做粗扫描 模型做边界修正 然后构建结构 最后做质量兜底 ``` 每个阶段只做一件事,可以独立调试、单独优化、单独换实现。 --- ### **四、总入口:DocumentStructureNodeExtractor** 入口类很短: ```java public List 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 rawSignals = batch.signals(); List allLines = batch.contextLines(); List resolvedSignals = ambiguityResolver.resolve(normalizedTitle, allLines, rawSignals); List drafts = hierarchyResolver.resolve(normalizedTitle, resolvedSignals); return treeValidator.validateAndBuild(normalizedTitle, drafts); } ``` 这里有几个设计点要重点理解。 #### **1. 这个类只做编排,不做规则判断** 它不直接写任何正则,也不直接写任何 LLM 调用代码。它的职责就是: ```text 把四个阶段串起来 保证上一阶段输出是下一阶段输入 ``` 这种"编排器"思想非常常见,例如 Spring 的责任链、流水线、Pipeline 模式。 #### **2. 空文本特殊处理** ```java if (normalizedText.isBlank()) { return List.of(buildOnlyRootNode(normalizedTitle)); } ``` 如果文档解析后正文为空(比如扫描版 PDF 没有 OCR、文件损坏、内容全是图片),不会让流水线跑空,而是直接返回一个根节点。 这是一个非常稳的设计。它保证下游永远拿到的是一棵合法的树,而不是空列表。 否则 Neo4j 写入、目录导航、切块策略推荐可能都会因为空列表崩掉。 #### **3. 标题为空时兜底为"文档"** ```java StrUtil.blankToDefault(documentTitle, "文档").trim(); ``` 这样无论用户传不传 documentName,根节点至少有一个名字。 --- ### **五、阶段一:信号提取(SignalExtractor)** 信号提取是这条流水线代码量最多的阶段。 它的核心思想是: > 逐行扫描文档纯文本,把每一行打上一个"结构信号类型"标签。 可以理解为给每行做"语义分类": ```text 这一行是标题 这一行是列表项 这一行是步骤 这一行是表格 这一行是引用 这一行是噪声 这一行是普通正文 这一行是空行 这一行像标题但不确定 ``` 输出是一个扁平的 `DocumentStructureSignal` 列表,每个信号包含: ```text lineNo:行号 rawText:原始文本 normalizedText:规范化文本 kind:信号类型 nodeCode:编号(如 1.2.3) title:标题文本 levelHint:层级提示 numericPath:数字路径 confidence:置信度 ``` #### **1. 它的设计原则是"宁可多标,不可漏标"** 信号提取阶段不追求 100% 准确,而是追求高召回。 为什么? 因为后面还有歧义消解阶段。 如果信号提取漏掉了,后面再也没机会补救;但如果信号提取多标了,后面可以由 LLM 修正。 例如: ```text 1、项目背景 ``` 不能直接判定它是列表项,而要标成 `HEADING_CANDIDATE`,把判断交给下一个阶段。 #### **2. 几个关键正则模式** 代码里定义了一堆正则,可以分成几类。 **强标题信号**(高置信度): ```text Markdown 标题:## 概述 多级数字编号:1.2.3 配置 中文章节:第一章 总则 附录:附录A 术语表 ``` 这些一旦匹配,基本可以确定是标题。 **有歧义的信号**: ```text 单级数字编号:1、概述 中文大纲:一、项目背景 ``` 这些可能是标题,也可能是列表项,需要上下文配合判断。 **列表类信号**: ```text 无序列表:- 项目一 显式步骤:第一步:安装 复选框:[ ] 待办 ``` 这些通常是列表项,但有些"步骤"也可能算结构节点。 **噪声信号**: ```text 页码:第 3 页 / Page 5 / 3 / 10 版权:版权所有 / Copyright 重复出现的页眉页脚 ``` 这些不是文档结构,应当被过滤掉。 --- ### **六、逻辑行 vs 物理行** 信号提取里有一个非常细的点: ```text 物理行:按 切出来的原始行 逻辑行:可能从一条物理行里再拆出多条 ``` 比如有些文档会写: ```text 步骤1:下载 步骤2:安装 步骤3:配置 ``` 这一条物理行里其实包含三个步骤。 如果不拆开,后续会把它当成一条信号处理,导致步骤识别和层级解析都出问题。 所以代码里有 `splitInlineSegments()`: ```text 按"行内显式步骤边界"拆分 拆完每个片段单独算逻辑行 每个片段单独算缩进级别 ``` 这个细节看起来不起眼,但它直接影响结构解析的准确率。 逻辑行还会保留: ```text 逻辑行号 物理行号 物理行内的片段序号 缩进级别 原始文本 规范化文本 ``` 这些字段后面会被上下文构建、列表层级、兄弟排序复用。 --- ### **七、行频次:用于检测噪声** 代码里有一段: ```java Map lineFrequency = buildLineFrequency(logicalLines); ``` 它统计每个规范化文本在文档里出现的次数。 为什么? 因为页眉、页脚、版权声明这些噪声往往会在多个页面重复出现。 例如: ```text 第 1 页 机密内部使用 版权所有 © 公司 ``` 这些行在每一页都出现一次。 如果只看单行,你很难判定它是噪声还是结构;但配合行频次: ```text 出现 3 次以上 长度不超过 120 字符 ``` 大概率就是噪声。 这是一个非常工程化的设计:**用全文统计特征辅助单行判断**。 --- ### **八、上下文 LineContext:标题判定的关键** 代码里有: ```java LineContext context = buildContext(logicalLines, index); ``` 它的作用是: ```text 向前找到最近一个非空行 向后找到最近一个非空行 记录前面是否有空行 记录后面是否有空行 ``` 为什么要这些信息? 因为标题的判定离不开上下文。 例如: ```text 这是上一段正文。 部署手册 接下来是部署步骤。 ``` 中间这一行"部署手册": ```text 前面有空行 后面有空行 后面是有内容的句子 本身是名词短语 ``` 这种行更像标题,而不是普通正文。 反过来: ```text 我们的项目目标 1、提高效率 2、降低成本 ``` `1、提高效率` 前一行是"我们的项目目标",但前面没有空行,后一行 `2、降低成本` 又是连续编号,所以这是列表项,不是标题。 这就是为什么 `LineContext` 是判断标题的核心特征。 ### **九、classify:十五条规则的优先级匹配** `classify()` 是这个阶段最核心的方法,它按优先级从高到低尝试 15 种匹配: ```text 1. 空行 2. 重复噪声(页眉页脚) 3. 页码噪声 4. Markdown 标题 5. 显式步骤 6. 中文章节 7. 附录 8. 多级数字编号 9. 表格行 10. 引用 11. 复选框 12. 无序列表 13. 单级数字编号(可能是标题或列表) 14. 中文大纲(可能是标题或列表) 15. 兜底分类(交给 DocumentLineClassifier) ``` 匹配到一个就返回,不再继续匹配。 这种"优先级链"的设计有几个好处: **第一,规则之间互不冲突。** 如果 `## 配置` 同时匹配了 Markdown 标题和单级数字编号,优先级高的 Markdown 标题先返回,不会混乱。 **第二,可读性强。** 按业务理解的顺序排列规则,而不是按代码长度。 **第三,扩展性强。** 要新增一种结构,例如检测 Word 自动编号,只需要插入一条规则即可,不影响其它。 这个就是责任链模式 + 优先级匹配的典型应用。 --- ### **十、最难的两条规则:歧义编号** 整个 classify 里最有意思的是这两段: ```text 单级阿拉伯数字编号:1、概述 中文大纲:一、项目背景 ``` 它们天然有歧义。 举几个例子: ```text 1、项目背景 2、技术选型 3、实施方案 ``` 看起来像目录标题,但也可能是列表项。 ```text 项目使用以下技术: 1、Spring Boot 2、Redis 3、Kafka ``` 这里就明显是列表项。 代码里用了三个组合判断: **1. 邻居序列检测 isNeighborSequence** ```text 当前是 2,前面是 1 或后面是 3 → 列表项 ``` 连续编号意味着这是一组并列条目,通常是列表。 **2. 引导列表检测 previousIntroducesList** ```text 上一行以":" 或 "::" 结尾 → 当前是列表项 ``` 引导句后面必然是列表,例如"包含以下内容:"。 **3. 启发式标题判断 looksLikePlainHeading** ```text 文本短 前后有空行 后面有内容 不含中文标点 ``` 满足这些才像标题。 最终决策: ```text 如果连续序列 或 引导列表 → 列表项 否则如果像标题 → HEADING_CANDIDATE (低置信度) 否则 → 列表项 ``` 也就是说,即使判定为像标题,也不直接标为 HEADING,而是 HEADING\_CANDIDATE,留给下一阶段裁定。 这就是"宁可多标,不可漏标"的体现。 --- ### **十一、为什么不直接全部丢给 LLM 判断?** 很多人看到这里会想:既然规则这么麻烦,为什么不直接让 LLM 来分类每一行? 原因有几个。 **1. 成本太高** 一个文档可能几千行,每行都调一次 LLM,成本爆炸。 **2. 速度太慢** 每行调一次 LLM,文档解析可能要几分钟到几十分钟。 **3. 不可控** LLM 的输出不稳定,无法保证 100% 返回合法分类。 **4. 大部分行其实很明确** Markdown 标题、多级数字编号、表格行这些非常确定,根本不需要 LLM。 所以这里采用的是"规则 + LLM"混合策略: ```text 高确定性的规则做粗扫描 低置信度的歧义行才交给 LLM 判断 ``` 这样既保证速度和成本,又能修正最容易出错的边界。 这是一种很典型的工程权衡。 --- ### **十二、阶段二:歧义消解(AmbiguityResolver)** 经过信号提取后,大部分行都有明确分类,但还有一批 `HEADING_CANDIDATE`(可能是标题也可能是列表项)。 这一阶段就是用 LLM 帮忙判定这些边界。 #### **1. 设计上的强约束** ```text 配置开关:llmDisambiguationEnabled 模型可用:chatModelProvider.getIfAvailable() 置信度区间:只挑置信度在 [0.5, 0.7] 之间的信号 数量上限:每次最多处理 N 个候选 失败回退:LLM 出错时静默回退到规则结果,不阻断主链 ``` 这些约束看似保守,实际非常重要。 它体现了一个原则:**LLM 是辅助,不是主流程。** 如果 LLM 服务挂了,文档解析必须还能正常完成,只是结构准确率略降。 #### **2. prompt 设计的重点** prompt 只给 LLM: ```text 文档标题 若干候选行的局部上下文窗口 初始判断结果 ``` 不会发整篇文档。 为什么? ```text 减少 token 消耗 避免无关内容干扰模型 让模型专注于"判边界" ``` prompt 里还会指定输出格式: ```text 严格返回 JSON 数组 不要附加解释 只返回 line_no、resolved_kind、level_hint ``` 这种"强格式化输出"非常重要,避免 LLM 自由发挥导致解析失败。 #### **3. prompt 抽到外部模板文件** 代码里有一段重要设计: ```text 提示词不写在 Java 代码里 而是放在 .st 模板文件中 通过 PromptTemplateService 渲染 ``` 为什么这样做? **第一,修改提示词不用改代码。** 调整措辞、增删规则只要改 `.st` 文件,不用重新编译。 **第二,提示词和业务逻辑解耦。** Java 代码只关心"传什么变量",模板文件只关心"怎么组织 prompt"。 **第三,统一管理。** 所有提示词模板放在 `resources/prompt/` 目录,通过 `PromptTemplateNames` 常量类引用。 这其实是 AI 工程化里非常成熟的做法。 --- ### **十三、阶段三:层级构建(HierarchyResolver)** 经过前两个阶段,你已经拿到一份"每行都有明确信号类型"的扁平列表。 这一阶段要把它变成一棵带父子关系的草稿树。 #### **1. 线性扫描 + 多维上下文** 整个方法是一次线性扫描,但同时维护多个"当前上下文": ```text currentSection:当前所在的 section 节点 currentListItem:当前所在的列表项节点 listStack:列表嵌套的缩进栈 latestHeadingByDepth:按深度索引最新标题 latestHeadingByNumericPath:按数字编号索引最新标题 ``` 为什么需要这么多上下文? 因为不同类型的信号要挂到不同的"当前节点": ```text 正文 → 附着到当前 section 或当前列表项 列表项 → 根据缩进找父节点 标题 → 切换 currentSection,清空列表 空行 → 打断列表上下文 ``` #### **2. 状态机式扫描** 可以画成状态机: ![[image-af8e42e0.png]] 这种线性扫描 + 状态维护是处理树形结构的经典方式。 #### **3. 标题深度推断** 这一段是最有意思的: ```text Markdown:# 数量就是层级 中文章节、附录:固定为 1 级 多级数字编号: 1.先找直接上级编号(1.2.3 的父级是 1.2) 2.找不到就退到同章节点(1.2.3 退到 1) 3.再找不到就用编号段数 ``` 这种"先找上级、找不到退化、最后兜底"的设计非常稳。 举个例子: ```text 1.2.3 数据库设计 ``` 正常情况下,系统应该已经见过 `1.2`,直接挂上去。 但如果文档跳着写,直接出现 `1.2.3`,而没有 `1.2`,系统会退到挂在 `1` 下面。 再不行就按编号段数判断,认为它是 3 级。 这种"多层兜底"思维体现了工程化的成熟度。 #### **4. 正文附着** 正文不会新建节点,而是附着到当前上下文。 ```text 当前在列表项内 → 附到列表项 否则附到 section 没有 section → 附到根节点 ``` 这样保证每个节点都有完整的内容文本。 后续切块策略可以基于 `contentText` 做切分。 --- ### **十四、阶段四:树校验(TreeValidator)** 经过层级构建,你已经有了一棵草稿树,但它可能存在一些问题: ```text 正文里出现一次文档标题,导致多了一层假 section 1.2.3 没有挂在 1.2 下面 父节点根本不存在 section 被错误挂到列表项下面 深度算错 路径没建立 兄弟关系没建 ``` 第四阶段就是做最终质量收口。 它执行六个步骤,**顺序非常重要**: ```text 1. 折叠重复标题 2. 修复数字编号父链 3. 修复非法父节点 4. 重算深度 5. 重建路径 6. 重建兄弟关系 ``` 为什么顺序重要? 因为: ```text 深度依赖父子关系 路径依赖深度和父节点 兄弟关系依赖父子关系 ``` 必须先把父子关系修复好,后面才能算深度和路径。 --- ### **十五、步骤一:折叠重复标题** 很多 PDF 解析出来的第一行就是文档标题本身。 例如: ```text 部署手册 部署手册 1. 环境准备 2. 安装步骤 ``` 如果不处理,文档结构里会出现: ```text 根节点:部署手册 └── section:部署手册(重复) ├── 1. 环境准备 └── 2. 安装步骤 ``` 这一层"重复标题 section"是冗余的,会让目录显示出多余的一层。 折叠的逻辑是: ```text 找到与文档标题相同的一级 section 把它的子节点全部提升到根节点下 删除这个重复 section ``` 结果变成: ```text 根节点:部署手册 ├── 1. 环境准备 └── 2. 安装步骤 ``` 这种细节看起来不重要,但实际它直接决定目录是否好看、是否清爽。 --- ### **十六、步骤二:修复** ### **数字编号父链** 文档解析的时候,标题可能不是按顺序出现的,或者层级构建阶段出了一些误差。 例如最终草稿里: ```text 1.2.3 数据库设计 → 父节点是根节点(错误) ``` 应该是: ```text 1.2.3 数据库设计 → 父节点是 1.2 系统设计 1.2 系统设计 → 父节点是 1 系统概述 ``` 修复逻辑是: ```text 建立 numericPath 索引表 对每个数字编号标题: 1.找直接上级编号(1.2.3 → 1.2) 2.找不到就退到同章节点(1.2.3 → 1) 3.找不到就挂到根节点 ``` 这是一种**重映射**操作。它不是修改信号识别的结果,而是重排父子关系。 --- ### **十七、步骤三:修复非法父节点** 可能存在两种非法情况: ```text 父节点编号在树里找不到 → 直接挂到根节点 section 被挂到列表项下面 → 提升到列表项的父节点 ``` 为什么 section 不能挂到列表项下面? 因为 section 是结构性节点,语义上不应该是某个列表项的子节点。 ```yaml 错误: 列表项:产品介绍 section:第一章 总则 正确: section:第一章 总则 列表项:产品介绍 ``` 这种修复体现了一种**类型安全**思想: ```text section 只能挂在 document 或更高层 section 下面 列表项可以挂在 section 或其他列表项下面 正文可以挂在任何容器节点下面 ``` ### **十八、步骤四:重算深度** 父子关系修好后,深度必须重新计算: ```text 根节点深度 = 0 其他节点深度 = 父节点深度 + 1 ``` 按节点编号顺序遍历,保证每个节点的父节点已经先算过深度。 这一步看着简单,但前面没修父子关系,这里就会算错。 --- ### **十九、步骤五:重建路径** 这一步要给每个节点重建两种路径: ```text canonicalPath:机器定位用 例如 /document/1/1.2/1.2.3 sectionPath:人类可读路径 例如 第一章 总则 > 1.1 项目背景 > 1.1.1 业务场景 ``` 为什么要两种? ```text canonicalPath 用于程序检索、URL 生成、唯一定位 sectionPath 用于前端展示、引用来源、对话回答 ``` 例如 RAG 回答里说: ```text 根据《部署手册》第一章 总则 > 1.1 项目背景 中的描述…… ``` 这就是用 sectionPath 渲染的。 机器内部跳转、章节定位则用 canonicalPath。 --- ### **二十、步骤六:重建兄弟关系** 最后一步是给同一父节点下的子节点建立前后兄弟链接: ```text prevSiblingNodeNo nextSiblingNodeNo ``` 为什么需要兄弟关系? 因为你的项目里有 Neo4j 图谱五种查询能力,其中之一是: ```text 邻接遍历(前后兄弟) ``` 用户问"上一章讲了什么",系统就需要找到当前章节的 prevSiblingNodeNo,然后定位到那个节点。 兄弟关系是图谱导航能力的基础。 --- ### **二十一、最终输出 DocumentStructureNodeCandidate** 树校验完成后,把每个 draft 转成最终的候选节点: ```text nodeNo:节点编号 nodeType:DOCUMENT / SECTION / LIST_ITEM / STEP parentNodeNo / prevSiblingNodeNo / nextSiblingNodeNo depth:深度 nodeCode:编号 title / anchorText:标题和锚点文本 canonicalPath / sectionPath:两种路径 contentText:该节点下的正文内容 itemIndex:列表项序号 ``` 这个对象是后续所有结构化能力的基础数据: ```text 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 用于子节点展开层级控制。 --- ### **二十六、这一节和后续模块的衔接** 到这里,系统已经完整拿到了: ```text cleanedText:清洗后的纯文本 DocumentAnalysisResult:文档画像 DocumentStructureNodeCandidate 列表:结构节点候选 ``` 接下来的链路是: ```text 1. 把纯文本重新上传为 txt,便于索引构建复用 2. 调用 DocumentStructureNodeService.replaceDocumentNodes() 写入数据库 3. 同步导航索引(供前端目录展示) 4. 同步 Neo4j 图投影(三层节点结构) 5. 生成文档画像(主题、关键词、摘要) 6. 进入切块策略推荐阶段 ``` 下一篇就要进入**策略推荐**了: ```text 系统是怎么根据 DocumentAnalysisResult 和结构节点判断该用哪种切块策略的? 什么时候选结构化切块? 什么时候用语义切块? 什么时候用 LLM 智能切块? 什么时候递归切块兜底? 推荐结果怎么落库? 怎么等待用户确认? ``` 到这里,你已经把"上传 → 异步消费 → 文本解析 → 结构提取"这条主线完整打通。下一篇我们继续往后,一起拆策略推荐。 --- **企业级项目导航**:⬅️ [[04-异步解析入口|04-异步解析入口]] | 05-文本转文档结构树 | ➡️ [[06-知识库系统的入口工程|06-知识库系统的入口工程]]