文字起こし結果のキャッシング:重複排除と冪等性
APIキャッシュ開発者

文字起こし結果のキャッシング:重複排除と冪等性

BMMamane B. MoussaMay 26, 2026Updated July 2, 20263 min read

Summarize this article with:

TL;DR

文字起こしは決定論的です。同じ音声ファイルを同じエンジンとモデルに通せば、常に同じ文字起こし結果が返ります。コンテンツハッシュキーとパラメータハッシュで結果をキャッシュすれば、同じコンテンツに対するAPI課金の繰り返しをなくせます。キャッシュされた文字起こしの保存コストはごくわずかで、再文字起こしを1回回避するだけで、その結果を何年も保存しておく費用を賄えます。このガイドでは、冪等性モデル、キャッシュキーの構築、PostgresとRedisの保存パターン、モデル更新時の無効化、そしてキャッシュ構築の価値を判断する計算方法を解説します。

文字起こしは決定論的です。 同じ音声ファイルを同じエンジンとモデルに通せば、毎回同じ文字起こし結果が得られます。それにもかかわらず、ほとんどの本番システムは同じファイルを何度も再文字起こしし、呼び出しごとに文字起こしAPIへ支払い続けています。キャッシングはこの決定論性を資産として扱うのです。

本ガイドは本番環境向けのキャッシングパターンを解説します。冪等的な重複排除のためのコンテンツハッシュキー、PostgresとRedisでの保存、モデル更新時の無効化、そしてキャッシュがどれほど早く元を取れるかを示す計算までカバーします。

冪等性こそが基盤である理由

RedisやPostgresを考える前に、ここでキャッシュが正しく機能する理由を考えてみましょう。文字起こしは(音声コンテンツ、エンジン、モデル、パラメータ)の純粋関数だからです。同じ入力なら、常に同じ出力。この性質は広義の冪等性と呼ばれます。操作を繰り返しても新しい情報は生まれない、ということです。

キャッシュはそれを活用するための仕組みにすぎません。仕組みを正しく構築すれば、再アップロードのたび、バックフィル実行のたび、再生成の必要がないのに押される「再生成」ボタンのクリックのたびに、無料のヒットが得られます。

キャッシュなしのパイプラインでお金を無駄にする4つのパターン:

  • ユーザーが同じ面接音声を2回アップロードする。2回とも再文字起こしされる。
  • 「再生成」ボタンが保存済みの結果を返す代わりにAPIを呼び出す。
  • バックフィルスクリプトが、すでに文字起こし結果のあるファイルを再処理する。
  • 下流処理だけが変わったのに、コーパス全体を再文字起こしする機能がデプロイされる。

それぞれが1回のAPI呼び出しを無駄にします。何千人ものユーザーで集計すれば、請求額は積み上がります。キャッシュなしのパイプラインでは25〜45%の再文字起こし率が観察されていますが、実際の率はワークフローによります。

コンテンツハッシュによる重複排除:キャッシュキー

正しいキャッシュキーは、音声ファイル内容のSHA-256ハッシュに、エンジンとモデルパラメータのハッシュを組み合わせたものです:

function cacheKey(audioBuffer, opts) {
  const audioHash = crypto.createHash('sha256').update(audioBuffer).digest('hex');
  const paramHash = crypto.createHash('sha256')
    .update(JSON.stringify({ engine: opts.engine, language: opts.language, model: opts.model }))
    .digest('hex')
    .slice(0, 16);
  return `transcript:${audioHash}:${paramHash}`;
}

音声ハッシュがコンテンツの識別子です。パラメータハッシュは「Deepgram Nova-3 Englishで文字起こしした」と「gpt-4o-transcribeで文字起こしした」を区別します。キャッシュヒットには両方が一致する必要があります。

なぜファイル名を使わないのか?ファイル名はメタデータであって識別子ではないからです。interview.mp3interview_final.mp3は同じファイルかもしれません。別々の2つのファイルが同じ名前を持つこともあります。コンテンツハッシュなら両方のケースを正しく解決できます。

