--- title: "07-结构节点提取的四阶段流水线" created: 2026-05-18 aliases: - 结构节点提取的四阶段流水线 tags: - 项目 --- # 结构节点提取的四阶段流水线 上一篇讲到 `TikaDocumentParserService.parse()` 完成了文本提取和清洗。按照 `parse()` 的执行顺序,接下来就是 `structureNodeExtractor.extract()`——它负责从清洗后的纯文本中提取出文档的结构节点(标题、列表、步骤等)。 这个方法内部是一条相当复杂的四阶段流水线,我们这篇单独拆开来讲。 ## 四阶段流水线总览 先看一张总览图: ![[FkhISBXYEfFS8l6uPT4Dv2hcNnDs-82351590.png]] ## 总入口:DocumentStructureNodeExtractor ```java /** * 文档结构节点提取器。 *

* 这是整条结构解析流水线的总入口,负责把“文档标题 + 解析后的纯文本” * 逐步转换成可落库的结构节点候选列表。 *

*

* 内部固定采用四阶段流水线: * 1. 信号提取:逐行识别标题、列表、步骤、正文、噪声等结构信号; * 2. 歧义消解:对低置信度候选行做二次判定; * 3. 层级构建:把扁平信号组装成带父子关系的 draft 树; * 4. 树校验:修复路径、层级和兄弟关系,生成最终节点。 *

*/ @AllArgsConstructor @Component public class DocumentStructureNodeExtractor { /** 第一阶段:信号提取器,负责将文本逐行扫描并识别出标题、列表、正文等结构信号 */ private final DocumentStructureSignalExtractor signalExtractor; /** 第二阶段:歧义消解器,对低置信度的信号借助 LLM 进行二次判定 */ private final DocumentStructureAmbiguityResolver ambiguityResolver; /** 第三阶段:层级解析器,将扁平的信号列表组装成带有父子关系的草稿树 */ private final DocumentStructureHierarchyResolver hierarchyResolver; /** 第四阶段:树校验器,修复无效父节点、重算深度、重建路径,输出最终候选节点 */ private final DocumentStructureTreeValidator treeValidator; /** * 文档结构提取的总入口方法。 *

* 这个方法本身不承载复杂规则判断,它更像一个“编排器”: * 负责把标题和正文送入四阶段流水线,并保证每一阶段的输出都成为下一阶段的输入。 *

*

* 其中有两个特别重要的边界语义: * 1. 如果正文为空,不进入任何复杂规则,直接返回一个只有 DOCUMENT 根节点的结果; * 2. 如果正文非空,哪怕后续没有抽出任何显式标题,也会在树构建阶段保留根节点, * 从而保证下游始终面对的是一棵合法结构树,而不是空列表。 *

* * @param documentTitle 文档标题,可为空;为空时统一兜底为“文档” * @param parsedText 解析后的纯文本正文 * @return 结构化节点候选列表,供后续结构节点落库、导航索引和图谱投影复用 */ 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(new DocumentStructureNodeCandidate( 1, DocumentStructureNodeTypeEnum.DOCUMENT.getCode(), null, 0, 0, 0, "", normalizedTitle, normalizedTitle, "/document", "", "", null )); } // 第一阶段:信号提取 —— 逐行扫描文本,识别标题、列表、噪声等结构信号 DocumentStructureSignalBatch signalBatch = signalExtractor.extract(normalizedTitle, normalizedText); // 从信号批次中取出原始信号列表(防御性空值处理) List rawSignals = signalBatch == null ? List.of() : signalBatch.signals(); // 从信号批次中取出所有行的规范化文本,供歧义消解时作为上下文窗口使用 List allLines = signalBatch == null ? List.of() : signalBatch.contextLines(); // 第二阶段:歧义消解 —— 对“像标题又像列表”的候选行做二次判定, // 目的是减少误把列表当标题、或误把标题当正文的情况。 List resolvedSignals = ambiguityResolver.resolve(normalizedTitle, allLines, rawSignals); // 第三阶段:层级构建 —— 将扁平信号列表组装为带有父子关系的草稿节点树。 List drafts = hierarchyResolver.resolve(normalizedTitle, resolvedSignals); // 第四阶段:树校验与构建 —— 修复层级异常、重算深度、重建路径,输出最终候选节点。 return treeValidator.validateAndBuild(normalizedTitle, drafts); } } ``` 这个类本身不承载复杂逻辑,更像一个"编排器"——把四个阶段串起来,每一阶段的输出作为下一阶段的输入。 ## 第一阶段:信号提取(SignalExtractor) 这是整条流水线中代码量最大的一个阶段。`DocumentStructureSignalExtractor` 的职责是:逐行扫描文档纯文本,通过正则模式匹配和启发式规则,把每一行分类为标题、列表项、步骤、表格行、引用、正文、噪声等结构信号。 ### 正则模式定义 先看它定义了哪些正则模式: ```java /** 匹配 Markdown 标题,如 "## 概述",捕获 # 号数量(层级)和标题文本 */ private static final Pattern MARKDOWN_HEADING_PATTERN = Pattern.compile("^(#{1,6})\\s+(.+)$"); /** 匹配多级数字编号标题,如 "1.2.3 配置说明",捕获编号和标题文本 */ private static final Pattern DECIMAL_HEADING_PATTERN = Pattern.compile("^(\\d+(?:\\.\\d+)+)\\s*[、.]?\\s*(.+)$"); /** 匹配单级数字编号行,如 "1、概述" 或 "2. 安装",可能是标题也可能是列表项 */ private static final Pattern SINGLE_LEVEL_DIGIT_PATTERN = Pattern.compile("^(\\d+)\\s*[、.]\\s*(.+)$"); /** 匹配中文章节标题,如 "第一章 绪论"、"第三节 方法" */ private static final Pattern CHAPTER_PATTERN = Pattern.compile("^(第([一二三四五六七八九十百\\d]+)[章节条部分])\\s*(.+)$"); /** 匹配附录标题,如 "附录A 术语表" */ private static final Pattern APPENDIX_PATTERN = Pattern.compile("^(附录\\s*([A-Za-z一二三四五六七八九十百\\d]+))(?:\\s+(.+))?$"); /** 匹配中文大纲编号,如 "一、项目背景"、"三、实施方案" */ private static final Pattern CHINESE_OUTLINE_PATTERN = Pattern.compile("^([一二三四五六七八九十百]+)[、.]\\s*(.+)$"); /** 匹配显式步骤标记,如 "第一步:安装" 或 "步骤2:配置" */ private static final Pattern EXPLICIT_STEP_PATTERN = Pattern.compile("^(?:第\\s*([0-9一二三四五六七八九十百]+)\\s*步|步骤\\s*([0-9一二三四五六七八九十百]+))\\s*[::、.]?\\s*(.+)$"); /** 匹配无序列表项,如 "- 项目一"、"* 项目二"、"• 项目三" */ private static final Pattern BULLET_PATTERN = Pattern.compile("^([-*+•])\\s+(.+)$"); /** 匹配页码噪声行,如 "第 3 页"、"Page 5"、"3 / 10" */ private static final Pattern PAGE_NOISE_PATTERN = Pattern.compile("^(?:第\\s*\\d+\\s*页|Page\\s*\\d+|\\d+\\s*/\\s*\\d+)$", Pattern.CASE_INSENSITIVE); /** 匹配版权声明噪声行 */ private static final Pattern COPYRIGHT_NOISE_PATTERN = Pattern.compile(".*(?:版权所有|未经授权|内部使用|copyright|all rights reserved|保密).*", Pattern.CASE_INSENSITIVE); ``` 这些正则覆盖了中英文文档中常见的各种结构标记。 ### extract 入口方法 ```java /** * 信号提取的入口方法。 * 将文档纯文本拆分为逻辑行,逐行进行结构信号分类,返回信号批次。 * * @param documentTitle 文档标题,用于去重和噪声检测 * @param parsedText 文档解析后的纯文本 * @return 信号批次,包含所有行的规范化文本列表和对应的结构信号列表 */ public DocumentStructureSignalBatch extract(String documentTitle, String parsedText) { // 对标题做空值保护和去除首尾空白 String normalizedTitle = safeText(documentTitle); // 将原始文本按换行符拆分,并处理行内步骤边界,生成逻辑行列表 List logicalLines = buildLogicalLines(parsedText); // 统计每行规范化文本的出现频次,用于检测重复的页眉/页脚噪声 Map lineFrequency = buildLineFrequency(logicalLines); // 预分配信号列表容量(逻辑行数 + 1 个文档标题信号) List signals = new ArrayList<>(logicalLines.size() + 1); // 如果文档标题非空,先插入一个 DOCUMENT_TITLE 类型的信号作为根信号(行号为 0) if (StrUtil.isNotBlank(normalizedTitle)) { signals.add(DocumentStructureSignal.builder() .lineNo(0) .rawText(normalizedTitle) .normalizedText(normalizedTitle) .kind(DocumentStructureSignalKind.DOCUMENT_TITLE) .title(normalizedTitle) .levelHint(0) .confidence(1.0D) .build()); } // 遍历每一个逻辑行,构建上下文(前后非空行),然后进行分类 for (int index = 0; index < logicalLines.size(); index++) { DocumentStructureLogicalLine logicalLine = logicalLines.get(index); // 构建当前行的上下文信息:前一个非空行、后一个非空行、前后是否有空行 LineContext context = buildContext(logicalLines, index); // 对当前行进行分类,生成对应的结构信号 signals.add(classify(normalizedTitle, logicalLine, context, lineFrequency)); } // 提取所有逻辑行的规范化文本,作为后续歧义消解阶段的上下文窗口 List contextLines = logicalLines.stream() .map(DocumentStructureLogicalLine::normalizedText) .toList(); return new DocumentStructureSignalBatch(contextLines, signals); } ``` 入口方法做了三件事:先把文本拆成逻辑行,然后统计行频次(用于噪声检测),最后逐行调用 `classify()` 进行分类。我们先看这三个前置步骤,再看核心的 classify。 ### buildLogicalLines:构建逻辑行 ```java /** * 将原始纯文本拆成"逻辑行"列表。 *

