MinerU-Popo:面向 RAG 的文档级 OCR 后处理框架

一句话总结

MinerU-Popo 是一个轻量级 OCR 后处理框架,通过四个聚焦子任务(文本/表格截断恢复、标题层次重建、图像-文本关联)将页面级 OCR 输出转换为连贯的文档级结构,在保持轻量部署的同时显著提升 RAG 系统的准确率和响应速度。

背景与问题

从页面级解析到文档级理解的鸿沟

基于 VLM 的 OCR 模型(如 MinerU、PaddleOCR、GLM-OCR 等)已成为文档解析的事实标准。这些模型能够准确提取页面级元素(段落、表格、图像等)及其边界框和文本内容。然而,下游应用如 RAG(检索增强生成)需要连贯的文档级信息,而现有 OCR 模型往往存在以下问题:

  1. 跨页连续性被破坏:段落和表格被页面边界截断后无法正确恢复
  2. 文档结构缺失:标题层次关系、图像与正文的语义关联等信息丢失
  3. 冗余信息干扰:OCR 输出包含大量与特定任务无关的元素




论文中给出的一个典型案例是跨页表格的解析:一个逻辑上的表格被分页符拆分成两个物理表格,准确重建需要同时分析跨页表格连续性和周围的标题层次结构。

现有 OCR 模型的能力缺口

能力MinerUPaddleOCRGLM-OCRDolphinMonkeyOCR
表格截断恢复支持部分支持不支持不支持支持
文本截断恢复部分支持部分支持部分支持不支持部分支持
标题层次重建不支持支持部分支持部分支持支持
图像-文本关联不支持不支持不支持不支持不支持

可以看到,没有任何一个现有 OCR 模型能够完整处理全部四种跨页关系,这为后处理框架留下了明确的优化空间。

核心思路

后处理而非端到端

MinerU-Popo 的核心设计决策是复用现有 OCR 输出,通过轻量级后处理重建文档级逻辑结构。这一选择基于以下观察:

  1. 接口兼容性:现代 OCR 模型输出的中间表示(阅读顺序的带类型块序列)在不同系统间基本一致
  2. 部署友好:轻量级后处理模型可在本地部署,满足隐私敏感和离线场景需求
  3. 成本可控:相比调用大模型重新解析整个文档,后处理的开销显著更低

四个聚焦子任务

MinerU-Popo 将文档级结构重建分解为四个独立的子任务:

方法详解

整体处理流程

根据源码结构,MinerU-Popo 的完整处理流程分为四个步骤:

1. 标签归一化(Label Normalization)

1.1 目的

将不同 OCR 模型的输出统一为 MinerU-Popo 的标准输入格式。

1.2 流程图

标签归一化流程

1.3 核心实现

源码位置:post_processing/label_normalization.py

模块输入输出

