1
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?

さくらのAI Engineで家計簿OCRを作ったら、ローカルLLMで苦戦したカテゴリ分類がほぼ解決した話

1
Last updated at Posted at 2026-08-24

さくらのAI Engineで、家計簿WebアプリのレシートOCRを試した

最近、個人で家計簿のWebアプリを作っています。

家計簿アプリで一番面倒なのは、やっぱり入力です。

日付、店名、金額、支払方法、カテゴリ。
1件ずつ手で入れればよいのですが、レシートが数枚たまると一気にやる気がなくなります。

そこで、レシート画像をアップロードしたら、AIにOCRと項目抽出をまとめてやってもらう機能を作りました。

今回はそのOCR部分を、さくらのAI EngineのVision対応モデルで試した内容をまとめます。

なお、家計簿はかなりプライベートな情報を含むので、この記事では実際のレシートや実データは使いません。
スクリーンショットも記事用の架空レシートで検証しています。

今回やったこと

この記事では、以下を扱います。

  • さくらのAI EngineをOpenAI互換APIとして呼ぶ
  • レシート画像をVisionモデルへ渡す
  • OCR結果をJSON Schemaで構造化する
  • アプリ側でZodによるruntime validationを行う
  • preview/Qwen3-VL-30B-A3B-Instructで動作確認する

キャンペーンページによると、さくらのAI EngineはOpenAI・Anthropic互換APIとして各種AIモデルを利用でき、Chat completionsは月3,000リクエストの無償枠があります。
記事投稿キャンペーンでは、さくらのAI Engineを使ったアプリ開発・検証などがテーマになっています。

作ったもの

家計簿のWebアプリに、OCRテスト用の画面を用意しました。

この画面では、以下を選んでOCRを試せます。

  • モデル
  • レシート画像
  • プロンプト
  • 画像認識チェック
  • 家計簿項目の抽出チェック

image.png

モデル一覧には、さくらのAI Engineから取得したモデルが出ています。

image.png

今回はVision用途なので、preview/Qwen3-VL-30B-A3B-Instructを使いました。

image.png

実装方針

構成はざっくりこんな感じです。

ブラウザ
  ↓ 画像 + モデル + プロンプト
Next.js API Route
  ↓ 画像前処理
さくらのAI Engine Chat Completions API
  ↓ JSON文字列
Zodで検証
  ↓
家計簿用の抽出結果として扱う

ポイントは、OCR結果をそのまま信じないことです。

LLMに「JSONで返して」とお願いしても、アプリから見ると外部入力です。
なので、戻ってきたJSONは必ずparseして、schemaに合っているか確認します。

OpenAI互換APIとして呼び出す

さくらのAI EngineはOpenAI互換APIとして扱えるので、アプリ側ではproviderを切り替えられるようにしました。

