0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OCR誤認識をLLMで補正する文書処理パイプラインの作り方

0
Last updated at Posted at 2026-10-31

紙の申請書や古いPDFをOCRすると、「0」と「O」、「1」と「I」、「ロ」と「口」のような誤認識が頻繁に発生します。単純な文字列置換だけでは文脈に応じた修正ができず、後工程の検索やデータ登録で問題になります。

この記事では、Tesseract OCRで抽出したテキストをLLMで補正し、補正前後の差分とOCR信頼度も保存するパイプラインを作ります。実際に試したところ、LLMに全文を自由変換させるよりも、「原文をできるだけ維持し、明らかな誤りだけを修正する」という制約を与えた方が、固有名詞や数値の破壊を抑えられました。

全体の処理フロー

OCRからLLMによる補正、検証、人手レビューまでの処理全体は次の流れです。

今回の処理は次の5段階です。

  1. 画像からTesseractでOCRする
  2. OCR結果と単語ごとの信頼度を取得する
  3. 信頼度の低い箇所を含むテキストだけLLMに渡す
  4. JSON形式で補正結果を受け取る
  5. 差分と信頼度を確認し、必要なら人手レビューに回す

特に重要なのは、LLMの出力をそのまま正解として扱わないことです。請求金額、日付、型番などは、補正後に形式チェックを行います。

今回はローカルLLMとしてOllamaを使いました。API部分を差し替えれば、別のLLM実行環境でも同じ構成を利用できます。

環境構築とOCR

まず必要なパッケージをインストールします。

python -m venv .venv
.venv\Scripts\activate
pip install pytesseract pillow requests

Tesseract本体と日本語データも必要です。インストール後、jpn が利用できるか確認します。

tesseract --list-langs

出力に jpn が含まれていない場合は、日本語の言語データを追加します。

Ollamaのモデルは、例として次のように取得しました。

ollama pull qwen2.5:7b

OCR結果と信頼度を取得するコードは以下です。

from pathlib import Path

import pytesseract
from PIL import Image
from pytesseract import Output


def run_ocr(image_path: str) -> tuple[str, list[dict]]:
    image = Image.open(image_path)

    data = pytesseract.image_to_data(
        image,
        lang="jpn+eng",
        config="--psm 6",
        output_type=Output.DICT,
    )

    words = []
    text_parts = []

    for i, text in enumerate(data["text"]):
        text = text.strip()
        if not text:
            continue

        confidence = float(data["conf"][i])
        item = {
            "text": text,
            "confidence": confidence,
            "left": data["left"][i],
            "top": data["top"][i],
            "width": data["width"][i],
            "height": data["height"][i],
        }
        words.append(item)
        text_parts.append(text)

    return " ".join(text_parts), words


if __name__ == "__main__":
    text, words = run_ocr("sample.png")
    print(text)

    low_confidence = [
        word for word in words if word["confidence"] >= 0
        and word["confidence"] < 70
    ]
    print(f"低信頼度の単語数: {len(low_confidence)}")

conf は必ずしも正確な確率ではありませんが、LLMに渡す対象を絞る目安として使えます。私の検証では、全ページを毎回補正するより、低信頼度のページや項目だけを対象にした方が、処理時間と不要な変更を減らせました。

LLMに補正させる

LLMには、次の制約を明示します。

  • 誤りがない部分は変更しない
  • 数字、日付、型番は推測で変更しない
  • 判断できない場合は原文を維持する
  • JSON以外を返さない
  • 変更理由を短く記録する
import json
import requests


OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "qwen2.5:7b"


def correct_with_llm(ocr_text: str, words: list[dict]) -> dict:
    low_confidence = [
        word for word in words
        if 0 <= word["confidence"] < 70
    ]

    prompt = f"""
あなたはOCR校正担当です。
以下のOCR原文を確認し、文脈上明らかな誤認識だけを補正してください。

制約:
- 原文をできるだけ維持する
- 数字、日付、金額、型番、固有名詞は推測で変更しない
- 判断できない箇所は変更しない
- 改行や記号は可能な限り維持する
- 次のJSON形式だけを返す

  ],
  "needs_review": true
}}

OCR原文:
{ocr_text}

低信頼度候補:
{json.dumps(low_confidence, ensure_ascii=False)}
""".strip()

    response = requests.post(
        OLLAMA_URL,
        json={
            "model": MODEL,
            "messages": [
                {
                    "role": "system",
                    "content": "指定されたJSON以外は出力しないでください。",
                },
                {"role": "user", "content": prompt},
            ],
            "stream": False,
            "format": "json",
            "options": {"temperature": 0},
        },
        timeout=120,
    )
    response.raise_for_status()

    content = response.json()["message"]["content"]
    return json.loads(content)

