📝

Claude Vision APIで実現する高精度OCRシステム

に公開

はじめに

OpenCVとTesseractによる従来のOCRでは精度が課題でしたが、LLMの登場により状況が大きく変わりました。本記事では、Claude Vision APIを使用した高精度なレシートOCRシステムの実装を紹介します。Modular Monolith + Clean Architectureに基づいた設計により、高いテスタビリティと保守性を実現しています。

従来のOCRでの課題

コロナ禍前、ある病院から検査申込書の自動読み取りシステムの開発依頼を受けました。ASP.NET CoreでOpenCVとTesseractを組み合わせて実装しましたが、日本語の読み取り精度が実用レベルに達しませんでした。

たとえば、検査申込書のような複雑な記入用紙ではなく、より簡単である以下のような単純なレシートでも正しく読み取れませんでした。

■実際のレシート
店舗: スーパーマーケット
きゅうり: 214円
トマト: 214円
合計: 428円

■Tesseract + OpenCVの読み取り結果
店舗: スー・ーマーケット
きゆうリ: Z14円
卜マ卜: 214R
合計: 4Z8円

このように文字の誤認識が多発し、特に以下の問題がありました。

  • カタカナと漢字の混同(「リ」と「り」、「卜」と「ト」など)
  • 数字とアルファベットの誤認識(「2」と「Z」、「8」と「B」など)
  • 濁点・半濁点の脱落
  • レイアウトの認識失敗による文字列の順序の乱れ

結果として試作段階で断念せざるを得ませんでした。

LLMによるOCRへの転換

生成AIの台頭により、LLMを使った画像認識が現実的な選択肢となりました。特にClaude 4.5 Haikuは、高速かつ高精度な画像認識能力を持ちながらコストも抑えられます。

そこで、Claude Vision APIを使用したレシートOCRシステムを試作しました。

システムアーキテクチャ

本システムはModular Monolith + Clean Architectureを採用しています。

Modular Monolith構造

機能別にモジュールを分割し、各モジュールが独立したClean Architectureを持つ設計です。

internal/
├── modules/
│   ├── vision/              # Vision API モジュール
│   │   ├── domain/          # AIResult エンティティ、リポジトリIF
│   │   ├── usecase/         # AI補正ユースケース
│   │   └── presentation/    # Vision API ハンドラー
│   ├── household/           # 家計簿モジュール
│   │   ├── domain/          # Receipt, ExpenseEntry エンティティ
│   │   ├── usecase/         # Receipt, Household ユースケース
│   │   └── presentation/    # Web UI ハンドラー
│   └── shared/              # 共有インフラストラクチャ
│       └── infrastructure/  # AI, Database, Cache 実装
├── presentation/            # プレゼンテーション層統合
│   ├── di/                  # DIコンテナ
│   └── http/                # ルーター、ミドルウェア
└── config/                  # 設定管理

Clean Architectureレイヤー

このアーキテクチャを選択した理由は以下の通りです。

モジュール分離による保守性向上

各モジュール(Vision、Household)が独立したドメインロジックを持ち、相互依存を最小限に抑えています。共有インフラストラクチャはsharedモジュールに集約し、重複を排除しています。

レイヤー分離の徹底

各レイヤーが明確な責務を持つことで、コードの保守性と拡張性が向上します。

  • Presentation Layer: HTTPリクエストの処理、Web UI、DIコンテナ
  • UseCase Layer: ビジネスロジックの実装
  • Domain Layer: エンティティと抽象化されたインターフェース
  • Infrastructure Layer: 外部システムとの接続実装(共有)

依存性の逆転

Domain LayerがInfrastructure Layerに依存しないよう、リポジトリパターンとDIを採用しました。これにより、例えばClaudeからOpenAIのGPT-4 Visionへの切り替えも容易に行えます。

テスタビリティの確保

各レイヤーが独立してテスト可能な構造になっており、モックを使用した単体テストが容易です。実際にユニットテストカバレッジは90%以上を達成しています。

