NuExtract 3でPDF・請求書をローカルでJSON抽出する|クラウドAIとの使い分け・選び方

NuExtract 3でPDF・請求書をローカルでJSON抽出する|クラウドAIとの使い分け・選び方 のアイキャッチ画像 AI×ライティング

請求書のPDFを開いて、発行日と請求番号を目で追い、明細を1行ずつ表計算に打ち込む。月末に何十枚も処理していると、転記ミスと終わりの見えない単純作業で時間が溶けていきます。やりたいのは「決まった項目を、決まった形で、機械的に抜き出す」こと。それだけのはずなのに、汎用の文章生成AIに丸投げすると今度は別の問題が顔を出します。

NuExtract3とは、文書から指定したスキーマのJSONを抽出する、構造化出力に特化したローカル実行のVLMである。

ここで扱うのは、この「決まった形で抜き出す」を専門にしたモデルを、手元のPCで動かして請求書PDFを検証可能なJSONに落とすところまでの道筋です。OCRと正規表現を継ぎ足したパイプラインや、汎用LLMへのプロンプト依存でなぜ抽出が崩れるのか。その症状から逆算して、構造化出力専用のアプローチが何を解決するのかを順番に見ていきます。

この記事の要点

  • NuExtract3はテンプレート(欲しい項目の型)を渡すと、その形に沿ったJSONを返す構造化抽出向けのモデル
  • 画像も読むVLMなので、PDFをページ画像化すれば、テキスト抽出済みでなくレイアウトを保ったまま入力できる
  • ローカルで動かせるため、文書を外部の推論APIへ送らない構成を組める(ただし外部接続を使わない前提での話)

請求書・PDFの情報抽出でつまずく具体ポイント

帳票の自動化が一筋縄でいかないのは、「読み取り」と「構造化」が別の難しさを抱えているからです。スキャンした請求書は文字が読めても、どの数字が小計でどれが税額かをプログラムが取り違える。逆に項目の意味は分かっても、出力が毎回違う形で返ってきて後処理が安定しない。多くの現場が踏むのは、このどちらか、あるいは両方の地雷でしょう。

最初に詰まるのは、たいていフォーマットの揺れです。取引先が10社あれば請求書のレイアウトも10通り。合計欄が右下にある書式もあれば、明細の下にまとめて並ぶ書式もあります。人間なら一目で「これが合計」と分かる配置の手がかりが、テキストだけを順番に読むツールには伝わりません。

汎用LLMにPDFを丸投げすると起きること

文章生成が得意なLLMに「この請求書から合計金額を教えて」と頼むと、それらしい答えは返ってきます。問題は、その答えの形が制御しにくい点。あるときは「合計は110,000円です」と文章で、別のときは「¥110,000」と通貨記号つきで、さらに別のときは「税込110000」と返す。プログラムから使うには、この自由な出力を毎回パースし直す羽目になります。

もっと厄介なのが、空欄を勝手に埋めてしまう挙動です。請求書に振込先が書かれていないのに、文脈から「ありそうな」値を補ってしまう。生成モデルは「自然な文章を続ける」ことに最適化されているため、抽出タスクでは事実にない情報をもっともらしく差し込むリスクが残ります。金額や口座番号でこれが起きると、目視で気づくまで誤りが下流に流れ続けるという、見つけにくい不具合になりかねません。

ここで効いてくるのが、出力形式の指定方法です。Structured Outputs(JSON Schema でスキーマを指定する仕組み)を使わず、自由形式のプロンプトだけで「JSONで返して」と頼む構成だと、波括弧の閉じ忘れ、文字列内の引用符のエスケープ漏れ、配列の途中への説明文の混入といった崩れが起こり得ます。構文として壊れたJSONはjson.loadsで例外になり、せっかくの抽出結果が丸ごと使えなくなる。逆に言えば、GPT-5.6 系のように Structured Outputs に対応したモデルなら、正常に完了した応答についてはスキーマ適合性を高く保てます(拒否や出力上限による不完全終了は別途ハンドリングが必要です)。ただしスキーマに適合することと、請求書から読み取った値そのものが正しいことは別問題で、後者の検証は結局こちら側で持つ必要があります。

OCR後の後処理が破綻するパターン

「ならばOCRで文字を起こしてから正規表現で抜けばいい」という発想は自然です。実際、定型フォーマットが1種類なら、この方式でもそれなりに回ります。ただ取引先が増えてレイアウトが多様化した途端、正規表現の例外パッチが雪だるま式に膨らみます。

OCRの読み取り精度そのものも変数になります。スキャンの傾き、かすれた印字、表の罫線が文字に重なった箇所。こうした入力では「0」と「O」、「1」と「l」、桁区切りのカンマと小数点の取り違えが起こります。後段の正規表現は「読めた文字列が正しい」前提で書かれているので、上流の誤読をそのまま受け取って誤った数値を確定させてしまう。