* 这里之所以不直接把按换行切出来的物理行原样往下传,是因为一条物理行里可能内联写了多个步骤, * 例如"步骤1:下载 步骤2:安装"。如果不先拆开,后续分类器就只能把整行当成一个信号, * 会让步骤识别和层级解析都变得不准确。 *

*

* 最终每个 DocumentStructureLogicalLine 都会携带: * 逻辑行号、原始物理行号、物理行内的片段序号、缩进级别、原始片段文本、规范化文本。 * 这些信息后面会被上下文构建、列表层级恢复和兄弟关系排序复用。 *

*/ private List buildLogicalLines(String parsedText) { String[] rawLines = StrUtil.blankToDefault(parsedText, "").split("\n", -1); List logicalLines = new ArrayList<>(rawLines.length); int logicalLineNo = 1; for (int index = 0; index < rawLines.length; index++) { String rawLine = StrUtil.blankToDefault(rawLines[index], ""); // 先尝试按"行内显式步骤边界"拆分;拆分后的一条物理行可能生成多条逻辑行。 List segments = splitInlineSegments(rawLine); if (segments.isEmpty()) { // 空物理行也保留下来,因为"前后是否有空行"是标题判定的重要上下文特征。 logicalLines.add(new DocumentStructureLogicalLine( logicalLineNo++, index + 1, 1, 0, rawLine, safeText(rawLine))); continue; } for (int segmentIndex = 0; segmentIndex < segments.size(); segmentIndex++) { String segment = segments.get(segmentIndex); // 每个片段单独计算缩进级别,后续列表层级恢复会用到这个值。 logicalLines.add(new DocumentStructureLogicalLine( logicalLineNo++, index + 1, segmentIndex + 1, countIndentLevel(segment), segment, safeText(segment))); } } return logicalLines; } ``` 这里有个细节值得注意:`splitInlineSegments()` 会用零宽前瞻正则检测行内的步骤边界(如"步骤1:下载 步骤2:安装"),把一条物理行拆成多条逻辑行。空行也会保留,因为"前后是否有空行"是后面标题判定的重要上下文特征。 ### buildLineFrequency:统计行频次 ```java /** * 统计每条规范化逻辑行在文档中出现的次数。 *

* 这一步主要服务于"重复噪声识别": * 如果某一短行在全文中频繁重复出现,它更可能是页眉、页脚、版本尾注或版权提示, * 而不是正常正文或标题。 *

*/ private Map buildLineFrequency( List logicalLines) { Map frequency = new LinkedHashMap<>(); for (DocumentStructureLogicalLine logicalLine : logicalLines) { if (logicalLine == null || StrUtil.isBlank(logicalLine.normalizedText())) { continue; } // 只按 normalizedText 计数,避免同一行因为前后空格差异被算成不同内容。 frequency.merge(logicalLine.normalizedText(), 1, Integer::sum); } return frequency; } ``` 频次表后面会被 `isRepeatedNoise()` 使用——如果某行出现 3 次以上且长度不超过 120 字符,大概率是页眉页脚之类的噪声。 ### buildContext:构建行上下文 ```java /** * 为当前逻辑行构造局部上下文。 *

* 这里会向前找到最近一个非空行,向后找到最近一个非空行, * 同时记录当前行前后是否跨过空行。 * 这些上下文信息会直接影响: * 1. 纯文本标题判定; * 2. 单级编号是否更像列表项; * 3. 上一行是否在引入一个列表。 *

*/ private LineContext buildContext(List logicalLines, int currentIndex) { DocumentStructureLogicalLine previousNonBlank = null; boolean blankBefore = false; for (int index = currentIndex - 1; index >= 0; index--) { DocumentStructureLogicalLine candidate = logicalLines.get(index); if (StrUtil.isBlank(candidate.normalizedText())) { // 只要中间跨过空行,就记住"前面有空行",后面标题判定会依赖它。 blankBefore = true; continue; } previousNonBlank = candidate; break; } DocumentStructureLogicalLine nextNonBlank = null; boolean blankAfter = false; for (int index = currentIndex + 1; index < logicalLines.size(); index++) { DocumentStructureLogicalLine candidate = logicalLines.get(index); if (StrUtil.isBlank(candidate.normalizedText())) { blankAfter = true; continue; } nextNonBlank = candidate; break; } return new LineContext(previousNonBlank, nextNonBlank, blankBefore, blankAfter); } ``` `LineContext` 是一个 record,包含四个字段:前一个非空行、后一个非空行、前面是否有空行、后面是否有空行。这些信息在后面的 `looksLikePlainHeading()`、`isNeighborSequence()`、`previousIntroducesList()` 中都会用到。 ### classify:核心分类方法 这是整个信号提取阶段最核心的方法——按优先级从高到低依次尝试 15 种匹配规则,一旦命中就立即返回: ```java /** * 核心分类方法 —— 对单个逻辑行进行结构信号分类。 * 按照优先级从高到低依次尝试匹配:空行 → 噪声 → Markdown标题 → 显式步骤 → 中文章节 → * 附录 → 多级数字编号 → 表格行 → 引用 → 复选框 → 无序列表 → 单级数字编号 → 中文大纲 → 兜底分类。 * 一旦匹配成功立即返回,不再继续后续匹配。 * * @param documentTitle 文档标题,用于检测重复标题噪声 * @param logicalLine 当前待分类的逻辑行 * @param context 当前行的上下文(前后非空行、前后是否有空行) * @param lineFrequency 行文本出现频次表,用于检测重复页眉/页脚 * @return 分类后的结构信号 */ private DocumentStructureSignal classify(String documentTitle, DocumentStructureLogicalLine logicalLine, LineContext context, Map lineFrequency) { int lineNo = logicalLine.lineNo(); String rawText = logicalLine.rawText(); String normalized = logicalLine.normalizedText(); // ========== 1. 空行检测 ========== if (normalized.isBlank()) { return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.BLANK, "", "", 0, null, List.of(), 1.0D); } // ========== 2. 重复噪声检测(页眉/页脚/版权声明等在文档中多次出现的行)========== if (isRepeatedNoise(documentTitle, normalized, lineFrequency.getOrDefault(normalized, 0))) { return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.NOISE, "", "", 0, null, List.of("repeated-running-header-or-footer"), 0.99D); } // ========== 3. 页码噪声检测(如 "第3页"、"Page 5"、"3/10")========== if (PAGE_NOISE_PATTERN.matcher(normalized).matches()) { return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.NOISE, "", "", 0, null, List.of("page-noise"), 0.98D); } // ========== 4. Markdown 标题匹配(如 "## 概述")========== Matcher markdown = MARKDOWN_HEADING_PATTERN.matcher(normalized); if (markdown.matches()) { String title = markdown.group(2).trim(); // 如果标题内容与文档标题相同,视为重复标题噪声而非有效标题 if (sameDocumentTitle(documentTitle, title)) { return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.NOISE, "", title, 0, null, List.of("duplicate-document-title"), 0.99D); } // # 号数量即为标题层级,同时尝试从标题文本中提取编号(如 "## 1.2 配置" 中的 "1.2") DocumentStructureSignal signal = signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.HEADING, extractCode(title), title, markdown.group(1).length(), null, List.of("markdown-heading"), 0.98D); // 从编号中解析出数字路径(如 "1.2" → [1, 2]),用于后续层级构建 signal.setNumericPath(extractNumericPath(signal.getNodeCode())); return signal; } // ========== 5. 显式步骤匹配(如 "第一步:安装" 或 "步骤2:配置")========== Matcher explicitStep = EXPLICIT_STEP_PATTERN.matcher(normalized); if (explicitStep.matches()) { // 从两个捕获组中取非空的那个作为步骤序号,转换为整数 Integer itemIndex = parseLooseNumber(StrUtil.blankToDefault(explicitStep.group(1), explicitStep.group(2))); return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.STEP_ITEM, "", explicitStep.group(3).trim(), null, itemIndex, List.of("explicit-step"), 0.96D); } // ========== 6. 中文章节标题匹配(如 "第一章 绪论"、"第三节 方法")========== Matcher chapter = CHAPTER_PATTERN.matcher(normalized); if (chapter.matches()) { // 章节编号部分,如 "第一章" String code = chapter.group(1).trim(); // 标题文本部分,如 "绪论" String title = chapter.group(3).trim(); // 与文档标题重复时标记为噪声 if (sameDocumentTitle(documentTitle, title)) { return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.NOISE, code, title, 0, null, List.of("duplicate-document-title"), 0.99D); } // 章节标题固定为深度 1(顶级标题) DocumentStructureSignal signal = signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.HEADING, code, title, 1, null, List.of("chapter-heading"), 0.96D); // 将中文数字章节号转换为整数,设置数字路径(如 "第三章" → [3]) Integer chapterNo = parseLooseNumber(chapter.group(2)); if (chapterNo != null && chapterNo > 0) { signal.setNumericPath(List.of(chapterNo)); } return signal; } // ========== 7. 附录标题匹配(如 "附录A 术语表")========== Matcher appendix = APPENDIX_PATTERN.matcher(normalized); if (appendix.matches()) { // 附录编号,如 "附录A" String code = appendix.group(1).trim(); // 附录标题可选,为空时使用编号本身作为标题 String title = StrUtil.blankToDefault(appendix.group(3), code).trim(); return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.HEADING, code, title, 1, null, List.of("appendix-heading"), 0.92D); } // ========== 8. 多级数字编号标题匹配(如 "1.2.3 配置说明")========== Matcher decimal = DECIMAL_HEADING_PATTERN.matcher(normalized); if (decimal.matches()) { // 编号部分,如 "1.2.3" String code = decimal.group(1).trim(); // 标题文本部分 String title = decimal.group(2).trim(); // 层级由编号的段数决定(如 "1.2.3" → 3 级) DocumentStructureSignal signal = signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.HEADING, code, title, Math.max(1, code.split("\\.").length), null, List.of("decimal-heading"), 0.95D); // 解析数字路径(如 "1.2.3" → [1, 2, 3]) signal.setNumericPath(extractNumericPath(code)); return signal; } // ========== 9. 表格行检测(以 | 开头结尾、包含 Tab、或多个 | 分隔)========== if (isTableRow(normalized)) { return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.TABLE_ROW, "", normalized, null, null, List.of("table-row"), 0.90D); } // ========== 10. 引用行检测(以 > 开头的 Markdown 引用)========== if (normalized.startsWith(">")) { return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.QUOTE, "", normalized, null, null, List.of("quote"), 0.88D); } // ========== 11. 复选框列表项匹配(如 "[ ] 待办" 或 "[x] 已完成")========== Matcher checkbox = CHECKBOX_PATTERN.matcher(normalized); if (checkbox.matches()) { return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.LIST_ITEM, "", checkbox.group(1).trim(), null, null, List.of("checkbox-list"), 0.92D); } // ========== 12. 无序列表项匹配(如 "- 项目一"、"* 项目二")========== Matcher bullet = BULLET_PATTERN.matcher(normalized); if (bullet.matches()) { return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.LIST_ITEM, "", bullet.group(2).trim(), null, null, List.of("bullet-list"), 0.90D); } // ========== 13. 单级阿拉伯数字编号匹配(如 "1、概述" 或 "2. 安装")========== // 这类行存在歧义:可能是标题(如 "1、项目背景"),也可能是列表项(如 "1、苹果") Matcher singleDigit = SINGLE_LEVEL_DIGIT_PATTERN.matcher(normalized); if (singleDigit.matches()) { String title = singleDigit.group(2).trim(); // 将编号文本转换为整数序号 Integer itemIndex = parseLooseNumber(singleDigit.group(1)); // 检查前后行是否构成连续序列(如 1、2、3),如果是则更倾向于列表项 boolean sequential = isNeighborSequence(itemIndex, OrderedMarkerFamily.ARABIC_SINGLE, context); // 检查前一行是否以冒号结尾(引导列表的标志,如 "包含以下内容:") boolean introducedByLeadIn = previousIntroducesList(context.previousNonBlank()); // 综合判断:非连续序列、非引导列表、且文本看起来像标题 → 标记为候选标题 boolean headingLike = !sequential && !introducedByLeadIn && looksLikePlainHeading(title, context); DocumentStructureSignal signal = signal( lineNo, rawText, normalized, logicalLine.indentLevel(), // 候选标题使用 HEADING_CANDIDATE(低置信度),后续由歧义消解阶段二次判定 headingLike ? DocumentStructureSignalKind.HEADING_CANDIDATE : DocumentStructureSignalKind.LIST_ITEM, singleDigit.group(1).trim(), title, headingLike ? 1 : null, itemIndex, // 记录分类原因,便于调试和歧义消解参考 List.of(headingLike ? "single-digit-ambiguous-heading" : sequential ? "single-digit-sequence-list" : "single-digit-list"), // 候选标题置信度较低(0.62),确定的列表项置信度较高 headingLike ? 0.62D : sequential || introducedByLeadIn ? 0.93D : 0.88D ); // 如果判定为候选标题,设置数字路径用于后续层级构建 if (headingLike && itemIndex != null && itemIndex > 0) { signal.setNumericPath(List.of(itemIndex)); } return signal; } // ========== 14. 中文大纲编号匹配(如 "一、项目背景"、"三、实施方案")========== // 与单级数字编号类似,也存在标题/列表项的歧义 Matcher chineseOutline = CHINESE_OUTLINE_PATTERN.matcher(normalized); if (chineseOutline.matches()) { String title = chineseOutline.group(2).trim(); // 将中文数字转换为整数序号 Integer index = parseLooseNumber(chineseOutline.group(1)); // 检查前后行是否构成中文大纲的连续序列(如 一、二、三) boolean sequential = isNeighborSequence(index, OrderedMarkerFamily.CHINESE_OUTLINE, context); // 检查前一行是否为引导列表的行 boolean introducedByLeadIn = previousIntroducesList(context.previousNonBlank()); // 综合判断是否更像标题 boolean headingLike = !sequential && !introducedByLeadIn && looksLikePlainHeading(title, context); DocumentStructureSignal signal = signal( lineNo, rawText, normalized, logicalLine.indentLevel(), headingLike ? DocumentStructureSignalKind.HEADING_CANDIDATE : DocumentStructureSignalKind.LIST_ITEM, chineseOutline.group(1).trim(), title, headingLike ? 1 : null, index, List.of(headingLike ? "chinese-outline-ambiguous-heading" : sequential ? "chinese-outline-sequence-list" : "chinese-outline-list"), headingLike ? 0.60D : sequential || introducedByLeadIn ? 0.92D : 0.86D ); if (headingLike && index != null && index > 0) { signal.setNumericPath(List.of(index)); } return signal; } // ========== 15. 兜底分类:使用 DocumentLineClassifier 做最后尝试 ========== DocumentLineClassifier.LineClassification fallback = documentLineClassifier.classify(normalized); // 如果行分类器未识别为标题,但启发式规则认为像标题(短文本、前后有空行、无句末标点) if (!fallback.isHeading() && looksLikePlainHeading(normalized, context)) { // 标记为候选标题,置信度最低(0.58),交由歧义消解阶段最终裁定 return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.HEADING_CANDIDATE, "", normalized, inferPlainHeadingLevel(context), null, List.of("plain-heading-candidate"), 0.58D); } // 所有模式均未匹配,归类为普通正文 return signal(lineNo, rawText, normalized, logicalLine.indentLevel(), DocumentStructureSignalKind.BODY, "", normalized, null, null, List.of("body"), 1.0D); } ``` > 信号类型一览 > > 每行最终会被分类为以下信号类型之一: > > - `DOCUMENT_TITLE`:文档标题(行号 0 的虚拟信号) > - `HEADING`:确定的标题(Markdown 标题、中文章节、多级数字编号等) > - `HEADING_CANDIDATE`:疑似标题(低置信度,需要歧义消解阶段二次判定) > - `STEP_ITEM`:显式步骤项(如"第一步:安装") > - `LIST_ITEM`:列表项(无序列表、有序列表、复选框等) > - `TABLE_ROW`:表格行 > - `QUOTE`:引用行 > - `BODY`:普通正文 > - `BLANK`:空行 > - `NOISE`:噪声行(页码、页眉页脚、版权声明等) ### 最容易出歧义的两种匹配 在 15 种匹配规则中,最有意思的是单级阿拉伯数字编号和中文大纲编号——它们天然存在歧义。比如 `1、项目背景` 到底是标题还是列表项? ```java // ========== 13. 单级阿拉伯数字编号匹配(如 "1、概述" 或 "2. 安装")========== Matcher singleDigit = SINGLE_LEVEL_DIGIT_PATTERN.matcher(normalized); if (singleDigit.matches()) { String title = singleDigit.group(2).trim(); Integer itemIndex = parseLooseNumber(singleDigit.group(1)); // 检查前后行是否构成连续序列(如 1、2、3),如果是则更倾向于列表项 boolean sequential = isNeighborSequence(itemIndex, OrderedMarkerFamily.ARABIC_SINGLE, context); // 检查前一行是否以冒号结尾(引导列表的标志) boolean introducedByLeadIn = previousIntroducesList(context.previousNonBlank()); // 综合判断:非连续序列、非引导列表、且文本看起来像标题 → 候选标题 boolean headingLike = !sequential && !introducedByLeadIn && looksLikePlainHeading(title, context); DocumentStructureSignal signal = signal( lineNo, rawText, normalized, logicalLine.indentLevel(), // 候选标题使用 HEADING_CANDIDATE(低置信度),后续由歧义消解阶段二次判定 headingLike ? DocumentStructureSignalKind.HEADING_CANDIDATE : DocumentStructureSignalKind.LIST_ITEM, singleDigit.group(1).trim(), title, headingLike ? 1 : null, itemIndex, List.of(headingLike ? "single-digit-ambiguous-heading" : sequential ? "single-digit-sequence-list" : "single-digit-list"), // 候选标题置信度较低(0.62),确定的列表项置信度较高 headingLike ? 0.62D : sequential || introducedByLeadIn ? 0.93D : 0.88D ); if (headingLike && itemIndex != null && itemIndex > 0) { signal.setNumericPath(List.of(itemIndex)); } return signal; } ``` 判断逻辑的核心思路是: - 如果前后行构成连续序列(1、2、3...),大概率是列表项 - 如果前一行以冒号结尾(如"包含以下内容:"),大概率是列表项 - 如果都不是,再用 `looksLikePlainHeading()` 做启发式判断——文本短、前后有空行、不以句末标点结尾 → 更像标题 但即使判断为"像标题",也只标记为 `HEADING_CANDIDATE`(置信度 0.62),而不是直接定性为 `HEADING`。最终裁定交给第二阶段的歧义消解器。 上面这段歧义判断代码里调用了好几个辅助方法,我们逐个看一下。 ### sameDocumentTitle:文档标题重复检测 ```java /** * 判断某一行文本是否与文档标题本质相同。 *

* 用于识别这种常见情况: * 文档名叫"部署手册",正文第一行又重复写了一遍"部署手册"。 * 这种行不应再被当成一个真实 section 节点,否则会多出一层无意义标题。 *

*/ private boolean sameDocumentTitle(String documentTitle, String candidate) { String left = normalizeComparableTitle(documentTitle); String right = normalizeComparableTitle(candidate); return StrUtil.isNotBlank(left) && left.equals(right); } ``` 比较之前会先做标准化处理:去掉 Markdown `#` 前缀、去掉文件扩展名后缀、去掉所有空格、转小写。这样 `## 部署手册` 和 `部署手册.pdf` 都能被识别为同一个标题。 ### isNeighborSequence:邻居序列检测 textjavascripttypescriptcsshtmlbashjsonmarkdownpythonjavaccpprubygorustphpsqlyaml Copy ```java /** * 判断当前编号是否与前后相邻编号形成有序序列。 *

* 这是区分"标题"与"列表项"的关键特征之一: * 如果当前是 2,前面是 1 或后面是 3,那么它更像有序列表项; * 如果找不到相邻序列证据,则它更有机会被当作标题候选。 *

*/ private boolean isNeighborSequence(Integer itemIndex, OrderedMarkerFamily family, LineContext context) { if (itemIndex == null || family == null) { return false; } // 只要前一个是 N-1 或后一个是 N+1,就认为当前编号处于一个连续序列中。 return isSequenceNeighbor(context.previousNonBlank(), itemIndex, family, -1) || isSequenceNeighbor(context.nextNonBlank(), itemIndex, family, 1); } ``` 举个例子:如果当前行是 `2、安装依赖`,前一行是 `1、下载源码`,那 `isNeighborSequence` 返回 true,当前行就会被判定为列表项而不是标题。这个方法支持两种编号族:`ARABIC_SINGLE`(阿拉伯数字)和 `CHINESE_OUTLINE`(中文大纲)。 ### previousIntroducesList:列表引导行检测 ```java /** * 判断上一条非空行是否在"引出一个列表"。 *

* 典型特征是以中文或英文冒号结尾,例如"包含以下内容:"。 * 如果当前编号行前面正好出现这种引导句,那么当前行更可能是列表项,而不是章节标题。 *

*/ private boolean previousIntroducesList(DocumentStructureLogicalLine previousNonBlank) { if (previousNonBlank == null) { return false; } String previous = safeText(previousNonBlank.normalizedText()); return previous.endsWith(":") || previous.endsWith(":"); } ``` 逻辑很简单:如果上一行以冒号结尾(如"系统要求如下:"),那后面跟着的编号行大概率是列表项。 ### parseLooseNumber:宽松数字解析 textjavascripttypescriptcsshtmlbashjsonmarkdownpythonjavaccpprubygorustphpsqlyaml Copy ```java /** * 宽松解析数字编号。 *

* 这里既支持阿拉伯数字,也支持常见中文数字表达, * 例如:"3" → 3、"十" → 10、"十二" → 12、"二十" → 20、"二十三" → 23 *

*

* 它主要用于章节编号、步骤编号、中文大纲编号的统一数值化, * 让后续邻居序列判断和 numericPath 计算都能在整数层面进行。 *

*/ private Integer parseLooseNumber(String text) { String normalized = safeText(text); if (normalized.isBlank()) { return null; } // 纯数字时直接走最简单路径。 if (normalized.chars().allMatch(Character::isDigit)) { return Integer.parseInt(normalized); } Map digitMap = Map.of( '一', 1, '二', 2, '三', 3, '四', 4, '五', 5, '六', 6, '七', 7, '八', 8, '九', 9 ); // 下面按最常见的中文十位表达做有限支持,覆盖结构编号里高频出现的形式。 if ("十".equals(normalized)) { return 10; } if (normalized.startsWith("十") && normalized.length() == 2) { return 10 + digitMap.getOrDefault(normalized.charAt(1), 0); } if (normalized.endsWith("十") && normalized.length() == 2) { return digitMap.getOrDefault(normalized.charAt(0), 0) * 10; } if (normalized.contains("十") && normalized.length() == 3) { return digitMap.getOrDefault(normalized.charAt(0), 0) * 10 + digitMap.getOrDefault(normalized.charAt(2), 0); } // 兜底处理"单个中文数字"。 return digitMap.get(normalized.charAt(0)); } ``` 这个方法在整个信号提取阶段被大量使用——步骤编号、章节编号、中文大纲编号、邻居序列判断都要先把文本编号转成整数。它覆盖了 1~99 范围内的中文数字表达,对于文档结构编号来说完全够用。 ### DocumentLineClassifier.classify:兜底行分类器 在 classify 方法的第 15 步(兜底分类)中,如果前面所有正则都没命中,会调用 `DocumentLineClassifier` 做最后一次尝试: ```java /** * 对单行文本做轻量级结构分类。 *

* 判定顺序遵循“高确定性规则优先,模糊规则后置”的原则: * Markdown 标题、附录、显式步骤、中文章节、多级数字编号等模式优先; * 单级数字编号和中文大纲这种有歧义的模式,则进一步看内容是否像标题; * 都不命中时回退成 BODY。 *

*/ public LineClassification classify(String line) { String normalized = safeText(line); if (normalized.isBlank()) { return new LineClassification(LineKind.BODY, 0, normalized, normalized); } Matcher markdownMatcher = MARKDOWN_HEADING_PATTERN.matcher(normalized); if (markdownMatcher.matches()) { // Markdown 标题是最明确的 heading 形态,# 数量直接对应层级。 int level = markdownMatcher.group(1).length(); return heading(level, markdownMatcher.group(2).trim(), normalized); } Matcher appendixMatcher = APPENDIX_PATTERN.matcher(normalized); if (appendixMatcher.matches()) { // 附录天然视为一级标题。 return heading(1, normalized, normalized); } Matcher explicitStepMatcher = EXPLICIT_STEP_PATTERN.matcher(normalized); if (explicitStepMatcher.matches()) { // “步骤1 / 第一步” 这类模式优先视为列表项或步骤项,而不是章节标题。 return listItem(normalized); } Matcher chapterMatcher = CHINESE_CHAPTER_PATTERN.matcher(normalized); if (chapterMatcher.matches()) { // 中文章节标题通常是较高层级标题,这里先统一给出 heading 判定。 return heading(2, normalized, normalized); } Matcher multiLevelDigitMatcher = MULTI_LEVEL_DIGIT_HEADING_PATTERN.matcher(normalized); if (multiLevelDigitMatcher.matches()) { // 1.2 / 2.3.4 这种多级编号,大概率就是标题层级结构。 String prefix = multiLevelDigitMatcher.group(1); return heading(prefix.split("\\.").length, normalized, normalized); } Matcher chineseOutlineMatcher = CHINESE_OUTLINE_PATTERN.matcher(normalized); if (chineseOutlineMatcher.matches()) { String content = chineseOutlineMatcher.group(2).trim(); // “一、xxx” 既可能是标题也可能是列表项,因此只做一层启发式判断。 if (looksLikeHeadingContent(content)) { return heading(1, normalized, normalized); } return listItem(normalized); } Matcher singleLevelDigitMatcher = SINGLE_LEVEL_DIGIT_LINE_PATTERN.matcher(normalized); if (singleLevelDigitMatcher.matches()) { String content = singleLevelDigitMatcher.group(2).trim(); // “1、xxx / 2. xxx” 同样存在标题/列表歧义,借助内容形态做轻量判断。 if (looksLikeHeadingContent(content)) { return heading(1, normalized, normalized); } return listItem(normalized); } if (normalized.startsWith("- ") || normalized.startsWith("* ") || normalized.startsWith("+ ") || normalized.startsWith("- [") || normalized.startsWith("* [") || normalized.startsWith("+ [")) { // 常见无序列表和任务列表标记,统一视为列表项。 return listItem(normalized); } // 其余情况统一作为正文。 return new LineClassification(LineKind.BODY, 0, normalized, normalized); } ``` `DocumentLineClassifier` 和 `DocumentStructureSignalExtractor` 的区别在于:它不依赖上下文(前后行、行频次),只看当前这一行的文本形态做判断。所以它更像一个"轻量级兜底分类器"——在主分类器的 15 条规则都没命中时,再用它做最后一次尝试。 它内部的 `looksLikeHeadingContent()` 也是一个启发式判断:文本不超过 24 个字符、不以句末标点结尾、不包含中文逗号/分号/句号/冒号,就认为更像标题内容。 ### looksLikePlainHeading:启发式标题判断 ```java private boolean looksLikePlainHeading(String text, LineContext context) { String normalized = safeText(text); if (text.isBlank()) { return false; } // 超过配置的最大纯标题字符数,不太可能是标题 if (normalized.length() > properties.getStructureParsing().getMaxPlainHeadingChars()) { return false; } // 以句末标点结尾的更像是正文句子而非标题 if (endsWithSentencePunctuation(normalized)) { return false; } // 包含 URL 的行不太可能是标题 if (normalized.contains("http://") || normalized.contains("https://")) { return false; } // 以 | 开头或结尾的行更像是表格行 if (normalized.startsWith("|") || normalized.endsWith("|")) { return false; } // 仅由重复的 -、=、_ 组成的行是分隔线 if (normalized.matches("^[\\-=_]{3,}$")) { return false; } // 标题的关键特征:前面或后面有空行(与正文隔开) boolean isolated = context.blankBefore() || context.blankAfter(); // 标题后面应该有实际内容 boolean nextLooksContent = context.nextNonBlank() != null && StrUtil.isNotBlank(context.nextNonBlank().normalizedText()) && !context.nextNonBlank().normalizedText().matches("^[:\\-\\s|]+$"); // 标题通常是名词短语,不包含中文逗号、分号、句号、冒号等 boolean nounLike = !normalized.contains(",") && !normalized.contains(";") && !normalized.contains("。") && !normalized.contains(":"); // 三个条件同时满足才判定为疑似标题 return isolated && nextLooksContent && nounLike; } ``` 这个方法用了一系列排除法:太长的不是、有句末标点的不是、有 URL 的不是、有中文标点的不是……最后剩下的"短文本 + 前后有空行 + 后面有内容 + 像名词短语"才算疑似标题。 ## 第二阶段:歧义消解(AmbiguityResolver) 第一阶段产出的信号里,最容易出错的就是那些 `HEADING_CANDIDATE`——它们置信度低,到底是标题还是列表项,规则很难 100% 判准。这时候就请 LLM 来帮忙做二次判定。 ```java public List resolve(String documentTitle, List allLines, List sourceSignals) { if (sourceSignals == null || sourceSignals.isEmpty()) { return List.of(); } // 只在配置开启且模型可用时生效 if (!Boolean.TRUE.equals(properties.getStructureParsing() .getLlmDisambiguationEnabled())) { return sourceSignals; } ChatModel chatModel = chatModelProvider.getIfAvailable(); if (chatModel == null) { return sourceSignals; } // 只挑出真正模糊、值得交给 LLM 看一眼的信号 List ambiguousSignals = sourceSignals.stream() .filter(signal -> signal != null && signal.isAmbiguous() && signal.getConfidence() >= properties.getStructureParsing() .getAmbiguityConfidenceFloor() && signal.getConfidence() <= properties.getStructureParsing() .getAmbiguityConfidenceCeil()) .limit(Math.max(1, properties.getStructureParsing() .getMaxAmbiguousSignalsPerCall())) .toList(); if (ambiguousSignals.isEmpty()) { return sourceSignals; } try { // prompt 中只放局部上下文窗口,避免模型被整篇文档无关内容干扰 String prompt = buildPrompt(documentTitle, ambiguousSignals, allLines); String content = ChatClient.builder(chatModel).build() .prompt().user(prompt).call().content(); List results = parse(content); // ... 将 LLM 结果合并回原始信号列表 ... } catch (Exception exception) { // 判歧失败时只回退,不阻断主链 log.warn("结构歧义判定失败,回退到规则结果: {}", exception.getMessage()); return sourceSignals; } } ``` > 歧义消解的设计约束 > > 这个阶段有意做了很强的约束: > > - 只在配置开启 + 模型可用时才生效,否则直接跳过 > - 只处理置信度落在指定区间内的模糊信号,不是所有信号都丢给 LLM > - 有数量上限,不会一次性发太多候选行 > - 失败时静默回退到规则结果,不阻断主链 > > 目的是让 LLM 只承担"判边界"的工作,而不是接管整套结构解析。 ### buildPrompt:构造判歧 prompt 这里把提示词抽到外部的 StringTemplate 模板文件中,通过 `PromptTemplateService` 统一渲染。先看两个模板文件,再看 Java 代码怎么调用它们。 #### 主模板:document-structure-ambiguity.st 这个模板定义了发给 LLM 的完整 prompt 框架,包含角色设定、输出格式要求、判歧规则,以及两个占位符 `` 和 ``: ```text 你是文档结构判歧助手。 你的任务是判断若干低置信度文本行,在当前上下文中更像: - HEADING:章节/小节标题 - LIST_ITEM:普通列表项 - BODY:普通正文 请严格返回 JSON 数组,不要附加解释: [ { "line_no": 12, "resolved_kind": "HEADING | LIST_ITEM | BODY", "level_hint": 1 } ] 规则: 1. 只有在非常像章节标题时才输出 HEADING。 2. 连续出现的编号项、步骤项、清单项优先判断为 LIST_ITEM。 3. 表格说明行、引用行、解释性句子优先判断为 BODY。 4. level_hint 只有 resolved_kind=HEADING 时才填写;没有把握时填 null。 5. 不要脑补目录结构,只依据提供的局部上下文判断。 文档标题: ``` #### 候选行模板:document-structure-ambiguity-candidate.st 每个低置信度候选行会用这个模板渲染成一个独立的文本块,包含行号、上下文窗口、初始分类结果等信息: ```text ### 候选行 初始判断: 初始标题: 初始编码: ``` #### Java 侧的调用代码 `buildPrompt` 本身变得非常简洁——只负责把文档标题和候选行文本块传给主模板: ```java /** * 构造发给 LLM 的判歧 prompt。 *

* 通过 PromptTemplateService 渲染外部 .st 模板文件, * 而不是在代码里硬编码提示词文本。这样修改提示词只需要改模板文件, * 不用动 Java 代码,也不用重新编译。 *

* * @param documentTitle 文档标题,会填充到模板的 documentTitle 占位符 * @param ambiguousSignals 需要 LLM 判歧的低置信度信号列表 * @param allLines 文档所有行的规范化文本,用于截取候选行的上下文窗口 * @return 渲染后的完整 prompt 文本 */ private String buildPrompt(String documentTitle, List ambiguousSignals, List allLines) { // 调用 PromptTemplateService 渲染主模板 document-structure-ambiguity.st, // 传入两个变量:documentTitle(文档标题)和 candidateBlocks(所有候选行拼接后的文本块) return promptTemplateService.render(PromptTemplateNames.DOCUMENT_STRUCTURE_AMBIGUITY, Map.of( "documentTitle", StrUtil.blankToDefault(documentTitle, "未命名文档"), "candidateBlocks", buildCandidateBlocks(ambiguousSignals, allLines) )); } ``` 候选行文本块的构建逻辑被拆到了 `buildCandidateBlocks` 方法中: textjavascripttypescriptcsshtmlbashjsonmarkdownpythonjavaccpprubygorustphpsqlyaml Copy ```java /** * 为每个低置信度候选行构建带上下文窗口的文本块。 *

* 每个候选行会截取前后 N 行作为局部上下文(N 由配置项 contextWindowLines 控制), * 然后用 document-structure-ambiguity-candidate.st 模板渲染成一段文本。 * 最终所有候选行的文本块拼接在一起,填充到主模板的 candidateBlocks 占位符中。 *

* * @param ambiguousSignals 需要判歧的候选信号列表 * @param allLines 文档所有行的规范化文本 * @return 所有候选行文本块拼接后的字符串 */ private String buildCandidateBlocks(List ambiguousSignals, List allLines) { StringBuilder builder = new StringBuilder(); // 防御性处理:allLines 为 null 时用空列表兜底 List safeLines = allLines == null ? List.of() : allLines; // 从配置中读取上下文窗口大小(候选行前后各取多少行),最小为 1 int contextWindow = Math.max(1, properties.getStructureParsing().getContextWindowLines()); for (DocumentStructureSignal signal : ambiguousSignals) { if (signal == null) { continue; } // 计算当前候选行在 allLines 中的索引(行号从 1 开始,索引从 0 开始) int currentIndex = Math.max(0, signal.getLineNo() - 1); // 计算上下文窗口的起止索引,确保不越界 int start = Math.max(0, currentIndex - contextWindow); int end = Math.min(safeLines.size() - 1, currentIndex + contextWindow); // 构建上下文行文本:候选行本身用 ">> " 前缀高亮,其余行用 " " 缩进对齐 StringBuilder contextBuilder = new StringBuilder(); for (int index = start; index <= end; index++) { contextBuilder.append(index + 1 == signal.getLineNo() ? ">> " : " ") .append(index + 1) .append(": ") .append(StrUtil.blankToDefault(safeLines.get(index), "")) .append('\n'); } // 用候选行模板渲染单个候选行的文本块,填入行号、上下文、初始分类等变量 builder.append(promptTemplateService.render( PromptTemplateNames.DOCUMENT_STRUCTURE_AMBIGUITY_CANDIDATE, Map.of( "lineNo", signal.getLineNo(), "contextLines", contextBuilder.toString().stripTrailing(), "initialKind", signal.getKind() == null ? "" : signal.getKind().name(), "initialTitle", StrUtil.blankToDefault(signal.getTitle(), ""), "initialCode", StrUtil.blankToDefault(signal.getNodeCode(), "") ))).append("\n\n"); } return builder.toString().trim(); } ``` prompt 的设计思路没有变:只给 LLM 看候选行周围的局部上下文窗口,不是整篇文档,既减少 token 消耗,又避免模型被无关内容干扰。变化在于实现方式——提示词文本从 Java 代码中剥离到了 `.st` 模板文件,通过 `PromptTemplateService` 统一加载和渲染。这样做的好处是: - **修改提示词不用改代码**:调整措辞、增删规则只需要编辑 `.st` 文件,不用重新编译部署 - **提示词和业务逻辑分离**:Java 代码只关心"传什么变量",模板文件只关心"怎么组织 prompt" - **统一管理**:所有提示词模板都放在 `resources/prompt/` 目录下,通过 `PromptTemplateNames` 常量类引用,方便查找和维护 ## 第三阶段:层级构建(DocumentStructureHierarchyResolver) 经过前两个阶段,我们拿到了一份"每行都有明确信号类型"的扁平列表。第三阶段要做的事情是:把这些扁平信号组装成一棵带父子关系的草稿树。 ```java /** * 将信号列表解析成结构草稿树。 *

* 处理方式是一次线性扫描: * 标题会切换当前 section, * 列表会维护缩进栈, * 正文/表格/引用则附着到当前 section 或当前列表项。 *

*/ public List resolve(String documentTitle, List signals) { List drafts = new ArrayList<>(); // 所有文档都从根节点开始,后续 section/list/body 都挂在它下面。 DocumentStructureNodeDraft root = new DocumentStructureNodeDraft(); root.setNodeNo(1); root.setLineNo(0); root.setNodeType(DocumentStructureNodeTypeEnum.DOCUMENT.getCode()); root.setParentNodeNo(null); root.setDepth(0); root.setNodeCode(""); root.setTitle(StrUtil.blankToDefault(documentTitle, "文档")); root.setAnchorText(StrUtil.blankToDefault(documentTitle, "文档")); root.setCanonicalPath("/document"); root.setSectionPath(""); root.setSourceFamily("document"); root.setConfidence(1.0D); drafts.add(root); int nextNodeNo = 2; DocumentStructureNodeDraft currentSection = root; DocumentStructureNodeDraft currentListItem = null; // listStack 用于维护当前列表嵌套关系,主要依据缩进层级。 Deque listStack = new ArrayDeque<>(); // 这两个索引表帮助数字标题恢复更合理的父子关系。 Map latestHeadingByDepth = new LinkedHashMap<>(); Map latestHeadingByNumericPath = new LinkedHashMap<>(); for (DocumentStructureSignal signal : signals) { if (signal == null || signal.getLineNo() == 0) { continue; } switch (signal.getKind()) { case BLANK -> { // 空行会打断列表上下文,但不会结束当前 section。 currentListItem = null; listStack.clear(); } case NOISE -> { } case TABLE_ROW, QUOTE, BODY -> { // 正文类信号不新建节点,而是附着到当前上下文。 appendBody(signal, currentSection, currentListItem, root, drafts); } case STEP_ITEM, LIST_ITEM -> { // 列表/步骤会新建 list-like 节点,并根据缩进找到父节点。 DocumentStructureNodeDraft listParent = resolveListParent(signal, currentSection == null ? root : currentSection, listStack, root); DocumentStructureNodeDraft listNode = buildListNode(signal, nextNodeNo++, listParent); drafts.add(listNode); currentListItem = listNode; registerListContext(signal, listNode, listStack); if (currentSection != null) { currentSection.appendLine(signal.getNormalizedText()); } } case HEADING, HEADING_CANDIDATE -> { // 标题会切换当前 section,并清空当前列表上下文。 DocumentStructureNodeDraft headingNode = buildHeadingNode( signal, nextNodeNo++, drafts, latestHeadingByDepth, latestHeadingByNumericPath ); drafts.add(headingNode); currentSection = headingNode; currentListItem = null; listStack.clear(); } default -> appendBody(signal, currentSection, currentListItem, root, drafts); } } drafts.sort(Comparator.comparing(DocumentStructureNodeDraft::getNodeNo)); return drafts; } ``` 整个方法是一次线性扫描,维护着几个关键的"当前上下文": - `currentSection`:当前所在的章节节点,遇到新标题就切换 - `currentListItem`:当前所在的列表项节点,遇到空行就清空 - `listStack`:列表嵌套的缩进栈,用于处理多级列表的父子关系 - `latestHeadingByDepth` / `latestHeadingByNumericPath`:标题索引表,用于恢复数字编号标题的父子关系 ### 标题深度推断 标题节点的深度推断是层级构建中最关键的逻辑: ```java private int resolveHeadingDepth(DocumentStructureSignal signal, List drafts, Map latestHeadingByDepth, Map latestHeadingByNumericPath) { String family = resolveHeadingFamily(signal); List numericPath = signal.getNumericPath() == null ? List.of() : signal.getNumericPath(); if ("markdown".equals(family)) { // Markdown 直接用 # 号数量作为层级 return Math.max(1, safeLevel(signal.getLevelHint(), 1)); } if ("chapter".equals(family) || "appendix".equals(family)) { // 中文章节和附录强制视为一级标题 return 1; } if ("decimal".equals(family)) { // 多级数字编号优先依赖 numericPath 恢复层级 if (numericPath.size() <= 1) { return 1; } // 先找直接上级编号(如 1.2 的父级是 1) Integer parentNodeNo = latestHeadingByNumericPath.get( numericKey(numericPath.subList(0, numericPath.size() - 1))); if (parentNodeNo != null) { DocumentStructureNodeDraft parent = findByNodeNo(drafts, parentNodeNo); if (parent != null) { return parent.getDepth() + 1; } } // 找不到直接上级,退到同章节点 Integer chapterParent = latestHeadingByNumericPath.get( numericKey(List.of(numericPath.get(0)))); if (chapterParent != null) { DocumentStructureNodeDraft parent = findByNodeNo(drafts, chapterParent); if (parent != null) { return parent.getDepth() + 1; } } // 都找不到,用编号段数作为层级 return numericPath.size(); } return Math.max(1, safeLevel(signal.getLevelHint(), 1)); } ``` 不同来源的标题用不同策略推断深度: - **Markdown**:`#` 号数量就是层级 - **中文章节 / 附录**:固定为 1 级 - **多级数字编号**(如 1.2.3):先找已有的父级编号节点,找到了就 `父级深度 + 1`;找不到就用编号段数 ### 正文附着逻辑 ```java private void appendBody(DocumentStructureSignal signal, DocumentStructureNodeDraft currentSection, DocumentStructureNodeDraft currentListItem, DocumentStructureNodeDraft root, List drafts) { String line = signal == null ? "" : signal.getNormalizedText(); if (StrUtil.isBlank(line)) { return; } // 如果当前在列表项内部,正文优先归属列表项 DocumentStructureNodeDraft target = currentListItem != null ? currentListItem : (currentSection == null ? root : currentSection); target.appendLine(line); // 同时为了保留 section 级大语义内容,也会同步追加到当前 section if (currentListItem != null && currentSection != null && !Objects.equals(currentSection.getNodeNo(), currentListItem.getNodeNo())) { currentSection.appendLine(line); } } ``` 正文不会新建节点,而是"附着"到当前上下文。如果当前在列表项里,正文归属列表项;同时也会追加到所在的 section,保证 section 级别的内容是完整的。 ## 第四阶段:树校验(DocumentStructureTreeValidator) 层级构建阶段产出的 draft 已经有了初步的树形关系,但仍然可能存在一些问题:重复标题节点、编号父链断裂、父节点缺失、section 被错挂在列表节点下面等。第四阶段就是做最终收口,输出稳定的、可落库的候选节点。 textjavascripttypescriptcsshtmlbashjsonmarkdownpythonjavaccpprubygorustphpsqlyaml Copy ```java public List validateAndBuild(String documentTitle, List drafts) { if (drafts == null || drafts.isEmpty()) { return List.of(); } Map draftMap = new LinkedHashMap<>(); for (DocumentStructureNodeDraft draft : drafts) { if (draft != null && draft.getNodeNo() != null) { draftMap.put(draft.getNodeNo(), draft); } } // 下面这些步骤共同把"草稿树"收敛成最终结构树。 collapseSyntheticTitleSection(documentTitle, draftMap); // 1. 折叠重复标题 repairNumberedHierarchy(draftMap); // 2. 修复数字编号父链 repairInvalidParents(draftMap); // 3. 修复非法父节点 recomputeDepths(draftMap); // 4. 重算深度 rebuildPaths(documentTitle, draftMap); // 5. 重建路径 rebuildSiblingLinks(draftMap); // 6. 重建兄弟关系 return draftMap.values().stream() .sorted(Comparator.comparingInt(DocumentStructureNodeDraft::getNodeNo)) .map(this::toCandidate) .toList(); } ``` > 处理顺序很关键 > > 这六个步骤的执行顺序不能随意调换:必须先折叠重复标题、修复父链,然后才能安全地重算深度、重建路径和兄弟关系。如果顺序反了,后面的步骤会基于错误的父子关系计算出错误的结果。 我们逐个看这六个步骤。 ### 步骤一:折叠重复标题 ```java /** * 折叠与文档标题重复的伪一级标题。 */ private void collapseSyntheticTitleSection(String documentTitle, Map draftMap) { String normalizedTitle = normalizeComparableTitle(documentTitle); if (normalizedTitle.isBlank()) { return; } Integer duplicateNodeNo = null; for (DocumentStructureNodeDraft draft : draftMap.values()) { if (draft == null || draft.getNodeNo() == null || draft.getNodeNo() == 1 || !draft.isSection() || !Objects.equals(draft.getParentNodeNo(), 1) || StrUtil.isNotBlank(draft.getNodeCode())) { continue; } if (normalizedTitle.equals(normalizeComparableTitle(draft.getTitle()))) { duplicateNodeNo = draft.getNodeNo(); break; } } if (duplicateNodeNo == null) { return; } // 把重复标题的子节点全部挂到根节点下,然后删除重复标题本身 for (DocumentStructureNodeDraft draft : draftMap.values()) { if (draft != null && Objects.equals(draft.getParentNodeNo(), duplicateNodeNo)) { draft.setParentNodeNo(1); } } draftMap.remove(duplicateNodeNo); } ``` 有些文档的正文里会重复出现一次文档标题(比如 PDF 提取出来的第一行就是标题),这会导致树里出现一个多余的一级 section。这一步就是找到这种重复标题,把它的子节点提升到根节点下,然后删掉它。 ### 步骤二:修复数字编号父链 ```java /** * 根据 numericPath 修复数字编号 section 的父链。 */ private void repairNumberedHierarchy(Map draftMap) { Map numericPathMap = new LinkedHashMap<>(); for (DocumentStructureNodeDraft draft : draftMap.values()) { if (draft == null || !draft.isSection()) { continue; } String key = numericKey(draft.getNumericPath()); if (StrUtil.isNotBlank(key)) { numericPathMap.putIfAbsent(key, draft.getNodeNo()); } } for (DocumentStructureNodeDraft draft : draftMap.values()) { if (draft == null || !draft.isSection()) { continue; } List numericPath = draft.getNumericPath(); if (numericPath == null || numericPath.isEmpty()) { continue; } if (numericPath.size() == 1) { // 一级编号(如 "1")直接挂到根节点 draft.setParentNodeNo(1); continue; } // 多级编号(如 "1.2.3")先找直接上级("1.2") String directParentKey = numericKey( numericPath.subList(0, numericPath.size() - 1)); Integer directParent = numericPathMap.get(directParentKey); if (directParent != null) { draft.setParentNodeNo(directParent); continue; } // 找不到直接上级,退到同章节点("1") String chapterParentKey = numericKey(List.of(numericPath.get(0))); Integer chapterParent = numericPathMap.get(chapterParentKey); if (chapterParent != null) { draft.setParentNodeNo(chapterParent); } } } ``` 比如文档里有 `1.1`、`1.2`、`1.2.1` 这样的编号标题,层级构建阶段可能因为扫描顺序的原因没有正确建立父子关系。这一步通过 numericPath 索引表重新修复:`1.2.1` 的父级应该是 `1.2`,`1.2` 的父级应该是 `1`。 ### 步骤三到六:修复 + 重算 + 重建 ```java /** * 修复非法父节点关系。 * 例如父节点不存在,或者 section 被错误挂在 list-like 节点下面。 */ private void repairInvalidParents(Map draftMap) { for (DocumentStructureNodeDraft draft : draftMap.values()) { if (draft == null || draft.getNodeNo() == 1) { continue; } DocumentStructureNodeDraft parent = draft.getParentNodeNo() == null ? null : draftMap.get(draft.getParentNodeNo()); if (parent == null) { draft.setParentNodeNo(1); // 父节点不存在,挂到根节点 continue; } if (draft.isSection() && parent.isListLike()) { // section 不应该挂在列表项下面,提升到列表项的父节点 draft.setParentNodeNo( parent.getParentNodeNo() == null ? 1 : parent.getParentNodeNo()); } } } /** * 基于最终 parentNodeNo 重新计算 depth。 */ private void recomputeDepths(Map draftMap) { DocumentStructureNodeDraft root = draftMap.get(1); if (root == null) { return; } root.setDepth(0); List ordered = draftMap.values().stream() .sorted(Comparator.comparingInt(DocumentStructureNodeDraft::getNodeNo)).toList(); for (DocumentStructureNodeDraft draft : ordered) { if (draft == null || draft.getNodeNo() == 1) { continue; } DocumentStructureNodeDraft parent = draftMap.get(draft.getParentNodeNo()); draft.setDepth(parent == null ? 1 : parent.getDepth() + 1); } } ``` `rebuildPaths` 会为每个节点重建两种路径: - `canonicalPath`:机器定位用的路径,如 `/document/1.2/1.2.1` - `sectionPath`:人类可读的章节链,如 `第一章 绪论 > 1.1 背景` `rebuildSiblingLinks` 会在同一父节点下的子节点之间建立前后兄弟关系(`prevSiblingNodeNo` / `nextSiblingNodeNo`)。 ### 最终输出 六个步骤全部完成后,把 draft 转成最终的 `DocumentStructureNodeCandidate`: textjavascripttypescriptcsshtmlbashjsonmarkdownpythonjavaccpprubygorustphpsqlyaml Copy ```java private DocumentStructureNodeCandidate toCandidate(DocumentStructureNodeDraft draft) { return new DocumentStructureNodeCandidate( draft.getNodeNo(), draft.getNodeType(), draft.getParentNodeNo(), normalizeSibling(draft.getPrevSiblingNodeNo()), normalizeSibling(draft.getNextSiblingNodeNo()), draft.getDepth(), draft.getNodeCode(), draft.getTitle(), draft.getAnchorText(), draft.getCanonicalPath(), draft.getSectionPath(), draft.contentText(), draft.getItemIndex() ); } ``` > DocumentStructureNodeCandidate 字段说明 > > 最终输出的每个节点包含: > > - `nodeNo`:节点编号 > - `nodeType`:节点类型(DOCUMENT / SECTION / LIST\_ITEM / STEP) > - `parentNodeNo` / `prevSiblingNodeNo` / `nextSiblingNodeNo`:父节点和前后兄弟 > - `depth`:在树中的深度(根节点为 0) > - `nodeCode`:编号部分(如 "1.2.3"、"第一章") > - `title` / `anchorText`:标题文本和锚点文本 > - `canonicalPath` / `sectionPath`:机器路径和人类可读路径 > - `contentText`:该节点下聚合的正文内容 > - `itemIndex`:列表项序号 ## 小结 整个结构节点提取流水线可以概括为: **逐行正则匹配 → 低置信度行交给 LLM 判歧 → 扁平信号组装成草稿树 → 修复父链/重算深度/重建路径 → 输出最终节点** 四个阶段各司其职:第一阶段追求"宁可多标不可漏标",第二阶段用 LLM 修正最容易误判的边界,第三阶段把扁平信号变成树,第四阶段做最终的质量兜底。这样设计的好处是每个阶段都可以独立调试和优化,不会互相耦合。 提取出来的结构节点会被 `DocumentStructureNodeService.replaceDocumentNodes()` 写入数据库,后续用于导航索引、结构图投影,以及策略推荐阶段判断"是否适合结构切块"。 下一篇我们来看策略推荐——系统是怎么根据解析结果自动生成 Parent/Child 切块方案的。 --- **企业级项目导航**:⬅️ [[06-知识库系统的入口工程|06-知识库系统的入口工程]] | 07-结构节点提取的四阶段流水线 | ➡️ [[08-切块策略落库|08-切块策略落库]]