Redisキャッシング戦略

本システムでは、APIコストと応答速度の最適化のため、Redisを使用したキャッシング戦略を採用しています。

画像ハッシュベースのキャッシュキー

同じ画像に対して常に同じキャッシュキーを生成するため、SHA256ハッシュを使用しています。

// generateCacheKey キャッシュキーを生成
func (h *VisionHandler) generateCacheKey(prefix string, data []byte) string {
    hash := sha256.Sum256(data)
    return fmt.Sprintf("vision:%s:%s", prefix, hex.EncodeToString(hash[:]))
}

これにより、画像の内容が同一であれば、ファイル名やアップロード時刻が異なってもキャッシュがヒットします。

Redisによる24時間キャッシング

Claude APIへのリクエスト結果をRedisで24時間キャッシュすることで、APIコストを削減しています。

// キャッシュキーの生成
cacheKey := h.generateCacheKey("receipt", imageData)

// Redisキャッシュチェック
if h.cacheRepo != nil {
    if cached, err := h.cacheRepo.Get(ctx, cacheKey); err == nil && len(cached) > 0 {
        // キャッシュヒット
        response := VisionResponse{
            Success: true,
            Text:    string(cached),
            Tokens: &AITokensResponse{
                InputTokens:  0,
                OutputTokens: 0,
                TotalTokens:  0,
            },
        }
        w.Header().Set("X-Cache", "HIT")
        w.WriteHeader(http.StatusOK)
        _ = json.NewEncoder(w).Encode(response)
        return
    }
}

// Claude Vision APIでレシート解析
aiResult, err := h.aiCorrectionUseCase.RecognizeReceipt(imageData)
if err != nil {
    h.sendError(w, fmt.Sprintf("Receipt recognition failed: %v", err), http.StatusInternalServerError)
    return
}

// Redisにキャッシュ保存(24時間)
if h.cacheRepo != nil {
    _ = h.cacheRepo.Set(ctx, cacheKey, []byte(aiResult.CorrectedText), 24*time.Hour)
}

このキャッシング戦略により、以下の効果が得られます。

  • 初回リクエスト: Claude APIへの完全なリクエスト
  • 24時間以内の再リクエスト: Redisキャッシュヒット(APIコストゼロ)
  • 24時間経過後: 再度Claude APIへリクエスト

レスポンスヘッダーにX-Cache: HITまたはX-Cache: MISSを付与することで、キャッシュの状態を確認できます。

実際の運用では、同じレシートを何度も読み取ることは稀ですが、開発・デバッグ時や、ユーザーが誤って同じ画像を複数回送信した場合などに効果を発揮します。

プロンプトエンジニアリング

用途に応じて3種類のシステムプロンプトを使い分けています。

なお、プロンプトの作成には、Anthropicが提供するプロンプトジェネレーターを活用しました。これは、タスクの説明を入力するだけでClaudeが最適なプロンプトを自動生成してくれる機能です。手動でプロンプトを試行錯誤するよりも、LLM自体にプロンプトを作成させる方が、より効率的かつ高品質な結果が得られます。

また、プロンプト改善ツールを使用することで、既存のプロンプトをさらに最適化できます。

レシート読み取り専用プロンプト

構造化されたJSON形式でデータを抽出します。特に重要なのは、total_amountの決定ルールです。

