RAG中PDF文档的处理和存储(一):使用PaddleOCR将PDF转换为Markdown

RAG中PDF文档的处理和存储(一):使用PaddleOCR将PDF转换为Markdown
前言PDFPortableDocument Format可移植文件格式是一种常见的文档格式。PDF 的本质是一种视觉排版指令而非结构化文本文件。它存储的是“在第 X 页坐标 (x,y) 处放置字符 A”这样的绘图命令而不包含段落、标题、表格等语义结构信息。 这意味着计算机看到的是散落在页面上的一个个字符坐标而非人类理解的“这是一段话”“这是一张表”标题和正文在 PDF 底层没有本质区别只是字体大小不同复制 PDF 表格到 Word 时结构完全丢失就是这个原因在 RAG 系统中PDF 文档处理是公认的第一道坎——解析质量直接决定了后续检索和生成的上限。实践中RAG 系统对 PDF 文档的处理存在两个独有且最核心的痛点复杂版面的布局解析。包括多栏或更复杂的布局下阅读顺序混乱标题、正文、图表标题、脚注和其他文字在内部无法准确区别标题层级无法还原表格行列关系丢失二维表格被强行压成一维文字图文混排的关联断裂公式无法准确识别页眉、页脚、页码、脚注和矢量图文字截断文本等等。扫描件、图片型、乱码等特殊 PDF无法正确识别文字需借助 OCR 等工具处理。即使有 OCR也存在中文竖排、表格混排时识别率骤降倾斜、模糊、低分辨率扫描件需要额外预处理等问题。LangChain中常用的几种PDF处理工具PyPDFLoader最基础的 PDF 加载器不支持复杂布局多栏、表格、无法识别文档结构标题/段落、不支持OCRPyMuPDFLoader可提取图片和链接、支持表格识别find_tables()、可获取文本格式信息字体、大小但不自动识别文档语义结构、需要手动处理布局分析不支持 OCRUnstructuredPDFLoader自动识别标题/表格/列表等结构元素支持 OCR但依赖较多、配置复杂而本系列文章将要提到的PaddleOCR很大程度上解决了上述方法存在的诸多问题。本系列文章将提供 RAG 中 PDF 文档的处理和存储方法计划分为三部分一使用PaddleOCR将PDF转换为Markdown本文章二一般Markdown文档的分块处理和存储三条款类文档的分块处理和存储一、PaddleOCR概述PaddleOCR 是百度基于飞桨PaddlePaddle深度学习框架开发的产业级OCR开发套件用于将 PDF 和图片转换为结构化的文本形式。PaddleOCR的优势采用“布局分析元素识别”的两阶段协同架构能精准还原多栏排版、图文混杂、跨页表格等复杂场景避免内容错乱和“幻觉”问题。支持‌109种以上语言‌的精准识别对中英文手写体、竖排文本、阿拉伯语等小语种都有高识别率还能将复杂数学公式直接转换为标准LaTeX代码自动提取图表数据生成结构化表格完美适配学术论文、多语言合同等专业文档场景。PaddleOCRVLLoader返回的不只是纯文本而是带语义标签的“文档地图”能同时输出文本流顺序、表格坐标、公式LaTeX、图表描述四类结构化结果所有版面细节信息都完整保留在metadata中方便后续检索、问答等下游流程做细粒度控制。支持官方API接口配置access_token即可开箱即用官方提供每天数万页的免费解析额度二、准备工作本系列文章使用 langchain-paddleocr 中的 PaddleOCRVLLoader。首先需要安装 langchain-paddleocrpip install langchain-paddleocr如果使用在线的 PaddleOCR 有关模型还需要通过 PaddleOCR 官网获取 AI Studio 访问令牌如使用已部署的 PaddleOCR 有关模型可跳过下列步骤直接进入下一节。。然后在 PaddleOCR 官网首页点击 API 按钮在代码区域可以看到以下内容# Please make sure the requests library is installed # pip install requests import json import os import requests import sys import time JOB_URL https://paddleocr.aistudio-app.com/api/v2/ocr/jobs TOKEN **************************************** MODEL PaddleOCR-VL-1.6 # ...记录下这里的 TOKEN将其设置为名称为 PADDLEOCR_TOKEN 的环境变量。下面步骤中要用到。三、基于 PaddleOCRVLLoader 处理 PDF 的总体逻辑总体逻辑是先将 PDF 转换为 Markdown再将 Markdown 进行分块处理。下面代码列出了本系列文章中涉及的模块导入以及总体逻辑方法pdf_splitter。pdf_splitter包含两步pdf2markdown方法 PDF 转换为 Markdown本篇主要介绍markdown_splitter方法Markdown 的分块处理下一篇介绍import json import logging import os import re from typing import Any, List, Dict import requests import spacy from dotenv import load_dotenv from langchain_core.documents import Document from langchain_paddleocr import PaddleOCRVLLoader from langchain_text_splitters import MarkdownHeaderTextSplitter from pydantic import SecretStr from tqdm import tqdm from my_chroma import document_chroma from ocr_utils import merge_formulas, html_table_to_markdown_table, image2text load_dotenv() # 如果将 PADDLEOCR_TOKEN 存入 .env 文件需添加此行代码 # 创建一个 logger logger logging.getLogger(__name__) def pdf_splitter(document_path: str, rename: strNone, chunk_size500, chunk_overlap100, title_format: list[str]None, extra_metadata: Dict[str, int|float|str|None]None): PDF 文档分块处理先转换为 Markdown再使用 markdown_splitter 分块处理 Args: document_path: PDF 文档路径 rename: 文档别名 chunk_size: 分块的最小长度 chunk_overlap: 重叠部分的最小长度 title_format: 标题格式例如 第一章 一、 一 1. (1)用于识别特定标题级别。 标题格式中的编号只允许使用 [一1①AaIi]其中 [AaIi] 必须使用半角方括号 [] 包围[一1①] 不用包围。 同一级标题包含多种不同格式的用 | 分隔例如 第1章|附录[A]。 None 表示按照默认方式识别标题级别。 extra_metadata: 文档的其他元数据例如版本号、修订时间、管理人、权限等 Returns: 处理后的 Document 列表 if not os.path.isfile(document_path): raise FileNotFoundError(f文件 {document_path} 不存在) if not document_path.endswith(.pdf): raise ValueError(f文件 {document_path} 不是 PDF 文件) pdf2markdown(document_path, rename, title_format) # 在pdf2markdown函数中会创建与原始PDF文件名称相同的目录 return markdown_splitter(f{document_path[:-4]}/{rename}.md, rename, chunk_size, chunk_overlap, extra_metadata)四、PDF 转换为 Markdown1. 函数声明和准备工作包括文件有效性创建输出目录无效标签和标题级别正则表达式的定义def pdf2markdown(document_path: str, rename: strNone, title_format: list[str]None): PDF 文档转换为 Markdown Args: document_path: PDF 文档路径 rename: 文档别名 title_format: 标题格式例如 第一章 一、 一 1. (1)用于识别特定标题级别。 标题格式中的编号只允许使用 [一1①AaIi]其中 [AaIi] 必须使用半角方括号 [] 包围[一1①] 不用包围。 同一级标题包含多种不同格式的用 | 分隔例如 第1章|附录[A]。 None 表示按照默认方式识别标题级别。 article_id_format: 按照条文编号分块的编号格式格式要求与 title_format 相同。例如 第一条 1.1.1。 if not os.path.isfile(document_path): raise FileNotFoundError(f文件 {document_path} 不存在) if not document_path.endswith(.pdf): raise ValueError(f文件 {document_path} 不是 PDF 文件) # 页码、页眉、页脚、页边等非正文和无效内容标签 IGNORE_LABELS [number, footnote, header, header_image, footer, footer_image, aside_text, formula_number, figure_title] document_name rename or os.path.basename(document_path)[:-4] document_dir os.path.dirname(document_path) / document_name # 创建用于输出 Markdown 文件和下载图片的目录 if not os.path.isdir(document_dir): os.mkdir(document_dir) if not os.path.isdir(document_dir/imgs): os.mkdir(document_dir/imgs) # 标题级别识别正则表达式 DEFAULT_TITLE_FORMAT [r第\s*[0-9一二三四五六七八九十]\s*章|chapter\s[0-9IVXⅠ-Ⅻ] r|附录\s*[A-Z0-9一二三四五六七八九十]*|appendix\s[A-Z0-9IVXⅠ-Ⅻ]* r|[0-9A-Z]\s, r[0-9A-ZⅠ-Ⅻ][\.\-][0-9]|第\s*[0-9一二三四五六七八九十]\s*节|section\s[0-9IVXⅠ-Ⅻ], r[0-9A-ZⅠ-Ⅻ][\.\-][0-9][\.\-][0-9]] if title_format: for i in range(len(title_format)): title_format[i] title_format[i].replace(一, [一二三四五六七八九十]) title_format[i] re.sub(r\s*1\s*, r\s*[0-9]\s*, title_format[i]) title_format[i] title_format[i].replace(①, [①-⑳㉑-㉟㊱-㊿]) title_format[i] re.sub(r\s*\[A]\s*, r\s*[A-Z]\s*, title_format[i]) title_format[i] re.sub(r\s*\[a]\s*, r\s*[a-z]\s*, title_format[i]) title_format[i] re.sub(r\s*\[I]\s*, r\s*[IVXⅠ-Ⅻ]\s*, title_format[i]) title_format[i] re.sub(r\s*\[i]\s*, r\s*[ivxⅰ-ⅻ]\s*, title_format[i]) if len(title_format) 3: title_format DEFAULT_TITLE_FORMAT[len(title_format):] else: title_format DEFAULT_TITLE_FORMAT2. 使用 PaddleOCRVLLoader 读取和识别 PDF 元素使用PaddleOCRVLLoader即可对PDF进行识别提取出PDF中的各类元素并转换为Markdown格式的文本# 接续 pdf2markdown 函数 logger.info(正在识别 PDF 文件...) loader PaddleOCRVLLoader( file_pathdocument_path, base_urlhttps://paddleocr.aistudio-app.com/, # 注意不要带api/v2/ocr/jobs运行中会自动添加 access_tokenSecretStr(os.getenv(PADDLEOCR_TOKEN)) ) load_document loader.load()[0] logger.info(识别 PDF 文件完成)虽然load_document中的page_content字段已经包含了转换后的Markdown文本但是这远远不够因为在这个文本中标题层级判断不准确PaddleOCR能识别出这段文字是标题但不负责判断标题级别算法块、代码块无特殊标记与正文混淆表格、图片采用HTML样式css定义较多占用上下文且不利于LLM读取处理而load_document中的metadata字段则具体列出了识别出的文档各区块坐标、类别、识别文本等信息。其总体格式如下{ source: 文件名.pdf, paddleocr_vl_raw_response: { job_id: 6893*********2800, data_info: { numPages: 1, # 总页数 pages: [ { width: 1191, # 页面宽度 height: 1684 # 页面高度 } ], type: pdf }, pages: [...] # **下面会具体说明** }source字段和paddleocr_vl_raw_response中的job_id、data_info显示的是文档和任务的基本信息。paddleocr_vl_raw_response中的pages字段是本文的重点它是一个字典列表每一页对应一个字典分别记录每一页的处理结果。具体格式如下pages: [ { markdown_text: 自动转换后的Markdown文本, markdown_images: { imgs/img_in_image_box_***_***_***_***.jpg: 提取出的图片URL, imgs/img_in_image_box_***_***_***_***.jpg: 提取出的图片URL, ... }, output_images: { layout_det_res: 标注有OCR识别结果的页面图片URL }, pruned_result: { page_count: null, width: 1191, height: 1684, model_settings: {...}, # 配置信息不用管 parsing_res_list: [...] # 文档块列表**下面将具体介绍** layout_det_res: {boxes: [...]} # 版面检测信息 }, input_image_url: 原始页面图片URL, exports: {}, markdown: {...} # 和 markdown_text 和 markdown_images 相同不用管 raw: {...} # 内容和前面各字段重复不用管 }, {...} # 格式同上 ]这里重点关注两块内容markdown_images提取出的图片名称用于在 parsing_res_list 中确定的相应位置插入图片路径定义。parsing_res_listOCR识别结果的核心部分解析出来的所有文档块列表。每一个 block 的结构parsing_res_list: [ { block_label: header, block_content: 页眉文本, block_bbox: [556, 84, 635, 109], block_id: 0, block_order: null, group_id: 0, block_polygon_points: [[556.0, 84.0], [634.0, 84.0], [634.0, 108.0], [556.0, 108.0]] }, ..., { block_label: text, block_content: 当前分块文本内容, block_bbox: [215, 609, 899, 667], block_id: 6, block_order: 6, group_id: 6, block_polygon_points: [[215.0, 609.0], [899.0, 609.0], [899.0, 667.0], [215.0, 667.0]] }, ... ]字段含义block_label区块类别模型预定义标签block_contentOCR 识别后的文本内容image 类型内容为空字符串block_bbox[xmin, ymin, xmax, ymax] 矩形包围框左上角‑右下角block_id全局唯一序号block_order阅读顺序null代表该块不参与正文排序图片、图注、页眉页脚group_id分组 ID预留字段block_polygon_points四边形四个顶点坐标适配倾斜文本顺序左上‑右上‑右下‑左下常见的block_label区块类别类型block_label名称含义文本text普通文本doc_title文档标题paragraph_title章节标题abstract摘要文本table表格algorithm算法或代码块display_formula独立成行的公式图片image图形、图片chart图表辅助元素formula_number公式编号figure_title图片名称footnote脚注number页码header页眉文本header_image页眉图片footer页脚文本footer_image页脚图片aside_text页边文本了解metadata字段中的内容后下面将从metadata中的paddleocr_vl_raw_response-pages中依次提取识别结果并输出为Markdown。3. 提取 PDF 各项元素# 接续 pdf2markdown 函数 # 每个页面是一个 page pages: List[Dict[str, Any]] load_document.metadata[paddleocr_vl_raw_response][pages] total_pages len(pages) title_levels: Dict[int, str] dict() paragraphs: List[str] [] paragraph_buffer footnotes: Dict[str, str] dict() def merge_notes(buffer: str): paragraph_list [buffer] # 注释处理某些 Markdown 可能不支持此类型 for note_mark in re.findall(r\$\^\{?[0-9①-⑳㉑-㉟㊱-㊿ivxⅰ-ⅻ*†‡]}?\$, buffer): note_id re.findall(r\$\^\{?([0-9①-⑳㉑-㉟㊱-㊿ivxⅰ-ⅻ*†‡])}?\$, note_mark)[0] paragraph_list[0] paragraph_list[0].replace(note_mark, f[^{note_id}]) if footnotes.get(note_id): paragraph_list.append(f[^{note_id}]: {footnotes[note_id]}) return paragraph_list下面逐页进行元素提取。首先下载提取出的图片而后对行内公式规范化对脚注进行统一整理。# 接续 pdf2markdown 函数 for page_i, page in enumerate(pages): logger.info(f正在处理第 {page_i 1} 页共 {total_pages} 页) # 各区块的内容和属性 parsing_res_list: List[Dict[str, Any]] page[pruned_result][parsing_res_list] # 提取出的图片 images: Dict[str, str] page[markdown_images] for image_name, image_url in images.items(): logger.info(f正在下载图片{image_name}) r requests.get(image_url) # 下载图片 with open(document_path[:-4] / image_name, wb) as f: # image_name 中已经包含了“imgs/”路径 f.write(r.content) # 预处理包括行内公式格式规范化和对脚注的整理 footnotes dict() for i, parsing_res in enumerate(tqdm(parsing_res_list)): block_label: str parsing_res[block_label] block_content: str parsing_res[block_content].strip() # 行内公式格式规范化 for bad_formula in re.findall(r(?:^|[^$])(\$\s[^$]\s\$)(?:[^$]|$), block_content): parsing_res_list[i][block_content] parsing_res_list[i][block_content].replace( bad_formula, ${}$.format(re.findall(r\$\s([^$])\s\$, bad_formula)[0]) ) if block_label ! footnote: continue # 解析脚注编号整理脚注 footnote_phraser re.match(r^[0-9①-⑳㉑-㉟㊱-㊿ivxⅰ-ⅻ*†‡][\s.], block_content) if footnote_phraser: # 非上标形式 footnote_id block_content[footnote_phraser.start():footnote_phraser.end()-1] footnotes[footnote_id] block_content[footnote_phraser.end():].strip() continue footnote_phraser re.match(r^\$\s*\^\{?[0-9①-⑳㉑-㉟㊱-㊿ivxⅰ-ⅻ*†‡]}?\s*\$, block_content) if footnote_phraser: # 上标形式 footnote_id re.findall(r^\$\s*\^\{?([0-9①-⑳㉑-㉟㊱-㊿ivxⅰ-ⅻ*†‡])}?\s*\$, block_content)[0] footnotes[footnote_id] block_content[footnote_phraser.end():].strip() # 对公式进行匹配整理 merge_formulas(parsing_res_list)上面代码的最后一行是对公式编号和独立成行公式进行匹配在 ocr_utils.py 中定义# ocr_utils.py def merge_formulas(parsing_res_list: List[Dict[str, Any]]): 编号与公式的匹配和导入 # 提取公式编号和公式的索引 formula_numbers [i for i, block in enumerate(parsing_res_list) if block[block_label] formula_number] display_formulas [i for i, block in enumerate(parsing_res_list) if block[block_label] display_formula] # 如果没有公式编号或没有公式直接返回 if not formula_numbers or not display_formulas: return # 遍历每一个公式编号寻找它归属的公式 for num_idx in formula_numbers: num_bbox parsing_res_list[num_idx][block_bbox] # 计算编号区块的中心点坐标 num_cx (num_bbox[0] num_bbox[2]) / 2 num_cy (num_bbox[1] num_bbox[3]) / 2 best_formula_idx -1 min_distance float(inf) # 遍历所有公式计算与当前编号的距离找出最近的一个 for formula_idx in display_formulas: formula_bbox parsing_res_list[formula_idx][block_bbox] # 计算公式区块的中心点坐标 formula_cx (formula_bbox[0] formula_bbox[2]) / 2 formula_cy (formula_bbox[1] formula_bbox[3]) / 2 # 使用欧几里得距离勾股定理计算两个区块中心点的距离 dist ((num_cx - formula_cx) ** 2 (num_cy - formula_cy) ** 2) ** 0.5 if dist min_distance: min_distance dist best_formula_idx formula_idx # 如果找到了匹配的公式进行替换操作 if best_formula_idx ! -1: tag_content parsing_res_list[num_idx][block_content] # 将公式末尾的 $$ 替换为 \tag{编号} $$ parsing_res_list[best_formula_idx][block_content] re.sub( r\$\$$, f \\\\tag{{{tag_content}}} $$, parsing_res_list[best_formula_idx][block_content] )随后是对每个区块逐一处理。先对脚注编号进行预先处理提取脚注编号并转换为 Markdown 的附注方式。按照标签block_label对标题、公式、图片、表格、算法/代码块和普通文本分别进行处理并添加到段落列表。标题按照预设或者用户定义的标题级别正则表达式判断标题级别并记录当前内容所在的标题路径。公式不同于一般文本需使用两个换行符分段公式前面只使用一个换行符。图片按照 block_bbox 字段定位对应的图片并检测下方是否有图片名称将图片显示为 Markdown 格式的标记并和名称一并写入段落列表。表格转换为 Markdown 格式并检测上方是否有表格标题。算法/代码块如果这一块是以“算法”或“Algorithm”开头则认定为算法块。由于Markdown没有专门处理偏向自然语言描述的算法的语法结构为便于后续处理该块会在上面添加*****Algorithm Block*****和*****End Algorithm*****作为标记。否则认定为代码块在前后添加“”标记。普通文本不属于上述特殊类型也不属于 IGNORE_LABELS 定义的无效类型根据末尾标点判断段落是否结束如果符合段落结束特征将段落加入到段落列表否则此时可能到达本页或者本栏末尾先加入到段落缓冲区。# 接续 pdf2markdown 函数 - for page_i, page in enumerate(pages): # 逐块处理 for i, parsing_res in enumerate(tqdm(parsing_res_list)): block_label: str parsing_res[block_label] block_content: str parsing_res[block_content].strip() # 跳过页眉、页脚、页边、页码和已在上一步处理的脚注 if block_label in IGNORE_LABELS: continue # 注释处理某些 Markdown 可能不支持此类型 note_ids: List[str] [] for note_match in re.finditer(r\$\s*\^\{[0-9①-⑳㉑-㉟㊱-㊿ivxⅰ-ⅻ*†‡]}\s*\$, block_content): note_id re.findall(r\$\s*\^\{([0-9①-⑳㉑-㉟㊱-㊿ivxⅰ-ⅻ*†‡])}\s*\$, note_match.group())[0] note_ids.append(note_id) block_content.replace(note_match.group(), f[^{note_id}]) # 标题的处理 if block_label in (doc_title, paragraph_title): title_level len(title_format)-1 # 表示第 title_level1 级标题 while title_level 0: # 必须从下级到上级匹配识别 if re.match(title_format[title_level], block_content, re.IGNORECASE): break title_level - 1 if title_level 0: # 未能识别出标题级别 if re.match(r序|preface|前\s*言|forward|摘\s*要|abstract|目\s*录|table of contents r|参考文献|reference|致\s*谢|acknowledgments|索\s*引|index, block_content, re.IGNORECASE): title_level 0 elif re.match(r[0-9A-Z]\., block_content): # 如果已经有“第一章”“Chapter 1”等一级标题序号则将“1.”开头的处理为四级标题否则为一级标题 title_level 3 if title_levels.get(0) else 0 else: # doc_title 处理为一级标题, paragraph_title 处理为二级标题 title_level 0 if block_label doc_title else 1 else: # 记录是否出现过带标号的各级标题 if not title_levels.get(title_level): title_levels[title_level] block_content paragraphs.extend(merge_notes(# * (title_level 1) block_content)) # 公式的处理 elif block_label display_formula: # 紧接前段文本只间隔一个换行符 paragraph_buffer \n block_content paragraphs.extend(merge_notes(paragraph_buffer)) paragraph_buffer # 图片的处理 elif block_label in (image, chart): block_bbox: List[int] parsing_res[block_bbox] # 检查下方是否有图片名称 next_block: dict[str, Any] parsing_res_list[i1] if i1len(parsing_res_list) else dict() if next_block.get(block_label, ) figure_title: figure_name: str next_block[block_content].strip() figure_match re.match(r(?:图\s*|figure |fig.\s*)[0-9A-Z.\-]\s, figure_name, re.IGNORECASE) if figure_match: figure_id figure_match.group().strip() # 将图片编号设置为斜体以与正文区分开 figure_name figure_name.replace(figure_id, f*{figure_id}*) paragraphs.extend( # 图片描述暂用占位符代替因为需要结合上下文生成待整个文档全部整理后再补充 [![Image_Description](imgs/img_in_{}_box_{}_{}_{}_{}.jpg) .format(block_label, block_bbox[0], block_bbox[1], block_bbox[2], block_bbox[3])] merge_notes(figure_name) ) else: paragraphs.extend( [![Image_Description](imgs/img_in_{}_box_{}_{}_{}_{}.jpg) .format(block_label, block_bbox[0], block_bbox[1], block_bbox[2], block_bbox[3])] ) # 表格的处理 elif block_label table: # 处理表格中的图片这步必须先做否则图片会被 BeautifulSoup 处理掉 for img in re.findall(rimg [^]/, block_content): img_name re.findall(rsrc\([^\])\, img)[0] block_content block_content.replace(img, f![Image_Description]({img_name})) # 将表格转为 Markdown 表格 table html_table_to_markdown_table(block_content) # 检查上方是否有表格标题 prev_block parsing_res_list[i-1] if i0 else dict() if prev_block.get(block_label, ) figure_title: table_name: str prev_block[block_content].strip() table_match re.match(r(?:表|table|tab.)\s*[0-9A-Z.\-]\s, table_name, re.IGNORECASE) if table_match: table_id table_match.group().strip() # 将表格编号设置为斜体以与正文区分开 table_name table_name.replace(table_id, f*{table_id}*) paragraphs.extend(merge_notes(table_name) merge_notes(table)) else: paragraphs.extend(merge_notes(table)) # 算法块、代码块的处理 elif block_label algorithm: if block_content.startswith((算法, Algorithm, ALGORITHM)): # 由于 Markdown 中没有专门处理描述性算法的语法只好自定义标记 paragraphs.extend([*****Algorithm Block*****\n block_content \n*****End Algorithm*****]) else: # 代码块 paragraphs.extend([\nblock_content\n]) # 普通文本的处理 else: for paragraph in block_content.split(\n): if not paragraph: continue # 空段落丢弃 paragraph_buffer paragraph if paragraph_buffer[-1] in .?!:。 \ or paragraph_buffer[-2:] in ( , ., ”, .”, 。”, ”, ), .), ., 。, , ], .], 。], ], ., 。, , .〕, 。〕, 〕, .】, 。】, 】): paragraphs.extend(merge_notes(paragraph_buffer)) paragraph_buffer elif re.search(r[0-9A-Za-z,;\)\]}$%*/’”]$, paragraph_buffer): # 数字、英文字母、英文标点后面添加空格避免与后续单词拼接 paragraph_buffer 为了更好地让大模型理解表格内容这里将 HTML 表格统一转换为 Markdown 格式。在 ocr_utils.py 中定义了将 HTML 表格转为 Markdown 表格的 html_table_to_markdown_table 函数# ocr_utils.py def html_table_to_markdown_table(html_table: str) - str: soup BeautifulSoup(html_table, html.parser) table soup.find(table) if not table: return rows [] # 第一步先解析所有行并处理 rowspan/colspan raw_rows [] max_cols 0 for tr in table.find_all(tr): row [] col_idx 0 for cell in tr.find_all([th, td]): rowspan int(cell.get(rowspan, 1)) colspan int(cell.get(colspan, 1)) text cell.get_text(stripTrue).replace(\n, br) row.append({ text: text, rowspan: rowspan, colspan: colspan, col_idx: col_idx }) col_idx colspan raw_rows.append(row) if col_idx max_cols: max_cols col_idx # 第二步合并表头处理前两行用 - 拼接 # 先判断第一行是否有 rowspan2 或 colspan1 的单元格 row1 raw_rows[0] dual_header False for cell in row1: if cell[colspan]1 or cell[rowspan]1: dual_header True break if dual_header: # 前两行作为表头 row1 raw_rows[0] row2 raw_rows[1] # 先构建一个空的表头模板 header [] * max_cols # 填充 row2_i 0 for cell in row1: start cell[col_idx] end start cell[colspan] for i in range(start, end): header[i] cell[text] if cell[rowspan] 1: header[i] - row2[row2_i][text] row2_i 1 header_row header # 剩下的行作为数据行 data_rows raw_rows[2:] else: # 此时表头只有一行 header_row [cell[text] for cell in raw_rows[0]] data_rows raw_rows[1:] # 第三步处理数据行的 rowspan把合并单元格拆分成多行 # 先构建一个“填充矩阵”记录每一行每一列的内容 # 先处理表头行 matrix [header_row] # 处理数据行的 rowspan需要维护一个“待填充的单元格列表” pending [] # [(col_idx, text, remaining_rows)] for row in data_rows: # 先把 pending 里的单元格填充到当前行 current_row [None] * max_cols new_pending [] for col_idx, text, rem in pending: current_row[col_idx] text if rem 1: new_pending.append((col_idx, text, rem - 1)) pending new_pending # 再处理当前行的单元格 col_idx 0 for cell in row: # 跳过已经被 pending 填充的列 while col_idx max_cols and current_row[col_idx] is not None: col_idx 1 if col_idx max_cols: break text cell[text] rowspan cell[rowspan] colspan cell[colspan] # 填充当前单元格的 colspan 范围 for i in range(col_idx, col_idx colspan): if i max_cols: break current_row[i] text # 如果 rowspan 1添加到 pending if rowspan 1: new_pending.extend([(i, text, rowspan - 1) for i in range(col_idx, col_idx colspan)]) pending new_pending col_idx colspan # 把当前行加入矩阵 matrix.append([x if x is not None else for x in current_row]) # 第四步生成 Markdown 表格 if not matrix: return # 表头 md | | .join(map(str, matrix[0])) |\n # 分隔线 md | | .join([---] * len(matrix[0])) |\n # 数据行 for row in matrix[1:]: md | | .join(map(str, row)) |\n return md4. 为图片添加描述全部区块处理完毕后为了更好地让大模型理解文档中的图片需要对图片添加描述。# 接续 pdf2markdown 函数 # 全局处理图片添加描述 for i, paragraph in enumerate(paragraphs): for img_match in re.finditer(r!\[[^]]*]\([^)]\), paragraph): img img_match.group() img_base re.findall(r]\(([^)])\), img)[0] img_name document_path[:-4] / img_base logger.info(f正在生成图片描述{img_name}) if paragraph[0] |: # 当前段落是表格 prev_context paragraph[:img_match.start()] next_context paragraph[img_match.end():] else: prev_context: list[str] [] next_context: list[str] [] # 图片上下文 # 提取上下各 3 个段落 j i - 1 while j 0: if paragraphs[j].strip(): prev_context.insert(0, paragraphs[j].strip()) if len(prev_context) 3: break j - 1 j i 1 while j len(paragraphs): if paragraphs[j].strip(): next_context.append(paragraphs[j].strip()) if len(next_context) 3: break j 1 statement image2text(img_name, prev_context\n.join(prev_context), next_context\n.join(next_context)) logger.info(f图片 {img_name} 的描述\n{statement}) paragraphs[i] paragraphs[i].replace(img, f![图片描述{statement}]({img_base}))image2text 是定义在 ocr_utils.py 中的一个函数通过视觉大模型将图片转换为文字描述。vlm_engine 函数实现的是一个视觉大模型 API 调用这里不再给出具体代码可参考本文最后的开源链接或者自行根据大模型厂商如阿里云百炼等官方 API 文档实现。# ocr_utils.py def image2text(image_path, prev_context: str None, next_context: str None): system_prompt textwrap.dedent( # Role 你是一个专业的RAG数据预处理专家。你的任务是根据提供的【图片】及其【上下文文本】生成一段极其精简且富含语义的图片描述Alt Text。 # Goals 生成的描述将用于向量数据库检索。因此描述必须包含图片中的关键实体、动作、数据趋势或核心概念并与上下文中的术语保持对齐。 # Constraints 1. **极度简洁**控制在100字以内。 2. **拒绝废话**严禁使用“这张图片展示了”、“如图所示”、“我们可以看到”等无意义的引导词直接描述核心内容。 3. **术语对齐**优先使用上下文中出现过的专业名词来指代图片中的物体或概念例如如果文中说“Transformer架构”图中是架构图描述中必须包含“Transformer架构”。 4. **客观描述**只陈述事实不进行主观推断。 5. **格式**仅输出一段纯文本不要包含任何标签或解释。 # Workflow 1. 分析图片识别核心主体人、物、图表、流程图等。 2. 阅读上下文提取与图片相关的关键实体和主题。 3. 将图片视觉信息与文本关键术语结合生成一句高密度的描述。 # Output Example 错误示例这张图片展示了一个关于2023年销售额的柱状图我们可以看到销售额在增长。 正确示例2023年Q1至Q4销售额增长趋势柱状图显示第四季度同比增长20%。 ).strip() prompt f上文{prev_context}\n下文{next_context} return vlm_engine(promptprompt, image_pathimage_path, system_promptsystem_prompt)最后将所有段落写入Markdown文件每个段落之间用两个换行符分隔with open(f{document_path[:-4]}/{document_name}.md, w, encodingutf-8) as output_file: output_file.write(\n\n.join(paragraphs))下图中输入的文档路径是 data/2211.17192v2.pdf且不另起别名运行上述 pdf2markdown 函数将会在 data 目录下创建一个 2211.17192v2 目录该目录下会创建一个 2211.17192v2.md 文件。由于 2211.17192v2.pdf 包含了图片还会在 data/2211.17192v2 目录下创建一个 imgs 目录里面存放了识别出的各个图片。结语以上是 RAG 中对 PDF 文档处理的第一部分将 PDF 转换为 Markdown 的实现。下一篇将介绍如何将输出的 Markdown 进行分块处理并存入向量数据库。