Gemini 2.5 Flash の値段は安いとはいえ、累計 5,000 万 DL の個人アプリ事業で複数本のアプリから同時に叩いていると、月の API 請求は地味に効いてきます。私の場合、ある癒し系アプリで「日記からその日の気分タグを推定する」機能を提供していますが、ピーク時に 1 秒あたり数十リクエストが飛ぶこともあり、内訳を見ると「ほぼ同じ内容のプロンプトで違うユーザーから何度も呼ばれている」状態でした。
完全一致のキャッシュは入れていたのですが、ユーザーが書く日記の文章は揺れます。「今日はちょっと疲れたかも」「今日はちょっと疲れました」「今日はちょっとつかれた」のように、表記は違っても意味はほぼ同じ。これに完全一致は当然ヒットしません。Gemini に毎回ゼロから問い合わせて、毎回同じ「ゆったり」「お疲れ」「リラックス」というタグを返してもらっていました。
この体験から、いわゆるセマンティックキャッシュ(意味で当てに行くキャッシュ)を自前で設計して投入することにしました。本記事は、その実装過程で踏んだ落とし穴と、本番運用に耐える形に仕上げるために整理したパターンをまとめたものです。2014 年からの個人アプリ開発、累計 5,000 万 DL、4 サイトの Dolice Labs を運営しながら得た「個人開発の規模で守るべきライン」の感覚も交えて書きます。
なぜ完全一致キャッシュでは AI 応答を使い回せないのか
通常の Web API のキャッシュは、URL とクエリ文字列の組をキーにします。同じ URL に同じパラメータで叩けば同じレスポンスが返るからです。
ところが LLM 応答のキャッシュでは、キーになるのは「自然言語のプロンプト」です。ユーザーは句読点や語尾、敬語のレベル、誤字までもバラバラに書きます。前述の日記の例だと、「今日はちょっと疲れたかも」 と 「今日はちょっと疲れました」 は完全一致キャッシュから見ると別物です。仮にハッシュキー化していても、たった 1 文字違うだけでミスになります。
私の癒し系アプリで実装当初の完全一致キャッシュをそのまま残しておくと、ヒット率は 6〜9% でした。100 リクエスト来ても 90 件以上は素通りで Gemini API に飛んでいくということです。これは「キャッシュを入れた割に効いてない」典型例で、運用初期は AdMob 収益で吸収していましたが、無料ユーザー比率が増えるにつれて目に見えて重荷になりました。
ここで効くのが「意味的に近いプロンプトを同じキャッシュエントリにマッピングする」セマンティックキャッシュです。プロンプトを埋め込みベクトルに変換し、過去のキャッシュエントリの中で十分近いものがあればそれを使う、という発想です。
セマンティックキャッシュが解く問題と、新しく持ち込まれる問題
セマンティックキャッシュは「表記揺れ」を吸収してくれます。一方、新しい問題も持ち込みます。意味的に近いつもりでも、ユーザーが期待する応答は微妙に違うことがある、という問題です。
たとえば「眠れない夜に試したいリラックス法は?」と「眠れない夜に試したいリラックス音楽は?」は、ベクトル距離だと近くなりがちです。しかしユーザーは前者には呼吸法やストレッチを期待し、後者にはプレイリストを期待しています。距離が近いからといってキャッシュから流用すると、回答の質が崩れます。
つまりセマンティックキャッシュは「ヒット率」と「品質劣化」のトレードオフを設計する仕組みになります。ヒット率を上げればコストは下がりますが、しきい値が緩いと無関係な質問にも当ててしまいます。逆にしきい値を厳しくすればキャッシュは効きません。私が運用しているアプリでは、最終的にユーザーが書く日記の長さに応じてしきい値を変える方式に落ち着きました。後述します。
アーキテクチャ全体像 — Embedding + ベクトルストア + 類似度しきい値
採用したアーキテクチャを最小構成で示します。個人開発レベルで現実的に運用できることを優先しています。
- リクエストが来たら、プロンプトを Gemini の
text-embedding-004 でベクトル化する
- ベクトルストアに対してコサイン類似度で上位 K 件を検索する
- 最高スコアが類似度しきい値以上ならキャッシュ命中。応答とメタデータを返す
- しきい値未満なら Gemini に問い合わせ、結果をベクトルと一緒にキャッシュに書き戻す
- ヒット率・しきい値・問い合わせ件数を CloudWatch 互換ログに出す
ベクトルストアは何でもいいのですが、私は Cloudflare Workers でホスティングしている関係で Cloudflare Vectorize にしました。Vectorize は 1,000 万ベクトルあたり月 1 ドル前後で、個人開発のスケールでは無視できるコストに収まります。応答本文は KV に置き、Vectorize には ID と最小メタデータだけ載せる形にしています。
import os
import time
import hashlib
import requests
GEMINI_KEY = os.environ["YOUR_GEMINI_API_KEY"]
EMBED_URL = "https://generativelanguage.googleapis.com/v1beta/models/text-embedding-004:embedContent"
GEN_URL = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"
def embed(text: str) -> list[float]:
r = requests.post(
EMBED_URL,
params={"key": GEMINI_KEY},
json={
"model": "models/text-embedding-004",
"content": {"parts": [{"text": text}]},
"taskType": "RETRIEVAL_QUERY",
},
timeout=10,
)
r.raise_for_status()
return r.json()["embedding"]["values"]
def generate(prompt: str) -> str:
r = requests.post(
GEN_URL,
params={"key": GEMINI_KEY},
json={"contents": [{"parts": [{"text": prompt}]}]},
timeout=30,
)
r.raise_for_status()
return r.json()["candidates"][0]["content"]["parts"][0]["text"]
上記は最小のラッパーです。taskType に RETRIEVAL_QUERY を指定しているのが地味に大事で、ここを間違えると同じ文章の埋め込みでもベクトルが微妙に変わりキャッシュが効かなくなります。書き込み時は RETRIEVAL_DOCUMENT を使うのが Google の推奨どおりですが、キャッシュ用途では「問い合わせ側に揃える」ため両方とも RETRIEVAL_QUERY で統一しています。Google のドキュメントから外れる選択ですが、キャッシュ命中率は揃えた方が明らかに上がりました。
Cloudflare Vectorize と KV を組み合わせた最小ストレージ層
Vectorize は埋め込みベクトルと小さなメタデータを保持し、応答本文は KV に置きます。理由は単純で、Vectorize は 1 ベクトルあたりのメタデータサイズに上限があり、長い応答本文を直接持たせるとすぐに上限に達するからです。
// Cloudflare Workers のハンドラ抜粋
interface Env {
VECTORIZE: VectorizeIndex;
KV: KVNamespace;
}
const SIM_THRESHOLD = 0.92; // 後でチューニング
export async function lookupCache(env: Env, vec: number[]) {
const result = await env.VECTORIZE.query(vec, {
topK: 3,
returnMetadata: "all",
});
const top = result.matches[0];
if (!top || top.score < SIM_THRESHOLD) return null;
const body = await env.KV.get(`ans:${top.id}`, "json");
if (!body) return null;
return { id: top.id, score: top.score, body };
}
export async function writeCache(
env: Env,
prompt: string,
vec: number[],
answer: string,
meta: { model: string; promptVersion: string }
) {
const id = await hashId(prompt + meta.promptVersion);
await env.VECTORIZE.upsert([
{
id,
values: vec,
metadata: {
model: meta.model,
promptVersion: meta.promptVersion,
createdAt: Date.now(),
},
},
]);
// KV は TTL を別に持たせる。Vectorize 側にはまだ TTL API がない
await env.KV.put(`ans:${id}`, JSON.stringify({ answer, savedAt: Date.now() }), {
expirationTtl: 60 * 60 * 24 * 7, // 7日
});
}
async function hashId(s: string): Promise<string> {
const data = new TextEncoder().encode(s);
const buf = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
ポイントは 3 つです。1 つ目はキャッシュキーの生成にプロンプト本文だけでなく promptVersion を混ぜていること。これは後で「プロンプトテンプレートを差し替えたいときに古いキャッシュを巻き添えで無効化したい」というユースケースに効きます。2 つ目は Vectorize と KV で TTL の扱いが違うこと。Vectorize には現状 TTL API がないので、古いベクトルは定期バッチで掃除する必要があります。3 つ目は topK: 3 にしている理由で、最上位だけを見ると同じスコアで複数ヒットしたときの揺らぎが出るため、上位 3 件を比較したログを残せるようにしてあります。
類似度しきい値のチューニング — 偽陽性が一番怖い
しきい値は最終的にユーザーへの応答品質を決めるところなので、本番投入前に必ず手元データで検証します。私は癒し系アプリのログから 500 件の入力ペアを抽出し、人手で「同じ応答でよい」「違う応答が必要」の二値ラベルを付けました。
| 類似度しきい値 | キャッシュ命中率 | 偽陽性率(品質劣化) | 月コスト削減率 |
| 0.80 | 約 72% | 約 11% | 約 60% |
| 0.88 | 約 55% | 約 4% | 約 45% |
| 0.92 | 約 42% | 約 1.2% | 約 35% |
| 0.95 | 約 28% | 約 0.4% | 約 22% |
数字は私のアプリでの実測値です。読者の方の用途次第ですが、品質劣化率を 5% 以下に抑えたいなら 0.88〜0.92 の間が落とし所になります。私は最終的に 0.92 を採用しました。割合からすると 42% しか当たりませんが、AdMob 月収を支えている主力アプリで品質を崩したくなかったためです。
この数字は導入のメンタルモデルとして大事です。「セマンティックキャッシュを入れたらコストが 8 割減りました」というブログ記事を信じてしきい値を緩めにすると、ユーザーから「変な答えが返ってくる」という低評価レビューが付き始めます。個人開発で星 1 のレビューが付くダメージはコスト削減効果を簡単に上回るので、保守的なしきい値で始めることをお勧めします。
TTL とバージョン付きキー — 古い知識を残さないための運用
セマンティックキャッシュの落とし穴の 1 つは「モデルやプロンプトが変わったあとも古い回答が居座る」ことです。Gemini 2.0 Flash から Gemini 2.5 Flash に切り替えたとき、私のアプリは知らない間に新旧モデルの応答が混在する状態になっていました。気付いたのはユーザーから「最近返事のテンションが違う」というメッセージが来てからです。
これを構造的に防ぐために、キャッシュキーには次の 3 つを混ぜます。
- プロンプトテンプレートのバージョン文字列(私は
promptVersion: "v3-2026-05" のように年月入りにしています)
- 使っているモデル名(
gemini-2.5-flash)
- 入力テキストのハッシュ
このうち 1 と 2 を変えれば、過去のキャッシュは検索条件から外れるため自動的に無視されます。Vectorize 側はメタデータでフィルタできるので、検索時に where: { model: "gemini-2.5-flash", promptVersion: "v3-2026-05" } のように絞ります。完全一致のキャッシュキーよりも複雑ですが、長く運用すれば必ず助けられる仕組みです。
TTL 自体は KV 側で 7 日にしています。これは「7 日以上前のキャッシュは、ユーザーの嗜好変化や新着情報を反映していない可能性があるので破棄する」という判断です。チャットボット用途ならもっと短くてよく、ドキュメント検索用途ならもっと長くてよい、というように、用途次第で 1 日〜30 日の範囲で動かすのが現実的です。
本番投入で必ず計測する 5 つの指標
導入してすぐに見るべき指標を整理しておきます。
- キャッシュ命中率 — 全リクエストのうちキャッシュから返した割合。30% 未満ならしきい値が厳しすぎるか、入力データの揺れが想定外
- 平均類似度スコア — 命中したリクエストの類似度。ヒストグラムを取り、しきい値ぎりぎりの命中が多いなら品質劣化を疑う
- コスト削減率 — Gemini API 呼び出し回数 × 推定単価で、キャッシュ導入前との差分を取る。私のアプリでは 35% でした
- 平均応答時間の短縮 — キャッシュヒット時は Gemini 呼び出しが省略されるため、私の環境で 600ms → 80ms 程度に縮みました
- 品質劣化フィードバック — レビュー、サポート問い合わせ、星評価のトレンド変化。1 週間単位で追う
これらを Logflare や Cloudflare Analytics Engine、または BigQuery に流して、週次でダッシュボード化することをお勧めします。私の場合は Workers Analytics Engine に書き込んで、Grafana で見ています。指標を取らずに導入すると「効いているはずなのに API 請求が下がらない」状態が続き、しきい値を緩めて品質を崩す、という最悪のループに入りやすいです。
個人開発でセマンティックキャッシュを使う判断基準
最後に「そもそも自分のアプリに必要なのか」を判断するための観点を整理します。私は次の 3 つを満たしたときだけ導入を推奨します。
- プロンプトが自由文で、ユーザー間で「意味は同じだが文字列は違う」入力が一定割合ある
- Gemini API のコストが月収の 5% 以上を占めている、もしくはアクセス急増時に間に合わなくなる
- 回答の鮮度要件が「数時間〜数日単位」で許容できる(リアルタイム性が必須ならキャッシュ自体が向いていない)
このうちどれかが欠けるなら、まずは完全一致キャッシュやリクエストデデュプリケーション(同じプロンプトが並行で来ているときに 1 回しか叩かない)から入る方が費用対効果が高いです。セマンティックキャッシュは設計コストも運用コストも高めで、向き合うべき問題があるアプリで初めて効きます。
私の癒し系アプリでは、3 つの条件を全部満たしていたので踏み切りましたが、別のシンプルな壁紙アプリには入れていません。「向き合うべき問題があるかどうか」で線引きするのが、個人開発の体力を消耗させないコツだと感じています。
実装は派手ではないものの、AdMob 収益を支える主力アプリで月 35% の API コスト削減と平均応答時間 600ms → 80ms の短縮を両立できました。読者の方のアプリでも、しきい値の保守的な設定から始め、計測指標を 1 週間追いかけて品質劣化が出ないことを確認してから少しずつ緩めていく、というステップで進めるのが現実的だと思います。
同じように個人で複数アプリを運用している方の参考になれば嬉しいです。お読みいただきありがとうございました。