レイアウト情報が失われる点も無視できません。単語の座標や表構造まで返すOCRもありますが、結果をプレーンテキストだけで受け取る構成では、ページの文字が一次元のテキスト列になります。その過程で「この数値は明細テーブルの3行目の単価列」という二次元の位置関係が落ちてしまうと、どの数字がどの項目に属するのかを後から復元するのは難しくなります。表が複数ページにまたがる請求書だと、なおさら。読み取り工程と構造化工程を別々の道具で継ぎ接ぎしていると、こうした境界でほころびが出やすいわけです。

つまり、欲しいのは「画像のレイアウトを保ったまま読み、決めた形のJSONだけを返す」一貫した仕組み。ここに構造化出力専用のVLMが効いてきます。

NuExtract3の仕組みとテンプレート方式

NuExtract系は、NuMindが公開している情報抽出向けのモデル群です。汎用的な対話や長文生成ではなく、「与えた文書から、指定した項目を、指定した形で抜き出す」ことに用途を絞っています。バージョンの正確な世代名やモデルサイズ、ライセンスの詳細は更新されるため、導入前に公式のモデルカードで最新の表記を確認してください。本記事では、抽出に使ううえでの考え方を中心に扱います。

中核にあるのは、出力を自由作文にさせず「テンプレートに沿わせる」という方針です。あらかじめ欲しい項目の型を渡しておき、モデルはその型の空欄を埋める形で値を返す。文章を生成させるのではなく、決められた枠に値を流し込ませることで、出力の形が毎回そろいます。これがJSON構文の安定につながる土台になります。

抽出テンプレート(NuExtract 独自形式)の渡し方

テンプレートは、抜き出したい項目を並べた「空のJSON」のようなものだと捉えると分かりやすいでしょう。請求書なら、発行日・請求番号・請求元・合計金額といったキーを並べ、値は空にしておく。モデルはこのテンプレートと文書を受け取り、各キーに対応する値を文書中から見つけて埋めて返します。

考え方を概念的に書くと、入力と出力はこういう対応になります。

// 渡すテンプレート(各項目に「欲しい型」を指定)
{
  "issue_date": "date-time",
  "invoice_number": "verbatim-string",
  "biller": "verbatim-string",
  "total_amount": "number"
}

// 返ってくるJSON(型に沿って値が埋まる)
{
  "issue_date": "2026-06-01",
  "invoice_number": "INV-2026-0042",
  "biller": "サンプル商事株式会社",
  "total_amount": 110000
}

このやり方の利点は、出力の構造を呼び出し側がコントロールできる点にあります。各キーの値には、抜き出したい型を文字列で指定します。そのまま文字で欲しいならverbatim-string、日付ならdate-time、数値ならnumberといった具合です。プロンプトで「JSONで返して」とお願いして祈るのではなく、型を先に固定しておくぶん、自由形式の生成より構造をそろえやすくなります。ただしJSONとして常に有効になる保証ではありません。公式モデルカードの約600文書を使った内部ベンチマークでも、27件がJSONとしてデシリアライズできなかったと報告されています。受け取った文字列は必ずjson.loadsでパースして検証し、失敗したら再試行か人手確認へ回す経路を用意しておきます。指定できる型名の一覧や正確な記法は、公式モデルカードを参照してください。

テンプレートに書いた範囲へ出力を寄せられる、という性質も押さえておきたいところ。生成モデルである以上「絶対に他の項目が混じらない」と保証されるわけではありませんが、抽出範囲を自分で限定しやすいのは利点として働きます。請求書から税額だけが欲しければ税額のキーだけを置けばよく、余計な情報を拾わせずに済みます。

テキストOCRモデルとVLMの違い

NuExtract3がVLM、つまり画像も入力にできる点は、帳票抽出で実用上の差を生みます。テキストOCRを前提とするモデルは「すでに文字列に起こされた入力」を必要とするため、前段にOCR工程が要る。そこで読み取りミスが起きれば、抽出モデルがどれだけ優秀でも誤った文字列を処理することになります。

VLMはページの画像そのものを読めるので、文字の並びだけでなく配置やレイアウトの手がかりも手がかりとして使える可能性があります。合計欄が枠で囲まれている、明細が罫線で区切られている、といった視覚的な構造が、どの値がどの項目かの判断材料になり得る。OCRで一次元のテキストに潰す前の情報を保ったまま扱える、というのがVLM方式の勘所です。

ただし、ここで測れているのは「レイアウトを含めて入力にできる」という入出力の枠組みであって、「あらゆる文書で抽出が正確」という品質保証とは別の話です。スキャン画質が悪ければ読み取りは当然落ちますし、見慣れない様式の請求書では取りこぼしも起こります。何が構造として保証され、何が文書次第で変わるのか。この線引きを意識しておくと、後で精度を検証するときに評価がぶれません。

ローカル環境の構築と最小実行

