書籍のOCRにLLMを組み合わせることで精度を上げるだけでなく文書構造や図も表現した記録
課題
手元にあるビジネス書1冊 (縦書き和文、120ページ分) のページ画像をテキスト化したかった。
入力は PNG 120 枚 (1ページ=1ファイル)、解像度は十分にきれい。図表はほぼなく、文章メイン。
「専用OCR」「ローカルLLM」「両者の併用」の3手法を実装し、文字精度・処理時間・Markdown構造化要素の数で比較した。
結論
| 手法 | 正解類似度 (本文1769文字基準, NFKC正規化後) | 120ページ処理時間 | Markdown構造化要素 |
|---|---|---|---|
| Qwen 単独 (qwen3.5:27b) | 93.47% | 約 80 分 | 不安定 (ループあり) |
| NDL OCR Lite 単独 | 99.49% | 約 5 分 | なし (プレーンテキスト) |
| NDL + Qwen ハイブリッド | 約 99.9% (実測で字単位の誤読ゼロ) | 約 142 分 | 600 個以上 |

NDL 単独でも 99% を超えており、専用OCRとしては既に十分高い。Hybrid はそれを 誤読ゼロ近傍 (1769文字中で残る差分は1〜2文字レベル) まで押し上げる。書籍全体 (約20万文字) で見ると、NDL 単独で約 1,000 文字の誤りが残るところを、Hybrid が 数百文字レベルまで削減するイメージ。
さらにNDLOCR-Liteだけでも文字化は困らないレベルだが、LLMを組み合わせることで、文書構造を残してくれ、図は文字で説明文に変えてもらったり、図はMermaid形式に変換、表は表として残すことなどが大きなメリットとしてある。
環境
- Mac (Apple Silicon) + Python 3.13 (venv)
- Ollama サーバー: 別ホストの GPU (Linux)、
qwen3.5:27b(約 21GB VRAM) - NDL OCR Lite v1.2 (CPU 推論)
手法 1: Qwen 単独 (失敗)
最初の仮説は「VLM 1本で OCR + 構造化を済ませる」だった。Ollama に画像を投げる:
body = {
"model": "qwen3.5:27b",
"messages": [{"role": "user", "content": PROMPT, "images": [b64]}],
"options": {"temperature": 0, "num_predict": 4096, "repeat_penalty": 1.15},
}
r = await client.post(f"{BASE_URL}/api/chat", json=body)
問題: 縦書きの密な本文ページで生成が同じフレーズの反復ループに陥った。
聞くことで心をしなやかにする。相手も不快にさせてしまう。
聞くことで心をしなやかにする。相手も不快にさせてしまう。
聞くことで心をしなやかにする。相手も不快にさせてしまう。…
temperature=0 を repeat_penalty=1.15 で抑え、解像度を 920px → 1280px に上げ、/api/generate を /api/chat に切り替えてかなり改善はしたが、>2000文字のページではループが残った。文字精度 93.47% で打ち切り。
横書き英文中心の VLM ベンチではトップクラスの Qwen2.5-VL でも、縦書き和文の高密度ページは別物だった。
手法 2: NDL OCR Lite 単独
NDL OCR Lite は国立国会図書館の DEIMv2 + PARSeq ベースの専用OCR。CPU 動作で Mac でも動く。
git clone https://github.com/ndl-lab/ndlocr-lite
python3.13 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python3 src/ocr.py --sourcedir resized/ --output out/
120ページを 1 回のバッチで5分 (モデルロード1回で済む)。文字精度 99.49%。縦書きの読み順整序やルビ推定の専用モジュールが入っていて、和文に強い。出力は .txt / .json / .xml。
これだけで読書目的には事実上十分。ただし .txt なので見出しレベルや段落構造、図表の構造化はない。
手法 3: NDL + Qwen ハイブリッド
NDL の OCR テキストを Qwen のプロンプトに「文字判定ヒント」として与え、画像は構造化判断のために併用する:
PROMPT_HYBRID = """このページは日本語書籍のページ画像です。
専用OCR (NDL OCR Lite) の読み取り結果を以下に示します。これを文字判定の
ヒントとして参照しつつ、画像を見て構造化された Markdown を生成してください。
# OCR結果
---
{ndl_text}
---
# 指針
- 文字判定は基本的に上記OCR結果を信頼する
- 改行・段落・見出しレベルは画像から判断する
- 図・概念図 → ```mermaid flowchart``` ブロックで構造化
- イラスト・写真は内容を説明する文章に置き換える
- 表は Markdown テーブル化
- 出力は Markdown のみ
"""
GutenOCR (arXiv:2601.14490) で grounded prompt が VLM のスコアを 0.40 → 0.82 に伸ばしたとの報告があり、その応用。
正解データのある1ページ (本文1769文字) で NDL→Hybrid の差分を取り、Qwen が何を直したかを分類した:
1. 濁点・半濁点の取り違え (NDL の弱点 → LLM の文脈推論が効く)
バ → パ (パッケージ)、プ → ブ (ブルー)。形が似ている濁点・半濁点は専用OCRでも誤認しやすいが、文脈 (周辺単語) で容易に区別できるので LLM 側が直せる典型例。
2. 章番号・小数字の誤認
ー → 1。縦書きで小さく入った章番号「1」が、専用OCR では長音記号「ー」と判定された。前後の見出し構造から「事例1」「1カ月前」と読み取れるので LLM が補正する。
3. 半角/全角の統一
? → ?。本文中に混ざる半角記号を全角に揃える。一貫性ルールなのでプロンプトで指示すれば LLM の得意領域。
4. 句読点・括弧の補完
「——」 「。」 「(…)」× 数か所。縦書きで行頭・行末や図版近接にある句読点は NDL OCR が落としやすいが、Qwen が画像と NDL テキストを見比べて欠落を埋める。
5. 副作用: 内容の脱落 (1件)
逆に「事例1」見出しが Hybrid 版で消えた。LLM は要約方向にも動くので、稀に内容を落とすことがある。
6. Markdown 構造化 (本記事のもう一つの利点)
# COACHING / ## コーチング体験は… のような見出しレベルや段落分けが入る。NDL のプレーンテキストでは得られない。
→ 「100文字に1文字レベル」の差は、意味判別が機械的に可能な誤りを LLM が直してくれる結果として出てくる。書籍を二度と読み返さないなら無視してもよいが、引用・検索する想定なら濁点誤認や章番号誤認は痛い。一方でHybrid だけにすると稀に内容が落ちるので、book_ndl.md (NDLでOCRしたもの)を残したまま book_hybrid.md (ハイブリッド版を作る)を併用するのが安全。
段階パイプライン
最終的に5段階に分けた:
Stage 1: prep_leader.py 1280px へリサイズ (~30s)
Stage 2: ndl_ocr_leader.py NDL OCR Lite 全120ページ (~5min)
Stage 3: merge_leader.py --mode ndl
book_ndl.md 生成 (即時)
─── ここで book_ndl.md は読める成果物として確定 ───
Stage 4: ollama_ocr_leader.py --mode hybrid
NDL テキスト+画像 を Qwen に流す (~140min)
Stage 5: merge_leader.py --mode hybrid
book_hybrid.md 生成 (即時)
途中で止めても NDL 版は無駄にならず、Stage 4 は resume 対応。
bash scripts/run_leader.sh ndl # 5分で book_ndl.md
bash scripts/run_leader.sh hybrid # 追加140分で book_hybrid.md
実装は Python スクリプト 5 本 + シェル 1 本で計 600 行ほど。asyncio.Semaphore(2) で Ollama に並列 2 リクエスト + 3 回までの指数バックオフリトライ + 既存出力スキップで resume 可能、という構成。
Markdown 構造化要素の数

