MinerU - PDF 转 Markdown 后多级标题丢失的两种解决方法

使用 MinerU 将 PDF 转换为 Markdown 时,正文标题可能全部变成一级标题,导致原有的章节层级丢失。本文介绍两种解决方法:启用 MinerU 的 LLM 辅助标题优化,让模型在解析阶段完成标题分级;或者根据 PDF 目录、书签等已有结构,对生成的 Markdown 做确定性的后处理。

问题现象

一份 PDF 原本包含章、节和小节,例如:

1 产品介绍
1.1 功能概述
1.1.1 使用条件

经过 MinerU 解析后,Markdown 中的标题却可能全部变成一级标题:

# 产品介绍
# 功能概述
# 使用条件

标题文字虽然还在,但层级关系已经消失。这会影响 Markdown 目录和阅读体验;如果文档还要进入知识库,切片结果也会缺少各级章节之间的上下文。

MinerU 社区对这一问题的说明是:标题分级需要使用 LLM 辅助能力;未启用时,识别出的标题可能统一以一级标题输出。解决问题有两个方向:让 MinerU 在解析时借助 LLM 推断层级,或者在解析完成后从 PDF 的现有结构中恢复层级。

两种方法如何选择

两种方法是两条独立路径:

方法 层级依据 适用场景 主要成本或限制
LLM 辅助标题优化 模型结合标题内容和上下文推断 PDF 没有可靠目录或书签,文档格式差异较大 需要模型 API,存在耗时、费用和结果波动
根据目录或书签恢复 PDF 已有的目录编号或书签层级 手册、规范等结构完整的 PDF 依赖标题匹配,需要编写或运行后处理脚本

如果处理的是来源复杂的大批量文档,可以先尝试 LLM 辅助标题优化。如果 PDF 本身已经带有可靠的目录或书签,应优先复用这些确定性结构;它们是文档作者明确给出的层级信息,通常比重新推断更稳定。

方法一:启用 LLM 辅助标题优化

LLM 辅助标题优化发生在 MinerU 的解析流程中。MinerU 先识别标题块,再把相关信息交给支持 OpenAI 协议的模型,由模型判断各标题应当属于哪一级。处理完成后,输出的 Markdown 会直接包含多级标题,不需要再运行单独的修复脚本。

1. 准备 MinerU 配置文件

MinerU 使用用户目录下的 mineru.json 保存扩展配置。执行内置模型下载命令时会自动生成该文件,也可以复制项目提供的 mineru.template.json 并重命名为 mineru.json。

2. 配置标题优化模型

在 mineru.json 中配置 llm-aided-config.title_aided:

{
  "llm-aided-config": {
    "title_aided": {
      "api_key": "your_api_key",
      "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
      "model": "qwen3.5-plus",
      "enable_thinking": false,
      "enable": true
    }
  }
}

这些字段分别用于连接模型服务和控制标题优化:

字段 作用
api_key 模型服务的访问密钥
base_url OpenAI 兼容接口地址
model 模型服务中实际可用的模型名称
enable_thinking 是否开启思考模式;服务商不支持时应删除该字段
enable 是否启用 LLM 辅助标题优化,必须设置为 true

3. 重新解析 PDF

保存配置后,重新执行 MinerU 解析:

mineru -p input.pdf -o output

LLM 辅助能力是在解析过程中执行的,修改配置不会自动修复此前已经生成的 Markdown,必须重新处理原始 PDF。

4. 检查配置是否生效

打开新生成的 Markdown,检查标题是否从全部 # 变成合理的 #、##、###。如果结果没有变化,应依次确认:

  1. mineru.json 是否放在当前运行用户的用户目录中。
  2. 当前版本的配置模板是否包含 title_aided 这一层。
  3. enable 是否为 true。
  4. api_key、base_url 和 model 是否能够正常调用。
  5. 使用容器部署时,配置文件是否确实挂载到了容器内运行用户的目录。

5. LLM 方案的局限

LLM 方案不依赖 PDF 自带目录或书签,适用范围更广,但它本质上仍是一次推断。模型、提示词会影响结果,标题数量和文档版式也是影响因素。在实际测试中,开启辅助能力后虽然不再全部是一级标题,却出现了大量标题都被判断为二级标题的情况。

除此之外,启用 LLM 还会增加网络调用、处理时延和模型费用。长文档包含大量标题时,这部分耗时会更加明显;敏感文档还需要评估标题和上下文发送给外部模型是否符合数据安全要求。