属性说明
输入各 OCR 模型的原始 JSON 输出(目录结构:post-process/<model_name>/<doc_id>/
输出归一化后的 JSON 文件(目录:outputs/label_normalization/<model_name>/
调用方式bash scripts/run_label_normalization.sh

核心数据结构

# 归一化后的块结构@dataclassclass NormalizedBlock:    block_id: str          # 块唯一标识,格式: "{doc_id}:{order}"    page: int              # 页码(从1开始)    bbox: list[float]      # 边界框 [x1, y1, x2, y2],归一化到 [0,1]    type: str              # 规范化类型: title/text/image/table/caption    content: str           # 文本内容    order: int             # 阅读顺序(全局排序)    popo_type: str         # Popo 内部类型: title/text/image/table/image_caption/table_caption/equation    title_level: int       # 标题级别(可选,部分 OCR 提供)    source_label: str      # 原始标签(可选,用于调试)

输入输出示例

# 输入: PaddleOCR 原始输出 (layout_parsing.json){    "result": {        "layoutParsingResults": [{            "prunedResult": {                "parsing_res_list": [{                    "block_label": "paragraph_title",                    "block_content": "Introduction",                    "block_bbox": [100, 200, 300, 250],                    "block_order": 1                }]            }        }]    }}# 输出: 归一化后的块{    "block_id": "doc001:0",    "page": 1,    "bbox": [0.1, 0.2, 0.3, 0.25],    "type": "title",    "content": "Introduction",    "order": 0,    "popo_type": "title",    "source_label": "paragraph_title"}

规范化类型映射(源码:label_normalization.py):

原始标签(PaddleOCR 示例)规范化类型Popo 类型
paragraph_title, doc_titletitletitle
image, chart, sealimageimage
tabletabletable
figure_titlecaptionimage_caption
inline_formula, display_formulatextequation
text, reference_contenttexttext
1.3 边界框坐标归一化

源码支持三种坐标格式转换:

def convert_bbox(bbox, page_width, page_height, bbox_scale):    # source: 保持原始坐标    # relative: 归一化到 [0, 1]    # thousand: 归一化到 [0, 1000]

2. 四个子任务的推理(Inference)

源码位置:post_processing/inference.py

模块输入输出

属性说明
输入归一化后的块序列(outputs/label_normalization/<model_name>/<doc_id>.json
输出带预测标签的块序列(outputs/inference/<model_name>/<doc_id>.json
调用方式bash scripts/run_inference.sh
依赖Qwen3-VL-4B 模型(通过 POPO_MODEL_PATH 环境变量指定)

输出块结构示例

{    "id": 5,                    # 块 ID    "type": "text",             # 块类型    "content": "段落内容...",    # 文本内容    "bbox": [0.1, 0.2, 0.9, 0.3],    "page": 1,    "contd": 6,                 # 文本截断: 下一个连续块的 ID(-1 表示不连续)    "level": -1,                # 标题层次: 仅标题类型有效    "image": -1,                # 图像关联: 关联的标题 ID(-1 表示无关联)    "table_merge": -1,          # 表格合并: 下一个连续表格的 ID(-1 表示不连续)    "cell_list": []             # 单元格合并策略: 每列的合并标记}
2.1 文本截断分析(Text Truncation Analysis)

目的:检测被页面边界截断的段落,预测相邻文本块是否应合并。

输入输出

属性说明
输入blocks: List[Dict] - 归一化后的块序列
过滤输出judge_blocks: List[Dict] - 候选块(仅保留首尾句)
模型输入图片 + 格式化的文本序列(包含 id, page, bbox, content)
模型输出contd 标签 - 下一个连续块的 ID(-1 表示不连续)

流程图

文本截断分析流程

:输入的图片使用的对应chunk中涉及页面图片拼接的图片,每张图片会标注实际的页码,页面之间使用黑色的border区分等操作。

输入过滤逻辑(源码:filter_contd() 函数):

def filter_contd(blocks):    """过滤文本截断分析的候选块"""    for i, block in enumerate(blocks):        if block["type"] in ["text", "list_item"]:            # 检查后续块是否满足合并条件            for pos in range(i+1, len(blocks)):                if merge_rules(block["content"], blocks[pos]['content']):                    # 记录候选对                    valid_pairs[i] = pos                    break    # 仅保留首句和尾句作为模型输入    head = get_head_sentence(text)    tail = get_tail_sentence(text)    text_short = head if head == tail else head + ' ... ' + tail

合并规则启发式(源码:merge_rules() 函数):

def merge_rules(str1, str2):    # 1. 前一块末尾是终止标点 → 不合并    if str1_trimmed[-1] in termination_chars:        return False        # 2. 前一块太短(<10字符)→ 不合并    if len(str1) < 10:        return False        # 3. 后一块是列表项前缀 → 不合并    if is_list_item(str2):        return False        return True

终止标点集合

termination_chars = {".", "。", "?", "!", "?", "!", ":", ":", "……", ";", ";"}
2.2 标题层次分析(Title Hierarchy Analysis)

目的:预测每个标题的层次级别(开放式整数)。

输入输出

属性说明
输入blocks: List[Dict] - 归一化后的块序列
过滤输出judge_blocks: List[Dict] - 标题块(仅保留 title 类型,内容截断到 50 字符)
模型输入图片 + 格式化的标题序列(包含 id, page, bbox, content)
模型输出level 标签 - 标题的层次级别(1, 2, 3, …)

输出示例

# 输入: 标题块列表[    {"id": 0, "content": "Introduction", "page": 1, "bbox": [...]},    {"id": 5, "content": "1.1 Background", "page": 1, "bbox": [...]},    {"id": 12, "content": "1.1.1 Motivation", "page": 2, "bbox": [...]}]# 模型输出"""<|id|>0<|level|>1<|id|>5<|level|>2<|id|>12<|level|>3"""# 解析后的块属性{"id": 0, "type": "title", "level": 1, ...}{"id": 5, "type": "title", "level": 2, ...}{"id": 12, "type": "title", "level": 3, ...}

流程图

输入过滤逻辑(源码:filter_title() 函数):

def filter_title(blocks):    """仅保留标题类型块"""    for block in blocks:        if block["type"] in ["title", "TOC-title", "section-title"]:            judge_blocks.append({                'idx': i,                 'content': blocks[i]['content'][:50],  # 截断到50字符                'page': blocks[i]['page'],                'bbox': bbox            })    return judge_blocks

模型输入格式(源码:add_title() 函数):

<image>Title Level Analysis: <|id|>0<|page|>1<|box|>100 200 300 400<|content|>Introduction<|id|>1<|page|>1<|box|>100 500 300 600<|content|>1.1 Background...

模型输出解析(源码:extract_label2() 函数):

def extract_label2(s):    # 解析格式: <|id|>1<|level|>2    result.append({"id": int(idx), "level": int(level)})
2.3 图像-文本关联分析(Image-Text Association)

目的:预测图像/表格与标题、说明文字的关联关系。

输入输出

属性说明
输入blocks: List[Dict] - 归一化后的块序列
过滤输出judge_blocks: List[Dict] - 视觉相关块(image/table/caption/title)
辅助输出exist_linking: Dict - 已知的嵌套关系(小图在大图内)
模型输入图片 + 格式化的视觉元素序列(包含 id, type, page, bbox, content)
模型输出image 标签 - 关联的标题 ID(-1 表示无关联)

输出示例

# 输入: 视觉块列表[    {"id": 3, "type": "image", "page": 1, "bbox": [...], "content": "None"},    {"id": 4, "type": "image_caption", "page": 1, "bbox": [...], "content": "Figure 1: Overview"},    {"id": 0, "type": "title", "page": 1, "bbox": [...], "content": "Introduction"}]# 模型输出"""<|src_id|>3<|tgt_id|>0<|src_id|>4<|tgt_id|>3"""# 解析后的块属性{"id": 3, "type": "image", "image": 0, ...}      # 图片关联到标题 0{"id": 4, "type": "image_caption", "image": 3, ...}  # 说明关联到图片 3

流程图

输入过滤逻辑(源码:filter_image() 函数):

def filter_image(blocks):    """保留视觉相关块"""    for block in blocks:        if block["type"] in ['image_block', 'image', 'table', 'chart',                              'table_caption', 'image_caption',                              "title", "section-title"]:            visual_blocks[i] = block    # 检查嵌套关系(小图在大图块内)    judge_blocks, exist_linking = check_overlap(visual_blocks, large_blocks)    return judge_blocks, exist_linking

模型输入格式(源码:add_image() 函数):

<image>Image-Text Correlation Analysis:<|id|>0<|type|>image<|page|>1<|box|>100 200 300 400<|content|>None<|id|>1<|type|>image_caption<|page|>1<|box|>100 450 300 500<|content|>Figure 1: System Overview<|id|>2<|type|>title<|page|>1<|box|>100 50 300 100<|content|>Chapter 1
2.4 表格截断分析(Table Truncation Analysis)

目的:检测跨页表格并预测列级单元格合并策略。

问题形式化(论文 §3):

分页常将一个逻辑表格拆分为跨页的多个片段。对于跨页边界的两个相邻表格元素 和 ,该子任务需要:

  1. 表格级连续标签:判断它们是否属于同一逻辑表格
  2. 列级合并策略(当 时):其中 表示第 列的边界单元格应保持分离(简单拼接)还是合并为一个连续单元格

核心难点:跨页截断的边界行中,不同列的单元格可能处于不同状态——某些列的单元格恰好完整结束,而另一些列的单元格被页面边界从中间截断。 的作用正是精确指示每一列应采取的处理方式。

输入输出

属性说明
输入blocks: List[Dict] — 归一化后的块序列
过滤输出merge_inputs: List[Dict] — 通过 6 项预筛选的跨页表格候选对
模型输入拼接页面图像(base64)+ 边界行文本 Prompt
模型输出(是否同表)+ (列级合并策略)
合并后输出合并后的完整表格 HTML,保留在 table1 中,table2 被标记为已合并

预筛选机制(源码:data_engine/table_merge_filter.py

为减少不必要的模型调用,系统在送入 VLM 前执行 6 项启发式检查,任一不通过即拒绝合并候选:

序号检查项函数通过条件
1表间文本_check_text_between_tables两表之间无 text/list_item/list 块
2标题一致性_check_caption_consistency两者都无标题 / 仅表1有 / 编号和文本一致
3续表标记_check_continuation_marker表2标题含 (续)(continued) 等标记(表2无标题则跳过)
4脚注数量_check_footnote_count表2有续表标记时表1脚注 ≤ 1;表2无标题时表1脚注 = 0
5宽度差异_check_width_differencebbox 宽度差 < 10%
6列数匹配_check_column_count总列数相同,或边界行的 effective/actual/visual 列数任一匹配

续表标记列表(续)(续表)(续上表)(continued)(cont.)(cont'd)(…continued)续表

预筛选的入口函数 filter_table_merge_candidates 按顺序执行全部 6 项检查,首个不通过即返回拒绝:

def filter_table_merge_candidates(blocks, table1_idx, table2_idx):    """6 项启发式预筛选,任一不通过即拒绝合并候选"""    checks = [        ("text_between_tables", _check_text_between_tables),        ("caption_consistency", _check_caption_consistency),        ("continuation_marker", _check_continuation_marker),        ("footnote_count", _check_footnote_count),        ("width_difference", _check_width_difference),        ("column_count", _check_column_count),    ]    for name, check_fn in checks:        # 注意:不同检查函数的参数签名不同        if name == "continuation_marker":            ok, reason = check_fn(blocks, table2_idx)  # 只需 table2_idx        else:            ok, reason = check_fn(blocks, table1_idx, table2_idx)        if not ok:            return False, "[{}] {}".format(name, reason)    return True, "all checks passed"

模型输入构造(源码:inference.pyadd_table_merge

通过预筛选后,系统提取两个表格的边界行数据,构造文本 Prompt,配合拼接页面图像送入 VLM:

def add_table_merge(upper_row_ss, lower_row_ss):    """构造表格合并任务的 Prompt"""    prompt = """## Table 1 (Previous Page - Last Table)**Caption:** :""**Last Row(s) Data:**{upper_row_ss}---## Table 2 (Current Page - First Table)**Caption:** :""**First Data Row(s):**{lower_row_ss}"""    return prompt

其中 upper_row_sslower_row_ss 的提取逻辑:

  • get_visual_last_row_cells_content_with_span_info(soup1) :提取表1最后几行的单元格文本,向上回溯最多 5 行,跳过全宽合并行(colspan 覆盖整行),返回 List[List[str]]。带 span 的单元格格式为 "rowspan=2, 单元格文本""colspan=3, 单元格文本"
  • get_table_first_data_row_cells_with_span_info(soup2, header_rows):提取表2首个数据行(跳过与表1重复的表头行),同样返回 List[List[str]]

完整模型输入示例

<image>   ← 拼接页面图像(page N 和 page N+1,中间有黑色分隔线)## Table 1 (Previous Page - Last Table)**Caption:** :""**Last Row(s) Data:**["Charlie Ander-", "82", "3rd pla-"]---## Table 2 (Current Page - First Table)**Caption:** :""**First Data Row(s):**["son", "", "ce"]

模型输出解析:通过 extract_last_coordinates 函数从模型响应文本中提取最后一个 JSON 数组,解析为 。


直观示例:跨页表格合并过程

假设一个 3 列的表格被页面边界截断。原始完整表格如下:

原始完整表格:┌──────────────────┬────────┬──────────┐│ Name             │ Score  │ Rank     │├──────────────────┼────────┼──────────┤│ Alice            │ 95     │ 1        │├──────────────────┼────────┼──────────┤│ Bob              │ 88     │ 2        │├──────────────────┼────────┼──────────┤│ Charlie Anderson │ 82     │ 3rd place│  ← 此行被页面边界截断├──────────────────┼────────┼──────────┤│ David Lee        │ 78     │ 4        │├──────────────────┼────────┼──────────┤│ Eve              │ 72     │ 5        │└──────────────────┴────────┴──────────┘

截断后,OCR 分别输出两段独立的 HTML 表格:

截断后的结果:第 1 页(表1)                                第 2 页(表2)┌──────────────────┬────────┬──────────┐    ┌──────────────────┬────────┬──────────┐│ Name             │ Score  │ Rank     │    │ Name             │ Score  │ Rank     │ ← 重复表头├──────────────────┼────────┼──────────┤    ├──────────────────┼────────┼──────────┤│ Alice            │ 95     │ 1        │    │ son              │        │ ce       │ ← 边界行(续接)├──────────────────┼────────┼──────────┤    ├──────────────────┼────────┼──────────┤│ Bob              │ 88     │ 2        │    │ David Lee        │ 78     │ 4        │├──────────────────┼────────┼──────────┤    ├──────────────────┼────────┼──────────┤│ Charlie Ander-   │ 82     │ 3rd pla- │    │ Eve              │ 72     │ 5        │└──────────────────┴────────┴──────────┘    └──────────────────┴────────┴──────────┘

关键观察

  • 表1最后一行的 Name 列被截断为 “Charlie Ander-”,Rank 列被截断为 “3rd pla-”,Score 列 “82” 完整结束
  • 表2第一数据行是 Charlie 行的续接部分:Name 列 “son”,Rank 列 “ce”,Score 位置为空(因 “82” 在表1已完整)
  • 模型需要判断:Name 列和 Rank 列的截断文本应与续接文本合并(),Score 列无需合并()

模型预测(即 cell_list = [1, 0, 1]):

含义边界单元格处理
第0列 (Name)1合并为连续单元格“Charlie Ander-” + “son” → “Charlie Anderson”
第1列 (Score)0保持分离表1的 “82” 保持不变,表2对应位置为空(不合并)
第2列 (Rank)1合并为连续单元格“3rd pla-” + “ce” → “3rd place”

注意: 表示"合并为一个连续单元格"(论文原文:be merged into one continuous cell), 表示"保持分离"(论文原文:remain separate, simple concatenation)。

取值与合并效果对照

边界单元格处理适用场景
[1, 1, 1]所有列都合并单元格内容被截断,跨页连续
[1, 0, 1]第0、2列合并,第1列独立混合场景:部分列截断,部分列恰好完整结束
[0, 0, 0]所有列保持独立两段表格恰好行对齐,无截断

合并执行(源码:post_processing/table_merge_utils.py

合并过程由 merge_table_html() 函数实现,分为 4 个阶段:

阶段 1:表头重复检测 — detect_table_headers(soup1, soup2)

逐行比较两表的前 N 行(最多 5 行),匹配条件包括:单元格数量、colspan、rowspan 和文本内容(经全角半角转换后比较)。返回重复的表头行数 header_count,表2的数据从 header_count 行开始。

阶段 2:列数对齐 — calculate_table_total_columns() + adjust_table_rows_colspan()

通过构建占用矩阵(occupied matrix)计算两表的实际总列数。若列数不一致,将列数较少的表格的 colspan 扩展:

  • 若待调整行与参考行结构相同(单元格数量和 colspan 分布一致),直接将每个单元格的 colspan 设为参考值
  • 否则将最后一列的 colspan 增加差值

阶段 3:边界单元格合并 — merge_row_cells_by_cell_list()

核心合并逻辑:

def merge_row_cells_by_cell_list(visual_last_row_cells, first_data_row2, cell_list, total_rows1):    """根据 cell_list 合并边界单元格"""    cells2 = first_data_row2.find_all(["td", "th"])    max_idx = min(len(visual_last_row_cells), len(cells2), len(cell_list))    last_row_idx = total_rows1 - 1    cells_to_remove = []    for i in range(max_idx):        if cell_list[i] != 1:  # q_j=0: 保持独立            continue        c1, origin_row_idx = visual_last_row_cells[i]        c2 = cells2[i]        # 合并文本内容(先清除 c1 的子节点)        text1 = c1.get_text(" ", strip=True)        text2 = c2.get_text(" ", strip=True)        merged_text = "".join(part for part in [text1, text2] if part)        for child in list(c1.contents):            child.extract()        if merged_text:            c1.string = merged_text        # 调整 rowspan        c2_rowspan = int(c2.get("rowspan", 1))        if origin_row_idx < last_row_idx:            # c1 本身跨行,增加 rowspan 并删除 c2            current_rowspan = int(c1.get("rowspan", 1))            c1["rowspan"] = str(current_rowspan + c2_rowspan)            cells_to_remove.append(c2)        elif c2_rowspan > 1:            # c2 跨行,将 c1 的 rowspan 设为 c2 的值            c1["rowspan"] = str(c2_rowspan)            cells_to_remove.append(c2)        else:            # c1 在最后一行且 c2 不跨行:清空 c2 内容,保留空单元格            for child in list(c2.contents):                child.extract()    # 删除已合并的单元格    for cell in cells_to_remove:        cell.decompose()    # 检查表2第一数据行是否已全部删除或清空    cells2_remaining = first_data_row2.find_all(["td", "th"])    if len(cells2_remaining) == 0:        return True    if all(not c.get_text(strip=True) for c in cells2_remaining):        return True    return False

阶段 4:追加剩余数据行

若表2第一数据行已完全合并(所有列 ),则从 header_count + 1 开始追加;否则从 header_count 开始(保留未合并的列)。


合并结果示意(以 为例):

合并后的表格:┌──────────────────┬────────┬──────────┐│ Name             │ Score  │ Rank     │├──────────────────┼────────┼──────────┤│ Alice            │ 95     │ 1        │├──────────────────┼────────┼──────────┤│ Bob              │ 88     │ 2        │├──────────────────┼────────┼──────────┤│ Charlie Anderson │ 82     │ 3rd place│  ← Name 和 Rank: 文本合并,c2 内容清空├──────────────────┼────────┼──────────┤│ David Lee        │ 78     │ 4        │├──────────────────┼────────┼──────────┤│ Eve              │ 72     │ 5        │└──────────────────┴────────┴──────────┘

关于 rowspan:当 c1 位于表1最后一行(origin_row_idx == last_row_idx)且 c2 的 rowspan 为 1 时,源码的 else 分支仅清空 c2 的文本内容、保留空单元格,而不设置 rowspan=2。视觉上 Name 和 Rank 列呈现跨行效果,但 HTML 层面是通过空单元格实现的。若 c1 本身已有 rowspan(从更早的行跨到最后一行),则进入第一个分支,真正增加 rowspan 值。


后续处理(源码:merge_cross_page_tables

merge_cross_page_tables() 函数遍历所有元素,根据模型输出的 table_merge 标记和 cell_list 执行实际的 HTML 合并操作:

def merge_cross_page_tables(elements):    """处理所有跨页表格合并,更新元素列表"""    id_to_index = {element["id"]: idx for idx, element in enumerate(elements) if "id" in element}    merged_partner_ids = set()    for idx, element in enumerate(elements):        if element.get("type") != "table":            continue        partner_id = element.get("table_merge", -1)        cell_list = element.get("cell_list", [])        if partner_id < 0 or partner_id in merged_partner_ids:            continue        partner_index = id_to_index.get(partner_id)        if partner_index is None or partner_index <= idx:            continue        partner = elements[partner_index]        # 执行 HTML 合并        merged_html = merge_table_html(            element.get("content", ""),            partner.get("content", ""),            cell_list        )        # 更新表1属性        element["content"] = merged_html        element["merged_locations"] = [            {"bbox": element["bbox"], "page": element["page"]},            {"bbox": partner["bbox"], "page": partner["page"]}        ]        element["merged_block_ids"] = [element["id"], partner["id"]]        merged_partner_ids.add(partner_id)    # 过滤掉已被合并的表格    return [e for e in elements if e.get("id") not in merged_partner_ids]

核心数据结构

# 候选表格对结构(预筛选通过后){    "table1_idx": 5,           # 上一页最后一个表格的块索引    "table2_idx": 12,          # 当前页第一个表格的块索引    "upper_row_ss": [          # 表1最后几行数据(含 span 信息)        ["Charlie Ander-", "82", "3rd pla-"]    ],    "lower_row_ss": [          # 表2首个数据行(已跳过重复表头)        ["son", "", "ce"]    ]}# 模型输出解析后cell_list = [1, 0, 1]  # Q_i: 第0列合并,第1列不合并,第2列合并# 合并后的块属性{    "id": 5,    "type": "table",    "content": "<html>...</html>",  # 合并后的完整表格 HTML    "table_merge": 12,              # 指向被合并的表格 ID    "cell_list": [1, 0, 1],         # 列级合并策略    "merged_locations": [           # 跨页位置信息        {"bbox": [...], "page": 1},        {"bbox": [...], "page": 2}    ],    "merged_block_ids": [5, 12]     # 合并的块 ID 列表}

整体流程图

注: 虽然已尽可能描述,但依然建议查看源码。

3. 动态分块与同步

模块输入输出

属性说明
输入items: List[Dict] - 待处理的元素列表(如标题块)
输入参数chunk_size: int - 每块目标元素数量(默认 50)
输入参数overlap: int - 重叠页数(默认 1)
输出chunks: List[List[Dict]] - 分块后的元素列表
输出boundaries: List[int] - 分块边界页码列表

核心函数

def adaptive_chunk(items: List[Dict], chunk_size: int = 50, overlap: int = 1) -> Tuple[List[List[Dict]], List[int]]:    """    自适应分块,在搜索窗口内选择标题最多的页面作为边界。        Args:        items: 待分块的元素列表,每个元素包含 'page' 字段        chunk_size: 每块的目标元素数量        overlap: 重叠页数        Returns:        chunks: 分块后的元素列表        boundaries: 分块边界页码列表    """def synchronize_chunks(chunks: List[List[Dict]], overlap: int = 1) -> List[Dict]:    """    同步各块的预测结果,使用重叠区域校准层次级别。        Args:        chunks: 各块的预测结果列表        overlap: 重叠页数        Returns:        全局一致的预测结果列表    """

输入输出示例

# 输入: 标题元素列表(跨多页)items = [    {"id": 0, "page": 1, "content": "Chapter 1", "level_pred": 1},    {"id": 1, "page": 2, "content": "1.1 Section", "level_pred": 2},    # ... 更多元素 ...    {"id": 50, "page": 10, "content": "Chapter 2", "level_pred": 1},]# 分块结果(假设每块约 25 个元素,重叠 1 页)chunks = [    # 块 1: 页 1-4(包含标题最多的边界)    [{"id": 0, "page": 1, ...}, {"id": 1, "page": 2, ...}, ...],    # 块 2: 页 3-7(与块 1 重叠页 3-4)    [{"id": 20, "page": 3, ...}, {"id": 21, "page": 4, ...}, ...],    # 块 3: 页 6-10(与块 2 重叠页 6-7)    [{"id": 40, "page": 6, ...}, {"id": 50, "page": 10, ...}]]# 同步前(块 1 和块 2 在重叠区域预测不一致)# 块 1 预测: 页 3-4 的标题层次 = [2, 2]# 块 2 预测: 页 3-4 的标题层次 = [1, 2]  <- 偏差# 同步计算deviation = avg([2-1, 2-2]) = 0.5  # 平均偏差# 同步后(块 2 的预测校准)# 块 2 校准: [1+0.5, 2+0.5] = [1.5, 2.5] -> 取整 [2, 3]

流程图

3.1 自适应分块(源码:adaptive_chunk() 函数)
def adaptive_chunk(items, chunk_size=50, overlap=1):    """动态分块,保持重叠区域"""    sorted_items = sorted(items, key=lambda x: x['page'])    pages = [item['page'] for item in sorted_items]    unique_pages = sorted(set(pages))        # 选择边界:在搜索窗口内选择标题最多的页面    boundaries = []    # ... 分块逻辑
3.2 同步机制

处理长文档时,使用重叠区域的预测偏差校准后续块:

块 1: [页 1-5]  → 预测标题层次: [1, 2, 2, 3, 2]块 2: [页 4-8]  → 预测标题层次: [2, 3, 2, 3, 2]                    ↑ 重叠区域 (页 4-5)                    偏差 = avg(块1[页4-5] - 块2[页4-5])最终块2层次 = 块2预测 + 偏差

4. 文档树构建

源码位置:post_processing/get_json_tree.py

模块输入输出

属性说明
输入带预测标签的块序列(outputs/inference/<model_name>/<doc_id>.json
输出JSON 树结构(outputs/build_tree/<model_name>/<doc_id>.json
辅助输出文本预览(outputs/build_tree_txt/<model_name>/<doc_id>.txt
调用方式bash scripts/build_tree.sh

核心函数

def construct_json_tree(input_file: str, output_dir: str, txt_dir: str) -> None:    """    构建文档树结构。        Args:        input_file: 推理后的 JSON 文件路径        output_dir: 输出目录(JSON 树)        txt_dir: 文本预览输出目录        Returns:        None(结果写入文件)    """def cp_init(cp_type: str, title: str, metadata: str, content: str,             level: int, location: List, block_ids: List) -> Dict:    """    创建树节点组件。        Args:        cp_type: 组件类型 (root/text/table/image/chart/seal)        title: 标题文本        metadata: 元数据(如脚注)        content: 正文内容        level: 层次级别(标题 ID 或 -1)        location: 位置信息列表 [{bbox, page}, ...]        block_ids: 关联的块 ID 列表        Returns:        组件字典,包含 children 列表    """def construct_by_level(text_components: List[Dict]) -> Dict:    """    基于标题级别构建层次树。        Args:        text_components: 文本组件列表(按阅读顺序)        Returns:        根节点字典(包含 children 树)    """

输入输出示例

# 输入: 带预测标签的块列表[    {"id": 0, "type": "title", "content": "Introduction", "level": 1, ...},    {"id": 1, "type": "text", "content": "First paragraph...", "contd": -1, ...},    {"id": 2, "type": "image", "content": "<html>...</html>", "image": 0, ...},    {"id": 3, "type": "title", "content": "1.1 Background", "level": 2, ...}]# 输出: JSON 树结构{    "type": "root",    "level": 0,    "children": [        {            "type": "text",            "title": "Introduction",            "level": 1,            "content": "<|txt_split|>First paragraph...",            "location": [{"bbox": [...], "page": 1}],            "block_ids": [0, 1],            "children": [                {                    "type": "image",                    "title": "",                    "content": "<html>...</html>",                    "location": [{"bbox": [...], "page": 1}],                    "block_ids": [2],                    "children": []                }            ]        },        {            "type": "text",            "title": "1.1 Background",            "level": 2,            "content": "",            "location": [{"bbox": [...], "page": 1}],            "block_ids": [3],            "children": []        }    ]}

流程图

4.1 树节点结构
def cp_init(cp_type="", title="", metadata="", content="", level=-1, location=None, block_ids=None):    return {        'type': cp_type,        # 节点类型: root/text/table/image        'title': title,         # 标题文本        'metadata': metadata,   # 元数据(如脚注)        'content': content,     # 正文内容        'level': level,         # 层次级别        'location': location,   # 位置信息列表        'block_ids': block_ids, # 关联的块 ID        'children': []          # 子节点    }
4.2 层次结构构建
def construct_by_level(text_components):    """基于标题级别构建树形结构"""    root = cp_init(cp_type="root", level=0)    stack = [{'node': root, 'level': 0}]        for cp in text_components:        level = cp['level'] if cp['level'] > 0 else 100                # 弹出栈直到找到父节点(级别更小)        while stack[-1]["level"] >= level:            stack.pop()        parent = stack[-1]["node"]                # 添加为子节点        parent["children"].append(cp)                # 压栈        stack.append({"node": cp, "level": level})        return root
4.3 视觉元素挂载
def add_special_elements(text_tree, elements):    """将图像/表格挂载到对应的标题节点"""    for element in elements:        if element['type'] in ['table', 'chart', 'image', 'seal']:            visual_component = cp_init(                cp_type=element['type'],                content=element['content'],                level=element['image']  # 关联的标题 ID            )            # 查找对应的标题节点并挂载            tree_node = get_node_by_id(text_tree, element['image'])            tree_node['children'].append(visual_component)

5. 输出示例

最终输出的 JSON 树结构示例:

{  "type": "root",  "level": 0,  "children": [    {      "type": "text",      "title": "Introduction",      "level": 1,      "content": "<|txt_split|>Document parsing converts...",      "children": [        {          "type": "image",          "title": "Figure 1: System Overview",          "content": "<html>...</html>",          "children": []        }      ]    },    {      "type": "text",      "title": "1.1 Background",      "level": 2,      "content": "<|txt_split|>Recent advances...",      "children": []    }  ]}

实验结果

数据集与设置

  • 训练数据:30K 实例(每个子任务)
  • 测试数据:PostDocBench(每个子任务 165 个实例)
  • 下游评测:ViDoRe V3(RAG)、MMDA(端到端 QA)
  • 基础模型:Qwen3-VL-4B(微调)
  • 训练资源:8×H200 GPU,6 小时

后处理性能(PostDocBench)

子任务指标结果
文本截断分析精确率 / 召回率87.8% / 93.8%
图像-文本关联分析精确率 / 召回率87.0% / 79.5%
标题层次分析TEDS90.6%
表格截断分析合并方法预测准确率92.7%

MinerU-Popo 在 TEDS 得分(90.6%)和推理速度(0.37 文档/秒)上均优于更大的模型(如 Qwen3-VL-32B 的 78.0% TEDS 和 0.04 文档/秒)。

标题层次改进(跨 OCR 模型)

基础 OCR处理前 TEDS处理后 TEDS
MinerU53.790.6
MonkeyOCR48.987.4
Dolphin60.483.5
PaddleOCR59.382.6
GLM-OCR53.581.8

RAG 性能(ViDoRe V3)

方法C.S.Fin.H.R.Ind.Phar.
MinerU-Popo84.449.566.858.771.6
Raw RAG82.348.763.260.464.4
Visual RAG80.758.464.859.767.6

MinerU-Popo 在五个子集中的四个上优于原始 OCR 的 RAG,并显著降低查询延迟(最高 70%)。

局限性与适用边界

论文明确提及的局限

  1. 训练数据覆盖:30K 训练实例虽然多样,但可能无法覆盖所有文档类型和布局变体
  2. OCR 质量依赖:后处理效果受限于输入 OCR 的质量,无法修复 OCR 的根本性错误
  3. 语言覆盖:论文未明确讨论多语言文档的处理能力
  4. 启发式内容较多:有较多的规则过滤,泛化性可能不足

工程实践中的注意事项

  1. OCR 模型选择:MinerU-Popo 支持多种 OCR 模型,但不同模型的输出格式需要适配
  2. 长文档阈值:动态分块的参数(重叠页数、分块大小)需要根据实际文档长度调整
  3. 部署成本:虽然比大模型便宜,但仍需 GPU 资源运行 Qwen3-VL-4B

工程启示

1. 后处理是性价比高的优化方向

与其训练更大的端到端模型,不如通过轻量级后处理修复现有 OCR 的结构性问题。MinerU-Popo 仅用 4B 参数就实现了显著的性能提升。

2. 任务分解降低复杂度

将复杂的文档结构重建分解为四个独立的子任务,每个任务可以独立优化和评估,降低了整体系统的复杂度。

3. 输入过滤是关键优化

通过任务特定的输入过滤,将模型输入从完整的 OCR 输出精简为关键元素,既减少了噪声又降低了计算开销。例如:

  • 文本截断分析:仅保留首尾句(get_head_sentence() + get_tail_sentence()
  • 标题层次分析:仅保留 title 类型块,内容截断到 50 字符

4. 同步机制解决分块一致性

重叠分块 + 偏差校准的同步机制为长文档处理提供了一种可扩展的解决方案,可借鉴应用于其他长上下文场景。

5. 统一接口设计

label_normalization.py 提供了统一的适配层,使得 MinerU-Popo 能够支持多种 OCR 模型:

# 支持的 OCR 模型DEFAULT_MODELS = [    "mineru",    "monkeyocr",    "PaddleOCR-VL-1.5",    "dolphin",    "glm-ocr",]

快速上手

安装与配置

# 1. 创建环境conda create -n popo python=3.10conda activate popopip install -r requirements.txt# 2. 下载模型hf download DreamEternal/MinerU-Popo --local-dir models/Mineru-Popo

运行流程

# 步骤 1: 准备 OCR 输出到 post-process/<model_name>/# 步骤 2: 标签归一化bash scripts/run_label_normalization.sh# 输出: outputs/label_normalization/<model_name>/# 步骤 3: 运行推理bash scripts/run_inference.sh# 输出: outputs/inference/<model_name>/# 步骤 4: 构建文档树bash scripts/build_tree.sh# 输出: outputs/build_tree/<model_name>/

学AI大模型的正确顺序,千万不要搞错了

🤔2026年AI风口已来!各行各业的AI渗透肉眼可见,超多公司要么转型做AI相关产品,要么高薪挖AI技术人才,机遇直接摆在眼前!

有往AI方向发展,或者本身有后端编程基础的朋友,直接冲AI大模型应用开发转岗超合适!

就算暂时不打算转岗,了解大模型、RAG、Prompt、Agent这些热门概念,能上手做简单项目,也绝对是求职加分王🔋

在这里插入图片描述

📝给大家整理了超全最新的AI大模型应用开发学习清单和资料,手把手帮你快速入门!👇👇

学习路线:

✅大模型基础认知—大模型核心原理、发展历程、主流模型(GPT、文心一言等)特点解析
✅核心技术模块—RAG检索增强生成、Prompt工程实战、Agent智能体开发逻辑
✅开发基础能力—Python进阶、API接口调用、大模型开发框架(LangChain等)实操
✅应用场景开发—智能问答系统、企业知识库、AIGC内容生成工具、行业定制化大模型应用
✅项目落地流程—需求拆解、技术选型、模型调优、测试上线、运维迭代
✅面试求职冲刺—岗位JD解析、简历AI项目包装、高频面试题汇总、模拟面经

以上6大模块,看似清晰好上手,实则每个部分都有扎实的核心内容需要吃透!

我把大模型的学习全流程已经整理📚好了!抓住AI时代风口,轻松解锁职业新可能,希望大家都能把握机遇,实现薪资/职业跃迁~

这份完整版的大模型 AI 学习资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值