基于Docling结合多模态大模型的的PDF转Markdown


项目地址:https://github.com/EvannZhongg/Docling2md.git


项目背景

基于我当前使用 LLM 构建知识图谱的处理的是一系列复杂的PDF文档,这些PDF包含图像、表格、文本等混合信息,结构复杂、不规则,无法直接使用。因此,需要一个高效、可扩展的结构化预处理流程来为后续实体提取、图谱生成等任务提供清洗后的输入,同时流程需要兼具准确和部署的方便。

例如 MinerUTextin ,它们转换的效果都非常好;但 MinerU 部署不太方便集成到项目中显得笨重, Textin 是商用软件价格不美丽;于是近期发现了 Docling 的开源模型,同时python中也可以直接安装 Docling 的依赖包避免下载模型,于是尝试了一下感觉效果还不错符合我的预期。

尤其是在对表格进行处理时,Docling 是将表格转化为管道表格结构,而 MinerUTextin 都是将表格转换为 HTML 格式;相比 HTML 格式,管道表格在传递给大模型时能够节省更多的token,减轻了大模型的负担。

本项目基于 Docling 框架,对 PDF 文件进行结构解析,结合多种大模型能力,实现了文档的多模态理解与 Markdown / JSON 格式结构化输出,为知识图谱构建奠定基础。


实现的思想与目标

项目的核心目标是提高PDF中信息保留的完整程度方便后续将纯文本信息输入给大模型构建知识图谱,并解决以下几个关键问题:

  1. PDF转Markdown:通过Docling将PDF内容转化为Markdown,尤其是提取表格和图片,并在Markdown中保持其结构。
  2. 复杂表格图像的处理与修复:通过视觉大模型处理PDF中的表格图片,修复异常或模糊的表格结构。
  3. 异常文本分词的修复:直接使用 Docling 的OCR有时会出现文本识别异常,对于长文本中的异常无空格情况,通过大模型进行自动分词修复,保证文本可读性。
  4. 对图片的描述与理解: 对识别出的图像(非表格)调用视觉大模型进行内容描述,生成自然语言图像摘要,并写入 Markdown 与结构 JSON。

流程示意:

graph TD
    A[PDF Document] --> B[DocumentConverter]
    B --> C[Structured Document]
    C --> D[Element Iteration]

    D --> E[Text Processing]
    D --> F[Table Processing]
    D --> G[Image Processing]

    E --> H[LLM Classification]
    F --> I[Table Structure Analysis]
    G --> J[VLM Captioning]

    H --> K[Markdown Output]
    I --> K
    J --> K

    K --> L[Markdown File]
    K --> M[JSON Metadata]
    K --> N[Extracted Images]

项目流程与技术细节

1. 表格结构识别与修复

  • 使用 Docling 自动检测表格区域,导出 DataFrame
  • 若存在列名重复或列数异常,启用视觉大模型模型对表格截图分块修复,生成标准 Markdown 管道表格(pipe table)。
  • 分块方式:按行高切图,并拼接小块确保高度。
  • 所有修复表格记录修复来源,存入 JSON 中以备审计与训练数据回溯。

表格异常处理的流程结合了 按固定行高切块基于最低高度与宽度的分块合并 两种方法。


按固定行高切块

在函数 split_table_image_rows 中,表格图像被按照固定的行高(默认为 400 像素)进行裁切。

1
2
3
4
5
6
7
8
def split_table_image_rows(pil_img: Image.Image, row_height: int = 400) -> list:
width, height = pil_img.size
slices = []
for top in range(0, height, row_height):
bottom = min(top + row_height, height)
crop = pil_img.crop((0, top, width, bottom))
slices.append(crop)
return slices

根据您上传的代码文件内容,表格异常处理的流程结合了按固定行高切块基于最低高度与宽度的分块合并两种方法。以下是具体的实现细节和举例说明:

  • 输入: 表格图像 pil_img 和一个可配置的 row_height(默认值为 400 像素)。
  • 逻辑:
    • 按照从上到下的顺序,每隔 row_height 像素裁切一次。
    • 如果剩余的高度不足 row_height,则取到最后的边界。
  • 输出: 返回一个包含多个裁切后图像片段的列表。

这种方式确保了表格图像能够被分割成多个子图像,每个子图像的高度不超过 row_height


基于最低高度与宽度的分块合并