temperature を0にしても、LLMの出力が完全に一定になるとは限りません。そのため、業務データでは補正結果を上書きせず、原文と並べて保存します。

補正結果を検証する

LLMの回答を受け取ったら、最低限の検証を行います。まず、出力が空になっていないか、変更量が大きすぎないかを確認します。

import difflib
import re


def validate_result(original: str, result: dict) -> list[str]:
    errors = []
    corrected = result.get("corrected_text", "")

    if not corrected.strip():
        errors.append("補正後テキストが空です")
        return errors

    ratio = len(corrected) / max(len(original), 1)
    if ratio < 0.7 or ratio > 1.3:
        errors.append("文字数の変化が大きすぎます")

    original_numbers = re.findall(r"\d[\d,.-]*", original)
    corrected_numbers = re.findall(r"\d[\d,.-]*", corrected)

    if original_numbers != corrected_numbers:
        errors.append("数字列が変更されています")

    if len(result.get("changes", [])) > 20:
        errors.append("変更件数が多すぎます")

    return errors


def show_diff(original: str, corrected: str) -> None:
    diff = difflib.unified_diff(
        original.splitlines(),
        corrected.splitlines(),
        fromfile="ocr",
        tofile="corrected",
        lineterm="",
    )
    print("\n".join(diff))

数字を完全に変更禁止にすると、OCRが「2024」を誤認識したケースまで直せなくなります。実運用では、項目ごとにルールを分けるのが現実的です。例えば、住所は文字の補正を許可し、請求金額は数字列が変わったら必ずレビューに回します。

処理結果は次のようなJSONで保存すると、後から監査できます。

{
  "source": "invoice_001.png",
  "ocr_text": "請求金額 1O,500円",
  "corrected_text": "請求金額 10,500円",
  "changes": [
    {
      "before": "1O,500",
      "after": "10,500",
      "reason": "金額欄でOが0と判断できる"
    }
  ],
  "needs_review": false,
  "ocr_low_confidence_count": 1
}

実運用で気を付けた点

最初はOCR全文をLLMに渡していましたが、ページ数が増えるほど不要な言い換えが発生しました。現在は、帳票の種類ごとにプロンプトを分け、項目名や許可される変更範囲を指定しています。

例えば、商品コードは正規表現で検証します。

import re


def validate_product_code(value: str) -> bool:
    return bool(re.fullmatch(r"[A-Z]{2}-\d{4}", value))

また、個人情報を含む画像を外部のAPIへ送信する場合は、契約、保存期間、アクセス制御を確認する必要があります。ローカルLLMを使う場合でも、ログや一時ファイルに原文が残る点には注意が必要です。

LLMの信頼度を別途数値化するより、次の条件を組み合わせてレビュー判定する方が扱いやすいと感じました。

  • OCR信頼度が低い
  • 変更された文字数が多い
  • 数字や型番が変更された
  • 許可されていない文字種が含まれる
  • LLMが needs_review: true を返した

まとめ

OCR誤認識の補正では、LLMに全文を自由に書き直させるのではなく、OCR結果、単語ごとの信頼度、帳票固有の検証ルールを組み合わせることが重要です。

今回の構成では、Tesseractで候補を抽出し、LLMには「明らかな誤りだけを変更する」と指示しました。さらに、原文・補正結果・変更理由を保存し、数字や型番の変更を自動検知することで、人手確認が必要な文書だけを切り出せます。

最初から完全自動化を目指すのではなく、補正候補を提示する支援ツールとして導入し、実データで誤補正のパターンを集めながらルールとプロンプトを調整するのが安全です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?