技术札记

为什么我在大模型前加了一层 OCR:图片理解不是把文件直接丢给模型

基于 Shixiseng OCR Service 的真实实现,拆解图片与扫描 PDF 如何经过安全检查、栅格化、OCR、结构化与异步调度,再以更稳定、可追踪的文本上下文交给大模型。

HOUHUIYANG.COM

扫码继续阅读

正在生成…

为什么我在大模型前加了一层 OCR:图片理解不是把文件直接丢给模型

houhuiyang.com/zh/notes/ocr-before-llm

我做 Shixiseng OCR Service,不是因为大模型“看不懂图片”,而是因为在真实业务里,能看懂一次能够稳定、低成本、可追踪地处理成千上万份文档,是两件完全不同的事。

招聘场景里经常出现简历截图、证书照片、聊天记录、扫描 PDF、倾斜拍摄的表格。最直接的方案,是把原图交给多模态大模型,然后让它提取姓名、学校、公司、时间和项目经历。Demo 往往很好看,但到了生产环境,问题会很快暴露:同一张图重复调用可能得到不同结构;页面方向、清晰度和文件大小影响结果;长 PDF 成本高、耗时长;识别错了以后,很难判断错在视觉读取、上下文组织,还是模型推理。

所以我的选择是在大模型前加一层独立 OCR。它不是为了替代大模型,而是把“看清楚字”和“理解这些字”拆开:OCR 负责稳定地产生文本、页码、坐标与置信度,大模型负责归一化、推理和业务判断。

OCR 位于原始文档与大模型之间的处理链路

先把感知和理解分开

如果把原图直接交给大模型,一个调用同时承担了图像解码、文字识别、版面理解、字段抽取和业务推理。任何一步出错,最终只会得到一个“看起来不太对”的 JSON。

拆开之后,链路变成:

图片 / 扫描 PDF
  → 文件真实性与资源上限检查
  → 页面栅格化与图像标准化
  → OCR 检测与识别
  → 文本、页码、坐标、置信度
  → 大模型抽取、归一化与推理
  → 业务规则校验与人工复核

这层中间结果很重要。比如模型把“2023.08”理解成“2028.03”,我可以回到对应页、对应文本框查看 OCR 原文和置信度;如果 OCR 本身正确,问题在提示词或字段规则;如果 OCR 已经错了,则应该调整图像处理、引擎或回退策略。系统终于可以回答“错在哪里”,而不是只能换一个更大的模型再试。

OCR 还能减少送入大模型的无效信息。空白页、装饰图、重复页和低价值区域可以提前过滤;正文可以按页、区块或字段相关性组织,而不是把几十张高清图片全部塞入上下文。对高频文档处理来说,这直接影响延迟、Token 成本和并发容量。

当前实现:先建立一条可靠的 OCR 基线

Shixiseng OCR Service 接受 JPG、PNG、WEBP、BMP、TIFF 和 PDF,提供同步与异步两套接口,但两者最终复用同一个识别用例。

同步接口适合单张图片和短文档:请求进入后完成校验、识别并直接返回。异步接口适合长 PDF 和批量任务:API 只保存任务元数据与临时文件,把工作交给 Celery Worker,调用方使用 job_id 查询状态。这里没有维护两套 OCR 逻辑,差别只在调度方式。

返回结果也不只有一段纯文本:

{
  "engine": "rapidocr-onnxruntime",
  "page_count": 2,
  "text": "完整文本……",
  "pages": [
    {
      "page_number": 1,
      "width": 1440,
      "height": 2036,
      "lines": [
        {
          "text": "2023.08 - 2025.06",
          "confidence": 0.986421,
          "box": [[112, 284], [486, 284], [486, 326], [112, 326]]
        }
      ]
    }
  ],
  "duration_ms": 1260
}

完整文本方便直接构造大模型上下文;逐页文本保留文档边界;坐标框支持回看原图、恢复阅读顺序和后续版面分析;置信度则可以触发复核或二次识别。结构化结果比“一大段 OCR 字符串”更有长期价值。

这套服务具体基于什么

这不是只写了一个 PaddleOCR 调用脚本。项目使用 Python 3.11 作为生产基线,把 Web 接口、文档处理、OCR 引擎与异步任务拆成独立层次。当前发布锁定的核心版本如下:

层次组件与锁定版本在系统里的职责
CPU OCRRapidOCR 3.4.2 + ONNX Runtime 1.22.1默认生产基线,负责文本检测、方向分类与文字识别
GPU OCRPaddleOCR 3.2.0 + 匹配 CUDA 的 PaddlePaddle GPU wheelGPU 节点的高吞吐识别引擎
PDF / 图片PyMuPDF 1.26.4文件解码、图片尺寸检查、PDF 分页与 RGB 栅格化
HTTP APIFastAPI 0.141.1 + Uvicorn 0.52.0上传、鉴权、Schema 校验、同步与任务查询接口
生产进程Gunicorn 23.0.0 + uvicorn-worker 0.4.0多 Worker、超时、优雅退出与请求回收
异步任务Celery 5.6.3 + Redis 6.4.0 客户端长文档排队、状态、重试、超时、幂等与限流
配置与日志Pydantic Settings 2.10.1 + Structlog 25.4.0强类型启动检查与 JSON 结构化日志

这里的“锁定版本”不是说它们永远最好,而是指这组版本已经作为一个整体进入依赖锁文件、自动化测试和发布流程。生产稳定性来自经过验证的组合,不是来自安装时临时获取各组件的最新版。升级 OCR 引擎时,必须连同模型权重、运行时、CPU 指令集或 CUDA 环境一起重新验收。

为什么 CPU 默认选 RapidOCR,而不是所有机器都装 PaddleOCR

RapidOCR 本身是面向多后端的 OCR 工具层;这个项目明确选择 ONNX Runtime 作为 CPU 推理后端。它的部署面相对小,不要求 CUDA,也不需要在普通 CPU 节点安装完整 PaddlePaddle 运行时,适合作为 Rocky Linux / AlmaLinux x86_64 服务器上的稳定基线。

CPU 引擎输出检测框、识别文字与置信度,服务再把不同版本 RapidOCR 的返回格式归一为自己的 PageResult。业务 API 因此不直接暴露第三方 SDK 对象。未来切换模型或引擎,调用方仍然使用同一套 JSON 契约。

PaddleOCR 放在哪里

PaddleOCR 是 GPU 可选实现,不是 CPU 基线的隐藏依赖。项目锁定 paddleocr==3.2.0,但没有在通用 runtime.lock 里硬锁一个 PaddlePaddle GPU wheel,因为 PaddlePaddle 必须根据目标机的 NVIDIA 驱动、CUDA 版本、操作系统和 Python ABI 选择匹配构建。正确做法是在 GPU 构建节点上先通过 nvidia-smi 确认环境,再安装官方兼容矩阵对应的 PaddlePaddle GPU 包,最后安装 PaddleOCR 并完成真实图片验收。

服务启动时会调用 paddle.is_compiled_with_cuda() 做能力探测。如果配置为严格 gpu,初始化失败就让 readiness 失败;如果配置为 auto 且允许降级,则记录明确告警后切到 RapidOCR CPU。这样不会出现“配置写着 GPU,实际上悄悄用 CPU 跑了几天却没人知道”的情况。

我怎样定义“稳定版”

我不直接把某个项目官网上的 latest 当作稳定版。对这套 OCR 服务,稳定至少满足五个条件:

  1. Python 版本、Linux 发行版、glibc、CPU 指令集或 CUDA 组合明确;
  2. Python 包使用精确版本和哈希锁定,构建过程可重复;
  3. 模型权重提前下载并记录版本,启动不访问公网;
  4. 在真实中文简历、截图、证书和扫描 PDF 上通过回归集;
  5. 同步、异步、超时、重试、进程重启与 CPU 降级都经过验收。

因此,PaddleOCR 3.2.0 只是版本号,“PaddleOCR 3.2.0 + 指定模型 + 匹配的 PaddlePaddle/CUDA + 目标机验收结果”才是一条可以发布的技术基线。

图片处理的第一原则:先判断输入是否值得识别

生产 OCR 的第一步不是调模型,而是控制输入。

服务不相信文件扩展名和客户端声明的 Content-Type,而是读取文件签名判断真实格式。上传还要经过文件大小、图片总像素、PDF 页数、单页渲染像素和文件可解码性检查;加密 PDF、损坏文件、超大图片会在进入 OCR 引擎前被拒绝。

这是准确率问题,也是稳定性和安全问题。一张尺寸异常的图片可能在解码时占用大量内存;一个页数失控的 PDF 会长时间占住 Worker;伪装扩展名的文件则不应该被底层解析器盲目处理。模型再准,也不能弥补输入边界失控。