もともとは、ollama を自宅サーバーで動かしていたのですが、さくらの AI Engine も OpenAI 互換で業界標準的なインターフェースだったので、接続先を環境変数に抽象化することが出来ました。(関連記事: https://zenn.dev/takajun/articles/0a3f487decab01)

環境変数はこのようなイメージです。

AI_PROVIDER=sakura
AI_BASE_URL=https://api.ai.sakura.ad.jp/v1
AI_API_KEY=...
AI_MODEL=preview/Qwen3-VL-30B-A3B-Instruct
AI_REQUIRES_IMAGE_INPUT=true
AI_STRUCTURED_OUTPUT_REQUIRED=true

実際のAPI keyは当然.envに置き、Git管理しません。
家計簿アプリなので、API keyだけでなく、アップロード画像やDBもGit管理しないようにしています。

Vision入力

レシート画像は、APIへ渡す前にリサイズしてbase64化します。

OpenAI互換のChat Completionsでは、メッセージのcontentにtextとimage_urlを並べる形にしました。

messages: [
  { role: 'system', content: RECEIPT_OCR_SYSTEM_PROMPT },
  {
    role: 'user',
    content: [
      { type: 'text', text: prompt },
      { type: 'image_url', image_url: { url: imageUrl } },
    ],
  },
]

imageUrlには、以下のようなdata URLを入れます。

const imageUrl = `data:${mime};base64,${base64}`;

JSON Schemaで抽出結果を固定する

最初は「画像を投げて文字起こしできればOK」くらいに考えていました。
ただ、家計簿に入れるなら、単なるOCRテキストでは少し足りません。

欲しいのは、店名、日付、合計金額、支払方法、カテゴリです。

そこで、response_formatにJSON Schemaを指定しました。

response_format: {
  type: 'json_schema',
  json_schema: {
    name: 'receipt_extraction',
    strict: true,
    schema,
  },
}

実際の schema は、以下のような形です。

{
  type: 'object',
  description: 'Receipt extraction result. Return only one JSON object matching this schema.',
  required: [
    'store',
    'date',
    'total_amount',
    'payment_method',
    'category',
    'confidence',
    'notes',
  ],
  properties: {
    store: {
      type: 'string',
      description: 'Store or merchant name printed on the receipt.',
    },
    date: {
      type: 'string',
      description: 'Transaction date normalized strictly to YYYY-MM-DD.',
    },
    total_amount: {
      type: 'number',
      description: 'Final tax-included total amount paid.',
    },
    payment_method: {
      type: 'string',
      description: 'Payment method. Use cash, credit, qr, ic, other, or unknown semantics.',
    },
    category: {
      type: 'string',
      enum: categoryOptions,
      description: 'Choose exactly one enum value.',
    },
    confidence: {
      type: 'number',
      description: 'Self-rated confidence from 0 to 1.',
    },
    notes: {
      type: 'string',
      description: 'Short note. Empty string if there is no note.',
    },
  },
}

ポイントは、category を自由入力にせず、アプリ側で使っているカテゴリ選択肢を enum として渡しているところです。(categoryOptions の詳細を割愛しています)
これにより、食費、食品、買い物、スーパー のような表記ゆれを減らし、後段の集計で扱いやすくしています。

Zodでruntime validationする

LLMのレスポンスは、TypeScriptの型だけでは守れません。

APIから返ってきた文字列をJSON.parseして、Zodで検証します。

ReceiptExtractionSchema の定義内容

export const ReceiptExtractionSchema = z.object({
  store: z.string(),
  date: z.preprocess(normalizeReceiptDate, IsoDateSchema),
  total_amount: z.number(),
  payment_method: z.string(),
  category: z.string(),
  confidence: z.number().min(0).max(1),
  notes: z.string(),
});

ReceiptExtractionSchema を使ってレスポンス検証

const parsed = JSON.parse(raw);
const safe = ReceiptExtractionSchema.safeParse(parsed);

if (!safe.success) {
  return {
    ok: false,
    reason: 'スキーマ検証失敗',
    raw,
  };
}

ここで落とすようにしておくと、壊れたJSONや、カテゴリenumに存在しない値がアプリ本体へ流れません。

個人開発だと「まあ自分しか使わないし」と思って雑に通したくなるのですが、家計簿データはあとで集計に使うので、入口で止める方が結果的に楽でした。

今回gpt-oss-120bを試して、date: "0000-00-00"や空のstoreがschemaを通ってしまうケースも見えました。
型だけでなく、日付の妥当性や必須文字列の空チェックも強めた方がよさそうです。

動作確認

記事用に、架空の画像を3パターン作って試しました。

実データではありません。

パターン 内容 期待カテゴリ
コンビニ風レシート おにぎり、サラダ、コーヒー 食費
ガス料金の払込票 ガス料金 5,627円 ガス
カフェのレシート タンブラー、ギフトバッグ 買物

preview/Qwen3-VL-30B-A3B-Instructで実行した結果です。

1. コンビニ風レシート

image.png

抽出結果はこのようになりました。

{
  "store": "サンプルストア",
  "date": "2026-08-24",
  "total_amount": 650,
  "payment_method": "cash",
  "category": "🍞食費",
  "confidence": 0.95,
  "notes": "※記事用の架空サンプルです"
}

食品が含まれているので、カテゴリは🍞食費になりました。

2. ガス料金の払込票

次はレシートではなく、ガス料金の払込票っぽい画像です。
払込取扱票、ご使用量のお知らせ、口座振替済領収証が並ぶ、実際の払込票に近いレイアウトの架空サンプルにしました。

image.png

抽出結果はこうなりました。

{
  "store": "サンプル都市ガス株式会社",
  "date": "2026-08-20",
  "total_amount": 5627,
  "payment_method": "other",
  "category": "🔥ガス",
  "confidence": 0.95,
  "notes": "ガス使用量30m³、前回指数790、今回指数820"
}

ここは一度、カテゴリが❓その他に寄りました。

そのため、プロンプトに「ガス料金、ガス代、都市ガス、プロパンガス、ガス料金の払込票・請求書・利用明細はガスカテゴリを選ぶ」と明示しました。

固定費の払込票は通常のレシートと見た目が違うので、分類ルールを明示した方が安定しそうです。

3. カフェでタンブラーを買ったレシート

最後は、店舗名だけ見ると外食っぽいけれど、購入品はタンブラーというパターンです。

image.png

抽出結果はこうなりました。

{
  "store": "サンプルカフェ",
  "date": "2026-08-24",
  "total_amount": 2500,
  "payment_method": "クレジットカード",
  "category": "🛒買物",
  "confidence": 0.95,
  "notes": "※飲食物ではない架空サンプルです"
}

カフェのレシートでも、明細が飲食物ではなく物品だったため、🍞食費ではなく🛒買物に分類されました。

このパターンはかなり大事だと思っています。
店舗名だけで分類すると「カフェ = 外食」になりがちですが、家計簿では何を買ったかの方が重要です。

結果まとめ

パターン 抽出金額 抽出カテゴリ 結果
コンビニ風レシート 650円 🍞食費 OK
ガス料金の払込票 5,627円 🔥ガス OK。ただし分類ルールの明示が必要
カフェでタンブラー購入 2,500円 🛒買物 OK

画面上では、モデル名と解析時間も確認できます。

モデル: preview/Qwen3-VL-30B-A3B-Instruct
解析時間: 1083ms / 1534ms / 1150ms

3パターン試してみると、単純なOCRだけでなく、明細を見たカテゴリ分類までかなり実用に近いところまでできそうでした。

Ollamaのqwen2.5vl:3bから変えてみて感じた差

もともとは、ローカルのOllamaでqwen2.5vl:3bを動かしていました。

小さめのVisionモデルをローカルで試せるのはかなり便利です。
APIキーも不要ですし、レシート画像を外に出さずに検証できます。

ただ、自分の家計簿用途ではひとつ困ったことがありました。

それは、カテゴリ分類がかなり買物に寄ってしまうことです。

食品スーパーのレシートも、公共料金っぽい明細も、店舗や書面の雰囲気だけで買物に倒れやすく、家計簿のカテゴリとしては後から直す必要がありました。

今回、さくらのAI Engineでpreview/Qwen3-VL-30B-A3B-Instructを使ってみると、この点がかなり改善しました。

少なくとも今回の3つのダミーパターンでは、期待したカテゴリに分類できています。

パターン qwen2.5vl:3bをローカルで使っていた時の課題感 qwen3での結果
食品を含むレシート 買物に寄ることがあった 🍞食費
ガス料金の払込票 買物またはその他に寄りやすい 🔥ガス
カフェでタンブラー購入 店舗名だけ見ると外食・食費に寄る可能性がある 🛒買物

もちろん、これだけで一般的に「精度100%」とは言えません。
ただ、手元の家計簿OCRという目的では、qwen3に変えてからはほぼ期待どおりの分類になりました。

この差は、モデルサイズや実行環境の差も大きいのだと思います。

ローカルでVision LLMを動かす場合、VRAMやメモリの制約があります。
手元のマシンで無理なく動かすには、どうしても小さめのモデルを選ぶことになります。
小さいモデルは軽くて扱いやすい一方で、レシートの細かい明細を読んだうえで、家計簿カテゴリへ落とし込むような判断では限界が出やすいです。

一方、APIで大きめのモデルを使うと、ローカルのVRAM制約から解放されます。
その分、OCR結果の読み取りだけでなく、「これは食品なのか、物品なのか、公共料金なのか」という分類の精度にも差が出るのだと感じました。

ローカルLLMは検証やプライバシー面で魅力があります。
ただ、実用アプリで入力修正の手間を減らしたい場面では、API経由で高性能なVisionモデルを使う価値はかなりあります。

gpt-oss-120bではうまく動かなかった

ついでに、モデル一覧に出ていたgpt-oss-120bでも同じOCR処理を試しました。

名前だけ見ると大きくて強そうなので、これでもいけるのではと思ったのですが、結果はだめでした。

ここは、実行結果だけでなく管理画面を見ると理由がわかりやすいです。

さくらのAI Engineのコントロールパネルでは、利用可能なモデル一覧とタグを確認できます。

↓利用可能モデル一覧
https://secure.sakura.ad.jp/ai/models/chat-completions

image.png

この一覧を見ると、preview/Qwen3-VL-30B-A3B-Instructにはマルチモーダルタグがあります。
一方で、gpt-oss-120bには少なくともこの画面上ではマルチモーダルタグが付いていません。

つまり、OCR用途で重要なのは「大きいLLMかどうか」ではなく、「画像入力を扱えるモデルかどうか」です。

同じOCR処理で試したところ、返ってきた内容はOCR結果として使えるものではありませんでした。

画面から試したケースでは、日付が空文字になり、アプリ側のschema validationで弾かれました。

スキーマ検証失敗: date: iso date

レスポンス本文も、画像を読めていないことがわかる内容でした。

{
  "category": "❓その他",
  "confidence": 0,
  "date": "",
  "notes": "画像が提供されていないため、情報を抽出できませんでした。",
  "payment_method": "unknown",
  "store": "",
  "total_amount": 0
}

別の架空レシート画像をAPI直叩きしたケースでも、似たように使えない値が返りました。

{
  "model": "gpt-oss-120b",
  "duration_ms": 30635,
  "extraction": {
    "store": "",
    "date": "0000-00-00",
    "total_amount": 0,
    "payment_method": "",
    "category": "✈︎旅行",
    "confidence": 0,
    "notes": ""
  }
}

30秒ほど待った結果、店名も金額も読めず、カテゴリもレシート内容と関係ないものになりました。

この挙動を見る限り、gpt-oss-120bは今回のような画像OCR用途には向いていません。
少なくとも、Chat Completionsのcontentに画像を含めて投げた今回の実装では、画像内容を理解しているようには見えませんでした。

理由はシンプルで、OCRにはVision入力、つまりマルチモーダル対応のモデルが必要だからです。

LLMとして高性能でも、画像を読めるとは限りません。
テキスト生成が得意なモデルと、画像を入力として解釈できるVisionモデルは別物です。

今回の用途では、preview/Qwen3-VL-30B-A3B-InstructのようにVision対応が前提のモデルを選ぶ必要がありました。

この失敗は、モデル選定でけっこう大事なポイントでした。
「大きいモデルなら何でもよい」ではなく、「画像入力に対応しているか」「structured outputで安定して返せるか」「アプリ側のschema検証を通るか」を見ないといけません。

利用量も管理画面で確認できる

さくらのAI Engineのコントロールパネルでは、モデル別の利用量も確認できます。

↓利用量確認画面
https://secure.sakura.ad.jp/ai/usages/model

image.png

この画面では、モデルごとのリクエスト数、入力トークン数、出力トークン数を見られます。

今回の検証では、preview/Qwen3-VL-30B-A3B-Instructへのリクエスト数もグラフで確認できました。
スクリーンショット上では、2026-08-24に13リクエスト程度です。

記事投稿キャンペーンのページにも、Chat completionsは月3,000リクエストの無償枠があると書かれています。

個人の家計簿OCRで考えると、レシート1枚につき1リクエストです。
毎日数枚レシートを処理しても、月3,000リクエストはかなり余裕があります。

たとえば1日10枚処理しても、30日で300リクエストです。
手元の検証やプロンプト調整を含めても、個人利用の家計簿用途ならまず足りそうだと感じました。

抽出した支出は一覧・集計で確認する

OCRで抽出した支出は、最終的には家計簿の一覧や集計で確認できるように実装しているので、OCRが動けば以下のようにいい感じで見られるようになりました 🎉

たとえば、月ごとの合計金額やカテゴリ別の金額はこのように見られます。

image.png

また、週ごとのまとまりと日別の支出も確認できます。

image.png

OCRだけで完結するのではなく、後から「今月は何にいくら使ったか」を見返せるところまでつなげるのが目的です。

このときカテゴリがぶれると集計が崩れるので、OCR時点でカテゴリenumに寄せておく意味があります。

ハマりどころ

OCRと家計簿登録は別物

単純に文字起こしするだけなら、レシート全体をテキスト化すれば終わりです。

でも、家計簿アプリで欲しいのは「登録可能な形」です。

たとえば、合計金額だけ欲しいのに小計や預かり金額を拾ってしまうと困ります。
日付も、曜日や時刻つきではなく、アプリで扱いやすいYYYY-MM-DDにしたいです。

なので、プロンプトでもschemaでも「最終支払額」「日付形式」「カテゴリ候補」を明示しました。

カテゴリは自由入力にしない

家計簿のカテゴリは、あとから集計します。

ここで自由入力を許すと、同じ意味のカテゴリが増えていきます。
自分はこういう表記ゆれをあとで直すのが苦手です。

今回は、アプリ側のカテゴリ選択肢をenumとして渡し、その中から選ばせるようにしました。

まとめ

さくらのAI EngineのVision対応モデルを使って、家計簿WebアプリのレシートOCRを試しました。

今回よかった点は以下です。

  • OpenAI互換APIとして呼べるので、既存の実装に組み込みやすい
  • Visionモデルに画像を渡して、OCRと項目抽出をまとめてできる
  • JSON SchemaとZodを組み合わせると、アプリで扱いやすい形に落とし込める
  • qwen3のVisionモデルで、架空レシートの店名・日付・合計金額・支払方法を抽出できた
  • ローカルの小さめVisionモデルで出ていたカテゴリ分類の不安定さが、API経由のqwen3ではかなり改善した
  • 3000回無料でAIが使えるのはPoc実装や、自分用のミニアプリ用途ではかなり使えそうなのでかなり良心的だと思いました!

今後も利用できるモデルが継続的に増えていき、なおかつコストもほかのAIプロバイダよりも低めなら、国産の安心さくらのAI Engine を使わせていただきたいと思える機会になりました!🎉👍🌸

💮💮💮💮💮💮💮

1
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
1
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?