const systemPromptReceipt = `あなたはレシート画像から家計簿用の情報を抽出する専門家です。
JSON形式で正確に情報を返してください。

【レシートの典型的な構造】:
1. 店舗名
2. 商品リスト(商品名と価格)
3. 小計または合計
4. 消費税額
5. お買上金額(これが実際の支払額)
6. お預かり(顧客が渡した金額)← これは支払額ではない!
7. お釣り

【最重要】total_amountの決定ルール:
✅ 正しい:「お買上金額」「合計金額」「小計」
❌ 間違い:「お預かり」「お釣り」「現金」

【最重要】total_amount の決定方法(この順序で実行):
1. items リストの price をすべて合計する
2. その合計値を total_amount として使用する
3. レシートに「お買上金額」の表示があっても、items の合計を優先する
4. 「お預かり」「お釣り」は絶対に使用しない

必須項目:
- store_name: 店舗名
- purchase_date: 購入日時(YYYY-MM-DD HH:MM形式、時刻不明なら12:00)
- total_amount: お買上金額(商品の合計金額、必ずitemsの合計と一致)
- tax_amount: 消費税額(不明な場合は0)
- items: 商品リスト(name, quantity, price)

出力形式:
{
  "store_name": "店舗名",
  "purchase_date": "2025-11-22 14:30",
  "total_amount": 1500,
  "tax_amount": 150,
  "payment_method": "現金",
  "items": [
    {"name": "商品名", "quantity": 1, "price": 500}
  ]
}

注意:
- 金額は数値型(カンマや円記号を除く)
- total_amount は必ず items の price の合計と一致させる
- JSONのみを返す(説明不要)`

このプロンプトの重要なポイントは、Claudeが「お預かり」を合計金額と誤認識しないよう、明示的にルールを定義している点です。

カテゴリ判定専用プロンプト

家計簿のカテゴリを自動判定します。

const systemPromptCategorize = `あなたは家計簿の仕訳け専門家です。
レシート情報から適切なカテゴリを判定してください。

利用可能なカテゴリ:
- 食費: 食品、飲料、外食
- 日用品: 洗剤、ティッシュ、トイレットペーパー等
- 交通費: 電車、バス、タクシー、ガソリン
- 医療費: 病院、薬局、薬
- 娯楽費: 映画、書籍、ゲーム、趣味
- 衣服費: 衣類、靴、アクセサリー
- 通信費: 携帯電話、インターネット
- 光熱費: 電気、ガス、水道
- 教育費: 学費、教材、習い事
- その他: 上記に該当しないもの

入力されたレシート情報から、最も適切なカテゴリを1つ選択してください。

出力形式:
{
  "category": "カテゴリ名",
  "confidence": 0.95,
  "reason": "判定理由(簡潔に)"
}

判定基準:
1. 店舗名から判断(例:スーパー→食費、ドラッグストア→日用品または医療費)
2. 商品名から判断(複数カテゴリにまたがる場合は主要な商品で判定)
3. 金額や購入パターンも考慮
4. 確信度(confidence)は0.0〜1.0で返す
5. JSONのみを返す(説明文は不要)`

汎用テキスト抽出プロンプト

レシート以外の文書にも対応できる汎用的なOCRプロンプトです。

const systemPromptGeneral = `この画像に含まれるすべてのテキストを正確に抽出してください。

抽出ルール:
1. 画像内のすべてのテキストを漏れなく抽出する
2. レイアウトや改行を可能な限り保持する
3. 日本語と英語の両方に対応する
4. 数字、記号も正確に抽出する
5. 読み取れない文字は[?]で表記する
6. 抽出したテキストのみを返す(説明不要)

出力形式:
抽出したテキストをそのまま返してください。`

LLMの限界とアプリケーション側での補完

Claude Vision APIは高精度ですが、完璧ではありません。特に金額計算で誤りが発生することがあります。

問題の具体例

以下のようなレシートで、Claudeが誤った合計金額を返すケースがありました。

■実際のレシート
商品A: 214円 × 1
商品B: 214円 × 1
商品C: 1671円 × 1
商品D: 705円 × 1
お買上金額: 2804円
お預かり: 3000円
お釣り: 196円

■Claudeの抽出結果
total_amount: 3000  // 誤り(お預かりを合計と誤認識)

アプリケーション側での自動修正

この問題に対処するため、アプリケーション側で商品の合計を計算し、Claudeの結果を強制的に上書きする仕組みを実装しました。

