業務・経理チームは、請求書、発注書、納品書、仕入先の見積書をスプレッドシートに手入力する作業に何時間も費やしています。スキャンや写真を読み取る OCR と、定義したフィールドをそのとおりに返すローカル言語モデルを組み合わせれば、こうした入力作業の大半を自動化できます。この記事では Tesseract と OCRmyPDF に Ollama の構造化出力を組み合わせます。各ドキュメントは抽出したフィールドを含む JSON ファイルになり、集計用のスプレッドシートでは明細の合計が一致しないドキュメントにフラグが付くため、担当者は例外だけを確認すれば済みます。すべての処理は自社のサーバー上で完結します。
# 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)をインストールします。
# 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 でスキャンする、書類を平らに置いて明るい場所で撮影する、などが有効です。
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
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 は担当者がそのドキュメントを確認すべきことを示します。
buyer、delivery_date を追加し、明細には SKU のフィールドを追加します。tracking_number、consignee、weight_kg、packages などのフィールドを使います。valid_until、payment_terms と単価を抽出すれば、1 つのシートで仕入先を比較できます。上記の date のように、フィールドに短い description を付けると、期待する形式をモデルに伝えられます。複数の種類のドキュメントを処理する場合は、種類ごとに 1 つのスキーマを用意し、スクリプトやフォルダーを分けてください。
qwen2.5:14b のような大きなモデルは、複雑なレイアウトや長い表をより正確に処理できますが、その分速度が落ちます。CPU サーバーでは、モデルにもよりますが、1 ページの請求書で通常数十秒、長い複数ページのドキュメントでは数分程度かかります。大量に処理する場合は夜間に実行してください。
多くの AI API も構造化出力に対応していますが、その場合ドキュメントはサーバーの外に送信されます。まず自社のプライバシー上の義務、プロバイダーの利用規約とサービス提供地域を確認してください。請求書、契約書、顧客データについては、通常はローカル環境のほうが安全です。
ほとんどのシステムは CSV ファイルをインポートできます。summary.csv の列を対応付けるか、JSON ファイルをインポート形式に変換してください。自動でインポートするのは OK の行だけにとどめ、CHECK の行は手作業での確認に回します。
上記の手順で解決しない場合は、サポートチケットを送信して IMIDC の 24 時間 365 日対応テクニカルサポートへご連絡ください。サーバー IP、OS バージョン、使用しているモデル、エラー出力に加え、可能であれば機密データを含まないサンプルドキュメントを添えていただくと、より迅速に調査できます。