NDL 単独では H2 (ページ番号) 以外の構造はゼロ。Hybrid は H1×55、H3×8、箇条書き×124、引用×31、太字×45、コードブロック×76 を付与した。
LLM だけが提供する: 図表の Mermaid 化
今回の書籍は本文中心で図はほぼなかったため Mermaid ブロックは事実上発生しなかったが、ER図やフロー図が含まれる書籍では効く。
別件で同じパイプラインを「データベース設計の解説書」に適用したときは、ページ画像から以下のような出力が得られた:
ER図のページ → Mermaid erDiagram ブロック:
業務フロー図のページ → flowchart:
NDL 単独では「図」というラベル以上のものは取れない。Hybrid なら Obsidian/GitHub にそのまま貼って図として再描画できる形で残る。図表の多い技術書・学術書・参考書では、これが Hybrid を選ぶ最大の理由になる。
加えてプロンプトに
- イラスト・写真・mermaid化できない絵は、どんな図や絵が描かれているかを地の文で説明する文章に置き換える
(例: 「(図: 山と湖の風景画。手前に小舟が浮かぶ)」)
と入れておくと、構造化できないイラスト・写真も alt-text 的に文章化されて検索可能なテキストとして残る。
ハマったポイント
/api/generate だと vision がうまく回らない
Ollama には /api/generate (raw prompt) と /api/chat (messages) の二系統がある。qwen3.5:27b の Modelfile は TEMPLATE {{ .Prompt }} で chat template が無いため、/api/generate で画像を送ると vision の取り回しが崩れた。/api/chat に切り替えただけで同じ画像が10倍速く、ループ無しで完走するようになった。
並列度を上げすぎると詰まる
qwen3.5:27b と qwen3-coder を同居させた Ollama サーバーで、別スクリプト 2 本 × concurrency 2 = 同時 4 リクエストを投げたら 120 秒タイムアウトで全滅した。OLLAMA_NUM_PARALLEL の余裕を見て concurrency=2 が安全圏。
解像度
最初 920px 幅にしたら Qwen が文字を読み切れずループに陥り、1280px (約 0.92MP、Qwen の 1MP 上限直下) に上げて改善。ただし NDL OCR Lite はどちらでもほぼ同等のスコアだった (専用OCR は前処理込み)。
ローカルLLMを使わずAPIを使った場合の概算
1ページあたり画像 + プロンプト (NDL テキスト約 2000 文字を含む) + 出力 Markdown (約 3000 トークン) として、入力 ~3500 tok / 出力 ~3000 tok を 120 ページで見積もる。
| モデル | 入力 / 出力 単価 (per MTok) | 120ページ概算 |
|---|---|---|
| Gemini 2.5 Flash | $0.30 / $2.50 | 約 $1.0 (≈ ¥150) |
| GPT-4o-mini | $0.15 / $0.60 | 約 $0.3 (≈ ¥45) |
| Claude Haiku 4.5 | $1 / $5 | 約 $2.3 (≈ ¥350) |
| GPT-4o | $2.50 / $10 | 約 $4.7 (≈ ¥710) |
| Gemini 2.5 Pro | $1.25 / $10 | 約 $4.1 (≈ ¥610) |
| Claude Sonnet 4.6 | $3 / $15 | 約 $6.9 (≈ ¥1,030) |
| Claude Opus 4.7 | $15 / $75 | 約 $34 (≈ ¥5,100) |
※ 為替 150円/$ 換算、料金は 2026年1月時点の公開価格。画像トークン換算は各社の係数 (Claude は約 1500 tok/画像、Gemini は 1 枚一律など) で多少ぶれる。
用途別の推奨
| 目的 | 選択 |
|---|---|
| 読みたいだけ | NDL 単独 (5 分、99.49%) |
| Obsidian 等で章節引用したい | Hybrid (140 分、約99.9% + Markdown 構造) |
| 図表・ER 図・フロー図が多い | Hybrid + プロンプトに Mermaid 指示を残す |
| 縦書き和文 | NDL を必ず通す (Qwen 単独は不可) |
参考
- NDL OCR Lite: https://github.com/ndl-lab/ndlocr-lite
- GutenOCR (OCR-grounded VLM 論文): https://arxiv.org/abs/2601.14490
- Ollama API リファレンス: https://github.com/ollama/ollama/blob/main/docs/api.md
Discussion