// parseReceiptJSON JSONからレシートエンティティを作成
func (uc *ReceiptUseCase) parseReceiptJSON(receiptJSON string, receiptID string) (*entity.Receipt, error) {
    // Claude APIは```json```で囲まれた形式で返すことがあるため、クリーンアップ
    cleanJSON := receiptJSON
    if idx := bytes.Index([]byte(receiptJSON), []byte("```json")); idx != -1 {
        cleanJSON = receiptJSON[idx+7:]
        if idx := bytes.Index([]byte(cleanJSON), []byte("```")); idx != -1 {
            cleanJSON = cleanJSON[:idx]
        }
    }
    cleanJSONBytes := bytes.TrimSpace([]byte(cleanJSON))

    var receiptData struct {
        StoreName     string `json:"store_name"`
        PurchaseDate  string `json:"purchase_date"`
        TotalAmount   int    `json:"total_amount"`
        TaxAmount     int    `json:"tax_amount"`
        PaymentMethod string `json:"payment_method"`
        ReceiptNumber string `json:"receipt_number"`
        Items         []struct {
            Name     string `json:"name"`
            Quantity int    `json:"quantity"`
            Price    int    `json:"price"`
        } `json:"items"`
    }

    if err := json.Unmarshal(cleanJSONBytes, &receiptData); err != nil {
        return nil, fmt.Errorf("failed to unmarshal JSON: %w", err)
    }

    // 【重要】total_amountをitemsの合計で強制的に上書き
    calculatedTotal := 0
    for _, item := range receiptData.Items {
        calculatedTotal += item.Price * item.Quantity
    }
    if calculatedTotal > 0 {
        receiptData.TotalAmount = calculatedTotal
    }

    // レシートエンティティの作成
    receipt := &entity.Receipt{
        ID:            receiptID,
        StoreName:     receiptData.StoreName,
        PurchaseDate:  purchaseDate,
        TotalAmount:   receiptData.TotalAmount,
        TaxAmount:     receiptData.TaxAmount,
        PaymentMethod: receiptData.PaymentMethod,
        ReceiptNumber: receiptData.ReceiptNumber,
        // ...
    }

    return receipt, nil
}

この仕組みにより、以下の効果が得られます。

  • Claudeが「お預かり」を合計と誤認識しても自動修正される
  • 商品の個数と単価から正確な合計金額が計算される
  • データベースには常に正しい金額が保存される

LLMは強力ですが、すべてを任せるのではなく、アプリケーション側で検証と補正を行うことが重要です。

その他の工夫点

決定的なレシートID生成

同じ画像から常に同じレシートIDを生成することで、重複登録を防止しています。

// generateDeterministicReceiptID 画像データから決定的なレシートIDを生成します
// 同じ画像データからは常に同じIDが生成されるため、重複レシート登録を防止できます
// 生成されるIDはUUID形式の文字列(36文字、8-4-4-4-12のハイフン区切り)ですが、
// RFC 4122準拠の真のUUIDではなく、SHA256ハッシュベースの決定的識別子です
func (uc *ReceiptUseCase) generateDeterministicReceiptID(imageData []byte) string {
    hash := sha256.Sum256(imageData)
    // SHA256ハッシュをUUID形式の文字列構造に変換(8-4-4-4-12 = 36文字)
    return fmt.Sprintf("%x-%x-%x-%x-%x",
        hash[0:4],
        hash[4:6],
        hash[6:8],
        hash[8:10],
        hash[10:16])
}

このアプローチにより、ユーザーが誤って同じレシート画像を複数回アップロードしても、データベースには1件のみ保存されます。

レシート明細のカテゴリ自動判定

レシート全体だけでなく、各明細項目ごとにカテゴリを自動判定しています。

