一. 前言:PDF 不是一張很長的圖片 #
把一張收據交給 VLM,要求它回傳 JSON,Demo 通常很漂亮。 換成二十頁的 PDF,問題立刻變多:
- 第 7 頁的金額到底引用自哪一行?
- 表格跨頁後,欄位名稱還算同一組嗎?
- PDF 有文字層時,為什麼還要讓模型看圖?
- 掃描頁的 OCR 與 VLM 不一致時,應該相信誰?
- 模型少看一頁,最後的 JSON 會不會仍然看起來很合理? 真正可用的文件擷取,不是「PDF 丟進模型,拿回答案」。 它應該是一條可以回頭檢查的資料管線:
PDF -> page inventory -> page images -> native text / OCR
-> MLX-VLM page records -> cross-page reconciliation
-> deterministic QA -> human review sample
今天拍拍君會把每個結論綁回頁碼與證據片段。 模型可以犯錯,但系統不能讓錯誤失去地址。 若你只要單張圖片問答,先看 MLX-VLM 本地圖片問答; 若輸入是彼此獨立的圖片,則看 MLX-VLM 批次結構化擷取。
二. 先定義輸出:欄位一定要帶證據 #
本篇用一般商務文件做示範,不假設每頁都是發票。 每個擷取欄位都保留值、頁碼、原文與信心層級:
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
class Evidence(BaseModel):
model_config = ConfigDict(extra="forbid")
page_number: int = Field(ge=1)
quote: str = Field(min_length=1, max_length=240)
class FieldValue(BaseModel):
model_config = ConfigDict(extra="forbid")
name: str = Field(min_length=1, max_length=80)
value: str = Field(min_length=1, max_length=240)
confidence: Literal["high", "medium", "low"]
evidence: list[Evidence] = Field(min_length=1, max_length=3)
class PageExtraction(BaseModel):
model_config = ConfigDict(extra="forbid")
page_number: int = Field(ge=1)
page_label: str = Field(min_length=1, max_length=40)
document_type: Literal[
"invoice", "receipt", "contract", "report", "form", "other"
]
summary: str = Field(min_length=1, max_length=300)
fields: list[FieldValue] = Field(max_length=20)
needs_review: bool
review_reason: str = Field(max_length=200)
為什麼 evidence 不能省?
因為 {"total": "1280"} 只有答案,沒有驗證路徑。
加上 page_number 與短引用後,人才能快速回到原頁確認。
引用不是模型的自由作文。
後面還要檢查 quote 是否真的出現在原生文字或 OCR 結果中。
三. 安裝:PDF、驗證與模型服務分開 #
建立乾淨環境:
mkdir pypy-pdf-extract
cd pypy-pdf-extract
uv init --python 3.12
uv add pymupdf pillow pydantic openai
若要對掃描頁使用 PyMuPDF OCR,系統還需要 Tesseract:
brew install tesseract tesseract-lang
tesseract --version
另開一個 terminal 啟動 MLX-VLM server:
uv tool install mlx-vlm
export PYPY_VLM_MODEL="mlx-community/Qwen3.5-4B-MLX-4bit"
mlx_vlm.server \
--model "$PYPY_VLM_MODEL" \
--host 127.0.0.1 \
--port 8080
先確認服務與模型清單:
curl --fail http://127.0.0.1:8080/health
curl --fail http://127.0.0.1:8080/v1/models
模型能力、記憶體與授權會隨 checkpoint 改變。 正式工作流應固定 model revision,不要只記一個漂亮的系列名稱。
四. 先盤點 PDF:頁數、文字層與頁面雜湊 #
不要一打開 PDF 就全部轉圖。
先建立 page inventory:
用 pymupdf.open() 逐頁記錄 index + 1、page.get_label()、
len(page.get_text("text", sort=True))、len(page.get_images(full=True)),
再替 72 DPI 灰階 pixmap 的 samples 計算 SHA-256。
page_number 是檔案中的實際順序;page_label 可能是 i、ii、1。
兩者都保留,才能處理前言使用羅馬數字的文件。
native_chars == 0 不必然代表空白頁。
它也可能是一整張掃描圖,或由大量向量線條模擬文字。
頁面雜湊則用來偵測輸入是否變更。
只用檔名當續跑 key,檔案被替換後會錯誤沿用舊結果。
五. 逐頁 rasterize:解析度要有上限 #
PyMuPDF 可以直接用 dpi 產生 page pixmap:
def render_pages(
pdf_path: Path,
output_dir: Path,
dpi: int = 180,
) -> list[Path]:
if not 96 <= dpi <= 300:
raise ValueError("dpi 必須介於 96 與 300")
output_dir.mkdir(parents=True, exist_ok=True)
outputs: list[Path] = []
with pymupdf.open(pdf_path) as document:
for index, page in enumerate(document, start=1):
target = output_dir / f"page-{index:04d}.png"
pix = page.get_pixmap(
dpi=dpi,
colorspace=pymupdf.csRGB,
alpha=False,
annots=True,
)
pix.save(target)
outputs.append(target)
return outputs
180 DPI 是可調的 baseline,不是神奇數字。
小字表格可能要提高解析度;海報尺寸頁面則可能產生超大圖片。
正式版應再用 Pillow 檢查 width * height,超過總像素上限就拒絕或降 DPI。
不要偷偷把橫向頁壓成直向。
保留長寬比與旋轉結果,表格欄位的位置關係才不會被破壞。
六. 文字層不是答案:先保留 blocks 與座標 #
數位 PDF 通常已有文字層。
直接 get_text("text") 很快,但輸出順序不一定符合人類閱讀順序。
先用 page.get_text("blocks") 取得 (x0, y0, x1, y1, text, ...),
清理空白後仍保留座標,不要立刻壓成一段純文字。
最簡單的 (y0, x0) 排序只適合單欄頁面。
雙欄報告會變成左右欄逐行交錯,表格更可能被切碎。
拍拍君會把閱讀順序當成需要驗證的資料:
- 保留原始座標,不只保留純文字;
- 先偵測欄位分隔,再分欄排序;
- 表格區域不要硬套一般段落規則;
- 用頁面影像讓 VLM 理解視覺結構;
- 抽查跨欄、頁首頁尾與腳註。 也就是說,文字層負責可搜尋的字串,頁面影像負責版面語意。 兩者不是二選一。
七. 只有需要時才 OCR #
PyMuPDF 文件提醒:OCR 比一般文字擷取慢很多。
因此先看原生文字量、頁面是否幾乎由圖片覆蓋,以及是否含大量模擬文字的小型向量圖形;只有符合條件時才建立 OCR TextPage:
def extract_ocr_text(page: pymupdf.Page) -> str:
text_page = page.get_textpage_ocr(
language="eng+chi_tra",
dpi=200,
full=True,
)
return page.get_text("text", textpage=text_page, sort=True).strip()
同一頁後續若還要搜尋 words 或 blocks,重用這個 TextPage。
不要每呼叫一次 get_text() 就重新 OCR。
OCR 擅長逐字轉錄,VLM 擅長理解版面與語意;兩者都可能錯。
最健康的策略是保存兩份輸出,讓分歧變成 QA 訊號。
八. 給每個請求不可遺失的頁面錨點 #
如果一次送多頁圖片,模型可能把「第幾張」與 PDF 頁碼混在一起。 本篇先採每頁一個請求,prompt 明確寫入頁碼:
def page_prompt(page_number: int, total_pages: int, page_label: str) -> str:
return f"""
[PAGE {page_number:04d} / {total_pages:04d}]
Printed page label: {page_label}
Extract only information directly visible on this page.
Every evidence.page_number must equal {page_number}.
Copy short evidence quotes exactly when legible.
Do not carry a value from another page.
If reading order or a value is uncertain, set needs_review=true.
Return JSON matching the supplied schema.
""".strip()
模型回傳後,再由程式驗證 page_number:
def assert_page_anchor(result: PageExtraction, expected: int) -> None:
if result.page_number != expected:
raise ValueError("模型回傳的 page_number 與請求不一致")
for field in result.fields:
if any(item.page_number != expected for item in field.evidence):
raise ValueError("欄位引用跨到錯誤頁面")
不要期待 prompt 永遠成功。 頁碼是 deterministic invariant,應該由程式強制檢查。
九. 呼叫 MLX-VLM:一頁、一份 Schema、一筆結果 #
把頁面 PNG 轉成 data URL:
import base64
import os
from openai import OpenAI
client = OpenAI(
base_url="http://127.0.0.1:8080/v1",
api_key="not-needed",
)
MODEL = os.environ["PYPY_VLM_MODEL"]
def png_data_url(path: Path) -> str:
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:image/png;base64,{encoded}"
送出 structured-output 請求:
def extract_page(
image_path: Path,
page_number: int,
total_pages: int,
page_label: str,
) -> PageExtraction:
response = client.chat.completions.create(
model=MODEL,
messages=[{
"role": "user",
"content": [
{
"type": "text",
"text": page_prompt(
page_number, total_pages, page_label
),
},
{
"type": "image_url",
"image_url": {"url": png_data_url(image_path)},
},
],
}],
response_format={
"type": "json_schema",
"json_schema": {
"name": "PageExtraction",
"strict": True,
"schema": PageExtraction.model_json_schema(),
},
},
temperature=0,
max_tokens=1200,
)
raw = response.choices[0].message.content or ""
result = PageExtraction.model_validate_json(raw)
assert_page_anchor(result, page_number)
return result
Schema 約束輸出形狀,不保證值、引用或頁碼語意正確。 所以接下來仍要拿原始文字做交叉檢查。
十. 引用驗證:正規化後仍找不到,就送人工複核 #
PDF 文字層常含多餘空白、換行或全形字元。 先做有限度正規化:
import re
import unicodedata
def normalize_text(value: str) -> str:
value = unicodedata.normalize("NFKC", value)
value = re.sub(r"\s+", "", value)
return value.casefold()
def quote_is_grounded(quote: str, baselines: list[str]) -> bool:
needle = normalize_text(quote)
return bool(needle) and any(
needle in normalize_text(text)
for text in baselines
if text
)
每頁的 baselines 可以包含 native text 與 OCR text。
只要引用在兩者都找不到,就加上 review reason。
這個檢查故意很保守。
表格儲存順序可能讓完整句子無法直接匹配,因此「找不到」代表需要看,不代表模型必然錯。
金額、日期與識別碼還應加入型別規則:
- 金額先移除千分位,再用
Decimal解析; - 日期保留原文,同時產生 ISO 格式候選;
- 編號不要轉成數字,避免丟失前導零;
- 同一頁多個相似值時,不要只比字串是否存在。
十一. OCR 與 VLM 分歧:不要用多數決假裝是真相 #
假設 OCR 看到 1,280.00,VLM 回傳 1,260.00。
正確處理不是隨便挑一個,也不是把 confidence 較高者當答案。
用 Decimal 正規化後比較,並保存頁碼、欄位、兩個原值、分歧原因與 review=true。
對數字、日期、帳號與合約條款,分歧門檻應比一般摘要更嚴格。
風險不是平均分布,QA 也不該平均用力。
十二. 跨頁整併:先收集候選,再決定唯一值 #
不要讓最後一頁靜默覆寫第一頁。
先依 field.name 把所有 page-level candidates 收集成 dict[str, list[FieldValue]]。
整併規則可以很明確:
- 只有一個候選,保留其引用;
- 多個候選正規化後相同,合併引用;
- 候選不同,不覆寫,標記 conflict;
highconfidence 不能自動消滅另一個有證據的值;- 文件總額可再與明細加總做 deterministic check。
跨頁表格尤其要小心。
頁尾的
subtotal與最後一頁的grand total不是同一欄位。 schema 應使用明確名稱,不要把所有數字都塞進total。 如果文件有「第 2 頁沿用第 1 頁表頭」,可以在整併階段加入前頁脈絡, 但 page-level extraction 仍要保留原始頁面證據。
十三. 續跑與可重現性:結果檔不是快取垃圾桶 #
每頁處理完成就寫一筆 JSONL,並保存輸入與模型資訊:
document_sha256
page_number / page_label
page_image_sha256
model_id / model_revision
mlx-vlm version
PyMuPDF version
prompt version
schema version
render_dpi
native_text_sha256
ocr_text_sha256
result / validation issues
processed_at
續跑 key 應把 document_hash、page_hash、model_revision、prompt_version 與 schema_version 串起來再做 SHA-256。
模型、prompt 或 schema 改了,就不應命中舊結果。
這不是浪費算力,而是避免新舊規則混成一份無法解釋的資料。
十四. 人工抽查:全隨機不夠,全部人工也不實際 #
建立兩層 sample:
1. 風險樣本 #
必查這些頁面:
- OCR 與 VLM 數字不一致;
- 引用無法在 baseline 找到;
- 模型標記
needs_review; - 欄位跨頁衝突;
- 文字極少、圖片佔比極高;
- 表格跨頁、旋轉頁或超寬頁;
- 金額、期限、帳號等高風險欄位。
2. 隨機樣本 #
從其餘「看似成功」頁面再抽固定比例。
只有看失敗案例,會漏掉那些安靜但自信的錯誤。
用 random.Random(fixed_seed).sample() 從非風險頁抽取固定比例,讓同一批輸入能重現抽樣結果。
正式稽核也應保存選樣規則與 reviewer 結論。
十五. 測試:先測不需要模型的規則 #
昂貴的 VLM 測試不應是第一道防線。 先測錯頁引用必須失敗、引用正規化、金額分歧、跨頁衝突與續跑 key 失效。 再準備一組小型 golden PDF:
- 純文字單欄頁;
- 雙欄報告;
- 掃描頁;
- 跨頁表格;
- 旋轉頁;
- 有意放入相似金額的頁面。 升級模型或套件時,用相同輸入比較欄位正確率、引用命中率、 人工複核率、每頁延遲與峰值記憶體。
十六. 隱私、安全與成本邊界 #
本機模型降低文件送往外部 API 的需求,但不代表資料自動安全。
- PDF、page PNG、OCR 文字與 JSON 都可能含敏感資料;
- page image 不應誤推進 Git;
- log 不要印出完整 data URL 或文件內容;
- 暫存目錄要設定保存期限;
- server 綁定
127.0.0.1,不要直接暴露到區域網路; - 模型下載與套件安裝仍會使用網路;
- PDF parser 與 OCR 都應處理不可信輸入的資源上限。 成本也要拆開量:PDF rasterization、OCR、vision prefill、decode、 Pydantic validation 與人工複核各自計時。 只看「每頁模型秒數」會漏掉大檔案轉圖與 OCR 的真正瓶頸。
十七. 常見踩雷 #
1. 一次把整份 PDF 轉成很多張圖送進同一個 prompt #
頁碼容易漂移,失敗後也很難只重跑一頁。 先逐頁建立可追蹤結果,再做跨頁整併。
2. PDF 有文字層,就完全不看影像 #
文字層可能缺少欄位位置、欄線、勾選框與閱讀順序。 保留 blocks 座標,並用頁面影像理解版面。
3. 每一頁都 OCR #
浪費時間,也可能用品質較差的 OCR 覆蓋原生文字。
先判斷是否需要,並重用 OCR TextPage。
4. JSON 合法就直接進資料庫 #
Schema 只證明形狀合法。 頁碼、引用、數字一致性與跨頁衝突仍要另外驗證。
5. 把模型 confidence 當機率 #
high 是模型產生的標籤,不是校準後的正確率。
用 golden set 與人工結果量出真正的錯誤分布。
6. 只抽查模型自己說不確定的頁面 #
最危險的是「看起來成功」的錯誤。 風險抽樣之外,仍要保留隨機樣本。
結語:模型看頁面,系統保存證據 #
PDF 文件擷取最重要的產物,不只是最後那份 JSON。 一條值得信任的管線還要保留:頁面影像、文字 baseline、OCR 結果、 模型版本、prompt 版本、每個欄位的引用,以及人工抽查決策。 今天的做法把任務拆成清楚的責任:
- PyMuPDF 負責頁面、文字、座標與 rasterization;
- OCR 提供掃描頁的文字 baseline;
- MLX-VLM 理解視覺版面並產生結構化候選;
- deterministic code 驗證頁碼、引用、數字與跨頁衝突;
- 人工抽查處理模型和規則都無法安全決定的部分。 先用五頁文件跑通,再加入二十頁、多欄、跨頁表格與掃描品質差的案例。 只要每個值都能回答「來自哪一頁、哪段證據、用哪個版本產生」, 這份資料就不只是模型猜得像,而是真的能被檢查。🔭