活用シーンとソリューション

請求書・注文書を OCR でデータ抽出し Excel へ:PDF・スキャン・写真をローカル LLM で処理

6 ステップ 13 分で読めます 50 回閲覧 0
目次

業務・経理チームは、請求書、発注書、納品書、仕入先の見積書をスプレッドシートに手入力する作業に何時間も費やしています。スキャンや写真を読み取る OCR と、定義したフィールドをそのとおりに返すローカル言語モデルを組み合わせれば、こうした入力作業の大半を自動化できます。この記事では Tesseract と OCRmyPDF に Ollama の構造化出力を組み合わせます。各ドキュメントは抽出したフィールドを含む JSON ファイルになり、集計用のスプレッドシートでは明細の合計が一致しないドキュメントにフラグが付くため、担当者は例外だけを確認すれば済みます。すべての処理は自社のサーバー上で完結します。

抽出した数値は下書きとして扱い、支払い、会計処理、通関に使う前に、合計金額、銀行口座情報、書類番号を必ず確認してください。個人データを含むドキュメントは、自社が負うプライバシー上の義務に従って処理する必要があります。自社のサーバーで処理することはその助けになりますが、義務に代わるものではありません。

ステップ1:ツールをインストールする

# Debian / Ubuntu: PDF text, OCR engine with English and Simplified Chinese, OCR for PDFs
apt install -y python3-venv poppler-utils tesseract-ocr tesseract-ocr-eng tesseract-ocr-chi-sim ocrmypdf
python3 -m venv /opt/extract
/opt/extract/bin/pip install ollama
ollama pull qwen2.5:7b

Ollama のセットアップは「Ollama でローカル LLM を運用する」で解説しています。その他の言語には、対応する Tesseract パッケージ(例:tesseract-ocr-jpn、tesseract-ocr-tha、tesseract-ocr-rus)をインストールします。

ステップ2:ドキュメントからテキストが正しく取り出せるか確認する

# Digital PDF (created by software): the text can be read directly
pdftotext -layout invoice.pdf - | head -40

# Scanned PDF: add a text layer with OCR first, then read it
ocrmypdf -l eng+chi_sim --skip-text scan.pdf scan-ocr.pdf
pdftotext -layout scan-ocr.pdf - | head -40

# Photo or screenshot
tesseract photo.jpg - -l eng+chi_sim | head -40

ここで出力されたテキストが読める状態であれば、モデルはそこからフィールドを抽出できます。文字化けしている場合は、まず入力の品質を改善してください。300 dpi でスキャンする、書類を平らに置いて明るい場所で撮影する、などが有効です。

ステップ3:抽出スクリプトを作成する

SCHEMA で取得したいフィールドを定義します。Ollama はモデルの回答を必ずこの構造に合わせるため、出力は常に有効な JSON になります。

cat > /opt/extract/extract.py <<'EOF2'
import csv, json, os, subprocess, sys, tempfile
import ollama

MODEL = os.environ.get("MODEL", "qwen2.5:7b")
LANG = os.environ.get("OCR_LANG", "eng+chi_sim")
SCHEMA = {
    "type": "object",
    "properties": {
        "supplier": {"type": "string"},
        "document_number": {"type": "string"},
        "date": {"type": "string", "description": "YYYY-MM-DD"},
        "currency": {"type": "string"},
        "total": {"type": "number"},
        "tax": {"type": "number"},
        "items": {"type": "array", "items": {"type": "object", "properties": {
            "description": {"type": "string"}, "quantity": {"type": "number"},
            "unit_price": {"type": "number"}, "amount": {"type": "number"}},
            "required": ["description", "quantity", "amount"]}},
    },
    "required": ["supplier", "document_number", "date", "currency", "total", "tax", "items"],
}

def text_of(path):
    run = lambda cmd: subprocess.run(cmd, capture_output=True, text=True).stdout
    if not path.lower().endswith(".pdf"):
        return run(["tesseract", path, "-", "-l", LANG])
    text = run(["pdftotext", "-layout", path, "-"])
    if len(text.strip()) > 50:
        return text
    with tempfile.TemporaryDirectory() as tmp:           # scanned PDF: OCR it first
        ocr = os.path.join(tmp, "ocr.pdf")
        subprocess.run(["ocrmypdf", "-q", "-l", LANG, "--skip-text", path, ocr], check=True)
        return run(["pdftotext", "-layout", ocr, "-"])