方法二:根据 PDF 目录或书签恢复标题层级

如果 PDF 已经包含可靠的目录或书签,就没有必要再让模型猜测。可以先让 MinerU 正常生成 Markdown,再从目录或书签建立“标题名称到标题等级”的映射,最后改写所有命中的一级标题。

这套后处理需要提取层级、建立映射,再改写标题并输出新文件:

  1. 从 PDF 目录或书签中提取标题及层级。
  2. 建立 标题名称 -> 标题等级 映射。
  3. 遍历 MinerU 生成的 Markdown,只处理 # 开头的一级标题。
  4. 命中映射时,将 # 改成对应数量的 #。
  5. 输出新文件并人工检查,不直接覆盖原始结果。

1. 从 PDF 目录提取层级

如果 PDF 的可见目录带有清晰的章节编号,可以通过编号中数字段的数量判断标题等级:

1 产品介绍 ................................ 1
1.1 功能概述 .............................. 3
1.1.1 使用条件 ............................ 5
2 快速开始 ................................ 8

章节编号中数字段的数量对应标题等级:

  • 1、2 对应一级标题;
  • 1.1 对应二级标题;
  • 1.1.1 对应三级标题。

将目录内容保存为 UTF-8 编码的 catalog.txt。后面的脚本会去掉章节编号、点线和页码,生成下面的映射:

产品介绍 -> 1
功能概述 -> 2
使用条件 -> 3
快速开始 -> 1

这种方式以目录编号为依据,适合目录规范的产品手册和技术文档。目录没有编号、只有视觉缩进时,纯文本中没有足够的信息判断层级,需要额外解析文字坐标,或者改用书签。

2. 从 PDF 书签提取层级

有些 PDF 没有可见目录,但阅读器侧边栏中存在多级书签。只要书签名称与正文标题基本一致,就可以直接把书签层级作为标题等级。

以 WPS Windows 版为例,可以在书签面板中导出书签,得到 XML 文件。不同版本的菜单名称可能略有差异,关键是确认导出的 XML 中包含类似下面的 NAME 和 INDENT 属性:

<ITEM NAME="产品介绍" INDENT="1" />
<ITEM NAME="功能概述" INDENT="2" />
<ITEM NAME="使用条件" INDENT="3" />

ITEM 的 NAME 和 INDENT 分别提供标题文字和书签层级:

  • NAME 作为标题文字;
  • INDENT 作为书签层级;
  • 如果最外层书签从 0 开始,则自动归一化为 Markdown 的一级标题;
  • 同名书签出现多次且层级不一致时,优先采用出现次数最多的层级,次数相同则取更靠近顶层的层级。

与目录编号相比,书签方案不要求标题包含 1.2.3 这样的数字前缀,只要求书签名称能够和 Markdown 标题对应。

3. 使用统一脚本处理 Markdown

