把上百页文档整份送进 OCR 时,中断和拼接错误往往来自不同层级:有的是页数多导致单次任务过长、资源被占满,有的是版面本身复杂(多栏、跨页表格、通栏标题)导致区块识别被拆开。这两种失败的判断信号不同,处理方式也不同。先不要急着改切分粒度,先用两组小规模对照测试把失败来源区分开,再决定是按页切、按版面区块切,还是先缩小单次输入规模。下面按“定位失败—选边界—写脚本—处理拼接—抽样核对”的顺序给出可落地的做法。
若整份送入在中途固定页数附近中断、内存持续上涨,问题偏向输入规模,先缩小批次而不是换切法;若中断总出现在多栏页、跨页表格或通栏标题处,问题偏向版面结构,应按版面区块切并在拼接阶段按页码加坐标回接。按页切实现简单、恢复容易,但容易把跨栏段落和跨页表格拦腰截断;按版面区块切保留版面完整性,代价是切分单元不规则、需要额外的合并判定和更细的断点记录。两种切法都建议保留批次清单和已完成标记,并用目录或条款编号做抽样核对。
先分清失败来自页数、版面复杂度还是内存占用
把资源问题和版面问题混在一起排查,最容易出现的误判是“再多切几页就好了”。实际上,如果失败原因是内存或超时,减少每批页数确实能让任务跑完,但拼接错误不会因此减少;如果失败原因是版面结构被切断,缩小批次只是让错误发生得更早、更零碎。
建议做两组对照输入,规模不必大,但要能看出差异:
- 少页复杂版面:取 5-10 页包含双栏正文、跨页表格、通栏标题的页面,整份送入。
- 多页简单版面:取 80-100 页以单栏正文为主的文档,同样整份送入。
两组都要记录三类信息:中断出现在第几页或第几个区块、进程内存或显存随页数增长的趋势、单页处理耗时是否随页数明显变长。这些信息通常可以从运行日志、进程监控(如 top、nvidia-smi 的周期采样)和任务返回结构里拿到,具体字段要结合实际使用的仓库和运行环境确认。
判断顺序建议如下:
- 先看中断位置是否与页数相关。多页简单版面在某个页数附近稳定中断,指向输入规模或资源限制。
- 再看中断是否与特定版面相关。少页复杂版面在表格页、多栏页稳定失败,指向版面解析而非规模。
- 最后看耗时曲线。单页耗时随页数上升明显,说明存在累积状态或内存压力,应按批次切分并控制单批页数。
只有第 1、3 类信号出现时,“先降低输入规模”才是对的解法;如果主要是第 2 类信号,缩小规模解决不了拼接错误,需要换切分粒度。
按页切与按版面区块切的边界分别落在哪
切分粒度的选择依据不是页数多少,而是文档里跨边界结构出现的密度。跨栏段落需要同一页内左右栏按阅读顺序接续;跨页表格需要上一页的表尾与下一页的表头对齐;通栏标题通常标志着一个新章节的开始,被切到独立单元时容易丢失与后文的层级关系。下面这张对照表用于判断两种切法各自的落点:
| 结构类型 | 按页切 | 按版面区块切 |
|---|---|---|
| 跨栏段落 | 整页保留,栏内顺序由解析器自行处理,风险集中在解析器是否理解分栏 | 按栏切出独立区块,需要在拼接时按 x 坐标还原左右栏顺序 |
| 跨页表格 | 必然被页边界截断,需在后处理中按表头特征合并 | 若表格区块跨越页边界,仍要跨单元合并,但表体结构更完整 |
| 通栏标题 | 与所在页正文同批处理,层级关系通常保留 | 可能被切成独立小区块,需按页码和纵坐标挂回后文 |
| 恢复与重跑 | 粒度规整,按页号续跑简单 | 单元不规则,需要稳定的单元 ID 才能续跑 |
容易在两种切法下被切断的结构包括:跨页续表、跨栏的引言或注释、图注与图分离、页脚注释与正文引用、多级标题与首段。判断是否要改用版面区块切,可以先统计一份文档里跨页表格和双栏页的占比;如果这类页面占多数,按页切的后处理成本会明显上升,按版面区块切更合适。
写出切分脚本骨架与批次记录
切分过程必须可追踪、可续跑,核心是三样东西:稳定的切分单元标识、批次清单、已完成标记。下面的骨架是通用的,具体的 OCR 调用、PDF 解析和版面检测接口需要按实际仓库的 API 替换。
# 结构示意,非可直接运行的完整实现
JOBS = "jobs.jsonl" # 批次清单
DONE = "done.jsonl" # 已完成标记
FAIL = "failed.jsonl" # 失败记录,便于单独重跑
def build_jobs(doc_path, mode, batch_size=8):
units = split_by_page(doc_path) if mode == "page" else split_by_layout(doc_path)
for u in units:
yield {
"doc": doc_path,
"unit_id": u.id, # 稳定 ID,例如 p0123-b04
"page": u.page,
"bbox": u.bbox, # 按页切时可为整页范围
"batch": u.page // batch_size,
}
def run():
done = load_ids(DONE)
for job in build_jobs(DOC, MODE):
if job["unit_id"] in done:
continue
try:
result = ocr(job) # 调用方式以实际仓库为准
save_json(result, f"out/{job['unit_id']}.json")
append(DONE, job["unit_id"])
except Exception as e:
append(FAIL, {**job, "err": str(e)})
几点落地建议:unit_id 用页码加区块序号构成,不要用自增序号,否则插页后全部错位;批次大小以“单批能稳定跑完”为准,先用小批次跑通再逐步放大;失败记录单独落盘,重跑时只读 failed.jsonl 而不重跑已完成单元。版面区块切分依赖版面检测,如果检测结果本身不稳定,同一个 unit_id 在两次运行中可能对应不同内容,建议把切分结果单独落盘一次,后续复用同一份切分清单。
处理跨页表格与跨栏段落的拼接
拼接的顺序规则建议固定为:先按页码升序,同一页内按区块的纵坐标升序,同一行内的多个栏按横坐标升序。这样得到的顺序与常规阅读顺序一致,也便于人工核对。跨栏段落的回接,重点看上一栏最后一个区块的末行是否以句末标点结束、下一栏首块是否以续行特征开头(如小写字母、连接词、缺少缩进的首行)。
跨页表格的合并判定可以按以下条件逐条检查:
- 下一页首个区块是否为表格,且列数与上一页表体一致。
- 下一页首行是否重复了上一页的表头文字;重复表头通常是续表信号,合并时保留一份表头。
- 上一页表末行是否存在明显的截断,例如单元格内文字无结尾标点且下一页首行紧接同列内容。
- 两页之间是否夹有通栏标题或分节符;如果有,通常说明是新表而非续表,不应合并。
段落被截断时的判定要保守:如果无法确认是续接还是新段落,宁可保留为两段并在中间加标记,也不要直接拼成一句。拼接错误一旦被拼进正文,后续抽样很难再发现。
抽样核对拼接后的文本连续性
拼接完成后需要有独立的核对动作,而不是只看输出是否非空。建议按目录或条款编号定位抽查点:从目录里挑出跨页的章节和含表格的章节,在输出文本中找到对应标题,比较标题后的首段起止是否与原文一致,特别注意段落开头是否被截掉半句、段尾是否与下一段混在一起。
抽查时可以用这样的对照方式:取原文某一页的最后一个段落和下一页的第一个段落,在拼接结果里搜索这两段的衔接处,确认中间没有插入多余的表头、页码或页脚文字。对表格,比较行数与列数是否与原文一致,检查表头是否只出现一次。
抽查比例不需要固定,但至少覆盖三类位置:跨页表格所在页、双栏页面、通栏标题之后的首页。若这三类位置都出现衔接异常,说明拼接规则需要调整而不是继续扩大抽查;若只有个别位置异常,记录下来作为已知边界,避免在后续批次里重复踩坑。