rows = []
for path in sys.argv[1:]:
    if not os.path.isfile(path):
        continue
    r = ollama.chat(model=MODEL, format=SCHEMA, options={"temperature": 0, "num_ctx": 8192}, messages=[
        {"role": "system", "content": "Extract the requested fields from the document text. "
         "Copy names, numbers and IDs exactly as written. Use an empty string or 0 "
         "for anything that is not in the text. Do not guess."},
        {"role": "user", "content": text_of(path)[:15000]}])
    d = json.loads(r.message.content)
    items = round(sum(i.get("amount", 0) for i in d["items"]), 2)
    ok = d["total"] > 0 and min(abs(items - d["total"]), abs(items + d["tax"] - d["total"])) < 0.02
    with open(os.path.splitext(path)[0] + ".json", "w", encoding="utf-8") as f:
        json.dump(d, f, ensure_ascii=False, indent=2)
    rows.append([os.path.basename(path), d["supplier"], d["document_number"], d["date"],
                 d["currency"], d["total"], d["tax"], len(d["items"]), "OK" if ok else "CHECK"])
    print(path, "OK" if ok else "CHECK", flush=True)

with open("summary.csv", "w", newline="", encoding="utf-8-sig") as f:
    w = csv.writer(f)
    w.writerow(["file", "supplier", "document_number", "date", "currency", "total", "tax", "lines", "check"])
    w.writerows(rows)
EOF2

ステップ4:フォルダー内のドキュメントを処理する

cd /data/invoices/2026-10
/opt/extract/bin/python /opt/extract/extract.py *.pdf *.jpg *.png
# For a large folder, run it in the background:
nohup /opt/extract/bin/python /opt/extract/extract.py *.pdf > extract.log 2>&1 &

各ドキュメントと同じ場所に、すべてのフィールドと明細を含む .json ファイルが作成され、summary.csv には 1 ドキュメント 1 行で一覧が出力されます。Excel で開いて check 列でフィルターしてください。OK は明細の合計(税額を加えた場合を含む)が合計金額と一致していることを、CHECK は担当者がそのドキュメントを確認すべきことを示します。

ステップ5:ドキュメントに合わせてフィールドを調整する

  • 発注書:buyer、delivery_date を追加し、明細には SKU のフィールドを追加します。
  • 配送書類:tracking_number、consignee、weight_kg、packages などのフィールドを使います。
  • 仕入先の見積書:valid_until、payment_terms と単価を抽出すれば、1 つのシートで仕入先を比較できます。

上記の date のように、フィールドに短い description を付けると、期待する形式をモデルに伝えられます。複数の種類のドキュメントを処理する場合は、種類ごとに 1 つのスキーマを用意し、スクリプトやフォルダーを分けてください。

ステップ6:精度を高める

  • 最も良い結果が得られるのはデジタル PDF です。スキャンや写真の場合は画像の品質に左右されます。
  • temperature を 0 に保つと、同じドキュメントからは常に同じ結果が得られます。
  • qwen2.5:14b のような大きなモデルは、複雑なレイアウトや長い表をより正確に処理できますが、その分速度が落ちます。
  • 元のファイルを JSON と並べて保管しておくと、監査の際にすべての数値の出典をたどれます。

よくある質問

1 件のドキュメントの処理にどのくらいかかりますか?

CPU サーバーでは、モデルにもよりますが、1 ページの請求書で通常数十秒、長い複数ページのドキュメントでは数分程度かかります。大量に処理する場合は夜間に実行してください。

代わりにクラウドの AI にドキュメントを送ることはできますか?

多くの AI API も構造化出力に対応していますが、その場合ドキュメントはサーバーの外に送信されます。まず自社のプライバシー上の義務、プロバイダーの利用規約とサービス提供地域を確認してください。請求書、契約書、顧客データについては、通常はローカル環境のほうが安全です。

結果を会計システムや ERP に直接取り込めますか?

ほとんどのシステムは CSV ファイルをインポートできます。summary.csv の列を対応付けるか、JSON ファイルをインポート形式に変換してください。自動でインポートするのは OK の行だけにとどめ、CHECK の行は手作業での確認に回します。

上記の手順で解決しない場合は、サポートチケットを送信して IMIDC の 24 時間 365 日対応テクニカルサポートへご連絡ください。サーバー IP、OS バージョン、使用しているモデル、エラー出力に加え、可能であれば機密データを含まないサンプルドキュメントを添えていただくと、より迅速に調査できます。

この回答はお役に立ちましたか?

関連チュートリアル