🧾

Vercel AI SDKで簡単に作るAI OCRアプリ

に公開

概要

Vercel AI SDK + Vercel AI Gateway + Bunを使ったサンプルとして領収書画像から項目を抜き出すAI OCRツールを作りました。
画像をGPT-4o・GPT-4o-mini・Gemini 2.5 Flashに同時に渡して、JSON化するまでを実装しています。

Vercel AI ~~ is 何?

Vercel AI SDKは、TypeScriptで利用できるAIモデルの抽象化ライブラリ
Vercel AI Gatewayは、複数のAIプロバイダをまとめて利用できるAPIサービス

です、すぐトレンドのモデルが変わるAI業界において、
モデル変更のコストや依存を最低限にしながらAIアプリを構築できます(Vercelには依存します)

作るもの

今回はVercel AI SDKとVercel AI Gatewayを使って、領収書の画像から情報を抽出するCLIツールを作ります。

サンプルなのでシンプルに、以下の3つの情報のみを抽出する構成にしました。

  • 発行日(YYYY-MM-DD形式)
  • 店舗名(支払先)
  • 合計金額(税込)

これらの項目を画像からGPT-4o・GPT-4o-mini・Gemini 2.5 Flashに抜き出させるまでを目標とします。

プロジェクト作成

Bunで初期化

今回はランタイムにBunを使います。
BunはTypeScriptを直接実行でき、シンプルなランタイムなのでCLIツール作成によく使っています。

別途Bunのインストールは必要ですが、環境構築は以下の通り、

mkdir receipt-ocr && cd receipt-ocr
bun init -y  # -y: 対話プロンプトをスキップしてデフォルト設定で初期化

依存パッケージのインストール

bun add ai zod consola
パッケージ 用途
ai Vercel AI SDK本体。様々なLLMプロバイダを統一インターフェースで利用可能
zod スキーマ定義。LLMからの出力を型安全に受け取る
consola ログ出力をきれいに表示(お好みで)

環境変数の設定

.env ファイルを作成し、Vercel AI GatewayのAPIキーを設定します。
キーはここから取得できます。

AI_GATEWAY_API_KEY=your-api-key

スキーマ定義

LLMからの出力形式をZodスキーマで定義します。src/schema.ts を作成してください。

src/schema.ts
import { z } from "zod"

export const ReceiptSchema = z.object({
  issue_date: z.iso.date().nullable().describe("発行日(YYYY-MM-DD形式)"),
  supplier_name: z.string().nullable().describe("支払先名・店舗名・発行者名"),
  total_amount: z.number().nullable().describe("税込合計金額(カンマなしの数値、JPY)"),
})

AI SDKにおけるzod schemaの書き方

.describe() でLLMに指示を出す

Zodの .describe() メソッドは、LLMに対して「このフィールドに何を入れてほしいか」を伝えるのに使えます。
プロンプトに詳細な指示を加える必要がなく、二重管理も不要になるので、使用することをおすすめします。

例えば "発行日(YYYY-MM-DD形式)" と書くことで、日付フォーマットを指定したりできます。

.nullable() で読み取れない場合に対応

AI OCRを行う場合にデータが欠ける可能性がある項目についてはハルシネーションを防ぐ意味合いでも
.nullable() を付けることで、読み取れなかった場合に null を返すよう指示できます。

抽出ロジック

画像からデータを抽出する処理を実装します。src/extractor.ts を作成してください。

src/extractor.ts
import { basename } from "node:path"
import { generateObject } from "ai"
import { consola } from "consola"
import { ReceiptSchema } from "./schema"

const SYSTEM_PROMPT = `
日本語のOCR・情報抽出エンジンです。
画像に明確に記載されている情報のみを抽出し、見つからない場合は null を返してください。
推測や補完は絶対にしないでください。
`

const extract = async (image: string, model: string) => {
  const { object } = await generateObject({
    model,
    schema: ReceiptSchema,
    system: SYSTEM_PROMPT,
    messages: [
      { role: "user", content: [{ type: "image", image: await Bun.file(image).arrayBuffer() }] },
    ],
  })
  return object
}

const processImages = async (models: string[], images: string[]) => {
  const tasks = models.flatMap((model) => images.map((image) => ({ model, image })))
  let done = 0

  consola.info(`処理開始: ${tasks.length}タスク`)

  const results = await Promise.all(
    tasks.map(async ({ model, image }) => {
      try {
        const data = await extract(image, model)
        return { model, image: basename(image), success: true, data }
      } catch (e) {
        return { model, image: basename(image), success: false, error: String(e) }
      } finally {
        process.stdout.write(`\r  進捗: ${++done}/${tasks.length}`)
      }
    }),
  )

  console.log()
  const ok = results.filter((r) => r.success).length
  consola.success(`完了: ${ok}成功 / ${results.length - ok}失敗`)
  return results
}