PDF 会由 PyMuPDF 按页渲染为 RGB PNG,目前默认使用 180 DPI,并关闭没有必要的 alpha 通道。每一页识别完成后立即释放像素对象和临时图片,避免长文档把所有页面同时留在内存里。DPI 也不是越高越好:分辨率提高可能改善小字,但宽高同时放大意味着像素数和内存近似按平方增长。正确做法是用自己的文档集评估准确率与吞吐量,再选基线。

真正有效的预处理,应该按失败类型触发

“预处理”很容易变成一串固定滤镜:灰度化、二值化、锐化、放大,全部做一遍。但每次都处理不仅增加延迟,还可能抹掉浅色文字、印章或表格线。

我更认可按问题触发的处理策略:

观察到的问题处理方式需要防止的副作用
页面旋转 90°/180°文档方向分类后旋转不要只依赖 EXIF
手机拍摄透视变形四角检测与几何展开裁掉页边内容
光照不均、背景发灰局部对比度或自适应二值化浅色字和印章消失
小字号、低分辨率有上限地放大或提高 PDF DPI内存与耗时急剧增加
轻微倾斜估算文本基线并校正表格线干扰角度判断
噪点、压缩块轻量去噪笔画被当成噪声删除
超宽截图或多栏页面版面分区后分别识别阅读顺序被打乱

当前服务已经完成格式探测、资源上限、PDF 分页栅格化和统一 RGB 输入,但还没有把方向校正、去畸变与版面恢复做成独立流水线。我会把它们作为下一阶段的可插拔 DocumentPreprocessor,而不是悄悄写进某个 OCR 引擎适配器。这样才能单独测试“处理前后究竟提高了多少”,也能针对证书、简历、聊天截图使用不同策略。

CPU 是基线,GPU 是容量选择

当前 CPU 基线使用 RapidOCR + ONNX Runtime,优点是部署轻、跨机器兼容性好,适合大多数内部节点。GPU 模式使用 PaddleOCR,适合页面量大、时延要求高的场景。auto 模式优先初始化 GPU,失败时可以明确告警并降级到 CPU。

我没有把 GPU 视为“更准确”的同义词。硬件主要改变吞吐量和可承载模型规模,准确率仍取决于模型、语言、输入质量和领域数据。生产环境必须保留 CPU 基线:GPU 驱动、CUDA、PaddlePaddle wheel 和模型版本任何一处不匹配,都可能让服务在发布后无法启动。

模型在启动阶段预热,readiness 只有在 OCR 引擎、任务状态 Redis 和 Celery Broker 都可用时才通过。模型文件提前放入共享目录,生产启动不依赖公网下载。这样发布失败会表现为“不接流量”,而不是第一个真实用户替我们完成模型初始化测试。

同步与异步不是两个 API 名字,而是两种容量模型

小图片同步返回最简单,但长 PDF 不应该占住 Web Worker。服务给同步识别设置总超时,超过后明确提示改用异步接口;异步任务则有软、硬时间限制和有限重试,只对可重试错误进行指数退避。

异步提交支持 Idempotency-Key。调用方网络超时后再次提交同一份任务,不会无意创建多份重复识别。任务状态在 Redis 中进行原子转换,只允许 queued → processing → succeeded/failed 这类合法路径;任务结束后删除临时文件,结果按 TTL 自动过期。

这也是把 OCR 放到大模型前面的好处:OCR 结果可以按文件哈希、引擎版本和预处理版本缓存。后续调整提示词或更换大模型,不需要再次读取图片;只有 OCR 模型或预处理策略变化时,才重新生成感知层结果。

公网可访问,不等于可以裸奔

业务接口使用 Bearer Token,生产环境没有配置足够强度的 Token 会拒绝启动。当前 Token 与上一枚 Token 可以短期并存,便于不停机轮换;比较使用常量时间方式,Token 不进入查询参数和日志。

Nginx 负责 TLS、请求体上限、连接超时和入口限流,应用层再按调用方做滑动窗口限流。进程以不可登录的 ocr-service 用户运行,systemd 开启 NoNewPrivilegesProtectSystemProtectHome 和独立临时目录。日志只记录 request ID、调用方、文件类型、大小、页数、引擎和耗时,不记录 OCR 正文、Token 与完整文件路径。

