把 PDF、扫描件、图片和 Word 文档转成适合 LLM / RAG 的 Markdown 与带坐标 JSON。
这个项目采用本地优先的混合管道:普通页面由 LiteParse 直接输出;扫描页通过 PaddleOCR;表格、多栏、图表等复杂页可再交给 OpenAI 兼容的视觉模型(默认 qwen3.7-flash)做视觉重构,并在证据不足时追加一次复核。
- PDF、PNG/JPG/TIFF/WebP、DOC/DOCX/ODT/RTF 上传
- Excel / PowerPoint / EPUB / CSV 由 anydoc 本地直转,绕开 OCR 与视觉模型(见「原生结构化格式」)
- LiteParse Markdown、逐页文本和 PDF point 坐标
- PaddleOCR 3.x 中文/英文 OCR,使用 PP-OCRv5 mobile 模型与 4000px 检测上限;本地单模型串行执行,避免 CPU 争用和内存峰值
- 两阶段逐页路由:解析前用 LiteParse 静态复杂度信号,解析后再用 OCR 真实产出复判扫描页
- 条件视觉复核:只有重构自评置信不足、留有存疑标记,或产出明显少于本地基线时才追加第二次调用
- 页面位图按目标长边渲染,不再固定 DPI;复杂页 VLM 调用并发执行
- 四档解析档位(Fast / Cost Effective / Agentic / Agentic Plus),默认开启视觉模型
- 自定义提示词:追加在内置提示词之后,不覆盖输出契约
- 双栏原文/结果预览,Markdown 与 JSON 下载
- JSON 包含逐页置信度、路由原因、复核问题和引用坐标
页面上用 Parse tier 四选一,把成本/质量的取舍收敛成一个决定。档位之间的差异都是真实的
管道行为(见 llmready/tiers.py),不是文案包装:
| 档位 | 用 VLM | 送哪些页 | 复核 | 位图长边 |
|---|---|---|---|---|
| Fast † | 否 | — | — | 1400 |
| Cost Effective | 是 | 仅强信号页(≥2 条升级理由) | 按需 | 1600 |
| Agentic(默认) | 是 | 路由判定的复杂页 | 按需 | 2000 |
| Agentic Plus | 是 | 全部页 | 每页强制 | 2400 |
† Fast 档下 Word 由 anydoc 直转(10.2s → 5ms),代价是没有分页和坐标。 见「原生结构化格式」。
默认就开 VLM,因为纯本地路径有硬上限:PaddleOCR / 百度云 OCR 只输出文本行和坐标, 不输出结构;LiteParse 的版面启发式又依赖原生文字层,在扫描页上完全失效。两者叠加, 本地路径对扫描表格能给的最好结果就是「文字对、行列乱」。想要正确的表格和图形语义, 必须让视觉模型看原图。
Cost Effective 的「强信号」指路由阶段二给出多条升级理由的页(既读不准又像表格)。 单一理由的页留在本地——这是它与 Agentic 的实际差别,不是限速。
Advanced options 里可以填自定义提示词,它追加在内置提示词之后:内置的硬性规则
(不猜测、不加解释、输出整页内容)仍然生效,不会被覆盖,因为下游的内容量护栏依赖它们。
flowchart LR
S["上传文件"] --> R{"原生结构化格式?<br/>xlsx / pptx / epub / csv"}
R -- "是" --> N["anydoc 直转 Markdown"] --> K
R -- "否" --> A["PDF / Image / Word"]
A --> B["LiteParse parse once"]
B --> C["Markdown + bbox + complexity"]
B --> D["Selective PaddleOCR"]
C --> E{"阶段一:静态路由<br/>needs_ocr / layout complex"}
D --> F{"阶段二:OCR 证据复判<br/>读得准?是表格?"}
E -- "简单页" --> G["Local Markdown"]
E -- "扫描页" --> F
E -- "版面复杂" --> H
F -- "证据不支持" --> G
F -- "低置信 / 多列表格" --> H["页面位图<br/>按目标长边渲染"]
H --> I["VLM 重构(并发)"]
I --> J{"自评置信不足<br/>或有存疑标记?"}
J -- "否" --> K["Markdown + JSON + citations"]
J -- "是" --> L["视觉复核"]
L --> K
G --> K
为什么路由要分两个阶段。 LiteParse 的版面启发式建立在原生文字层的字体和坐标上。
扫描页没有文字层,它的 layout 信号会全空——data/ 那份 8 页扫描件实测每页都是
is_complex=False、reasons=[]、column_count=1。如果只用解析前的静态信号,
needs_ocr 一票通过,任何扫描件都是 100% 的页进 VLM,路由层形同虚设。
阶段二在 OCR 跑完后,用它的真实产出反推版面:识别置信度普遍偏低(本地文本不可信), 或者横行上普遍并列 3 个以上文本块(表格/多栏,纯 OCR 给不出行列关系)——命中才升级。 这与 LlamaParse Auto Mode 的思路一致:先用便宜的档位跑,拿结果决定是否为这一页付高价。
这里没有再加 Agent 框架。当前问题只有确定的两步模型调用,直接代码比工作流 DSL 更清楚,也更容易测试。
xlsx/xls/xlsm/xlsb、pptx/ppt/ppsx、ods/odp、epub、csv 在入口就分流出去,由
anydoc(Rust,本地,无需 API key)直接转成
GFM Markdown,整条跳过渲染、路由和 VLM。
因为这些格式自己就带着结构:表格的行列、幻灯片的标题层级都在文件里显式存着。 主管道那套「OCR 找回文字 → 视觉模型猜版面」是为丢了结构的扫描页设计的,用在这里 纯属加钱降质。实测一份 200 行的 xlsx,直转耗时 7ms。
代价写在明处,不粉饰:
- 没有页边界,输出是单页(
page_number恒为 1)。anydoc 不携带页信息,按标题或 长度硬切出来的页码是假的,写进 JSON 只会骗下游。 citations为空,没有 PDF point 坐标,UI 上也没有页面预览图。- 解析档位对这条路径不起作用——它不调模型,选哪档结果都一样,页面上会明说。
PDF 永远不走这里。 anydoc 的 PDF 路径只提取现成文字层、且不做 OCR——data/ 那份
8 页扫描件直接报 UnsupportedError: PDF has no extractable text。就算有文字层,它也
不给坐标和页面位图,等于把两阶段路由和 VLM 的地基抽掉。
Word 系(doc/docx/docm/odt/rtf)看档位。 LiteParse 读 Word 要先用 LibreOffice 转
PDF,实测 data/测试.docx 要 10.2s,anydoc 是 5ms——2000 倍不是零头。但那 10 秒买的
不只是坐标,是页面位图,也就是「这一页还有救」:
| anydoc | LiteParse 路径 | |
|---|---|---|
| 耗时 | 5ms | 10.2s |
| 页 / 坐标 / 预览图 | 无 | 4 页 / 11 条 bbox / 4 张图 |
| 内容 | 更全(合并单元格字段没丢) | 有丢失和错位 |
| 表格结构 | 畸形(表头 9 列、分隔行 14 列) | 合法但被切碎 |
两条本地路径在带合并单元格的表单上都会翻车,只是翻法不同;真正的解法是让 VLM
看着原图重构。所以启用 VLM 的档位下 Word 必须走 LiteParse——没有图就永远升不了级。
而 Fast 档不调模型,升级路径根本用不到,那 10 秒就只买了坐标,于是 Fast 档的 Word
改走 anydoc 直转。判据是 enable_vlm,不是档位名(见 anydoc_source.handles)。
代价是同一份 Word 在不同档位下输出结构不同(Fast 档单页无坐标)。页面在上传后、点 Run 之前就把这件事说清楚,不靠用户自己发现。
flowchart TB
subgraph SG1["界面"]
APP["app.py<br/>Streamlit 页面"]
end
subgraph SG2["编排"]
PIPE["pipeline.py<br/>DocumentPipeline"]
end
subgraph SG3["决策与工具"]
RT["routing.py<br/>两阶段路由决策"]
IM["images.py<br/>位图尺寸治理"]
MD["models.py<br/>Markdown / JSON 输出契约"]
end
subgraph SG4["执行"]
LP["liteparse<br/>解析 · 渲染 · 复杂度信号"]
AD["anydoc_source.py<br/>原生结构直转"]
VLM["vlm.py<br/>OpenAIVisionEnhancer"]
end
subgraph SG5["OCR sidecar"]
PD["paddle_server.py<br/>127.0.0.1:8829"]
BD["baidu_ocr_server.py<br/>127.0.0.1:8830"]
end
APP --> PIPE
PIPE --> AD
PIPE --> RT
PIPE --> IM
PIPE --> LP
PIPE -."PageEnhancer 插拔点".-> VLM
PIPE --> MD
APP --> MD
LP -- "HTTP OCR 契约" --> PD
LP -- "HTTP OCR 契约" --> BD
routing.py 只做决策不做 IO,images.py 只管像素,vlm.py 只管一次模型往返——
三者都可以脱离管道单独测试。DocumentPipeline 是唯一知道「先做什么再做什么」的地方。
下图是开启 VLM 增强、输入一份扫描件时的完整往返。注意两个关键分支: 阶段二可以把页从 VLM 收回,视觉复核只在证据不足时才发生。
sequenceDiagram
autonumber
participant UI as Streamlit
participant PL as DocumentPipeline
participant RT as routing
participant LP as liteparse
participant OCR as OCR sidecar
participant IM as images
participant EN as PageEnhancer
UI->>PL: parse(file, enable_vlm=True)
rect rgb(240, 240, 248)
Note over PL,OCR: 解析:文字层 + 按需 OCR
PL->>LP: parse(dpi=OCR_RENDER_DPI)
loop 仅文字稀疏的页
LP->>OCR: POST /ocr(页面位图)
OCR-->>LP: 文本行 + bbox + confidence
end
LP-->>PL: pages(markdown, text_items, complexity)
end
rect rgb(245, 240, 240)
Note over PL,RT: 两阶段路由
PL->>RT: decide_route(complexity)
RT-->>PL: 阶段一:扫描页一律保守判为需要 VLM
PL->>RT: refine_with_ocr_evidence(text_items)
RT-->>PL: 阶段二:读得准且非表格的页被收回
end
rect rgb(240, 245, 240)
Note over PL,IM: 位图尺寸治理
PL->>LP: screenshot(dpi 按页面长边反算)
LP-->>PL: 页面位图
PL->>IM: downscale(long_side=VLM_IMAGE_LONG_SIDE)
IM-->>PL: 送模型的图
end
rect rgb(248, 245, 235)
Note over PL,EN: 复杂页并发重构
par 并发上限 PAGE_WORKERS
PL->>EN: enhance(第 3 页)
and
PL->>EN: enhance(第 5 页)
end
EN->>EN: 重构并自评 confidence
alt 自评置信低于 0.9 或页面留有存疑标记
EN->>EN: 视觉复核(重发整页图)
else 自评可信
EN-->>EN: 跳过第二次调用
end
EN-->>PL: EnhancedPage(markdown, confidence, issues)
end
PL->>IM: downscale(long_side=PREVIEW_IMAGE_LONG_SIDE)
IM-->>PL: 预览图
PL-->>UI: DocumentResult
不开启 VLM 时,最后两个 rect 区块整体跳过:管道退化成「LiteParse + 按需 OCR」, 不产生任何外部模型调用。
调 VLM 时用的是 response_format={"type": "json_object"},不是 strict 的
json_schema。这不是偷懒——曾经优先发 strict schema、厂商拒绝才回退,而百炼不拒绝:
它接受了,然后把输出砍得只剩一行标题。同一页 data/20250804-202240.png、同一模型实测:
| response_format | markdown 产出 | 结果 |
|---|---|---|
json_schema(strict) |
33 字符 | 只有一行标题,自评 confidence 仍是 0.95 |
json_object |
1283 字符 | 9 项指标、阈值、区间归属全部正确 |
模型本身完全看得懂那一页(不加 JSON 约束裸问时答得一字不差),是 strict 解码把它压垮了。
输出契约改由 vlm.py 里的 pydantic 模型保证,比依赖各厂商参差的 strict 实现更可靠。
配套的安全网是复核触发条件里的内容量护栏:模型自评在「整段漏掉」这类失败上完全不可靠 (实测丢了 97% 内容仍自评 0.95),所以产出短于本地 OCR 基线一定比例时强制复核。
模型偶尔会返回空内容、非法 JSON,或把对象包进数组。这类抽风的处理分三层:
- 归一:单元素数组、双重编码的字符串都能无歧义还原,直接还原不报错。
- 重试:其余不可用响应重试一次;仍失败则报错,并把实际收到的内容片段附在错误里 (否则这类偶发问题在生产里无从查起)。
- 降级:该页保留本地结果,
route标为vlm-failed,失败原因写进verification_issues,页面上以警告条展示。一份 50 页的文档不该因为第 37 页抽风就整个报废。
但所有复杂页都失败时会直接抛错——那是凭证、网络或模型不可用这类系统性故障,
降级只会给出一份「看着正常、其实全是本地结果」的假结果。这与 LiteParse 的
ocr_failure_fatal 是同一个判断逻辑。
llmready/pipeline.py 的 PageEnhancer Protocol 是插拔点:任何能「看一页图、还原成
Markdown」的后端都可以实现它,通过 DocumentPipeline(enhancer_factory=...) 注入。
class PageEnhancer(Protocol):
def enhance(self, *, page_number: int, image_bytes: bytes,
liteparse_markdown: str) -> EnhancedPage: ...当前实现 OpenAIVisionEnhancer 走通用视觉模型。文档专用 VLM(PaddleOCR-VL、dots.ocr、
MinerU 等)自带完整版面理解,可以忽略 liteparse_markdown 草稿,按 OCR sidecar 那套
HTTP 契约在本机起一个进程即可接入。
- macOS / Linux
uv- Python 3.12 或 3.13(PaddleOCR 当前依赖不支持本机的 Python 3.14)
- LibreOffice:Word 文档转换需要。macOS 可执行
brew install --cask libreoffice - 可选:OpenAI 兼容视觉模型凭据(
OPENAI_API_KEY/OPENAI_BASE_URL/OPENAI_MODEL),只在开启复杂页 VLM 增强时使用;默认指向百炼兼容模式
makemake(等价于 make run)会先 uv sync,再启动应用。make help 列出全部目标。
需要视觉模型时把凭据写进 .env(make run / make smoke 会自动加载),或直接导出环境变量:
export OPENAI_API_KEY="你的 API Key" # 可选,默认指向百炼兼容模式
# export OPENAI_BASE_URL="https://dashscope.aliyuncs.com/compatible-mode/v1"
# export OPENAI_MODEL="qwen3.7-flash"不使用 make 时等价于:
uv sync
uv run python run.pyrun.py 会先启动所有已配置的 OCR sidecar(本地 PaddleOCR 总是启动;百度云只在三个凭证齐全时启动),再在 http://localhost:8507 启动 Streamlit,退出页面进程时也会收掉自己启动的 OCR 进程。首次真正处理扫描页时 PaddleOCR 会下载并缓存模型,因此会比后续请求慢。
页面默认选中 Agentic 档,会调用视觉模型(默认百炼兼容模式,可在 Advanced options 里改成任何 OpenAI 兼容的 Base URL / Model)。不想产生任何外部调用就选 Fast 档。OCR 始终在本机进程里运行(百度云后端会把扫描页图片转发到百度云)。
默认走本地 PaddleOCR。想额外用百度云高精度 OCR(含位置版),把三个凭证写进 .env:
BAIDU_OCR_APP_ID=...
BAIDU_OCR_API_KEY=...
BAIDU_OCR_SECRET_KEY=...然后照常 make run:run 目标发现有凭证就自动 uv sync --extra baidu,run.py 会把
本地 PaddleOCR 和百度云两个 sidecar 同时拉起。之后在页面侧边栏的「OCR 后端」下拉框
里随时切换,切换即生效,不需要改环境变量或重启。下拉框里每个选项都带「就绪 / 不可用」
状态,一眼能看出哪个能用。
凭证不全时百度云 sidecar 直接跳过启动,页面上显示为「不可用」。注意:baidu-aip 是
可选依赖,只跑 uv sync 装不上;此时 sidecar 仍能启动,但 /health 返回 503 并说明缺什么,
UI 同样显示「不可用」,不会让你上传完文件才撞上 500。
百度云 sidecar 同样在本机跑一个进程,只是它内部把扫描页图片转发到百度云识别。
OCR_BACKEND 环境变量现在只决定页面默认选中哪个后端,不再决定启动哪个。
单元测试不需要模型或网络:
uv run pytest -m "not integration"真实扫描件测试需要先启动 OCR 服务:
uv run python -m llmready.paddle_server
uv run pytest -m integration仓库内的真实测试文件位于 data/JS20250728085-2024年体检报告副本.pdf。
也可以直接生成可检查的 Markdown/JSON:
uv run python -m scripts.smoke_test data/JS20250728085-2024年体检报告副本.pdfMarkdown 使用 HTML 注释保留页边界:
<!-- page: 1; route: paddleocr -->JSON 顶层固定为:
{
"schema_version": 1,
"source": {},
"summary": {},
"pages": []
}每页包含 route、confidence、复杂度原因、Markdown,以及原生文本/PaddleOCR 的 bbox 坐标。坐标沿用 LiteParse:左上角为原点,单位是 PDF point(1/72 英寸)。
route 取值:liteparse / paddleocr / vlm / vlm-failed(降级页),以及 anydoc——
原生结构化格式的直转路径,该路径下 citations 恒为空数组。
- VLM 只能提高复杂版面重构质量,不能保证零错误;数字和医学报告仍应回看原页。
- 扫描件必须逐页运行本地 PaddleOCR,耗时会明显高于带文字层的 PDF;当前 macOS CPU 环境不支持 PaddleOCR 官方 HPI 加速后端。
- 本地 OCR 串行执行是现在最大的单点耗时:扫描件的端到端耗时里,OCR 占绝大部分。PaddleOCR 单模型实例非线程安全,提高并发需要在 sidecar 侧持有多个模型,内存翻倍且在 CPU 上会互相争用,因此保持串行。
- 阶段二的版面复判是启发式,不是版面模型:它只看 OCR 的置信度分布和文本块的横向排布。图表页如果文字稀疏,可能不会被判为复杂而漏掉 VLM;阈值在
llmready/config.py可调。 - PaddleOCR 只输出文本行和坐标,不输出结构。扫描页的表格行列和阅读顺序目前完全依赖 VLM 重建。要根治这一层,需要接入自带版面理解的文档专用 VLM(见「接入其他解析后端」)。
- LiteParse 对 Word 的支持依赖 LibreOffice;缺失时会明确报错。
- anydoc 直转路径不产出页边界和坐标(见「原生结构化格式」)。它也不做 OCR:纯图片的幻灯片里的文字不会被提取,这类文件应该导成图片/PDF 走主管道。
- 同一份 Word 在 Fast 档和其他档下的输出结构不同:Fast 档是单页无坐标,其余档位是逐页带坐标。这是拿结构换 2000 倍速度的自觉取舍,页面在解析前会明确提示。
- 图片/扫描件必须能访问本地 PaddleOCR 服务。应用不会在 OCR 故障时伪装成“成功解析”。
- 选百度云 OCR 后端时,扫描页图片会上传到百度云处理;高精度含位置版有单独的免费额度,大量文档可能产生费用。同一页实测百度的中文识别明显优于本地 PP-OCRv5 mobile(例如「体检编号」本地读成「休检号」),但它不返回逐条置信度,因此路由阶段二的低置信信号在百度后端上不起作用,只剩表格/多栏判定。
- 当前最多处理前 50 页,避免一次上传意外吃满本机内存和 VLM 预算。
- LiteParse 官方文档
- 复杂度与逐页路由
- Markdown 输出
- OCR 配置与 HTTP 契约
- 多格式输入
- anydoc(Office / EPUB / CSV → Markdown,MIT)
- OpenAI Chat Completions API(视觉模型调用契约)
- 百炼 OpenAI 兼容 Chat API(默认 Base URL)
