双引擎 OCR 编排:逐页判定、视觉优先、partial 语义与三层超时

图片型 PDF 的输入比想象中复杂。我们的桌面 AI 应用用「视觉模型优先、tesseract 离线兜底」双引擎处理扫描件,这篇讲编排层的决策------引擎怎么选、失败怎么降级、超时怎么分层。

编排问题:一个 PDF 里可能有两种页

真实世界的 PDF 不是「纯扫描件」或「纯文字版」二选一------混合体是常态 :封面是扫描图、正文是文字层、附录又是照片。所以编排的第一层不是「选引擎」,而是逐页判定:

typescript 复制代码
// read-document.ts
const sparsePages = pageTexts
  .filter((p) => pageNeedsOcr(p.text))   // < 50 字符/页判为需要 OCR
  .map((p) => p.pageNumber);

文字层稠密的页保留原文(pdf.js 提取的文字层永远比 OCR 准),只有稀疏页送 OCR。最终 assembleText 按页序合并两路文本:OCR 是补缺,不是替换。这个设计让混合 PDF 的识别质量天然最大化,也让「50 页文档只有 3 页扫描图」的常见 case 只花 3 页的 OCR 成本。

引擎选择:视觉优先,tesseract 兜底

text 复制代码
resolveVisionTarget(deps) 存在?
  ├─ 是 → 视觉引擎(qwen2.5vl 等)
  │         └─ 零产出且致命失败 → 降级 tesseract
  └─ 否 → tesseract(WASM 离线,chi_sim+eng)

为什么视觉优先?早期已经建好视觉基础设施(vision target 解析、image_url wire、Ollama 真机验证),视觉模型对混排、低清、手写体的识别质量显著优于 tesseract。tesseract 的价值定位是离线降级:零网络、零模型配置,装了应用就能用。

降级条件的措辞值得精确:视觉引擎「零产出且致命」才降级 (vision.fatal && ocrByPage.size === 0)。视觉模型中途失败但已有部分产出时,不降级------重新用 tesseract 跑一遍已成功的页是浪费,且两引擎混排的文字风格不一致。这里的判断标准是「哪些错误重试能救回来」:

失败类型 策略 理由
视觉路线渲染失败 fatal,整份降级 pdf.js/canvas 挂了,重试无意义
视觉单页识别失败 跳过该页,continue 模型偶发,下一页可能好
tesseract 渲染失败 break 同上,整份层面的问题
tesseract 单页超时 break(worker 已脏) worker 状态不可信,不可复用

注意 tesseract 单页超时是 break 而不是 continue------超时后的 WASM worker 内部状态不可信,继续用它识别后续页面可能返回脏数据。降级与终止的粒度必须匹配组件的可复用性语义。

partial:49/50 页成功不是失败

旧设计的倾向是整篇二值:成功或 failed。但考虑真实场景:50 页扫描件,49 页识别成功、1 页超时------把整份文档判死、49 页内容不可检索,是对用户最差的结果。

这里引入 partial 状态:

typescript 复制代码
return {
  text,
  engine,
  partial: partial || capped || timedOut || ocrByPage.size < targets.length,
  processedPages: ocrByPage.size,
  totalPages,
};

partial 的触发条件有四种:单页跳过、超 50 页截断(capped)、总超时、以及最终产出页数少于目标页数。partial 文档照常分片入向量库、可被 RAG 命中,UI 上给琥珀色「部分 OCR」徽标------检索可用性与诚实标注不矛盾。

配套的状态契约走一次加法迁移:documents 加 ocr_status(running/done/failed/skipped)与 ocr_engine(vision/tesseract)两列,老文档一律 NULL 表示「非 OCR 来源」。延续项目铁律:只 ADD COLUMN,不改已有列、不回填数据。

三层超时:页、文、外部取消

OCR 是长任务(50 页扫描件视觉识别可能跑 10 分钟),超时设计分三层,各自独立:

text 复制代码
OCR_PAGE_TIMEOUT_MS  = 30s    单页识别上限,超时跳过该页
OCR_TOTAL_TIMEOUT_MS = 5min   全文总预算,超了收尾为 partial
OCR_MAX_PAGES        = 50     页数硬顶,超出截断并标 partial

实现上用 AbortSignal.any() 组合三根信号线:

typescript 复制代码
const pageSignal = AbortSignal.any([
  rootSignal,              // 外部取消(用户中断)
  deadlineSignal,          // 总预算 deadline
  AbortSignal.timeout(OCR_PAGE_TIMEOUT_MS),  // 单页超时
]);

三段信号语义不同,处理也不同:单页超时 → 跳过继续;总超时/外部取消 → 已有产出收尾为 partial。tesseract 路径不能复用 AbortSignal.timeout(WASM worker 不认 AbortSignal),用 Promise.race 版的 withPageTimeout 兜底------同一个超时语义,两种实现机制,编排层对调用方屏蔽这个差异。

两个收尾细节:总 deadline 的 setTimeout 在 finally 里 clearTimeout(否则进程退出前定时器挂着);外部 signal 的 listener 也要 removeEventListener。长任务编排的资源清理和主逻辑同等重要------这些不抛错的泄漏会在长跑应用里慢慢积累。

失败的用户体验:OCR_GUIDANCE_MESSAGE

两条引擎都不可用、或最终全文为空时,抛 OcrFailedError(OCR_GUIDANCE_MESSAGE)。这个提示语是设计过的:不是「OCR failed」,而是告诉用户能做什么------「该 PDF 为扫描件且未配置视觉模型,请在设置中添加视觉模型(如 qwen2.5vl)后重试」。本地优先产品的报错文案必须自带出路,因为用户没有客服可找。

小结

双引擎 OCR 编排的核心决策:逐页判定而非整份二选一(混合 PDF 是常态)、视觉优先但零产出才降级(重试要判断救不救得回)、partial 是一等状态(检索可用性与诚实标注并存)、三层超时三种处理(粒度匹配组件可复用性)。整个 ocr-runner.ts 250 行,是这个项目里「错误处理密度」最高的文件------OCR 链路的每一环都可能失败,编排层的价值就是给每种失败配一个体面的结局。

下一篇 B10 收官:复盘------一个人的项目怎么给数据设计「出口」。

相关推荐
计算机魔术师1 小时前
Reddit 每周被 Google 偷走 100 万次点击,CEO 摊牌了
前端
前端繁华如梦1 小时前
用 React Three Fiber 做一栋能编辑的教学楼数字孪生
前端
前端繁华如梦1 小时前
用 React Three Fiber 做一套智慧医院数字孪生:编辑器、大屏和 BIM 浏览器
前端
俊昭喜喜里2 小时前
理解全局跨域CORS配置
前端
锋行天下5 小时前
DeepSeek写Langgraph就是个大傻逼,一本正经胡说八道
前端·人工智能·后端
jktantan5 小时前
每个节点 41 字节:把一棵百万节点树拆进 14 个 typed array
前端·javascript·vue.js·算法·typescript
计算机魔术师6 小时前
Anthropic官方插件被「寄生」,背后是同一类攻击又来了
前端
我命由我123456 小时前
访客系统形式(有用户概念、无用户概念)
java·开发语言·前端·javascript·python·产品运营·产品经理
key_3_feng6 小时前
评估与对齐:Agent 自我进化的护栏
前端·数据库·agent