文档里可能包含姓名、电话、邮箱、身份证和求职经历。对这样的服务,“日志更详细”不是默认正确答案。真正有用的是能够用 X-Request-ID 串起 Nginx、API、Worker 和任务结果,同时不复制用户隐私。

我采用的部署方式

这套服务的生产基线是 Rocky Linux 9 / AlmaLinux 9、Python 3.11、Redis 6+、Nginx、systemd 与 Jenkins。API 由 Gunicorn + Uvicorn Worker 运行,长任务由独立 Celery Worker 承担。CPU 与 GPU 依赖分开构建,不能把开发机上的虚拟环境直接复制到服务器。

发布使用不可变 release:

/opt/ocr-service/
├── releases/20260824-<git_sha>/
├── shared/.env
├── shared/models/
├── shared/tmp/
└── current -> releases/20260824-<git_sha>/

Jenkins 在与生产兼容的 Linux 节点上完成 Ruff、Mypy、pytest、覆盖率、依赖审计和 wheel 构建,生成带 SHA-256 校验的发布包。目标机为新 release 创建独立 venv、按哈希安装锁定依赖,再原子切换 current 软链接并重启 Worker 与 API。冒烟测试失败时,软链接切回上一版;环境文件、模型和业务临时目录不跟代码一起回滚。

部署之后至少检查四件事:进程是否存活、readiness 是否通过、同步图片能否得到结构化结果、异步任务能否从排队走到完成。只看到 systemd 的 active (running),并不能证明模型、Redis 和任务队列真的可用。

OCR 最佳实践不是某个模型排行榜

如果让我总结这次实现里最重要的原则,会是下面几条:

  1. 保留原始文件,也保留中间结果。 原图用于复核,OCR 结果带页码、坐标、置信度和版本信息,才能定位回归。
  2. 用真实业务样本建立评测集。 清晰扫描件上的平均准确率,不能代表倾斜手机照片、双栏简历和带表格证书。
  3. 按失败类型预处理。 方向、透视、光照、分辨率、版面顺序是不同问题,不要用一组固定滤镜处理所有文件。
  4. 低置信度要有去处。 可以局部放大后二次识别、切换引擎、让多模态模型查看原区域,或进入人工复核,不能假装所有字符同样可信。
  5. 文本层和视觉层互相补位。 OCR 提供可搜索、低成本、可追踪的文本;表格、印章、图表和复杂布局仍可能需要版面模型或多模态大模型。
  6. 准确率、延迟和成本一起评估。 更高 DPI、更重模型和更多预处理都有价格,最终要看字段级准确率与整条业务链路的 P95。
  7. 版本必须进入结果。 OCR 引擎、模型、预处理配置和文档哈希共同决定结果,否则升级后无法解释差异。

如果把这些原则落实成一套可执行流程,我会这样做:

1. 先建立评测集,再调参数

从真实流量按失败类型分层抽样,而不是只收集清晰样本:原生 PDF、扫描 PDF、手机拍照、倾斜、阴影、低分辨率、双栏简历、表格、印章、中英文混排都应保留。训练集、调参集与回归集分开,困难样本不能只增加、不分类。

通用字符错误率 CER 可以观察 OCR 本身,但业务最终更应该看字段级指标:姓名完全匹配率、手机号准确率、日期标准化准确率、教育与工作经历的召回率。对送入大模型的链路,还要分别记录:

OCR 字符错误率 / 字段召回率
预处理前后准确率差值
单页 P50 / P95 耗时
每页内存峰值
低置信度比例与人工复核率
大模型最终字段正确率与单文档成本

只有这样,才能判断一次优化究竟提高了 OCR,还是仅仅让某几张示例图看起来更清晰。

2. 原生 PDF 先抽文本,扫描页才做 OCR

这是当前服务下一步值得补的优化。PDF 如果已经包含可靠文字层,应优先用 PyMuPDF 直接提取,并保留字号、区块与坐标;只对没有文字层、文字层异常或以图片为主的页面执行 OCR。原生文本通常比重新栅格化识别更准确,也更快、更省内存。

但不能只用“能否抽出几个字符”判断。部分扫描 PDF 带有质量很差的隐藏 OCR 层,需要用文本长度、可打印字符比例、坐标有效性和抽样置信规则决定是复用、混合,还是整页重做 OCR。

3. 预处理必须可以开关、版本化和 A/B 对比

