我做 Shixiseng OCR Service,不是因为大模型“看不懂图片”,而是因为在真实业务里,能看懂一次和能够稳定、低成本、可追踪地处理成千上万份文档,是两件完全不同的事。
招聘场景里经常出现简历截图、证书照片、聊天记录、扫描 PDF、倾斜拍摄的表格。最直接的方案,是把原图交给多模态大模型,然后让它提取姓名、学校、公司、时间和项目经历。Demo 往往很好看,但到了生产环境,问题会很快暴露:同一张图重复调用可能得到不同结构;页面方向、清晰度和文件大小影响结果;长 PDF 成本高、耗时长;识别错了以后,很难判断错在视觉读取、上下文组织,还是模型推理。
所以我的选择是在大模型前加一层独立 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 OCR | RapidOCR 3.4.2 + ONNX Runtime 1.22.1 | 默认生产基线,负责文本检测、方向分类与文字识别 |
| GPU OCR | PaddleOCR 3.2.0 + 匹配 CUDA 的 PaddlePaddle GPU wheel | GPU 节点的高吞吐识别引擎 |
| PDF / 图片 | PyMuPDF 1.26.4 | 文件解码、图片尺寸检查、PDF 分页与 RGB 栅格化 |
| HTTP API | FastAPI 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 服务,稳定至少满足五个条件:
- Python 版本、Linux 发行版、glibc、CPU 指令集或 CUDA 组合明确;
- Python 包使用精确版本和哈希锁定,构建过程可重复;
- 模型权重提前下载并记录版本,启动不访问公网;
- 在真实中文简历、截图、证书和扫描 PDF 上通过回归集;
- 同步、异步、超时、重试、进程重启与 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 开启 NoNewPrivileges、ProtectSystem、ProtectHome 和独立临时目录。日志只记录 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 最佳实践不是某个模型排行榜
如果让我总结这次实现里最重要的原则,会是下面几条:
- 保留原始文件,也保留中间结果。 原图用于复核,OCR 结果带页码、坐标、置信度和版本信息,才能定位回归。
- 用真实业务样本建立评测集。 清晰扫描件上的平均准确率,不能代表倾斜手机照片、双栏简历和带表格证书。
- 按失败类型预处理。 方向、透视、光照、分辨率、版面顺序是不同问题,不要用一组固定滤镜处理所有文件。
- 低置信度要有去处。 可以局部放大后二次识别、切换引擎、让多模态模型查看原区域,或进入人工复核,不能假装所有字符同样可信。
- 文本层和视觉层互相补位。 OCR 提供可搜索、低成本、可追踪的文本;表格、印章、图表和复杂布局仍可能需要版面模型或多模态大模型。
- 准确率、延迟和成本一起评估。 更高 DPI、更重模型和更多预处理都有价格,最终要看字段级准确率与整条业务链路的 P95。
- 版本必须进入结果。 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 单独拿到一个漂亮分数,而是让图片进入大模型之前,先变成一份更干净、更可解释、也更值得信任的上下文。