ここからは実際に動かす段階です。NuExtract系はローカルで実行できる公開モデルとして提供されているため、手元のPCにPython環境と推論ライブラリを用意すれば、1ドキュメントの抽出を試せます。クラウドのAPIキー発行や従量課金の設定なしに、まず自分の請求書サンプルで挙動を確かめられるのが利点。

最初に全体の流れを掴んでおきましょう。やることは大きく3つです。まずPythonと推論ライブラリを入れる。次に公式のモデルカードからモデルを取得する。最後に、文書とテンプレートを渡して抽出を1件実行する。難所はモデルの取得と最初の1回が通るかどうかで、そこさえ越えれば後はテンプレートを差し替えるだけで応用が利きます。

初回はいきなり請求書の本番データではなく、項目が少ない簡単な文書サンプルで「JSONが返ってくる」状態をまず作るのがおすすめ。テンプレートのキーを2〜3個に絞って成功させてから、項目を足していくと切り分けが楽になります。

必要な環境とモデルの取得

前提になるのはPythonの実行環境と、Hugging Faceからモデルを読み込む推論ライブラリです。一般的には仮想環境を作り、必要なパッケージをインストールしてから始めます。

# 仮想環境を作って有効化(例)
python -m venv .venv
source .venv/bin/activate   # Windowsは .venv\Scripts\activate

# 推論に使うライブラリの導入(device_map="auto" を使うため accelerate も入れる)
pip install -U transformers accelerate pillow
# torch は環境依存。PyTorch公式セレクタ(https://pytorch.org/get-started/locally/)でOS/CUDA対応版を導入

導入するパッケージの正確な組み合わせや、GPUを使う場合のPyTorchのビルド指定は環境で変わります。CUDA対応版のインストール方法などは、公式の導入手順とモデルカードの記載に合わせてください。ここで挙げているのは「何を入れる工程があるか」を示す概念的な例です。

NuExtract3は4Bクラスと小型です(NuMindは4BクラスのVLMと説明する一方、Hugging Faceのメタデータではフルモデルが約5Bと表示されます。表記差の理由はモデルカード上では明示されていないため、本記事では4Bクラスとして扱います。量子化版だからパラメータ数が減る、という話ではありません)。そのため、動かすためのハードルは高くありません。量子化版(GGUF)を llama.cpp や LM Studio などのランタイムで動かせばモデルの占有メモリは3〜4GBほどまで下がり、エントリークラスのGPUはもちろん、GPUを積んでいないPCでも、CPUと十分なメインメモリ(16GB程度が目安)があれば動作自体は可能です。ただし速度は大きく落ちるため、量をこなす実用処理ではGPUの利用が現実的です。Apple Silicon向けには、Metal上で動くMLX量子化版もNuMindから公開されています。ユニファイドメモリは、ディスクリートGPUのVRAM上限に縛られない点が利点です(速度や実用性はMacのメモリ容量・入力画像・量子化方式によるため、実環境での確認が必要です)。

必要なメモリの見積もりは、公開されている配布ファイルの容量から当たるのが確実です。公式の MLX 4bit 版は約3.03GB、GGUF の Q4_K_M も同程度の容量で配布されているため、4bit 量子化なら3〜4GBのクラスに収まります。非量子化(bf16)で読み込む場合は、4Bクラスの重みをそのまま持つぶん、この数倍を見ておく形になります。ただし配布ファイルの容量と実行時のピークメモリは別物で、入力画像の解像度・ページ数・コンテキスト長・ランタイムの実装によって上振れします。最低スペックはモデルのバージョンや量子化方式で変わるため断定はしませんが、専用のハイエンドGPUがなければ動かない、という類のモデルではありません。まずは小さな文書で1件試すのが確実です。量子化方式ごとのVRAMと速度の関係は、姉妹サイトのローカルLLMの量子化はどれを選ぶか(Q4_K_M・Q8_0・FP16の実測)が参考になります。

モデルの取得は、推論ライブラリ経由でモデル名を指定して読み込む形が一般的です。初回はダウンロードが走るため、回線とストレージに余裕を見ておいてください。正確なモデル名(リポジトリ名)は公式モデルカードに記載されているものを使います。記憶や推測で似た名前を打ち込むと、別物を引いてくる原因になります。

最小コードで1件抽出するまで

環境が整ったら、文書とテンプレートを渡して抽出を1件実行します。下のコードは概念例ではなく、公式モデルカードのTransformers例に合わせた最小構成です。モデルの読み込みから、画像とテンプレートを渡してJSON文字列を受け取るまでを、この順番でそのまま動かせます。

# 公式モデルカードのTransformers例に合わせた最小構成
import json
import torch
from PIL import Image
from transformers import AutoModelForImageTextToText, AutoProcessor

model_id = "numind/NuExtract3"

processor = AutoProcessor.from_pretrained(model_id, trust_remote_code=True)
model = AutoModelForImageTextToText.from_pretrained(
    model_id,
    dtype=torch.bfloat16,
    device_map="auto",
    trust_remote_code=True,
).eval()