每个预处理步骤都应记录输入、输出尺寸、判断理由与配置版本。方向校正、去畸变、二值化和超分辨率不能永久修改唯一原图,也不能作为无法关闭的黑盒。一次模型升级如果准确率下降,应能够用同一批原图重放“旧预处理 + 旧模型”和“新预处理 + 新模型”。

PaddleOCR 3.x 已提供文档方向分类与文本图像去畸变能力,但是否启用仍要由自己的样本评测决定。手机拍照材料可能明显受益,干净扫描件则可能只增加时延。

4. 阅读顺序不能等于 OCR 返回数组顺序

单栏页面可以按文本框的纵坐标、横坐标排序,但双栏简历、表格和侧边栏需要先做版面分区。给大模型时,应显式保留页和区块,例如 <page 1><block type="experience">...</block></page>。否则每个字都识别正确,也可能因为顺序错误而把两段经历拼成一段。

5. 用置信度做路由,不把它当作绝对概率

不同 OCR 引擎、模型和字符类型的置信度不可直接横向比较。阈值需要在本领域样本上校准。更实用的路由是:高置信度直接进入大模型;中置信度对局部区域放大或切换引擎;关键字段低置信度时同时提供原图裁剪给多模态模型;仍然冲突则进入人工复核。

姓名、手机号、身份证号、日期等字段还可以做格式与业务规则校验,但校验只能发现异常,不能擅自“修正”为另一个看似合理的值。系统必须保留原始 OCR 证据。

6. 并发按内存测算,不按 CPU 核数拍脑袋

OCR 的峰值资源取决于页面像素、模型大小、推理线程和同时处理的页数。Web Worker 数、ONNX Runtime 线程、Celery concurrency 如果分别按 CPU 核数设置,叠加后很容易过度并发。当前 Worker 默认 concurrency=1 是保守起点,应该通过压力测试测出单任务峰值内存和 P95,再逐步提高。

GPU 同样不能简单开多个 Worker。多个进程各自加载模型会重复占用显存;更合理的方式通常是每张 GPU 固定少量 Worker、限制预取,并监控队列长度、单页耗时、显存和 CPU 降级次数。

7. 升级必须用同一批文档做差分验收

升级 RapidOCR、ONNX Runtime、PaddleOCR 或模型权重时,对固定回归集同时运行旧版与新版,比较文本差异、字段指标、耗时和资源。只有“平均值提高”还不够,还要查看哪些样本变差,特别是数字、日期、专有名词和中英文混排。

上线时先灰度少量流量,结果写入版本信息但不污染业务判断;确认指标后再扩大。保留旧 release、旧模型和切换开关,才能在驱动、运行时或模型出现回归时快速恢复。

交给大模型时,我会保留什么

大模型输入不应该只是 OCR 的 text 字段。我会保留页码与区块边界,按阅读顺序组织文本;对日期、公司、学校、联系方式等关键字段附带来源页与置信度;对低置信度片段提供原图裁剪或明确的“不确定”标记;对表格则优先转换为 Markdown 或结构化单元格,而不是压平成一段文字。

最终提示词可以要求模型输出字段、证据和不确定性:

{
  "field": "graduation_date",
  "value": "2025-06",
  "evidence": {
    "page": 2,
    "text": "2023.08 - 2025.06",
    "ocr_confidence": 0.986421
  },
  "needs_review": false
}

这不会让大模型永远不犯错,但会让错误可见、可回放、可度量。对招聘、合同、财税和档案类系统来说,这比一次回答“看起来很聪明”更重要。

最后的判断

我并不认为所有图片都必须先 OCR。对场景理解、图表问答、视觉关系和少量临时图片,直接使用多模态大模型往往更自然。但当任务以文字为核心,要求批量处理、稳定抽取、证据定位、隐私控制和成本可预测时,独立 OCR 仍然是一层很有价值的基础设施。

Shixiseng OCR Service 当前完成的是可靠基线:安全接收图片与 PDF,按页识别,返回结构化结果,支持 CPU/GPU、同步/异步、鉴权、限流、幂等、观测与可回滚部署。下一步不是盲目换更大的 OCR 模型,而是建立真实失败样本集,补齐按需预处理、版面恢复、置信度回退和字段级评测。

我的目标从来不是让 OCR 单独拿到一个漂亮分数,而是让图片进入大模型之前,先变成一份更干净、更可解释、也更值得信任的上下文。

延伸阅读

返回技术札记