在函数 merge_small_chunks 中,对裁切后的图像片段进一步处理,确保每个片段的高度和宽度满足最低要求(因为视觉大模型在上传图片时有最小像素要求,需要长宽大于 10 像素,为避免切块过小,这里默认高度为 300 像素,宽度为 20 像素)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def merge_small_chunks(chunks: list, min_height: int = 300, min_width: int = 20) -> list:
merged_chunks = []
temp_chunk = None
for chunk in chunks:
width, height = chunk.size
# 如果当前块尺寸不足,则尝试拼接上下块
if height < min_height or width < min_width:
if temp_chunk is None:
temp_chunk = chunk
else:
# 拼接上下块
new_chunk = Image.new("RGB", (max(temp_chunk.width, chunk.width), temp_chunk.height + chunk.height))
new_chunk.paste(temp_chunk, (0, 0))
new_chunk.paste(chunk, (0, temp_chunk.height))
temp_chunk = new_chunk
else:
# 如果有未处理的临时块,先保存
if temp_chunk is not None:
merged_chunks.append(temp_chunk)
temp_chunk = None
merged_chunks.append(chunk)
# 添加最后一个临时块(如果有)
if temp_chunk is not None:
# 如果整个表格图片的高度低于最小高度,则按最低高度计算
if temp_chunk.height < min_height:
new_chunk = Image.new("RGB", (temp_chunk.width, max(temp_chunk.height, 20)))
new_chunk.paste(temp_chunk, (0, 0))
merged_chunks.append(new_chunk)
else:
merged_chunks.append(temp_chunk)
return merged_chunks
  • 输入: 裁切后的图像片段列表 chunks,以及最低高度 min_height(默认 300 像素)和最低宽度 min_width(默认 20 像素)。
  • 逻辑:
    • 遍历所有片段:
      • 如果某个片段的高度或宽度小于最低要求,则将其与下一个片段拼接,直到满足最低尺寸要求。
      • 如果某个片段已经满足最低要求,则直接加入结果列表。
    • 对于最后一个临时块,如果其高度仍低于最低要求,则创建一个新的图像块,填充到最低高度。
  • 输出: 返回一个经过合并后的图像片段列表。

3. 综合处理流程

在主流程中,表格异常的处理逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
if not table_df.columns.is_unique or table_df.shape[1] < 2:
log.warning(f"表格 {table_counter} 结构异常,使用多轮图像推理修复")
# 自动图像切块
sub_images = split_table_image_rows(pil_img)
# 拼接不符合尺寸限制的切块
sub_images = merge_small_chunks(sub_images)

# 使用局部变量管理该表格的修复任务
chunk_futures = []
for idx, chunk_img in enumerate(sub_images):
future = vlm_executor.submit(ask_table_from_image, chunk_img)
chunk_futures.append((future, idx, chunk_img))

# 收集结果
full_md_lines = []
for future, idx, chunk_img in chunk_futures:
try:
chunk_md = future.result()
lines = chunk_md.splitlines()
if idx == 0:
full_md_lines.extend(lines) # 表头 + 分隔线
else:
full_md_lines.extend(lines[2:]) # 仅数据行
except Exception as e:
log.warning(f"表格分块处理失败: {e}")

markdown_lines.append(f"<!-- 表格 {table_counter} 使用 Qwen 修复,已分块拼接 -->")
markdown_lines.append("\n".join(full_md_lines))
markdown_lines.append("")
json_data.append({
"type": "table",
"level": level,
"image": table_image_filename.name,
"source": "reconstructed_by_qwen_chunked",
"markdown": "\n".join(full_md_lines),
"page_number": element.prov[0].page_no,
"bbox": bbox
})
continue

流程说明:

  1. 检测表格异常:
    • 如果表格的列名不唯一 (table_df.columns.is_unique) 或者列数少于 2 (table_df.shape[1] < 2),认为表格结构异常,这些异常通常是 OCR 模型本身的不足导致的表格生成失败。
  2. 按固定行高切块:
    • 使用 split_table_image_rows 将表格图像按固定行高(默认 400 像素)切分为多个片段。
  3. 合并小块:
    • 使用 merge_small_chunks 合并高度或宽度不足的片段,确保每个片段的高度至少为 300 像素,宽度至少为 20 像素。
  4. 并发调用大模型:
    • 对每个合并后的片段并发调用 ask_table_from_image 函数,将图像转换为 Markdown 表格。
  5. 拼接结果:
    • 将所有片段的 Markdown 表格拼接起来:
      • 第一个片段保留表头和分割线。
      • 其余片段仅保留数据行。
  6. 保存结果:
    • 将修复后的表格以 Markdown 格式保存,并记录相关信息到 JSON 文件中。

举例说明