def run_nuextract(messages, **chat_template_kwargs):
    inputs = processor.apply_chat_template(
        messages,
        add_generation_prompt=True,
        tokenize=True,
        return_dict=True,
        return_tensors="pt",
        **chat_template_kwargs,
    ).to(model.device)
    with torch.inference_mode():
        generated_ids = model.generate(**inputs, max_new_tokens=4096, do_sample=False)
    generated_ids = generated_ids[:, inputs.input_ids.shape[1]:]
    return processor.batch_decode(
        generated_ids, skip_special_tokens=True, clean_up_tokenization_spaces=False,
    )[0].strip()

# 抽出したい項目を「型」で宣言(verbatim-string / date-time / number など)
template = {
    "issue_date": "date-time",
    "invoice_number": "verbatim-string",
    "total_amount": "number",
}

# 文書ページを画像として読み込む(PDFは事前にページ画像化しておく)
page = Image.open("invoice_page1.png").convert("RGB")
messages = [{"role": "user", "content": [{"type": "image", "image": page}]}]

result_json = run_nuextract(
    messages,
    template=json.dumps(template, indent=4),
    enable_thinking=False,
)
print(result_json)

trust_remote_code=True は、モデルリポジトリ内のカスタムコードをローカルで実行する設定です。請求書のような機密文書を扱う前に提供元とコードを確認し、本番では revision を固定して検証済みの環境で実行してください。

ポイントは、テンプレートをapply_chat_templateの引数として渡すこと。欲しい項目の型を宣言しているので、返ってくるのはその型に沿ったJSON文字列です。受け取った文字列はjson.loadsでパースし、構文として読めるかをまず確認します。ここでパースが通れば、テンプレートで指定したキーに値が入っているはず。

PDFを入力にする場合は、ページを画像に変換する一手間が要ります。PDFを画像化するライブラリでページごとに画像へ起こし、その画像をモデルに渡す流れです。複数ページの請求書なら、ページごとに抽出して結果をまとめるか、関連するページを束ねて渡すかを後で設計します。まずは1ページ・1テンプレートで「JSONが返り、パースが通る」最小の成功を作るのが先決。

最初の1件が通ったら、テンプレートのキーを請求書向けに増やしていきます。発行日や請求番号といった単純な単一項目から始め、明細のような繰り返し構造はその後で足すと、どの項目で精度が落ちるかを切り分けやすくなります。この最小実行を土台に、次は請求書の本番スキーマ設計と抽出結果の検証へ広げていきます。

請求書PDFを構造化JSONに抽出する手順

最小実行で型に沿ったJSONが返るところまで来たら、次は請求書という実務の対象に合わせてテンプレートを作り込んでいく段階。ここがこの作業の山場です。請求書は発行元によってレイアウトがまるで違うのに、抜きたい項目はだいたい共通しています。発行日、請求番号、請求元、明細、小計、消費税、合計。この「共通して欲しい型」をテンプレートに落とし込めるかどうかで、抽出の安定度が大きく変わってきました。

汎用LLMにそのまま文章で「この請求書から項目を抜いて」と頼むと、戻り値の形が毎回ぶれます。構造化出力に特化したモデルにテンプレートを渡す方式なら、戻り値のキー構成が固定されるので、後段のプログラムが値を受け取りやすい。この差が、量産処理では効いてきます。

請求書向け抽出スキーマの設計

請求書スキーマで最初に決めるのは、単一項目と繰り返し項目の切り分けです。発行日や請求番号は文書に1つだけ出てくる単一項目。一方、明細行は商品名・数量・単価・金額が何行も並ぶ繰り返し構造です。これを配列として表現します。

invoice_template = {
    "issuer_name": "verbatim-string",      # 請求元の会社名
    "invoice_number": "verbatim-string",   # 請求番号
    "issue_date": "date-time",             # 発行日
    "due_date": "date-time",               # 支払期限
    "line_items": [                         # 明細行(繰り返し)
        {
            "description": "verbatim-string",
            "quantity": "number",
            "unit_price": "number",
            "amount": "number",
        }
    ],
    "subtotal": "number",                  # 小計
    "consumption_tax": "number",           # 消費税
    "total_amount": "number",              # 合計
}

設計のコツは、いきなり全項目を盛り込まないこと。まず単一項目だけのテンプレートで安定させ、明細の配列は後から足します。そうすると、精度が落ちたときに「単一項目で落ちたのか、繰り返し構造で落ちたのか」を切り分けやすい。最初から全部入りにすると、どこが原因なのか分からなくなります。

項目名も抽出精度に響きます。taxのような曖昧なキーより、consumption_taxのように何の税かを明示したほうが、モデルが該当箇所を見つけやすくなる傾向が筆者環境ではありました(公式仕様として保証されているものではありません)。NuExtract3 はテンプレートに加えて instructions や in-context examples も渡せるので、キー名だけで解決しないときは項目の説明文や例示を足すほうが効きます。日本語の請求書を扱うなら、キー名は英語でも、会社名や品目などの文字列項目は日本語のまま返る前提でテンプレートを組みます(金額はnumber型なので数値で返ります)。