// categorizeReceiptItems 明細項目ごとにカテゴリーを判定
func (uc *ReceiptUseCase) categorizeReceiptItems(receipt *entity.Receipt) error {
    if len(receipt.Items) == 0 {
        return nil
    }

    // 商品名リストを作成
    itemNames := make([]string, len(receipt.Items))
    for i, item := range receipt.Items {
        itemNames[i] = item.Name
    }

    // AI APIで一括カテゴリー判定
    itemsInfo := fmt.Sprintf("店名: %s\n以下の商品それぞれのカテゴリーを判定してください(食費、日用品、医療費、娯楽費、交通費、通信費、光熱費、その他):\n", receipt.StoreName)
    for i, name := range itemNames {
        itemsInfo += fmt.Sprintf("%d. %s\n", i+1, name)
    }

    result, err := uc.aiRepo.CategorizeReceipt(itemsInfo)
    if err != nil {
        // AI APIエラーの場合は全てデフォルトカテゴリーを設定
        for i := range receipt.Items {
            receipt.Items[i].Category = "その他"
        }
        return nil
    }

    // レスポンスをパース
    categories, err := uc.parseItemCategories(result.CorrectedText, len(receipt.Items))
    if err != nil {
        // パースエラーの場合は全てデフォルトカテゴリーを設定
        for i := range receipt.Items {
            receipt.Items[i].Category = "その他"
        }
        return nil
    }

    // 各明細項目にカテゴリーを設定
    for i := range receipt.Items {
        if i < len(categories) && categories[i] != "" {
            receipt.Items[i].Category = categories[i]
        } else {
            receipt.Items[i].Category = "その他"
        }
    }

    return nil
}

これにより、スーパーマーケットのレシートで「野菜→食費」「洗剤→日用品」のように、商品ごとに適切なカテゴリが自動的に割り当てられます。

エラーハンドリングの工夫

Claude APIは時々、JSON形式のデータをコードブロック(```json)で囲んで返すことがあります。これに対応するため、パース前に不要な文字列を除去しています。

// Claude APIは```json```で囲まれた形式で返すことがあるため、クリーンアップ
cleanJSON := receiptJSON
if idx := bytes.Index([]byte(receiptJSON), []byte("```json")); idx != -1 {
    cleanJSON = receiptJSON[idx+7:]
    if idx := bytes.Index([]byte(cleanJSON), []byte("```")); idx != -1 {
        cleanJSON = cleanJSON[:idx]
    }
}
cleanJSONBytes := bytes.TrimSpace([]byte(cleanJSON))

BUN ORMによるデータベース操作

MySQLへのアクセスには、軽量で高速なBUN ORMを使用しています。

// Create レシートを作成
func (r *BunReceiptRepository) Create(ctx context.Context, receipt *entity.Receipt) error {
    model := r.toModel(receipt)

    // トランザクション内で実行
    return r.db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {
        if _, err := tx.NewInsert().Model(model).Exec(ctx); err != nil {
            return fmt.Errorf("failed to create receipt: %w", err)
        }

        if len(model.Items) > 0 {
            if _, err := tx.NewInsert().Model(&model.Items).Exec(ctx); err != nil {
                return fmt.Errorf("failed to create receipt items: %w", err)
            }
        }

        return nil
    })
}

トランザクションを使用することで、レシート本体と明細項目の整合性を保証しています。

構造化ログ出力

Go 1.21から標準ライブラリに追加されたlog/slogを使用し、構造化ログを出力しています。

// LoggerWithHealthCheck ヘルスチェックを除外するロギングミドルウェア
func LoggerWithHealthCheck(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // ヘルスチェックは正常時ログ出力しない
        if r.URL.Path == "/health" {
            rw := &responseWriter{
                ResponseWriter: w,
                statusCode:     http.StatusOK,
            }
            next.ServeHTTP(rw, r)

            // 異常時のみログ出力
            if rw.statusCode != http.StatusOK {
                slog.Error("Health check failed",
                    "status", rw.statusCode,
                )
            }
            return
        }

        // 通常のログ処理
        start := time.Now()
        rw := &responseWriter{
            ResponseWriter: w,
            statusCode:     http.StatusOK,
        }
        next.ServeHTTP(rw, r)

        duration := time.Since(start)
        slog.Info("HTTP request",
            "method", r.Method,
            "path", r.URL.Path,
            "status", rw.statusCode,
            "bytes", rw.written,
            "duration", duration,
        )
    })
}