假设我们有一个PDF文件,其中包含一个异常的表格图像。该表格的高度为 1208 像素,宽度为 900 像素,代码会按照以下步骤处理该表格图像:

在函数 split_table_image_rows 中,表格图像会被按固定行高(默认为 400 像素)进行裁切。

  • 表格高度为 1208 像素,固定行高为 400 像素
  • 裁切的片段范围如下:
    • 第一块:top=0, bottom=400 → 高度为 400 像素。
    • 第二块:top=400, bottom=800 → 高度为 400 像素。
    • 第三块:top=800, bottom=1200 → 高度为 400 像素。
    • 第四块:top=1200, bottom=1208 → 高度为 8 像素。

最终会生成 4 个片段,其中前 3 个片段的高度为 400 像素,最后一个片段的高度为 8 像素。

在函数 merge_small_chunks 中,会对裁切后的片段进行检查,确保每个片段的高度和宽度满足最低要求(默认高度为 300 像素,宽度为 20 像素)。

  • 第一块:高度为 400 像素,宽度为 900 像素 → 符合最低要求,直接保留。
  • 第二块:高度为 400 像素,宽度为 900 像素 → 符合最低要求,直接保留。
  • 第三块:高度为 400 像素,宽度为 900 像素 → 符合最低要求,直接保留。
  • 第四块:高度为 8 像素,宽度为 900 像素 → 不符合最低高度要求(300 像素),需要与前面的块合并。

最终结果:

  • 第四块(8 像素) 会与 第三块(400 像素) 合并,生成一个新的片段,高度为 408 像素,宽度为 900 像素

因此,经过合并后,最终会有 3 个片段

  1. 第一块:高度为 400 像素,宽度为 900 像素。
  2. 第二块:高度为 400 像素,宽度为 900 像素。
  3. 第三块:高度为 408 像素,宽度为 900 像素。

在主流程中,合并后的片段会并发调用视觉模型(ask_table_from_image)进行修复,在收集所有片段的修复结果后,代码会将这些片段拼接成完整的 Markdown 表格。

  • 第一块 的修复结果会保留表头和分割线。
  • 第二块第三块 的修复结果只会保留数据行。
  • 最终将所有片段的结果拼接成一个完整的 Markdown 表格。

使用视觉模型修复表格示例效果展示

709f27681f3bf94551b729283c54046a-table-17

1
2
3
4
5
| tD(on)  | - | 20 | - | ns | V_DS=400V, V_GS=0 - 12V, I_D=3A, R_G=30Ω |
| ------- | - | -- | - | -- | ---------------------------------------- |
| tR | - | 7 | - | ns | V_DS=400V, V_GS=0 - 12V, I_D=3A, R_G=30Ω |
| tD(off) | - | 80 | - | ns | V_DS=400V, V_GS=0 - 12V, I_D=3A, R_G=30Ω |
| tF | - | 6 | - | ns | V_DS=400V, V_GS=0 - 12V, I_D=3A, R_G=30Ω |

2. 文本内容处理

  • 使用 OCR 与 Docling 检测文本段落。
  • 文本类型判断:使用 OpenAI 文本模型判断是“标题”还是“正文”。
  • 英文分词修复机制:检测长串无空格文本,调用大模型进行英文分词恢复,提高可读性与准确性。
1
2
3
4
5
示例输入:
THISISATESTSTRING

示例输出:
THIS IS A TEST STRING

3. 图片内容理解

  • 对识别出的图像(非表格)调用视觉大模型进行内容描述,生成自然语言图像摘要,并写入 Markdown 与结构 JSON。
  • 摘要会插入在图片 alt text 中,例如:
1
![这是一个苹果](./1ca13ed90bf22bae348f93027fb876d5-picture-2.png)

4. 生成Markdown文档

在完成表格和图片的提取、文本处理之后,最终将所有提取的信息转换为Markdown文档,并保存到本地。每个表格、图像、文本都会被格式化成相应的Markdown格式,并以图像和表格形式嵌入。

  • Markdown 文档(结构化展示图像、表格、文本,便于人工核查与发布)
  • JSON 文档(结构化、含坐标 bbox、页码、分块来源,适合下游知识图谱任务进行锚点的标记)

该项目代码参考 Docling 框架,对 PDF 文件进行结构解析,结合多种大模型能力(如 Qwen-VL、OpenAI 文本模型),实现了文档的多模态理解与 markdown/json 格式结构化输出,为知识图谱构建奠定基础。

但该项目在处理复杂表格时仍存在不足,例如表格中存在字符是横着摆放时,或者是存在图像嵌入在表格中,则无法保证原有的结构。