原則として、テンプレートに書いた項目が出力対象になります。文書中に該当情報が見つからない場合は、単一値は null、配列は [] で返されます。ただし生成結果が常にテンプレートどおりの有効な JSON になる保証はないため、構文とキー構成の検証は別途必要です。この性質を使い、請求書に存在しない可能性がある項目もテンプレートに含めておけば、その項目が「無い」または抽出に失敗した可能性を検出できます。必須項目の null・空チェックを設計に入れておくと、後段の品質管理に効いてきます。

抽出結果のバリデーションと信頼度の扱い

戻ってきたJSONをそのまま会計システムへ流すのは危険です。モデルの抽出結果は、必ず元の請求書と突合する前提で扱います。最低限のバリデーションとして、必須フィールドの欠落チェックと、金額の整合チェックを入れておくと事故が減りました。

# Python 3.10 以上を前提(Decimal | None などの型表記を使用)
import json
from decimal import Decimal, InvalidOperation


def reject_nonstandard_number(value: str):
    """NaN / Infinity など JSON 標準外の定数を弾く。"""
    raise ValueError(f"非標準の数値です: {value}")


def to_decimal(value) -> Decimal | None:
    """金額を Decimal にする。空・未指定は None(0 とは区別する)。"""
    if value is None or value == "":
        return None
    if isinstance(value, bool):          # bool は int の一種なので先に弾く
        raise ValueError("真偽値は金額として扱えません")

    normalized = (
        str(value).replace(",", "").replace("円", "").replace("¥", "").strip()
    )
    try:
        amount = Decimal(normalized)
    except InvalidOperation as exc:
        raise ValueError(f"金額を数値化できません: {value!r}") from exc
    if not amount.is_finite():
        raise ValueError(f"有限の金額ではありません: {value!r}")
    return amount


def validate_invoice(result_json: str) -> list[str]:
    try:
        # parse_float=Decimal で、小数を float に落とさずそのまま Decimal で受ける
        data = json.loads(
            result_json,
            parse_float=Decimal,
            parse_constant=reject_nonstandard_number,
        )
    except (json.JSONDecodeError, ValueError) as exc:
        return [f"JSONのパースに失敗しました: {exc}"]

    if not isinstance(data, dict):
        return ["JSONの最上位がオブジェクトではありません"]

    errors = []
    required = ["issuer_name", "invoice_number", "issue_date", "total_amount"]
    for key in required:
        if data.get(key) in (None, "", []):
            errors.append(f"必須項目が空です: {key}")

    amounts: dict[str, Decimal | None] = {}
    for key in ("subtotal", "consumption_tax", "total_amount"):
        try:
            amounts[key] = to_decimal(data.get(key))
        except ValueError as exc:
            errors.append(str(exc))
            amounts[key] = None

    subtotal, tax, total = amounts["subtotal"], amounts["consumption_tax"], amounts["total_amount"]
    if subtotal is not None and tax is not None and total is not None:
        if subtotal + tax != total:
            errors.append("小計+税と合計が一致しません")
    else:
        errors.append("金額項目が不足しているため整合チェックできません")

    return errors

この例で確認できるのは、小計・消費税・合計という3項目の単純な算術整合だけです。明細行そのものは検証していないため、明細金額の合計や、値引き・送料・源泉徴収・軽減税率・端数処理を含む請求書では別のチェックが要ります(そうした請求書では「小計+税=合計」が成立しないこともあります)。なお金額の計算には float や int ではなく decimal.Decimal を使い、JSON のパース失敗や必須項目の欠落を「0」として扱わないようにしてください。上のコードは parse_float=Decimal を指定して、小数を一度 float に落とさずに受けています(型表記の都合で Python 3.10 以上が前提です)。逆に小計と税の合算が合計と合わなければ、どこかの行を読み落としたか、桁を取り違えた可能性が高い。ただし整合が取れても、抽出値が正しいとは限りません。請求元・請求番号・税区分の取り違えや、同じ誤読に基づくたまたまの一致は、この検査では検出できない。こうした項目は別途のルールチェックやサンプリング目視に委ね、整合チェックは怪しい1枚を拾い上げる一次フィルタと位置づけます。

抽出されたJSONを人の確認なしに会計処理へ自動確定させないでください。構造化出力モデルでもゼロミスではありません。特に手書き文字、かすれたスキャン、複雑な明細レイアウトでは読み取りが揺れます。整合チェックを通った請求書は確認の優先順位を下げ、エラーが出た文書を先に見る運用にとどめてください。自動確定の可否は、金額の上限・取引先・監査要件・サンプリング検査といった組織のルールで判断します。突合先は元帳に限らず、元のPDF・発注や検収の記録・会計データなど、その時点で存在する記録に合わせます。

