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

背景与问题
从页面级解析到文档级理解的鸿沟
基于 VLM 的 OCR 模型(如 MinerU、PaddleOCR、GLM-OCR 等)已成为文档解析的事实标准。这些模型能够准确提取页面级元素(段落、表格、图像等)及其边界框和文本内容。然而,下游应用如 RAG(检索增强生成)需要连贯的文档级信息,而现有 OCR 模型往往存在以下问题:
- 跨页连续性被破坏:段落和表格被页面边界截断后无法正确恢复
- 文档结构缺失:标题层次关系、图像与正文的语义关联等信息丢失
- 冗余信息干扰:OCR 输出包含大量与特定任务无关的元素




论文中给出的一个典型案例是跨页表格的解析:一个逻辑上的表格被分页符拆分成两个物理表格,准确重建需要同时分析跨页表格连续性和周围的标题层次结构。
现有 OCR 模型的能力缺口
| 能力 | MinerU | PaddleOCR | GLM-OCR | Dolphin | MonkeyOCR |
|---|---|---|---|---|---|
| 表格截断恢复 | 支持 | 部分支持 | 不支持 | 不支持 | 支持 |
| 文本截断恢复 | 部分支持 | 部分支持 | 部分支持 | 不支持 | 部分支持 |
| 标题层次重建 | 不支持 | 支持 | 部分支持 | 部分支持 | 支持 |
| 图像-文本关联 | 不支持 | 不支持 | 不支持 | 不支持 | 不支持 |
可以看到,没有任何一个现有 OCR 模型能够完整处理全部四种跨页关系,这为后处理框架留下了明确的优化空间。
核心思路
后处理而非端到端
MinerU-Popo 的核心设计决策是复用现有 OCR 输出,通过轻量级后处理重建文档级逻辑结构。这一选择基于以下观察:
- 接口兼容性:现代 OCR 模型输出的中间表示(阅读顺序的带类型块序列)在不同系统间基本一致
- 部署友好:轻量级后处理模型可在本地部署,满足隐私敏感和离线场景需求
- 成本可控:相比调用大模型重新解析整个文档,后处理的开销显著更低

四个聚焦子任务
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_title | title | title |
| image, chart, seal | image | image |
| table | table | table |
| figure_title | caption | image_caption |
| inline_formula, display_formula | text | equation |
| text, reference_content | text | text |
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):
分页常将一个逻辑表格拆分为跨页的多个片段。对于跨页边界的两个相邻表格元素 和 ,该子任务需要:
- 表格级连续标签:判断它们是否属于同一逻辑表格
- 列级合并策略(当 时):其中 表示第 列的边界单元格应保持分离(简单拼接)还是合并为一个连续单元格
核心难点:跨页截断的边界行中,不同列的单元格可能处于不同状态——某些列的单元格恰好完整结束,而另一些列的单元格被页面边界从中间截断。 的作用正是精确指示每一列应采取的处理方式。
输入输出:
| 属性 | 说明 |
|---|---|
| 输入 | 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_difference | bbox 宽度差 < 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.py → add_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_ss 和 lower_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% |
| 标题层次分析 | TEDS | 90.6% |
| 表格截断分析 | 合并方法预测准确率 | 92.7% |
MinerU-Popo 在 TEDS 得分(90.6%)和推理速度(0.37 文档/秒)上均优于更大的模型(如 Qwen3-VL-32B 的 78.0% TEDS 和 0.04 文档/秒)。
标题层次改进(跨 OCR 模型)
| 基础 OCR | 处理前 TEDS | 处理后 TEDS |
|---|---|---|
| MinerU | 53.7 | 90.6 |
| MonkeyOCR | 48.9 | 87.4 |
| Dolphin | 60.4 | 83.5 |
| PaddleOCR | 59.3 | 82.6 |
| GLM-OCR | 53.5 | 81.8 |
RAG 性能(ViDoRe V3)
| 方法 | C.S. | Fin. | H.R. | Ind. | Phar. |
|---|---|---|---|---|---|
| MinerU-Popo | 84.4 | 49.5 | 66.8 | 58.7 | 71.6 |
| Raw RAG | 82.3 | 48.7 | 63.2 | 60.4 | 64.4 |
| Visual RAG | 80.7 | 58.4 | 64.8 | 59.7 | 67.6 |
MinerU-Popo 在五个子集中的四个上优于原始 OCR 的 RAG,并显著降低查询延迟(最高 70%)。
局限性与适用边界
论文明确提及的局限
- 训练数据覆盖:30K 训练实例虽然多样,但可能无法覆盖所有文档类型和布局变体
- OCR 质量依赖:后处理效果受限于输入 OCR 的质量,无法修复 OCR 的根本性错误
- 语言覆盖:论文未明确讨论多语言文档的处理能力
- 启发式内容较多:有较多的规则过滤,泛化性可能不足
工程实践中的注意事项
- OCR 模型选择:MinerU-Popo 支持多种 OCR 模型,但不同模型的输出格式需要适配
- 长文档阈值:动态分块的参数(重叠页数、分块大小)需要根据实际文档长度调整
- 部署成本:虽然比大模型便宜,但仍需 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%免费】

420

被折叠的 条评论
为什么被折叠?



