請求書処理の現場で「書類タイプが30種類を超えたあたりから、Gemini単発呼び出しの精度がガタ落ちした」瞬間を経験したことがあります。金額の抽出漏れが突然増え、新しく追加したベンダーの請求書が既存カテゴリに誤分類される。月末にまとめて手戻りが発生して、コストはさほど変わっていないのに人件費だけが跳ねていく。
そこから試行錯誤して落ち着いたのが、Geminiを呼ぶ前に「分類層」を挟む2段構成でした。分類層をML(embedding+クラスタリング)で作るか、LLM(Gemini 3 Flash)で作るか。この選択肢を、フォーマット数と成長性で分けるのが現時点で一番ハマっている整理の仕方です。モデルはGemini 3世代を前提に、日本語書類で踏んだ具体的な落とし穴もいくつか挟みます。
単発呼び出しで詰まる3つの場面
最初にGemini 3 Proへ全書類を投げていた時期にぶつかった壁は、主にこの3つでした。順番に書きます。
ひとつ目は、汎用プロンプト1枚では書類タイプ固有の癖を拾いきれないこと。A社請求書は備考欄に発注番号が手書きで混入する、B社領収書は税率が縦2段組で記載される、C社の見積書は合計欄が右上ではなく左下にある。こうした暗黙ルールを「このプロンプトで全部対応しよう」とすると、プロンプトが数千トークンに膨らみ、かつ後半のクラス説明への注意がLLM側で薄れて取りこぼしが出ます。Lost in the Middle の指摘どおり、Gemini 3世代でも完全には解消していない傾向です。
ふたつ目のコストは地味に効いてきます。Gemini 3 Proで書類1ページを抽出すると、画像トークン約560(media_resolution=medium)+プロンプト約500+出力約300の構成で、1件あたり$0.006前後。月10,000件で$60、月100,000件で$600規模です。Flashに寄せれば$15/月まで落ちますが、今度はタイプごとの最適プロンプトが当てられないので抽出精度が落ち始める、という別のトレードオフが浮上します。
みっつ目、そして最も怖いのがサイレント失敗でした。新しいベンダーの書類が来ても、LLMはどこかのカテゴリに寄せて返してくるので、分類ミスに気づけない。仕訳ミスや請求額ズレの業務影響は、APIコスト差を一瞬で食い尽くします。「知らないものには知らないと言ってほしい」という、普通のプログラムなら当然の期待が、LLM任せだと成立しない。これが分類層を挟む最大の動機になりました。
2段階パイプラインに寄せると何が変わるか
構成はシンプルで、Geminiに投げる前にタイプ判定を1段入れるだけです。
この構成の何が効くかというと、抽出プロンプトを書類タイプごとに育てられる点です。A社請求書の備考欄ルールも、B社領収書の縦書き税率も、その書類専用のプロンプトに閉じ込めておけばよく、他の書類タイプに副作用が出ない。プロンプトの肥大化も止まります。
この「分類器で入力を振り分けて専門化されたプロンプトに渡す」構成は、Anthropicが Building Effective Agents で整理しているLLMワークフローパターンのうち Routing(プロンプトルーティング)に該当します。汎用1枚プロンプトで全部やる代わりに、軽量な分類器と専門化されたサブプロンプトに分解する設計です。本記事はこのRoutingパターンを書類AIに特化させたときの「分類器を何で作るか」を扱います。
肝心の「分類層を何で作るか」が本記事の主題です。候補は3つ。
- Gemini 3 Flashで分類させるLLM方式
- embeddingと軽い分類器で済ませるML方式
- ML主体+低信頼度のみLLMに逃がすハイブリッド方式
どれを選ぶかは、扱うフォーマット数と半年後の見積もりで決めています。
MLとLLMの比較で効いてくる観点
7観点を並べて書くとAIが書いた定型表になるので、実務で気にしている順に散文で整理します。
コストとレイテンシ。Gemini 3 Flashで1件分類すると画像込みで約$0.0003、数百ms〜1秒。ML側は Gemini Embedding 2(マルチモーダル)を使えば1件約$0.0002、ローカルでLayoutLMv3を動かせば embedding 計算が数十msで済みます(GPU償却費は別途)。決定論的なのもML側の強みで、同じ入力に対して必ず同じ出力が返るので、後段のテストが書きやすくなります。
新カテゴリ追加と未知フォーマット検出の向き不向きが、運用で一番差が出るところです。LLM分類は「プロンプトに新カテゴリを足す」だけで済むので気軽。ただし既存カテゴリに似た書類が来ると勝手に寄せてくるので、未知検出は実質できません。ML分類は再学習や再クラスタリングが必要な代わりに、クラスタ中心からの距離やHDBSCANのノイズラベル(-1)で「どれにも属さない書類」が明示的に落ちてきます。サイレント失敗を構造的に防げるのはこちらです。
テンプレート粒度の分離は、実運用でじわじわ効く話です。同じ「請求書」カテゴリでも、A社とB社では抽出フィールドの位置が違う。この「社ごとの粒度」をLLMプロンプトで記述しきるのは正直しんどく、embedding空間の幾何で勝手に分かれてくれる方が楽でした。Unsupervised Document and Template Clustering でも、書類種別より細かいテンプレート単位の教師なし発見が実運用で効くと報告されています。
フォーマット数で線を引くなら30が分水嶺
境界線は実務感覚で次のように置いています。
| フォーマット数 | 推奨構成 |
|---|---|
| 〜15 | LLM分類で十分 |
| 15〜30 | ハイブリッド(ML主体+LLMフォールバック) |
| 30〜50 | ML分類が明確に勝つ |
| 50+ | ML+階層分類が必須 |
15までは素直にGemini 3 Flashに投げる方が楽で、ML側の運用コストをかける意味が薄い。30を超えたあたりから、プロンプト肥大化と類似クラスの識別劣化が同時に効き始めて、LLM分類の精度が見えて落ちてきます。50を超えると、単一の分類器では破綻するので階層化が必要になります。
ここで大事なのは、「今のフォーマット数」ではなく「半年後の見積もり」で決めること。半年後に30種類に育つ10種類なら、最初からMLで組んだ方が、後で全部書き直すより安く済みました。少なくとも自分が経験したプロジェクトでは、LLM分類からML分類への移行コストがかなり重かったので、早めに切り替える側に倒しています。
ML分類の実装は意外とシンプル
必要な依存関係はこれだけです。
pip install transformers torch pillow scikit-learn hdbscan google-generativeai
google-generativeai は旧SDKです。2026年時点では google-genai(新SDK、from google import genai)への移行が進んでいます。本記事のコードは旧SDK前提で書いていますが、新SDKに読み替える場合は types.GenerateContentConfig の書き方に差し替えてください。
embeddingモデルは用途で3択から選ぶ形になります。
| モデル | RVL-CDIP精度 | OCR | VRAM目安 | 向いている使い方 |
|---|---|---|---|---|
| LayoutLMv3-base | 95.44% | 必要 | 8GB | OCR品質が高く、テキスト+レイアウトを両方使いたい |
| Donut | 95.30% | 不要 | 8GB | OCR不要で運用を軽くしたい、日本語fine-tuneあり |
| ColPali v1.3 | 検索向け | 不要 | FP16で8-12GB | 多言語や検索を重視、PaliGemma-3Bベース |
LayoutLMv3を使う場合、embedding取得のコードはこう落ち着きます。
from transformers import LayoutLMv3Processor, LayoutLMv3Model
from PIL import Image
import torch
processor = LayoutLMv3Processor.from_pretrained(
"microsoft/layoutlmv3-base", apply_ocr=True
)
model = LayoutLMv3Model.from_pretrained("microsoft/layoutlmv3-base")
model.eval()
def embed(image_path: str) -> torch.Tensor:
image = Image.open(image_path).convert("RGB")
inputs = processor(
image,
return_tensors="pt",
truncation=True,
max_length=512,
)
with torch.no_grad():
outputs = model(**inputs)
return outputs.last_hidden_state[:, 0, :] # CLSトークン (1, 768)
ここで地味に効くのが .convert("RGB")。グレースケールやRGBA混在のPDFを食わせると静かに落ちるので、入力正規化を最初に入れておくと夜中にエラーで起こされずに済みます。torch.no_grad() を忘れるとVRAMが無駄に膨らむのと、truncation=True, max_length=512 を付けないと長文書類で暗黙切り捨ての警告が出続けるので、このあたりは最初に潰しておくと後で楽です。
GPUを用意したくないチームは、ローカル推論を諦めて Gemini Embedding 2(マルチモーダルMaaS)を使う手もあります。1件約$0.0002で、月10,000件でも$2程度。VRAM予算がない状況なら素直にこちらを選んだ方が運用が軽くなります。
HDBSCANで未知フォーマットを「発見」する
手元に既存書類が1,000枚あって、何種類あるか正確にわからない状態から始める場合、クラスタ数を事前指定せず未知書類もノイズラベル(-1)で明示的に拾えるHDBSCANが構造的に向きます。
import hdbscan
import numpy as np
embeddings = np.vstack([embed(p).detach().numpy() for p in document_paths])
clusterer = hdbscan.HDBSCAN(
min_cluster_size=5, # 書類総数1,000件なら 0.5% 目安
min_samples=3,
metric='euclidean',
prediction_data=True,
)
labels = clusterer.fit_predict(embeddings)
# labels == -1 が未知フォーマット(ノイズ)
min_cluster_size は書類総数の0.5〜1%あたり(1,000件なら5〜10)が初期値の目安です。HDBSCANの強みは、クラスタ数を事前指定せずに「似た書類のグループ」を自動で切り出してくれる点と、どれにも属さない書類が -1 ラベルで落ちてくる点。この -1 がそのまま「見たことのない書類」の検出に使えます。
上のコードは768次元のembeddingに直接HDBSCANを当てる最小構成です。実プロジェクトでは UMAP で15次元程度に削減してから HDBSCAN を当てる2段構成が定石とされており、BERTopic がその代表的な実装です。高次元のままだと密度推定が機能しにくく、min_cluster_size を下げないとほぼ全件がノイズに倒れる症状が出ることがあります。精度を詰めるなら UMAP を挟む構成を検討してください。
ただしHDBSCANは本番ルーティングには向きません。非決定性があって、同じデータでも実行ごとに結果がずれることがあり、再現性が求められる本番環境だとデバッグが難しくなります。発見フェーズはHDBSCAN、運用フェーズは決定論的なKNN、という分担が現実的でした。
from sklearn.neighbors import KNeighborsClassifier
# metric='cosine' は sklearn では algorithm='brute' が強制される
knn = KNeighborsClassifier(n_neighbors=5, metric='cosine')
knn.fit(train_embeddings, train_labels)
def route(image_path: str) -> tuple[str, float]:
vec = embed(image_path).detach().numpy() # (1, 768)
probs = knn.predict_proba(vec)[0] # (n_classes,)
idx = int(probs.argmax())
return str(knn.classes_[idx]), float(probs[idx])
返り値の第2要素は、KNNが選んだラベルに対する近傍5件中の多数決比率で、これを信頼度として扱います。低信頼度のケースをLLMフォールバックに回すルーティングがこの値1つで書けるようになります。
信頼度ゲートで3段フォールバックを組む
信頼度が低い書類をGemini 3 Flashに、さらに曖昧ならGemini 3 Proにエスカレーションする構成が、コストと精度のバランスで一番ハマりました。
# flash_classify / pro_classify は Gemini の分類専用プロンプト呼び出し
# 戻り値は (label: str, confidence: float)
def process(image_path: str) -> dict:
label, confidence = route(image_path)
if confidence < 0.7:
# ML分類の信頼度が低い → Gemini 3 Flash でフォールバック
label, flash_conf = flash_classify(image_path)
if flash_conf < 0.6:
# Flashでも曖昧 → Gemini 3 Pro へエスカレーション
label = pro_classify(image_path)
return extract(image_path, label)
自分のプロジェクト(書類種30種以下、閾値0.7/0.6)では、全体の70〜90%がML分類で確定し、残りがFlash、さらにごく少数がProに回る分布に落ち着きました。ただしクラス間の分離度に強く依存するので、実データでの比率は全然違うことがあります。特に似たフォーマット(A社請求書とA社発注書など)が多い場合はML確定率が下がるので、運用開始後に閾値を再調整する前提で組むのが現実的です。
閾値設計は、HDBSCANのノイズ検出(label == -1)とKNNの predict_proba を組み合わせるのが一番安定しました。中心からの距離でp95閾値を使う方法もあって、こちらはどの書類種にどれくらい「遠い」かを監視できるので、データドリフト検知も兼ねられます。
50種類超は階層分類で段階的に絞る
フォーマット数が50を超えると、単一分類器ではクラス境界が曖昧になりすぎるので、段階的に絞り込む構成が必要になりました。
階層分類で一番踏んだのがcascading errorでした。第1段階で「請求書」を「領収書」に誤分類すると、第2段階のベンダー別分類がまるごと的外れになる。特に「請求書兼発注書」のような境界ケースでは第1段階の信頼度が低く出るので、そういう書類は複数の第2段階分類器を並列に走らせて整合性をチェックする方式で逃げています。全書類でGeminiを呼ぶより安く、境界ケースにも強くなりました。
日本語書類で踏んだ具体的な落とし穴
一番時間を溶かしたのは、印鑑が数字の上にかぶった請求書の読み取りです。Gemini 2.5 Pro時代は「¥38,500」の3が印鑑で潰れて「¥8,500」と読まれることがあり、仕訳金額が1桁違ってからの検算で発覚しました。Gemini 3 Pro世代では media_resolution=high を指定するとかなり改善していて、印鑑重なりの誤読はほぼ見かけなくなっています。
次に困ったのが元号の日付変換。「令和6年3月」を「2024年3月」に変換する処理は、プロンプトで明示的に指示しないと「R6-03」のような中間表記で返ってくることがあり、後段の会計システムで弾かれました。書類タイプ別のプロンプトに「日付は必ず西暦で返す」という1行を入れておくだけで解決しますが、汎用プロンプト1枚構成だとこういう書類別の作法を差し込む場所がなくなります。分類層を挟んだ恩恵がここで効いてきました。
書類別の media_resolution 設定はこんな感じで切り替えています。
RESOLUTION = {
"invoice_vendor_a": "medium",
"invoice_vendor_b": "medium",
"receipt": "low", # レシートは低解像度で十分
"handwritten": "high", # 手書き・印鑑重なりは高解像度必須
"unknown": "medium",
}
def extract(image_path: str, doc_type: str):
# 2026-05時点ではPreview段階。最新IDは公式ドキュメントで要確認
model = genai.GenerativeModel("gemini-3.1-pro-preview")
return model.generate_content(
[PROMPTS[doc_type], Image.open(image_path)],
generation_config={"media_resolution": RESOLUTION[doc_type]}
)
本記事では「Gemini 3 Pro」「Gemini 3 Flash」をモデル世代名として参照していますが、2026-05時点で具体的なAPIモデルIDはまだPreview段階で動いています。gemini-3-pro-preview は2026-03-09にshutdownされ、現在は gemini-3.1-pro-preview が推奨IDです。本番投入時は必ず 公式モデル一覧 で最新の正式IDを確認してください。また media_resolution パラメータの指定方法もSDKバージョンで異なり、新SDK(from google import genai)では types.GenerateContentConfig(image_config=types.ImageConfig(media_resolution=...)) のネスト構造です。
月10,000件での月額比較
実際の構成別の月額です。前提は、画像トークン560(medium)+プロンプト500+出力300、2026年4月時点の公式料金(Gemini 3 Pro: 入力$2/1M・出力$12/1M、Flash: 入力$0.50/1M・出力$3/1M)。
| 構成 | 月額 | 備考 |
|---|---|---|
| Gemini 3 Flash 単発抽出 | 約 $14 | 2.5 Pro相当の精度をFlash価格で |
| Gemini 3 Pro 単発抽出 | 約 $57 | 最高精度だが割高 |
| Gemini 3 Flash 分類 + 3 Pro 抽出 | 約 $60 | Flashで分類、Proで抽出 |
| ML分類 + Gemini 3 Pro 抽出 | 約 $59 | ML分類コストは誤差 |
| ML分類 + Gemini 3 Flash 抽出(10%を 3 Pro へエスカレーション) | 約 $20 | 実運用で一番ハマった |
最安構成は「ML分類 + Gemini 3 Flash抽出 + 信頼度による3 Proエスカレーション」で、単発Pro構成の約1/3に収まります。ただし、抽出漏れ1件の手戻り工数(確認・修正・再処理)を工数換算すると、月数千円〜数万円のAPIコスト差は一瞬で吹き飛ぶことがあるので、最安を選ぶのは目的次第です。
自分の経験だと、月間処理件数が数万件を超えて精度SLAも厳しいプロジェクトでは、APIコスト最適化より抽出漏れの業務影響抑制のほうが重要になります。このあたりは会社の監査要件や会計締めスケジュールとの相談になります。
まとめ
書類AIパイプラインの設計を何度かやり直して、最終的に効いたのは「フォーマット数が半年後にいくつになっているか」という見立てでした。今10種類で困っていなくても、半年で30種類に育つ見通しがあるなら、最初からMLベースで組むほうが後のリライトコストが軽い。逆に10種類で安定しているなら、Gemini 3 Flashの単発でも十分に戦えます。
今も残っている課題は、日本語の手書き帳票で印鑑の濃淡が強いケースの誤読と、階層分類の第2段階でベンダー別テンプレートが月1〜2件ペースで増える運用のしんどさです。後者については、HDBSCANで月1回の自動再クラスタリングを回して、新しいテンプレート候補を検知するジョブを動かすことで落ち着かせています。
Gemini 3 Proが手に入る世代になっても、「LLM1発で全部やる」よりは「LLMに届くまでの前処理をML側に寄せる」ほうが、少なくとも定型書類の世界では素直に精度もコストも改善する、というのが今のところの結論です。
参考文献
軸となる論文
- Liu, N. F. et al. (2023). Lost in the Middle: How Language Models Use Long Contexts. arXiv:2307.03172
- Sampaio, P. R., Maxcici, H. (2025). Unsupervised Document and Template Clustering using Multimodal Embeddings. arXiv:2506.12116
- Scius-Bertrand, A. et al. (2024). Zero-Shot Prompting and Few-Shot Fine-Tuning: Revisiting Document Image Classification Using Large Language Models. arXiv:2412.13859 (ICPR 2024)
- McInnes, L., Healy, J., Astels, S. (2017). hdbscan: Hierarchical density based clustering. JOSS, 2(11), 205
- Guo, C. et al. (2017). On Calibration of Modern Neural Networks. ICML 2017. arXiv:1706.04599
文書理解モデル
- Huang, Y. et al. (2022). LayoutLMv3: Pre-training for Document AI with Unified Text and Image Masking. arXiv:2204.08387
- microsoft/layoutlmv3-base (HuggingFace)
- Kim, G. et al. (2022). OCR-free Document Understanding Transformer (Donut). arXiv:2111.15664
- naver-clova-ix/donut-base (HuggingFace)
- Faysse, M. et al. (2024). ColPali: Efficient Document Retrieval with Vision Language Models. arXiv:2407.01449
- vidore/colpali-v1.3 (HuggingFace)
- RVL-CDIP Dataset
Gemini 公式ドキュメント
- Google AI: Gemini API Pricing
- Google AI: Document Understanding (
media_resolution仕様含む) - Gemini Embedding 2 — Google Blog
- google-genai Python SDK (新SDK)
- google-generativeai Python SDK (旧SDK)
実装ツール
- scikit-learn: KNeighborsClassifier
- HDBSCAN Python implementation (GitHub)
- PyTorch
- transformers (Hugging Face)
関連実装記事・参考資料
- Anthropic: Building Effective Agents(Routingパターンの一次出典)
- Lu, R., Liu, H., Hou, S. (2026). Evaluation of Embedding-Based and Generative Methods for LLM-Driven Document Classification. arXiv:2604.04997(geoscience documents 領域だが手法比較の観点で参考)
- Document Classification and Entity Extraction with Gemini — Google Cloud Community
- Document Classification with LLM and ML — Andy Bosyi
- Lessons from Running an LLM Document Processing Pipeline in Production
- Gemini 2.5 Pro Japanese OCR (Zenn)