信頼度の扱いも実務では悩みどころ。モデルが返すのは値そのものであって、「どのくらい確信して抽出したか」のスコアが必ず付くわけではありません。そこで、整合チェックの通過・必須項目の充足・桁数の妥当性といった外形的な指標を自前で組み合わせ、擬似的な信頼度として運用する方法が現実的でした。完璧な確信度が得られない以上、機械チェックで足切りして人の目を要所に集中させる、という割り切りが要ります。

クラウドAI(GPT-5.6 Sol)とどう使い分けるか|コスト・データ送信・運用の観点

ローカルで動く構造化出力VLMと、クラウドのマルチモーダルモデル。どちらで請求書を処理すべきかは、精度の優劣だけでは決まりません。判断を分けるのは、文書を外部に送ってよいか、どれだけの量を回すか、初期構築にどこまで手をかけられるか。この3つです。

クラウド側の比較対象は、2026年7月時点の現行フラッグシップである GPT-5.6 Sol を置きます。GPT-5.6 Sol は画像や文書を直接読み取り、スキャンや帳票から構造化JSONを返す処理にも対応しています。そのため「クラウドモデルはJSONの構造を安定させにくい」という以前の図式は薄れています。ただし請求書から読み取る値そのものの精度は別問題です。本記事では同一帳票による比較実測をしていないため、抽出精度の優劣は判断せず、実データでの評価結果と運用条件を合わせて選定します。

下の表は、両者を運用面で並べたものです。数値での精度比較は、同じ請求書サンプルに対して両方を走らせた実測がないと公平に語れないため、ここでは傾向と運用条件で整理しています。

比較軸 NuExtract3(ローカルVLM) クラウドVLM(GPT-5.6 Sol など)
実行場所 手元のGPU/サーバー 提供元のクラウド
文書の外部送信 外部APIへ送らない構成にできる APIへ画像を送信する
課金体系 API従量課金なし。ハード・電力に加え、環境構築や監視・保守の手間がコストになる 従量課金(入力・出力トークン)。環境管理の負担は小さい
セットアップの手間 大きい(環境構築・モデル取得) 小さい(APIキーのみ)
カスタムスキーマ追従 テンプレート方式で型に沿いやすい Structured Outputs で JSON Schema を指定
オフライン可否 可(モデル取得後・外部API不使用の構成。初回取得はネット要) 不可(常時接続が前提)

抽出精度とJSON構文の安定性

測れる次元と測れない次元を分けて考える必要があります。テンプレートを渡したときのJSON構文の安定性、つまり「壊れたJSONが返ってこないか」という点では、構造化出力に特化したモデルが型を保ちやすい設計になっています。一方、あらゆる業務帳票での抽出品質、たとえば崩れたレイアウトや特殊な様式での読み取り精度は、文書の種類に大きく左右されるため一律には語れません。

GPT-5.6 Sol のような最新のクラウドモデルは画像入力と構造化出力に対応し、幅広い文書を扱えます。ただし自社の請求書に対する抽出精度は本記事では比較実測していないため、機能対応と実精度は分けて捉えてください。本記事で確認できる NuExtract3 の明確な利点は、テンプレート方式で型をそろえやすい設計と、ローカルで完結できる運用面です。読み取り精度の優劣については、同一帳票での比較検証が必要です。どちらを採るかは、その検証結果と、扱う文書の均一性、データを手元に置く必要性の兼ね合いで決まります。

コストとデータ送信先の違い

コスト構造は根本的に違います。クラウドAPIは処理した文書の量に比例して費用が積み上がる従量課金。月に数千枚、数万枚の請求書を回すなら、この積み上がりは無視できません。ローカル実行なら処理ごとの課金は発生しないため、処理量が多く、既存のハードを高い稼働率で使える場合は1件あたりの平均コストが下がっていきます。ただし必ず安くなるとは限りません。ハードの償却、電気代、保守や障害対応の手間、稼働率、抽出ミスの再処理、それに関わる人の時間まで含めた総保有コストで比べる必要があります。

データの送信先も選定を分ける要素。取引先の請求書には、社名・金額・取引内容といった機密が詰まっています。これを外部のAPIへ送ることに制約がある組織では、手元で完結できるローカル実行の価値が大きい。ただし、この「外部に出ない」という性質は無条件ではありません。ローカルモデル単体で動かし、外部の推論APIやWeb検索機能を呼ばない構成にして初めて成り立つ条件付きの利点です。便利だからと外部サービスを組み込めば、その経路でデータは外へ出ます。

逆にクラウド側を使う場合は、データの取り扱いも確認しておきます。OpenAI APIは、明示的にオプトインしない限りAPI送信データをモデルの学習には使いません。一方で不正利用の監視のため、通常は最大30日ログが保持される場合があります。ゼロデータ保持(ZDR)や監視の緩和は承認制で、対象のエンドポイントや機能にも制限があります。加えて Responses API では設定によりアプリケーションの状態が既定で30日保持される点にも注意が要ります。また、PDFを Files API へアップロードしてファイルIDで参照する構成では、アップロード済みファイルは手動で削除するか expires_after で期限を設定するまで保持されます。Base64 による直接入力など、入力方法によって保存されるデータの種類が異なるため、機密文書を扱うなら、組織の契約内容と利用する経路ごとの設定を確認してから採用してください。

