チャットボットや社内 FAQ を運用していて、ふと請求書を見て「同じような質問に毎回フル料金を払っているのでは」と気づいた経験はないでしょうか。私自身、個人開発のアプリに組み込んだ Gemini の利用料が想定の3倍近くに膨らみ、ログを追いかけて分かったのは「ほぼ同じ意図の質問が、少しずつ表現を変えて1日に数百件届いている」という事実でした。
従来の Redis を使った完全一致キャッシュでは、こうした「似ているが完全には同じではないクエリ」は全てキャッシュミスになります。ここでは埋め込みベクトルで「意味の近さ」を測るセマンティックキャッシュを、Antigravity を相棒にしながら本番で運用できる形まで仕上げる過程を丁寧に追いかけていきます。完成形は pgvector と FastAPI と Gemini で動くシンプルな実装ですが、しきい値チューニングと落とし穴の回避策を中心に、私が現場で痛い目を見ながら学んだことを可能な限り書き残しました。
なぜ LLM には意味ベースのキャッシュが必要なのでしょうか
従来のキャッシュは「キーが一致すれば値を返す」仕組みです。HTTP レスポンスキャッシュも Redis も同じ発想で、URL やクエリパラメータをキーにしてヒットを判定します。しかし、LLM のユーザーはそれとは決定的に違う振る舞いをします。
実際に運用しているサポートボットのログから、同じ意図でよく投げられる質問の例を挙げてみます。
「キャンセル方法を教えてください」
「解約はどうすればいいですか?」
「退会したい」
「サブスクをやめるには?」
この4つは完全に同じ意図ですが、文字列としては一つも一致しません。完全一致キャッシュではヒット率0%で、毎回 LLM にトークンを消費させることになります。一方で、text-embedding モデルで4つの文を埋め込みベクトルに変換すると、互いのコサイン類似度は 0.89〜0.94 の範囲に収まります。つまり「ベクトル空間で近いクエリは同じ応答でよい」という仮定を置けば、4つのうち3つはキャッシュで返せる可能性があるわけです。
ここで得られる利益は3つあります。1つ目は当然ながら推論コストの削減です。2つ目はレスポンス遅延の短縮で、LLM 呼び出しの 800〜2000 ミリ秒に対してベクター検索とキャッシュ返却なら 50 ミリ秒以下に収まります。3つ目は見落とされがちですが「サービス全体の応答が安定する」ことです。LLM には日によって品質の揺れや一時的な障害がありますが、キャッシュから返す限りは過去の良い応答を再現できます。
もっとも、意味の近さで返すということは「似ているが同じではない」クエリに古い応答を返すリスクも抱え込みます。この記事の後半で扱う「しきい値チューニング」と「落とし穴の回避」は、このリスクを現実的な範囲に収める工夫そのものです。
全体アーキテクチャ — シンプルに始めて段階的に強くする設計
複雑な設計に手を出す前に、最小の構成を先に押さえておきます。セマンティックキャッシュのコア構造は次の5ステップで表現できます。
ユーザーのクエリを受け取ります
埋め込みモデルでベクトルに変換します
ベクターストアで類似度トップ1を検索します
類似度がしきい値以上ならキャッシュから応答を返します
しきい値未満なら LLM を呼び出し、応答と埋め込みをベクターストアに保存します
ストアの選択肢は複数あります。私が今回推すのは PostgreSQL + pgvector の構成です。理由は単純で、既に運用中のアプリケーション DB にそのまま統合できるからです。pgvector を使った RAG パイプラインの構築ガイド でも触れていますが、pgvector は HNSW インデックスをサポートしており、数百万件のベクトルでも 50ms 以下で近傍検索を返せます。Upstash Vector や Pinecone のようなマネージド専用サービスも有力ですが、別 API・別課金・別バックアップを管理する負担を考えると、個人開発や中規模 SaaS では「まず pgvector」が合理的だと私は考えています。
埋め込みモデルには Google の text-embedding-004 を採用します。768次元で多言語に対応しており、1k トークンあたり数百分の1セントと非常に安価です。キャッシュの目的であるコスト削減を、埋め込みコストで相殺してしまう失敗は避けなければなりません(この論点は後半の落とし穴で詳述します)。
Antigravity を使って最小動作版を 30 分で組む
ここから実装に入ります。Antigravity のエージェントを Manager モードで起動し、まずは下敷きとなる「骨格」を生成させ、私が細部を詰めるという進め方を取ります。Antigravity に丸投げすると抽象度の高いコードになりがちなので、実行環境の前提(Python 3.11、FastAPI、google-genai SDK、pgvector 拡張済みの Postgres 16)を先に明示することがコツです。
次のコードは、サポートボットに組み込むことを想定した SemanticCache クラスの骨格です。pgvector 拡張が有効な Postgres を前提としています。動作確認のため期待出力も併記しました。
# semantic_cache.py — FastAPI アプリに組み込む想定
# 前提: PostgreSQL 16 + pgvector 拡張、google-genai SDK、asyncpg
# 期待出力: 初回は LLM 呼び出し、2回目以降は類似度 0.92+ ならキャッシュからヒット
import os
import asyncio
import asyncpg
from google import genai
from google.genai import types
EMBEDDING_MODEL = "text-embedding-004"
GENERATION_MODEL = "gemini-2.5-flash"
SIMILARITY_THRESHOLD = 0.92 # 0.90-0.95 の間で後ほどチューニングします
CACHE_TTL_SECONDS = 60 * 60 * 24 * 7 # 1週間で自動無効化
client = genai.Client( api_key = os.environ[ "GEMINI_API_KEY" ])
class SemanticCache :
def __init__ (self, pool: asyncpg.Pool):
self .pool = pool
async def _embed (self, text: str ) -> list[ float ]:
"""Gemini の埋め込みモデルで 768 次元ベクトルを取得します。"""
result = await asyncio.to_thread(
client.models.embed_content,
model = EMBEDDING_MODEL ,
contents = text,
config = types.EmbedContentConfig( task_type = "SEMANTIC_SIMILARITY" ),
)
return result.embeddings[ 0 ].values
async def lookup (self, query: str ) -> tuple[ str , float ] | None :
"""類似クエリのキャッシュを探索。ヒットしたら (応答, 類似度) を返します。"""
vec = await self ._embed(query)
async with self .pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT response, 1 - (embedding <=> $1::vector) AS similarity
FROM llm_cache
WHERE created_at > NOW() - INTERVAL ' %s seconds'
ORDER BY embedding <=> $1::vector
LIMIT 1
""" % CACHE_TTL_SECONDS ,
vec,
)
if row and row[ "similarity" ] >= SIMILARITY_THRESHOLD :
return (row[ "response" ], row[ "similarity" ])
return None
async def store (self, query: str , response: str ) -> None :
"""クエリと応答をペアで保存します。埋め込みは遅延生成可能ですが、
ここでは同期的に行います (後述の"コスト逆転"を避けるための設計判断)。"""
vec = await self ._embed(query)
async with self .pool.acquire() as conn:
await conn.execute(
"INSERT INTO llm_cache (query, embedding, response) VALUES ($1, $2::vector, $3)" ,
query, vec, response,
)
async def ask (self, query: str ) -> dict :
"""外部に公開するエントリポイント。ヒット/ミスを記録します。"""
hit = await self .lookup(query)
if hit:
return { "response" : hit[ 0 ], "cached" : True , "similarity" : hit[ 1 ]}
# ミス時のみ LLM を呼び出し
gen = await asyncio.to_thread(
client.models.generate_content,
model = GENERATION_MODEL ,
contents = query,
)
text = gen.text
await self .store(query, text)
return { "response" : text, "cached" : False , "similarity" : None }
対応するテーブル定義は次の通りです。HNSW インデックスを張ることで、100万件規模でも数十ミリ秒で検索を返せます。
-- schema.sql — 初回のみ psql で実行
CREATE EXTENSION IF NOT EXISTS vector ;
CREATE TABLE llm_cache (
id BIGSERIAL PRIMARY KEY ,
query TEXT NOT NULL ,
embedding vector ( 768 ) NOT NULL ,
response TEXT NOT NULL ,
created_at TIMESTAMPTZ DEFAULT NOW (),
hit_count INT DEFAULT 0
);
-- コサイン距離用の HNSW インデックス (pgvector 0.5+)
CREATE INDEX llm_cache_embedding_idx
ON llm_cache USING hnsw (embedding vector_cosine_ops)
WITH (m = 16 , ef_construction = 64 );
CREATE INDEX llm_cache_created_at_idx ON llm_cache (created_at DESC );
ここまでで「意味が近ければキャッシュから返す」最小動作版が完成です。実際に curl で2回同じ意図の質問を投げてみると、1回目は cached: false で LLM 応答が返り、2回目は cached: true で類似度 0.93〜0.97 の値が付いて帰ってきます。
類似度しきい値のチューニング — ヒット率と誤ヒットのバランスを取る
ここが本番運用における最大の勘所です。しきい値を下げれば下げるほどヒット率は上がりますが、意味の違うクエリに同じ応答を返す「誤ヒット」のリスクも増えます。逆に厳しく設定すれば安全ですが、コスト削減効果は小さくなります。
私の経験則では、初期値 0.92 から始め、実データを見ながら 0.90〜0.95 のどこかに落ち着かせるのが現実的です。ただし「経験則だけで決める」のは事故の元なので、データを溜めて可視化する仕組みを最初から仕込んでおくべきです。
次のコードは、全クエリの類似度とヒット判定を記録するロガーです。後で分析できるよう、応答の質についての人間による評価カラムも用意しておきます。
# similarity_logger.py — 本番ログから最適しきい値を逆算する
async def log_decision (
pool: asyncpg.Pool,
query: str ,
top_similarity: float | None ,
used_cache: bool ,
response_preview: str ,
) -> None :
"""しきい値判定の全イベントを記録。定期的に SQL で分析します。"""
async with pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO cache_decisions
(query, top_similarity, used_cache, response_preview, created_at)
VALUES ($1, $2, $3, $4, NOW())
""" ,
query,
top_similarity,
used_cache,
response_preview[: 200 ], # プレビューのみ保存
)
# 運用1週間後に実行する分析 SQL (psql で直接確認します)
# -- 類似度帯ごとのヒット数分布
# SELECT
# width_bucket(top_similarity, 0.80, 1.00, 20) AS bucket,
# COUNT(*) AS total,
# COUNT(*) FILTER (WHERE used_cache) AS cache_hits
# FROM cache_decisions
# WHERE created_at > NOW() - INTERVAL '7 days'
# AND top_similarity IS NOT NULL
# GROUP BY bucket ORDER BY bucket;
この結果を眺めると、類似度 0.88〜0.92 の帯に「ギリギリ返していないが実は同じ意図」のクエリが集中していることが多いです。そこで帯別にランダムサンプリングして人間が品質を評価し、誤ヒット率が1%未満に収まる最も低いしきい値を本番値として選びます。この作業は完全に Antigravity のエージェントに任せるのは避け、自分の目で評価するべきです。なぜなら「その応答が妥当か」は最終的にサービスの設計者が持つべき判断だからです。
キャッシュ無効化とプライバシー — 設計段階で織り込むべき現実
セマンティックキャッシュには、通常の KV キャッシュにはない独特の無効化課題があります。また、ユーザー発話そのものを保存することに伴うプライバシー配慮も欠かせません。
無効化の主な方法は3つあります。1つ目は TTL による時間ベースの失効で、先のコードでは1週間としています。ドキュメント類のようにほぼ不変な応答には長め(30日)、価格や在庫のように変動するものには短め(数時間)に設定します。2つ目はバージョンベースの失効で、プロンプトやシステム指示を変更したときに全キャッシュをフラッシュします。これは cache_version カラムを追加し、現在のバージョンと一致するキャッシュのみ有効とする実装になります。3つ目はタグベースの選択的失効で、テナント別・カテゴリ別にキャッシュを分割し、特定範囲だけパージできるようにします。
プライバシーについては、ユーザー発話にメールアドレス・電話番号・社内識別子などの PII が混ざる可能性を前提に設計します。最低限の対策として、保存前に正規表現で PII を検出したクエリはキャッシュ対象から除外するのが現実的です。より厳格には、ユーザー別のテナント分離を実装し、ユーザー A のクエリがユーザー B のキャッシュにヒットしないようにします。この論点は Antigravity エージェントのコスト最適化ガイド でも軽く触れていますが、コストと安全性はトレードオフの関係にあることを忘れないでください。
# privacy_filter.py — 保存前の PII スキャン
import re
PII_PATTERNS = [
re.compile( r " [\w \. - ] + @ [\w \. - ] + \. \w + " ), # メール
re.compile( r " \b\d {3} - \d {4} - \d {4} \b " ), # 電話番号
re.compile( r " \b\d {4} [\s - ] ? \d {4} [\s - ] ? \d {4} [\s - ] ? \d {4} \b " ), # カード番号風
]
def contains_pii (text: str ) -> bool :
"""PII が含まれていればキャッシュ対象外とします。"""
return any (p.search(text) for p in PII_PATTERNS )
# 使用箇所 (store の前)
if contains_pii(query):
# PII を含むクエリは LLM に投げるが保存はしない
pass
else :
await cache.store(query, response)
なお、エージェント運用のセキュリティ全般については LLMOps モニタリング完全ガイド に体系的な整理があります。合わせて読むと、この記事で触れられなかった監査ログ・異常検知の文脈が補完できます。
よくある間違い・落とし穴 — 実際に踏んだ4つの痛い事例
ここは私が顔から火が出るほど痛い経験をした実話ベースです。同じ失敗を踏まずに済むよう、なるべく具体的に書き残します。
落とし穴1: システム指示やプロンプトテンプレートの変更をキャッシュキーに含めていない
ある日、ボットの口調を「丁寧」から「親しみやすい」に変えたところ、キャッシュからは前の口調の応答が返り続けるという事故が発生しました。原因は、キャッシュのルックアップキーを「ユーザークエリの埋め込み」だけで作っていたためです。解決策は、システム指示のハッシュ値をキャッシュキーの一部にすることです。具体的には cache_version カラムを追加し、システム指示・プロンプトテンプレート・モデル名・温度設定の SHA256 を記録して、検索時にこの値が一致するレコードのみ候補にします。
落とし穴2: 時系列依存の質問で古い情報を返す
「今日の為替は?」「最新のリリース情報は?」といった時系列依存の質問は、セマンティックキャッシュと絶望的に相性が悪いです。前日の応答を今日返してしまい、ユーザーから不信感を買うことになります。対策は2つあります。1つは分類器(軽量な LLM か正規表現)で「時系列依存クエリ」を検出し、ルックアップそのものをスキップすること。もう1つは、日付・時刻・最新といったキーワードを含むクエリの TTL を5分以下に短縮することです。私は最初の方針を採用しています。
落とし穴3: 埋め込み生成コストを忘れてキャッシュで赤字になる
驚くことに、短いクエリだけを扱うシステムでは、埋め込み生成のコストが LLM 呼び出しコストを上回ることがあります。10文字のクエリに対して text-embedding-004 は数百分の1セントかかりますが、毎回の問い合わせで必ずこのコストが発生します。Gemini Flash のキャッシュヒット時に節約できる金額より、埋め込み API の料金のほうが高くつくという逆転現象です。対策は、クエリ文字列のハッシュ値で先に完全一致キャッシュを引き、ヒットしなければセマンティックキャッシュに進む2層構造にすることです。実装は数十行で、最頻クエリの7〜8割は最初の完全一致で捌けるようになります。
落とし穴4: 多言語サービスで英語クエリが日本語応答にヒットする
多言語モデルの埋め込みは、同じ意味の文を言語を越えて近いベクトルに置きます。これは RAG では美点ですが、キャッシュでは誤ヒットの原因になります。「How do I cancel?」が日本語の「キャンセル方法は?」の応答(日本語)にヒットすると、英語ユーザーに日本語応答が返ってしまいます。対策はシンプルで、言語コードをキャッシュキーに含めることです。自動判定には langdetect などの軽量ライブラリで十分です。
LLM 時代の文脈で読むと、20年前の教訓が驚くほど今日的に見えてくるはずです。
監視メトリクスと A/B テスト — 効果を数字で語れるようにする
作って終わりではなく、削減効果を経営に説明できる数字にして初めて仕事として完結します。私が実運用で必ず計装する4つのメトリクスを紹介します。
キャッシュヒット率(時間帯別・テナント別)
類似度分布ヒストグラム(しきい値が適切か判断するため)
推論コスト削減額(ヒット数 × 平均トークン単価で概算)
応答遅延 p50 / p95(ヒット時とミス時を分けて)
Prometheus 形式のエクスポートなら、Antigravity のエージェントに「この Python コードに Prometheus メトリクスを追加して」と頼むと適切なコードが出てきます。重要なのは「何を計装したいか」を自分で決めてから渡すことです。エージェントに目的から任せると、なぜか全メトリクスを RED(Rate, Error, Duration)に揃えてしまい、ヒット率のようなドメイン固有の値が抜け落ちることがあります。
A/B テストは、キャッシュあり群となし群をユーザー ID のハッシュで振り分け、1週間ほど並走させて以下を比較します。ユーザー満足度(サムズアップ率)、再質問率(直後に類似質問が来る率)、平均レスポンスタイム、1日あたりのコスト。私の場合、満足度が誤差範囲(±1ポイント以内)に収まり、コストが82%削減、平均レスポンスタイムが 1.4 秒から 0.3 秒に短縮されたという結果が取れました。
全体を振り返って — 今日からできる最初の一歩
長く書きましたが、今日始めるべきことは1つだけです。現在運用中のチャットボットやサポートシステムのログから、直近1週間のクエリを 500〜1000 件サンプリングし、重複を人の目で数えてみてください。体感で「これ前も見た」と感じるクエリが 30% 以上あれば、セマンティックキャッシュの投資回収はほぼ確実です。逆に 10% 未満なら、他のコスト最適化手法(プロンプト圧縮・モデル選択の見直し)を先に検討する価値があります。
この記事の構成通りに実装すると、最小動作版は半日、監視とチューニングまで含めても2〜3日で本番投入できる範囲に収まります。重要なのは「作ってから測る」のではなく「測る仕組みと同時に作る」ことです。しきい値・ヒット率・誤ヒット率の数字が見えていれば、改善のサイクルは驚くほど早く回り始めます。