下面的脚本同时支持目录文本和书签 XML。它只修改一级标题,跳过围栏代码块中的 #,并将结果写入新文件。标题未命中时默认保持原样,避免目录或书签不完整时误删合法标题。

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""根据 PDF 目录或书签恢复 MinerU Markdown 的多级标题。"""

from __future__ import annotations

import argparse
import re
import sys
import unicodedata
import xml.etree.ElementTree as ET
from collections import Counter, defaultdict
from pathlib import Path
from typing import Iterable


# 匹配 Markdown 一级标题,避免匹配二级及以上标题
H1_PATTERN = re.compile(r"^#\s+(.*\S)\s*$")

# 匹配带阿拉伯数字章节编号和页码的目录条目
CATALOG_PATTERN = re.compile(
    r"^\s*(?P<number>\d+(?:\.\d+)*)(?:[.、])?\s+"
    r"(?P<body>.+?)\s+(?P<page>\d+)\s*$"
)

# 匹配目录标题末尾的点线
DOT_LEADER_PATTERN = re.compile(r"[.·…\s]+$")

# 匹配 Markdown 围栏代码块的起始标记
FENCE_PATTERN = re.compile(r"^\s*(`{3,}|~{3,})")


# 统一标题中的全角字符和连续空白,降低格式差异带来的匹配失败
def normalize_title(title: str) -> str:
    # 转换后的标题文本
    normalized_title = unicodedata.normalize("NFKC", title)
    return re.sub(r"\s+", " ", normalized_title).strip()


# 从带章节编号的目录文本中建立标题层级映射
def build_catalog_map(catalog_path: Path) -> dict[str, int]:
    # 标题层级映射
    title_levels: dict[str, int] = {}

    # 目录文本中的当前行
    for line in catalog_path.read_text(encoding="utf-8-sig").splitlines():
        # 当前目录行的匹配结果
        match = CATALOG_PATTERN.match(line)
        if match is None:
            continue

        # 当前目录条目的章节编号
        section_number = match.group("number").rstrip(".")
        # 去掉点线后的目录标题
        raw_title = DOT_LEADER_PATTERN.sub("", match.group("body"))
        # 用章节编号的段数计算标题等级
        title_level = len(section_number.split("."))
        # 用于匹配 Markdown 的规范化标题
        normalized_title = normalize_title(raw_title)

        if normalized_title:
            title_levels[normalized_title] = title_level

    return title_levels


# 从 WPS 导出的书签 XML 中建立标题层级映射
def build_bookmark_map(xml_path: Path) -> dict[str, int]:
    # 书签 XML 根节点
    root = ET.parse(xml_path).getroot()
    # 同名书签对应的全部原始层级
    title_indents: dict[str, list[int]] = defaultdict(list)

    # XML 中的当前书签节点
    for item in root.iter("ITEM"):
        # 当前书签名称
        raw_name = item.get("NAME")
        # 当前书签的原始层级
        raw_indent = item.get("INDENT")
        if raw_name is None or raw_indent is None:
            continue

        try:
            # 转换为整数后的书签层级
            indent = int(raw_indent)
        except ValueError:
            continue

        # 用于匹配 Markdown 的规范化书签名称
        normalized_name = normalize_title(raw_name)
        if normalized_name:
            title_indents[normalized_name].append(indent)

    # 每个书签名称最终采用的原始层级
    selected_indents: dict[str, int] = {}
    for title, indents in title_indents.items():
        # 当前标题各层级的出现次数
        indent_counts = Counter(indents)
        # 当前标题层级的最高出现次数
        highest_count = max(indent_counts.values())
        # 出现次数并列时保留更靠近顶层的层级
        selected_indents[title] = min(
            indent
            for indent, count in indent_counts.items()
            if count == highest_count
        )

    if not selected_indents:
        return {}

    # 导出书签中的最小层级,可能从 0 或 1 开始
    base_indent = min(selected_indents.values())
    # 归一化后的 Markdown 标题层级
    title_levels = {
        title: max(1, indent - base_indent + 1)
        for title, indent in selected_indents.items()
    }
    return title_levels


# 按标题层级映射重写 Markdown 中的一级标题
def process_markdown(
    markdown_path: Path,
    output_path: Path,
    title_levels: dict[str, int],
    demote_unmatched: bool,
) -> tuple[int, int, int]:
    # Markdown 原始行
    source_lines = markdown_path.read_text(encoding="utf-8-sig").splitlines(
        keepends=True
    )
    # 处理后的 Markdown 行
    output_lines: list[str] = []
    # 当前代码围栏使用的标记字符
    active_fence: str | None = None
    # Markdown 一级标题总数
    total_count = 0
    # 成功匹配并改写的标题数
    matched_count = 0
    # 未命中并降级为正文的标题数
    demoted_count = 0

    # Markdown 中的当前行
    for line in source_lines:
        # 当前行的代码围栏匹配结果
        fence_match = FENCE_PATTERN.match(line)
        if fence_match is not None:
            # 当前行的完整围栏标记
            fence_token = fence_match.group(1)
            # 当前围栏使用的标记字符
            fence_character = fence_token[0]
            if active_fence is None:
                active_fence = fence_character
            elif active_fence == fence_character:
                active_fence = None
            output_lines.append(line)
            continue

        if active_fence is not None:
            output_lines.append(line)
            continue

        # 当前行的一级标题匹配结果
        heading_match = H1_PATTERN.match(line.rstrip("\r\n"))
        if heading_match is None:
            output_lines.append(line)
            continue

        total_count += 1
        # 保留原始写法的标题文本
        original_title = heading_match.group(1).strip()
        # 用于查询层级的规范化标题
        normalized_title = normalize_title(original_title)
        # 当前标题对应的目标层级
        target_level = title_levels.get(normalized_title)

        if target_level is not None:
            # 保留当前文件使用的换行符
            line_ending = "\r\n" if line.endswith("\r\n") else "\n"
            output_lines.append(
                f"{'#' * target_level} {original_title}{line_ending}"
            )
            matched_count += 1
        elif demote_unmatched:
            # 保留当前文件使用的换行符
            line_ending = "\r\n" if line.endswith("\r\n") else "\n"
            output_lines.append(f"{original_title}{line_ending}")
            demoted_count += 1
        else:
            output_lines.append(line)

    output_path.write_text("".join(output_lines), encoding="utf-8")
    return total_count, matched_count, demoted_count


# 校验输入文件并执行标题恢复
def main(arguments: Iterable[str] | None = None) -> int:
    # 命令行参数解析器
    parser = argparse.ArgumentParser(
        description="根据 PDF 目录或书签恢复 MinerU Markdown 的多级标题"
    )
    # 结构来源参数组,目录和书签只能选择一种
    source_group = parser.add_mutually_exclusive_group(required=True)
    source_group.add_argument("--catalog", type=Path, help="目录文本文件路径")
    source_group.add_argument("--xml", type=Path, help="WPS 书签 XML 文件路径")
    parser.add_argument("--md", type=Path, required=True, help="MinerU Markdown 路径")
    parser.add_argument("--out", type=Path, help="处理结果的输出路径")
    parser.add_argument(
        "--demote-unmatched",
        action="store_true",
        help="将结构来源中未出现的一级标题降级为正文",
    )
    # 解析后的命令行参数
    parsed_arguments = parser.parse_args(arguments)

    # 需要校验的输入文件路径
    input_paths = [parsed_arguments.md]
    if parsed_arguments.catalog is not None:
        input_paths.append(parsed_arguments.catalog)
    if parsed_arguments.xml is not None:
        input_paths.append(parsed_arguments.xml)

    # 当前待校验的输入文件
    for input_path in input_paths:
        if not input_path.is_file():
            print(f"[错误] 文件不存在:{input_path}", file=sys.stderr)
            return 1

    # 从目录或书签建立的标题层级映射
    if parsed_arguments.catalog is not None:
        title_levels = build_catalog_map(parsed_arguments.catalog)
    else:
        title_levels = build_bookmark_map(parsed_arguments.xml)

    if not title_levels:
        print("[错误] 没有解析到任何标题层级,请检查输入格式。", file=sys.stderr)
        return 1

    # 未显式指定时使用的输出文件路径
    default_output_path = parsed_arguments.md.with_name(
        f"{parsed_arguments.md.stem}.processed{parsed_arguments.md.suffix}"
    )
    # 最终输出文件路径
    output_path = parsed_arguments.out or default_output_path
    # Markdown 处理结果统计
    total_count, matched_count, demoted_count = process_markdown(
        parsed_arguments.md,
        output_path,
        title_levels,
        parsed_arguments.demote_unmatched,
    )

    print(f"结构标题数:{len(title_levels)}")
    print(f"Markdown 一级标题数:{total_count}")
    print(f"成功恢复层级:{matched_count}")
    print(f"未命中并降级:{demoted_count}")
    print(f"处理结果:{output_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

将脚本保存为 restore_headings.py,然后根据 PDF 的结构来源选择一种运行方式。

使用目录文本时执行:

python restore_headings.py \
  --catalog catalog.txt \
  --md mineru-output.md

使用 WPS 书签 XML 时执行:

python restore_headings.py \
  --xml bookmarks.xml \
  --md mineru-output.md

默认输出文件名为 mineru-output.processed.md。如果确认所有未出现在目录或书签中的一级标题都是误判,还可以增加 --demote-unmatched,将这些标题转换为普通段落:

python restore_headings.py \
  --xml bookmarks.xml \
  --md mineru-output.md \
  --out mineru-output.fixed.md \
  --demote-unmatched

不要在第一次运行时直接启用这个参数。目录可能省略“前言”“附录”等内容,书签也可能不完整;先查看统计结果和未命中的标题,再决定是否降级更安全。

两种方法的取舍

LLM 辅助标题优化的优势是接入 MinerU 原有流程,不要求 PDF 提供目录或书签,也不需要为不同目录格式编写规则。代价是需要依赖外部模型,并且结果具有不确定性。模型判断错误时,很难通过一条固定规则解释或修正所有文档。

目录或书签方案需要多一步后处理,却能直接使用文档已有结构。相同输入和脚本会得到相同结果,方便复现、测试和批量处理。它的主要风险是标题匹配:目录、书签和正文只要存在 OCR 错字或字符差异,就可能无法命中。

参考