RAG前処理・OCRパイプラインへの組み込み

請求書の構造化は、それ単体でも価値がありますが、RAG(検索拡張生成)の前処理として組み込むと別の効きどころが出てきます。PDFをプレーンテキストだけに変換してチャンク化する単純な構成だと、レイアウト情報が失われ、どこが金額でどこが日付なのかをモデルが取り違えやすい(座標や表構造、Markdown、ページ情報を保持できるPDFパーサーを使えば、この前提は変わります)。先にJSON化してからインデックスを作ると、フィールド単位で意味を持った検索ができるようになります。ローカルでのRAG構築そのものは、Ollama・ChromaDBで作るローカルRAGOllamaでローカルRAGパイプラインを構築する方法で扱っています。

抽出JSONをRAGのメタデータに使う

抽出したJSONは、検索のメタデータとして使えます。たとえば「2026年の○○社向け請求書で、合計が10万円を超えるもの」といった絞り込みは、本文をベクトル検索するより、構造化済みのフィールドでフィルタするほうが正確で速い。発行日・取引先・金額がメタデータとして付いていれば、検索の前段で候補を絞り込み、本文検索はその中だけに限定できます。資料を読み込んで扱うツールという観点では、NotebookLMのような文書活用サービスとも組み合わせられます。

役割分担を整理すると、既存のOCRが「画像から文字を読み取る」工程を担い、NuExtract3のような構造化出力モデルが「読み取った内容を決めた型に整える」工程を担う、という形が考えられます。ただしVLMは画像から直接構造化できるため、用途によってはOCR工程を挟まずに済む場合もある。どちらの構成にするかは、扱う文書の画質と、既存パイプラインとの兼ね合いで決めます。

大量文書のバッチ前処理では、1枚ずつ処理する素朴なループより、GPUのメモリに収まる範囲でまとめて流すほうがスループットが上がります。本記事に載せた Transformers のコードは1件ずつ処理する最小例なので、実際にまとめて流すには vLLM のようにバッチ処理や継続的バッチングに対応したランタイムを使います(公式モデルカードにも vLLM でのデプロイ例があります)。夜間にまとめて構造化し、翌朝には検索可能なメタデータ付きインデックスができている、という運用イメージ。前処理を分離しておけば、後段のRAGモデルを差し替えても前処理資産はそのまま使えます。なお抽出後のRAGで精度が出ないときは、RAGハルシネーションの解決法が切り分けの参考になります。

動かない・精度が出ないときの分岐

ここからは、実際に動かして詰まったときの切り分けです。症状ごとに原因の見当が違うので、まず何が起きているかを特定するところから始めます。

抽出が空・壊れるときの切り分け

返ってきたJSONがパースできない、あるいは全フィールドが空。この2つは原因が分かれます。

JSONがパースで落ちる場合、戻り値の文字列にモデルの余計な前置きや後置きが混じっていることがあります。json.loadsに渡す前に、最初の{から最後の}までを切り出す応急処置で通ることもあります。ただし文字列内の波括弧や複数JSONが混じると誤抽出を招くため、本番処理ではこの方式に頼らず、パースに失敗したら再試行するか要確認として人手に回すのが安全です。それでも壊れるなら、テンプレートの構造が複雑すぎてモデルが型を保てていない可能性。明細の配列を一度外し、単一項目だけにして通るか確認します。

フィールドが軒並み空で返るときは、入力画像の品質を疑います。解像度が低い、文書が傾いている、コントラストが弱いといった画像は、VLMでも読み取りが落ちます。PDFを画像化する際の解像度(DPI)を上げる、傾き補正を入れる、といった前処理で改善する場合がある。原因が画質なのかテンプレートなのかを切り分けるため、きれいなサンプル1枚で必ず動作確認をしてから、本番の汚れた文書へ進むのが順序です。

テンプレートのキー名が文書の項目と意味的に離れていると、該当値を見つけられず空が返りやすくなります。「金額」を抜きたいのにキーがvalueのように抽象的だと精度が落ちる傾向が筆者環境ではありました。total_amounttax_amountのように何の値かが明確なキー名にする、あるいは instructions や例示を添えることで改善する場合があります。空が続くときは、画質と並んでキー名の具体性も疑ってください。

VRAM不足・速度が出ないとき

GPUのメモリが足りずに落ちる、いわゆるOOM(Out of Memory)。これはモデルのサイズと、扱う画像の解像度・枚数に対してVRAMが不足しているサインです。対処の方向はいくつかあります。量子化された軽量版のモデルを使う、入力画像の解像度を下げる、複数ページを一度に渡さず1ページずつ処理する。このあたりを順に試して、メモリ使用量を抑えます。