メモリに読み込まずに大きなファイルをハッシュする

数百MB規模の音声ファイルの場合、ハッシュ計算のためにバッファ全体を読み込むのは無駄です。代わりにストリーミングでハッシュを計算します:

import { createReadStream } from 'fs';
import { createHash } from 'crypto';

function hashFile(path) {
  return new Promise((resolve, reject) => {
    const hash = createHash('sha256');
    const stream = createReadStream(path);
    stream.on('data', chunk => hash.update(chunk));
    stream.on('end', () => resolve(hash.digest('hex')));
    stream.on('error', reject);
  });
}

これはチャンク単位で読み込みながらハッシュを逐次更新するため、ファイルサイズにかかわらずメモリ使用量は一定に保たれます。

ストレージ:永続化にはPostgres、ホットパスにはRedis

Postgresをプライマリストアとして使う

Postgresが適切なデフォルトです。文字起こし結果へのアクセスは頻繁ではなく、ストレージは安価です。スキーマは次のとおりです:

CREATE TABLE transcript_cache (
  cache_key TEXT PRIMARY KEY,
  audio_hash TEXT NOT NULL,
  engine TEXT NOT NULL,
  language TEXT NOT NULL,
  transcript JSONB NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  last_accessed TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_audio_hash ON transcript_cache (audio_hash);

アクセス追跡付きの参照を1つのクエリで行います:

async function getCached(key) {
  const row = await db.query(
    `UPDATE transcript_cache SET last_accessed = NOW() WHERE cache_key = $1 RETURNING transcript`,
    [key]
  );
  return row.rows[0]?.transcript;
}

UPDATE ... RETURNINGパターンは、アクセスタイムスタンプの更新と行の取得を1回のラウンドトリップで行います。

Redisをホットパス層として使う

一部の文字起こし結果(最近の録音、人気の共有コンテンツなど)に非常に頻繁にアクセスされる場合は、Redisを使うことでPostgresの負荷を減らし、参照をサブミリ秒に短縮できます:

async function getCached(key) {
  const redisResult = await redis.get(key);
  if (redisResult) return JSON.parse(redisResult);

  const pgResult = await db.query(
    'SELECT transcript FROM transcript_cache WHERE cache_key = $1',
    [key]
  );
  if (pgResult.rows[0]) {
    await redis.setex(key, 3600, JSON.stringify(pgResult.rows[0].transcript));
    return pgResult.rows[0].transcript;
  }

  return null;
}

この例ではRedisのTTLは1時間です。PostgresにはTTLがなく、エントリは明示的に退去させるまで残ります。ほとんどのパイプラインではPostgresだけで十分です。Redisの追加は、Postgresのレイテンシが実際のボトルネックだと測定できてからにしましょう。

完全なパターン:レースセーフな挿入

async function getOrTranscribe(audioBuffer, opts) {
  const key = cacheKey(audioBuffer, opts);

  const cached = await getCached(key);
  if (cached) return cached;

  const result = await transcribeAPI(audioBuffer, opts);

  await db.query(
    `INSERT INTO transcript_cache (cache_key, audio_hash, engine, language, transcript)
     VALUES ($1, $2, $3, $4, $5)
     ON CONFLICT (cache_key) DO NOTHING`,
    [key, hashAudio(audioBuffer), opts.engine, opts.language, result]
  );

  return result;
}

ON CONFLICT DO NOTHINGは重要な役割を担っています。同じ音声ファイルへの同時リクエストが2つ来ると、どちらもキャッシュミスになり、どちらもAPIを呼ぶ可能性があります。先の挿入が勝ち、後の挿入は静かに破棄されます。どちらの呼び出し元も結果を受け取れます。ロックも不要で、重複エントリも発生しません。

ストレージコストの計算

ここでキャッシングの経済性が具体的になります。

文字起こし1回あたりのコスト。 Deepgram Nova-3の録音済み英語は1分あたり$0.0043です(2026年7月時点、deepgram.com/pricingより)。1時間の音声でおよそ$0.26です。OpenAIのgpt-4o-transcribeは1分あたり$0.006で、1時間あたり約$0.36です(developers.openai.comの料金より)。

文字起こし結果の保存コスト。 英語の話し声1時間分のプレーンな文字起こしテキストは約48KBです(UTF-8で1分あたり約800文字)。単語レベルのタイムスタンプ、発話、トピック、感情を含むDeepgramのJSONBレスポンス全体は、話者数や詳細度にもよりますが、1時間あたり500KB〜1MBになります。AWS RDS PostgreSQLのストレージは約$0.115/GB・月です(AWSの料金ページより、2026年7月)。

DeepgramのJSON 1MBを1か月保存するコストはおよそ$0.000115です。再文字起こし1回の回避が$0.26なので、キャッシュヒット1回で、その文字起こし結果を約2,200か月間保存しておく費用を賄えます。

損益分岐点の問題はストレージコストではありません。エンジニアリング時間です。すでにPostgresを運用しているシステムなら、このパターンは約30行のコードです。月$200を文字起こしに使い、再文字起こし率10%のパイプラインなら、月$20の回収、つまりわずか半日の作業で年間約$240の節約になります。

API側のコスト削減手法については、文字起こしAPI呼び出しのコスト最適化と、より幅広い文字起こし料金比較を参照してください。

モデル更新時の無効化

キャッシュキーの設計により、モデル更新は自動的に処理されます。 Whisper Large-v3から新しいモデルに切り替えるとき、モデル名はパラメータハッシュの一部なので、既存のエントリはもう一致しません。新しいリクエストは新しいキーを生成し、新規の文字起こしを実行します。古いエントリは孤立し、最終アクセス時刻に基づく退去クエリによって自然に消えていきます。

その他の無効化シナリオは2つあります:

顧客報告のエラー。 ユーザーが不正確な文字起こし結果を報告し、やり直しを求めます。管理用エンドポイントを公開します:

async function invalidateCacheForJob(jobId) {
  const job = await db.jobs.findOne({ id: jobId });
  await db.query(
    'DELETE FROM transcript_cache WHERE cache_key = $1',
    [job.cache_key]
  );
}

サイズベースの退去。 退去処理がないと、文字起こしテーブルは際限なく成長します。これが懸念になるようなら、最終アクセス日でパージします:

DELETE FROM transcript_cache
WHERE last_accessed < NOW() - INTERVAL '90 days';

ほとんどのチームにとって、文字起こし結果は他のデータに比べて小さく、テーブルは何年も管理可能な状態を保てます。退去させる前に計測しましょう。

長尺ファイル向けのセグメント単位キャッシング

非常に長い録音(数時間のポッドキャスト、終日のカンファレンス音声)の場合、フラットなキャッシュは完全にミスするか、非常に大きなblobをキャッシュすることになります。セグメント単位のキャッシングの方がきめ細かい制御が可能です:

音声を5分のチャンクに分割します。各チャンクを個別にハッシュし、チャンクごとにキャッシュを確認します。キャッシュされていないセグメントだけを文字起こしします。これは編集ワークフローにも有効です。小さな変更を加えたファイルをユーザーが再アップロードした場合、変更されたセグメントだけを再文字起こしすればよくなります。

単語レベルのキャッシングは過剰です。参照ごとのストレージオーバーヘッドが見合いません。5〜10分のセグメントが適切な粒度です。

ユースケース別のキャッシュヒット率

本番パイプラインにおける現実的な範囲:

ユースケース典型的なヒット率
同じファイルのユーザーによる再送8〜15%
社内ツールの再実行60〜80%
バックフィル/バッチ再処理80〜95%
マルチテナントSaaS(独立したユーザー)0.5〜3%

最初の行が典型的な本番ケースです。月$500の文字起こし請求に対する10%のヒット率は、月$50の節約になります。バックフィルのケースが最もリターンの大きいシナリオです。コーパスの再処理が必要になったとき、キャッシュがあれば潜在的に大きな再支出がほぼゼロになります。

キャッシュ効果の追跡

キャッシュが機能していることを把握できるよう、最初からカウンターを追加しましょう:

async function getOrTranscribe(audioBuffer, opts) {
  const key = cacheKey(audioBuffer, opts);
  const cached = await getCached(key);

  if (cached) {
    metrics.increment('transcription.cache.hit');
    return cached;
  }

  metrics.increment('transcription.cache.miss');
  const result = await transcribeAPI(audioBuffer, opts);
  await saveToCache(key, result);
  return result;
}

ヒット/ミス比率を継続的に監視します。ヒット率が予期せず低下したら、何かが変わったサインです。キャッシュ無効化の波、ハッシュをバイパスする新しいファイル形式、既存コンテンツに対して新しいキーを生成するエンジンのアップグレードなどが考えられます。

Webhookとの統合

キャッシングは非同期Webhookパイプラインときれいに組み合わせられます。詳細は文字起こしのWebhook vs ポーリングで解説しています。Webhookハンドラは元の音声ハッシュとAPIパラメータからキャッシュキーを計算し、完了時にキャッシュテーブルへ書き込みます。以降、同じ音声へのリクエストはAPIを完全にスキップします。

社内ツールを構築しているチームにとって、キャッシングは月50時間を超えるあたりで最もROIの高い最適化になることが多いです。詳細は社内文字起こしツールの構築を参照してください。

ConvertAudioToTextの音声アップロードツール
ConvertAudioToTextの音声アップロードツール

キャッシュ層を管理せずにシンプルな音声文字起こしを行いたい場合は、ConvertAudioToTextがサーバーサイドで重複排除と結果保存を処理するため、インフラを一切保守することなく、再送されたファイルの結果を即座に受け取れます。

よくある質問

ファイル名とファイル内容のどちらをハッシュすべきですか?

内容をハッシュしてください。ファイル名は当てになりません。interview.mp3としてアップロードされた面接音声とinterview_final.mp3としてアップロードされた同一の面接音声は、同じキャッシュエントリにヒットすべきです。逆に、どちらもinterview.mp3という名前の別々の2つのファイルが衝突してはいけません。音声バイト列のSHA-256だけが信頼できる識別情報です。

新しいWhisperやDeepgramのモデルにアップグレードするとどうなりますか?

古いキャッシュエントリは有効なままですが、新しいリクエストとは一致しなくなります。モデル名はキャッシュキーのパラメータハッシュの一部であるため、モデルバージョンを上げるたびに異なるキーが生成され、新規の文字起こしが実行されます。古いエントリは最終アクセス時刻に基づく退去ポリシーによって自然に期限切れになるため、一括削除する必要はありません。

Redisは必要ですか?それともPostgresだけでキャッシュできますか?

ほとんどのパイプラインではPostgresだけで十分です。主キーインデックスを使ったキャッシュ参照は高速です。最近アクセスした少数の文字起こし結果へのトラフィックが高いことが測定されており、Postgresのレイテンシが実際のボトルネックである場合にのみ、Redisをホットパス層として追加してください。

期待できるキャッシュヒット率はどのくらいですか?

ユースケースに大きく依存します。同じファイルのユーザーによる再送(最も一般的なケース)では、通常8〜15%のヒット率になります。バックフィルやバッチ再処理ジョブでは80〜95%に達することもあります。ユーザーが互いに関係のないファイルを独立してアップロードするマルチテナントSaaSが最も難しいケースで、0.5〜3%程度となり、文字起こし量が増えるまでは複雑さに見合わない可能性があります。

参考資料

Try transcription free

Convert any audio or video to clean, unwatermarked text — speaker labels, timestamps, and AI summaries included. First 10 minutes free, no account.

Related Articles