案例 #1808:PDF 强制扫描件 OCR
PR:#1808 | 14 文件 +210/-6 | 2026-06-25 合入 | 栈:Python docreader + Go knowledge_process + 上传 UI
结论先行
部分 PDF(网页打印、扫描件、图片型页面)带有 低质量或误导性 text layer — 自动 router 把它们当「文本页」,只抽出少量乱码,跳过 scanned-page OCR/VLM 路径。结果:ingest 成功、chunk 极少或为空、检索 永远空。本 PR 增加 force-scanned 解析路径:全局 env DOCREADER_PDF_FORCE_SCANNED + 单次上传 parser_engine_overrides.pdf_force_scanned + 上传 UI 勾选,强制走 scanned-page OCR,并与自动扫描路由 对齐 metadata(scanned_page_count 等)。
适合谁读:改 docreader PDF router、ingest 配置透传、上传 UI 的全栈贡献者。
链 Ch39 解析天花板 · Ch28 docreader · 对比 case-1785(噪声过滤)与 case-1743(跨语言管线)。
1. 问题现象(What)
用户故事
- 用户上传 扫描 PDF 或 浏览器「打印为 PDF」 的文档
- 控制台显示解析 完成,无报错
- 知识库 chunk 数 ≈0 或只有封面乱码
- 任意 QA:检索不到内容
修复前 / 修复后
| 观测 | 修复前 | 修复后 |
|---|---|---|
| ingest 状态 | 成功 | 成功 |
| chunk 数 | ≈0 / 乱码 | OCR 后正常文本 |
| 用户操作 | 无杠杆 | UI 勾选 / env / override |
为何「成功但空」
自动 PDF router 看到「存在 text layer」→ 分类为 text page → 走 text extract → 实际可用字符极少 → 不触发 OCR/VLM。用户感知是 ingest 假成功。
类比
一本书表面有目录(text layer),但正文页是照片 — 自动流程只读目录就当读完了全书,没有翻开照片页做 OCR。
定位步骤
rg "force_scanned|FORCE_SCANNED|pdf_force_scanned" docreader/ internal/
rg "UploadConfirmDialog|parser_engine_overrides" frontend/
阅读顺序:pdf_parser.py router 分支 → Go knowledge_process_config.go override 合并 → UploadConfirmDialog.vue 勾选透传。
2. 根因分析(Why)
三栈配置透传
flowchart TD
A[UploadConfirmDialog.vue<br/>勾选「扫描件 OCR」] --> B[上传请求携带<br/>parser_engine_overrides]
B --> C[Go knowledge_process<br/>types + service 合并 override]
C --> D[tenant 级 override 之上叠加 upload 级]
D --> E[docreader pdf_parser.py<br/>force_scanned 分支]
E --> F[scanned-page OCR/VLM]
F --> G[chunk + embed + 检索]
H[DOCREADER_PDF_FORCE_SCANNED<br/>全局 env] --> E
与自动路由的分歧点
| 路径 | 触发条件 | 结果 |
|---|---|---|
| 自动 text | text layer 非空(哪怕垃圾) | 少量乱码 chunk |
| 自动 scanned | router 判 scanned | OCR → 正常文本 |
| force-scanned(本 PR) | 用户/运维 显式指定 | 跳过误导性 text layer,走 OCR |
关键文件
| 层 | 文件 | 职责 |
|---|---|---|
| UI | frontend/.../UploadConfirmDialog.vue + i18n |
仅当批次含 PDF 时显示 toggle |
| Go types | internal/types/knowledge_process.go |
parser_engine_overrides 结构 |
| Go service | knowledge_process_config.go 等 |
upload override 覆盖 tenant override |
| Python | docreader/parser/pdf_parser.py |
force 分支进 scanned 解析 |
| 测试 | test_pdf_router.py、Go override 序列化测 |
text PDF vs force-scanned |
PR 要求 force 路径输出 metadata 与自动 scanned 对齐:
scanned_page_count/text_page_countembedded_image_count/vector_figure_count
便于下游监控与 UI 展示一致。
3. 解决方案(How)
PR 四段式
| What | Why | How | Test |
|---|---|---|---|
| 扫描 PDF 空 chunk | text layer 误导 router | force-scanned 三栈 + env | pytest + Go 测 + UI |
两级开关
- 运维全局:
DOCREADER_PDF_FORCE_SCANNED=true— 适合扫描件为主的部署 - 用户单次上传:
parser_engine_overrides.pdf_force_scanned: true— 默认自动,必要时手动
合并规则:upload 级叠在 tenant 级之上 — 避免租户默认误伤所有 PDF,又允许单次补救。
为何不做「自动检测 text layer 质量」?
- 启发式(乱码率、字符数)易 误伤 合法小字体 PDF
- 本 PR 选择 显式 fallback — 产品可控,成本用户自知(OCR 慢/贵)
- PR 说明:不强制用户切 MinerU 等外部 parser — builtin 路径内补救
Python 测试要点
test_pdf_router.py:
- 普通 text PDF + force off → text 路径
- 同文件 + force on → scanned 路径
- 断言 metadata 字段存在
4. 与 #1785 / #1725 对比
| 案例 | 层 | 问题类型 |
|---|---|---|
| case-1785 | Python parser | 噪声 进 chunk |
| case-1725 | Go docparser | 格式 不满足 chunker 保护 |
| case-1808 | Py+Go+UI | 策略路由 选错解析路径 |
三者合成 ingest checklist:选对路径 → 清噪声 → 归一化格式 → 再分块。
5. 排查 playbook:「ingest 成功但检索空」
- 看 chunk 数 / 平均长度 — 是否 ≈0
- 看 knowledge 解析 metadata —
text_page_countvsscanned_page_count - 若是扫描 PDF — 重传并开 force-scanned
- 对比 lab D 组 query 命中数
6. 常见误区
错误认知:有 text layer 就是文本 PDF。
正确理解:web-print / OCR 残留层可能是 误导信号。
错误认知:解析失败才会空 chunk。
正确理解:策略误判 也会产出「合法空结果」— 更隐蔽。
错误认知:只改 Python pdf_parser 就够。
正确理解:无 UI/types 透传,用户 无法 在正确入口触发 — 14 文件是 完整产品闭环。
错误认知:ingest 报错才需要排查,成功就说明解析没问题。
正确理解:策略误判 会产出「合法空 chunk」— 必须看 text_page_count / scanned_page_count metadata,必要时开 force-scanned 重传。
7. 思考点
- force-scanned 的 成本(时延、GPU)如何在 UI 上告知用户?
- 全局 env 与 per-upload override 冲突时,应以谁为准?(PR:upload 叠 tenant)
- 若 OCR 后仍空,下一步应查 VLM 配额 还是 PDF 加密?
- 设计 A/B:自动 text quality 分数 vs 纯手动 toggle,各适合什么租户?
章末自检
- 画出 UI → Go → Python 三栈透传图
- 解释「ingest 成功但检索空」的排查顺序
- 说清 force-scanned 与 MinerU 外部 parser 的定位差异(PR 原文)