量子化版を使えば、1ページ単位の請求書抽出は数GBのメモリに収まり、8GBクラスのGPUやCPU実行でも動かせます。ただし、複数ページを束ねて高解像度で一度に渡すと、メモリ消費が跳ね上がります。実際に必要なメモリ量は、選ぶモデルの量子化レベルと入力画像のサイズで変わるため、ご自身の環境で1枚を流しながら使用量を実測して見極めるのが確実です。具体的なシステム要件の数値は、使うモデルによって変わるので公式のモデルカードを参照してください。

速度が出ないときは、CPUで動かしていないかをまず確認します。VLMは画像処理を含むため、CPU推論では1枚あたりの時間が大きく延びがち。GPUに載っているかをモデル読み込み時のログで確認し、載っていなければデバイス指定を見直します。GPUに載っていてもなお遅いなら、画像解像度を処理に必要な最低限まで下げることで時間を縮められる場合がありました。

NuExtract3の仕様まとめ

種別 構造化出力特化のローカル実行VLM
提供元 NuMind(公式モデルカードを参照)
入力 文書画像・PDF(画像化して入力)
出力 テンプレートで指定した型のJSON
実行形態 手元のGPU/サーバーでローカル実行可
ライセンス・対応言語・モデルサイズ バージョンにより異なるため公式の最新情報を確認

まとめ

請求書PDFをJSONに落とす作業は、テンプレート設計から始まります。単一項目で土台を固め、明細の配列を後から足し、必須フィールドの欠落と金額の整合をプログラムでチェックする。この流れを押さえれば、人が全件を目視しなくても怪しい1枚を機械的に拾い上げられるようになります。

ローカルのNuExtract3とクラウドのGPT-5.6 Sol は、どちらも構造化抽出をこなせる以上、精度の優劣で選ぶより、文書を外部に送ってよいか・どれだけの量を回すか・初期構築にどこまで手をかけられるかで選び分けるのが実務的でした。機密文書を手元で完結させたい、大量に回してコストを抑えたいならローカル。とにかく手早く汎用的に処理したいならクラウド、という住み分け。クラウド側のモデル選びはClaude vs ChatGPT 徹底比較ローカルではなくクラウドで使うべきかの比較も参考になります。ただしローカルの「外部に出ない」利点は、外部APIを呼ばない構成にして初めて成り立つ条件付きである点は崩せません。

測れる次元と測れない次元を分けることも忘れないでください。JSON構文の安定性は構造化出力モデルが保ちやすい一方、あらゆる帳票での抽出品質は文書の種類に左右されます。最終的な値の確定は、元のPDF・発注や検収の記録・会計データなど、その時点で利用できる原資料との突合に委ねる。まずはきれいなサンプル1枚を1ページ・1テンプレートで通し、成功体験を土台に請求書の本番スキーマへ広げていくのが、遠回りに見えて確実な道です。

よくある質問

Q. NuExtract3は商用利用できますか?

本記事で扱う公式の NuExtract3 と GGUF 版は、2026年7月時点でモデルカード上 Apache License 2.0 として公開されており、ライセンス条件を守れば商用利用できます。再配布や改変を行う場合は LICENSE・NOTICE・帰属表示の条件を確認してください。第三者が公開している量子化版や派生版を使う場合は、そのリポジトリのライセンスを個別に確認する必要があります。

Q. 日本語の請求書でも精度は出ますか?

テンプレートのキー名を具体的にし、入力画像の解像度を確保すれば、日本語請求書でも構造化抽出は試せます。ただし公式が示すのは多言語対応であって、自社帳票での精度を保証するものではありません。手書きやかすれたスキャンでは読み取りが揺れるため、必ず自社の実データで検証してください。

Q. GPT-5.6 Sol などのクラウドAPIより安く運用できますか?

処理量と、手元のハードをどれだけ動かせるかによります。クラウドAPIは文書量に比例して従量課金が積み上がるので、大量処理では処理ごとの課金がないローカル実行が有利に働きやすい。ただしハードの償却・電気代・保守・再処理の手間まで含めた総保有コストで見ないと逆転することもあります。少量なら構築の手間が小さいクラウドが手軽です。

Q. 必要なVRAMはどのくらいですか?

選ぶモデルの量子化レベルと入力画像のサイズで変わります。配布ファイルの容量で見ると4bit量子化版は3〜4GB程度なので、エントリークラスのGPUやCPU実行も選択肢に入ります。ただし実行時のピークメモリは画像の解像度やページ数で上振れします。正確な要件は公式モデルカードと、ご自身の環境での実測で確認してください。

Q. OCRは別途必要ですか?

NuExtract3はVLMとして画像を直接読めるため、用途によってはOCR工程を挟まずに構造化できます。ただし既存のOCRパイプラインがある場合は、読み取りをOCR、型への整形を構造化モデルに分担させる構成も選べます。

参考資料

タイトルとURLをコピーしました