抽出ロジックのポイント

generateObject で構造化出力

Vercel AI SDKの generateObject 関数を使うと、さっき書いたZodスキーマに沿った形式でLLMからの出力を受け取れます。
object に型がつくので、後続処理もかなり実装がしやすいです。

const { object } = await generateObject({
  model,           // "openai/gpt-4o" のような形式
  schema: ReceiptSchema,  // Zodスキーマ
  system: SYSTEM_PROMPT, // プロンプト文字列
  messages: [...], // 画像をArrayBufferで渡す
})

モデル指定は プロバイダ/モデル名 形式

Vercel AI SDKでは、モデルを openai/gpt-4o や google/gemini-2.5-flash のような形式で指定します。
この統一フォーマットのおかげで、モデルを切り替えるのがかなり簡単です。
モデルのリストについてはこのサイトで確認できます。

https://vercel.com/ai-gateway/models

画像の渡し方

{ type: "image", image: await Bun.file(image).arrayBuffer() }

画像は ArrayBuffer 形式で渡します。
Bunの Bun.file().arrayBuffer() を使えば、ファイルパスから直接読み込めます。

メイン処理

最後にエントリーポイントを作成します。src/index.ts を作成してください。

src/index.ts
import { join } from "node:path"
import { consola } from "consola"
import { processImages } from "./extractor"

// 入力ディレクトリ
const INPUT_DIR = "work/input"
// 出力ディレクトリ
const OUTPUT_DIR = "work/output"
// 使用モデル
const models = ["openai/gpt-4o", "openai/gpt-4o-mini", "google/gemini-2.5-flash"]

const glob = new Bun.Glob("*.{jpg,jpeg,png,webp,gif,pdf}")
const images = Array.from(glob.scanSync(INPUT_DIR)).map((f) => join(INPUT_DIR, f))

if (images.length === 0) {
  consola.error(`🚫 画像が見つかりません: ${INPUT_DIR}`)
  process.exit(1)
}

consola.info(`🖼️  ${images.length}枚 × ${models.length}モデル`)

const results = await processImages(models, images)

const outputPath = join(OUTPUT_DIR, "results.json")
await Bun.write(outputPath, JSON.stringify(results, null, 2))

consola.success(`💾 ${outputPath}`)

メイン処理のポイント

複数モデルの定義

const models = ["openai/gpt-4o", "openai/gpt-4o-mini", "google/gemini-2.5-flash"]

使いたいモデルを配列で定義するだけです。
Vercel AI SDKのおかげで、画像入力からzodを使った構造化出力までが抽象化されて、プロバイダが違っても同じコードで処理できます。

実行方法

# work/input に領収書画像を配置してから実行
bun run src/index.ts

実行結果と考察

実際に5枚の領収書画像(コンビニ、書店、ドラッグストアなど)で試してみました。
使用したモデルは以下の通り、

const models = [
  "openai/gpt-4o",
  "openai/gpt-4o-mini",
  "google/gemini-2.5-flash",
  "anthropic/claude-3-5-haiku-latest",
  "xai/grok-2-vision-1212",
];

複数のAIモデルを使った領収書のテキスト化が行われた画面

Vercel AI SDKの対応機能別モデルリストを見ながら適当にモデルを選びましたが、
今回の実行結果だと claude-3-5-haiku と grok-2-vision において、ハルシネーションが見られました。

この構成のメリット

  • AIモデル変更が簡単: openai/gpt-4o を google/gemini-2.5-flash に変えるだけでモデル切り替えができます。プロバイダごとにSDKを入れ替えなくてもOKで便利です。

  • 型安全な構造化出力: Zodスキーマを定義するだけで、LLMからの出力を型付きで受け取れます。JSONのパースや型変換の手間がありません。

  • Bunでシンプルに: TypeScriptを直接実行できるのでCLIツール作成におすすめです。

参考リンク

GitHubレポジトリ

今回実装したCLIアプリの実装を公開しています。Vercel AI GatewayのAPIキーをご用意いただければすぐに試せます。

https://github.com/y-migita/receipt-ocr


PoCで複数のLLMを試して比較検討したい時におすすめな
Vercel AI SDK + Vercel AI Gateway + Bun構成の紹介でした。
ほかにいい実装方法があればぜひ教えてください!

Discussion