ヘルスチェックエンドポイントは正常時のログを抑制し、異常時のみ出力することで、ログの可読性を向上させています。

実装結果

従来のOCRと比較して、以下の改善が見られました。

  • 日本語認識精度: ほぼ100%(従来は70%程度)
  • レイアウト理解: 構造化されたデータとして抽出可能
  • 金額計算: アプリ側の補正によりほぼ100%の精度
  • 処理速度: 平均2〜3秒(Redisキャッシュヒット時は50ms以下)
  • カテゴリ自動判定: 商品ごとに適切なカテゴリを自動割り当て

特に、レシートの構造を理解して店舗名、日時、商品リスト、合計金額を正確に抽出できる点は、従来のOCRでは困難でした。

テストカバレッジ

ユニットテストを徹底的に実装し、高いテストカバレッジを達成しています。

  • config: 90.9%
  • modules/vision/domain: 100.0%
  • modules/vision/usecase: 100.0%
  • modules/household/domain/entity: 100.0%
  • modules/household/usecase: 94.4%
  • presentation/http/middleware: 100.0%

全体: 90%以上達成

TestContainersを使用することで、MySQL・Redisを含む統合テストも実装しています。

技術スタック

  • 言語: Go 1.23+
  • アーキテクチャ: Modular Monolith + Clean Architecture
  • AI: Anthropic Claude API (Haiku 4.5)
  • データベース: MySQL 8.0 (BUN ORM)
  • キャッシュ: Redis 7
  • ログ: log/slog (構造化ログ)
  • テスト: TestContainers (MySQL, Redis)
  • 設定: YAML (gopkg.in/yaml.v3)
  • コンテナ: Docker & Docker Compose

Docker Composeによる簡単デプロイ

本システムはDocker Composeで簡単にデプロイできます。

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: vision-api
    ports:
      - "8080:8080"
    environment:
      - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-rootpass}
      - PORT=8080
    depends_on:
      redis:
        condition: service_healthy
      mysql:
        condition: service_healthy
    networks:
      - vision-network
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    container_name: vision-redis
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data
    networks:
      - vision-network
    restart: unless-stopped

  mysql:
    image: mysql:8.0
    container_name: vision-mysql
    ports:
      - "3306:3306"
    environment:
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-rootpass}
      - MYSQL_DATABASE=household
    volumes:
      - mysql_data:/var/lib/mysql
      - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    networks:
      - vision-network
    restart: unless-stopped

networks:
  vision-network:
    driver: bridge

volumes:
  redis_data:
  mysql_data:

起動は以下のコマンドだけです。

# .envファイルを作成
cat > .env << EOF
ANTHROPIC_API_KEY=your-api-key-here
MYSQL_ROOT_PASSWORD=rootpass
EOF

# Docker Composeで起動
docker compose up -d

まとめ

LLMを活用したOCRシステムは、従来の画像処理ベースのOCRと比較して圧倒的に高い精度を実現できます。ただし、以下の点に注意が必要です。

  • APIコストを管理すること(Redisキャッシング戦略は非常に重要です)
  • LLMの出力を盲信せず、アプリケーション側での検証と補正を行うこと
  • 用途に応じたプロンプトの最適化を繰り返し実施すること
  • 決定的なID生成により重複登録を防止すること

Modular Monolith + Clean Architectureを採用することで、将来的なAIモデルの切り替えや機能拡張にも柔軟に対応できる設計となっています。各モジュールが独立しているため、Vision APIモジュールを他のプロジェクトに再利用することも容易です。

本システムのソースコードは以下のリポジトリで公開しています。

https://github.com/okamyuji/vision-api-app

Discussion