「キャッシュを入れたいんですが、似たような質問で違う答えが返ってきたら怖くて」— これは私自身が3つのプロダクトでセマンティックキャッシュを実装したときに、毎回最初に出てきた懸念でした。
完全一致のキャッシュ(Redis に SHA-256 ハッシュで保存するアレ)はすぐに実装できます。しかし本番のユーザーは同じ質問でも、句読点や語順、敬体・常体の違いで毎回キーが変わります。結果としてキャッシュヒット率は 5% にも満たず、Claude API のコストはほぼそのまま、応答時間も縮みません。
セマンティックキャッシュは「意味的に近いプロンプト」を embedding で検出し、過去のレスポンスを再利用する仕組みです。うまく設計すれば、ヒット時の応答は 50ms 程度で返り、Claude API への課金は発生しません。一方で、ナイーブに実装すると「金融商品の質問に医療系の回答を返す」ような事故が起きます。本記事は、その境界線を正しく引くための実装ガイドです。
なぜセマンティックキャッシュが必要なのか — 完全一致では届かない現実
個人開発で複数の小さなプロダクトを回している私にとって、API のコストは抽象的な費目ではなく、機能を出せるか諦めるかの分かれ目でした。だからこそ、キャッシュの設計には慎重にならざるを得ませんでした。
私が運用しているサポートボットでは、ユーザーの質問の 70% が「同じ意図・違う表現」でした。「ログインできません」「サインインが通らない」「アカウントに入れない」— 完全一致キャッシュでは全て別のキーになります。これを embedding ベースで類似判定すれば、3つの質問は1つのキャッシュエントリに集約できます。
セマンティックキャッシュが特に効くのは、「ユーザーの語彙が広いが、回答パターンは限定的」な領域です。FAQ ボット、商品検索アシスタント、エラー対応ヘルプなどが典型例です。逆に「個別のコードを毎回レビューしてもらう」用途では効きにくく、無理に入れると却って事故になります。
最初に決めるべきは「キャッシュさせていい領域」と「絶対に新鮮な応答を返す領域」の境界線です。私の現場では、cacheable: true というフラグをエンドポイントごとに明示的に立てる設計にしました。デフォルトは false、効果が確実な箇所だけ明示的に有効化します。これだけで「うっかり全エンドポイントに入れて壊した」事故を防げます。
アーキテクチャ全体像 — 3層キャッシュとデータフロー
本番に耐えるセマンティックキャッシュは、単独で使わず「3層構造」で組むのがおすすめです。
- 第1層: 完全一致キャッシュ(Redis、TTL 短め)— 最速・誤ヒットゼロ
- 第2層: セマンティックキャッシュ(Vector DB、しきい値判定あり)— 中速・要ガード
- 第3層: Claude API(フォールバック)— 最遅・最も確実
クエリは第1層から順に問い合わせ、ヒットしたら即返却します。第2層は類似度しきい値(後述の通り 0.92 前後を私は採用しています)を満たしたエントリのみを採用し、満たさなければ第3層に流します。
データフローはおおむね以下のようになります。tenant_id を含めることがマルチテナント分離の鍵で、これを忘れるとテナントAの回答がテナントBに漏れる事故になります。
# semantic_cache.py — 3層キャッシュの呼び出しフロー(pseudo-real)
import hashlib
import time
from typing import Optional
from anthropic import Anthropic
from redis import Redis
import qdrant_client
from qdrant_client.models import PointStruct, VectorParams, Distance, Filter, FieldCondition, MatchValue
EXACT_TTL_SEC = 3600 # 第1層: 1時間で失効
SEMANTIC_THRESHOLD = 0.92 # 第2層: cosine 類似度しきい値
SEMANTIC_TTL_SEC = 86400 # 第2層: 24時間で失効
def get_cached_response(
tenant_id: str,
user_query: str,
redis: Redis,
qdrant: qdrant_client.QdrantClient,
embed_fn, # 後述: Voyage や Cohere の embedding 関数
anthropic: Anthropic,
feature_id: str, # cacheable: true のエンドポイント識別子
) -> dict:
# 第1層: 完全一致
exact_key = f"cache:exact:{tenant_id}:{feature_id}:{hashlib.sha256(user_query.encode()).hexdigest()}"
cached = redis.get(exact_key)
if cached:
return {"text": cached.decode(), "layer": "exact", "latency_ms": 1}
# 第2層: セマンティック
embedding = embed_fn(user_query) # 1024 次元想定
hits = qdrant.search(
collection_name="claude_cache",
query_vector=embedding,
query_filter=Filter(must=[
FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)),
FieldCondition(key="feature_id", match=MatchValue(value=feature_id)),
]),
limit=1,
score_threshold=SEMANTIC_THRESHOLD, # しきい値未満は返らない
)
if hits:
# 第1層にも書き戻して次回を高速化
redis.setex(exact_key, EXACT_TTL_SEC, hits[0].payload["response"])
return {"text": hits[0].payload["response"], "layer": "semantic", "score": hits[0].score}
# 第3層: Claude API(フォールバック)
start = time.perf_counter()
response = anthropic.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": user_query}],
)
latency_ms = int((time.perf_counter() - start) * 1000)
text = response.content[0].text
# 両層に保存(汚染検査を経たエントリのみ — 後述)
redis.setex(exact_key, EXACT_TTL_SEC, text)
qdrant.upsert(
collection_name="claude_cache",
points=[PointStruct(
id=hashlib.sha256(f"{tenant_id}:{feature_id}:{user_query}".encode()).hexdigest()[:16],
vector=embedding,
payload={"tenant_id": tenant_id, "feature_id": feature_id,
"query": user_query, "response": text, "ts": time.time()},
)],
)
return {"text": text, "layer": "api", "latency_ms": latency_ms}
期待出力は以下のようなログです。layer が exact か semantic で返れば API への課金は発生していません。
{"layer": "exact", "latency_ms": 1}
{"layer": "semantic", "score": 0.94}
{"layer": "api", "latency_ms": 1820}
ステップ 1: embedding と類似度しきい値の設計
ここが本記事で最も重要なセクションです。しきい値が低すぎると誤ヒット(全く違う質問に過去の回答を返す)が起きます。高すぎるとキャッシュヒット率が伸びず、入れた意味がなくなります。
私の経験則は「日本語混在のサポート用途で cosine 類似度 0.90〜0.94」「英語の技術 Q&A で 0.88〜0.92」です。embedding モデルは voyage-3-large か cohere-embed-multilingual-v3 のような多言語対応モデルを推奨します。Claude そのものは embedding を返さないので、外部ベンダーを併用する設計になります。
しきい値を決めるとき、勘で選んではいけません。代わりに「過去ログから100ペアを人間がラベル付けし、PR 曲線(Precision-Recall)で最適点を見つける」のが正攻法です。最低限のスクリプトを示します。
# threshold_tuning.py — しきい値選定の評価スクリプト
import json
import numpy as np
from sklearn.metrics import precision_recall_curve
# 形式: {"query_a": "...", "query_b": "...", "should_match": True/False}
with open("labeled_pairs.jsonl") as f:
pairs = [json.loads(line) for line in f]
# 各ペアの cosine を計算(embed_fn は本記事の関数を流用)
similarities = []
labels = []
for p in pairs:
a = embed_fn(p["query_a"])
b = embed_fn(p["query_b"])
cos = float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
similarities.append(cos)
labels.append(1 if p["should_match"] else 0)
precision, recall, thresholds = precision_recall_curve(labels, similarities)
# Precision >= 0.98 を満たす最小 threshold を採用(誤ヒット2%以下が目安)
acceptable = [(t, p, r) for t, p, r in zip(thresholds, precision[:-1], recall[:-1]) if p >= 0.98]
if acceptable:
t, p, r = min(acceptable, key=lambda x: x[0])
print(f"推奨しきい値: {t:.4f} (precision={p:.3f}, recall={r:.3f})")
else:
print("Precision 0.98 を満たすしきい値なし — embedding モデル変更を検討")
なぜ Precision を優先するかというと、「キャッシュが効かない」のは課金が増えるだけですが、「誤った回答を返す」のはユーザー信頼を失う事故だからです。ここはトレードオフではなく、Precision を 0.98 以上に固定して Recall を最大化する、という非対称な最適化が正解です。
ステップ 2: キャッシュキーとマルチテナント分離
マルチテナント SaaS でセマンティックキャッシュを使う場合、tenant_id を vector の payload と filter 条件の両方に必ず含めてください。私は過去、payload のみに入れて filter を忘れた構成で、テナント A のキャッシュがテナント B に返る事故を起こしました。発覚は内部監査の数日後で、肝が冷えました。
加えて、機能(feature_id)単位でも分離します。「FAQ ボット」と「コードレビュー」では適切なしきい値も TTL も違うため、コレクションを分ける、または filter で完全に隔離するのが安全です。機能単位でしきい値・TTL を分ける設計は、テナント分離と並んで事故を防ぐ土台になります。
データ漏洩の懸念がある業務(医療・金融・法務)では、そもそもセマンティックキャッシュ自体を cacheable: false で運用する判断もあり得ます。「キャッシュしないことのコスト」と「漏洩したときの損害」を天秤にかける、という基本姿勢を忘れないでください。
ステップ 3: キャッシュ汚染を防ぐガード層
セマンティックキャッシュ最大のリスクは「悪意ある入力で生成された有害なレスポンスがキャッシュに残り、後続ユーザーに配信される」汚染問題です。この対策として、私は書き込み前に3つのチェックをかけています。
# cache_guard.py — 書き込み前ガード
import re
INJECTION_PATTERNS = [
r"ignore (previous|all) instructions",
r"system prompt",
r"あなたは今から",
r"以下の指示を無視",
]
def is_safe_to_cache(query: str, response: dict, anthropic: Anthropic) -> bool:
# ガード1: プロンプトインジェクションらしき入力を弾く
if any(re.search(p, query, re.IGNORECASE) for p in INJECTION_PATTERNS):
return False
# ガード2: stop_reason が end_turn 以外(途中で切れた / 安全装置で停止)はキャッシュしない
if response.stop_reason not in ("end_turn",):
return False
# ガード3: LLM-as-a-judge でレスポンス自体の安全性を1秒で判定
judge = anthropic.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=10,
messages=[{"role": "user", "content":
f"次のテキストにヘイトスピーチ・違法行為の助言・個人情報が含まれていれば NO、安全なら YES のみ返してください:\n\n{response.content[0].text[:2000]}"}],
)
return judge.content[0].text.strip().upper().startswith("YES")
重要なのは、ガード2で stop_reason を見ていることです。max_tokens で途中切断されたレスポンスをキャッシュすると、ユーザーは何度同じ質問をしても永遠に途中で切れた回答を見続けることになります。これは私が初期に踏んだ典型的な落とし穴です。
ガード3の LLM-as-a-judge は Haiku を使うため約 0.0003 USD/件で済みます。書き込み時のみ走らせれば、トラフィックの 5% 程度(キャッシュミス率に応じる)にしか発生しません。
ステップ 4: フォールバックと劣化モード
Vector DB(Qdrant、Pinecone、pgvector など)が落ちたとき、サービスごと止めてはいけません。セマンティックキャッシュは「あれば嬉しい」最適化レイヤーであり、「なくても動く」設計にしておきます。
# graceful_fallback.py — Qdrant 障害時に Claude API へ劣化
from contextlib import suppress
def safe_semantic_lookup(qdrant, query_vector, **kwargs):
try:
return qdrant.search(query_vector=query_vector, **kwargs)
except (qdrant_client.http.exceptions.UnexpectedResponse, ConnectionError) as e:
# ログには出すが、上位には None を返してフォールバック扱いに
log_metric("semantic_cache.lookup_error", 1, tags={"err": type(e).__name__})
return None
# 使い方
hits = safe_semantic_lookup(qdrant, embedding, collection_name="claude_cache",
query_filter=tenant_filter, limit=1, score_threshold=0.92)
if hits is None:
# Qdrant 障害 — Claude API へ直接フォールバック
response = anthropic.messages.create(...)
加えて、Vector DB の応答が遅いときに API 全体を遅延させない工夫も必要です。asyncio.wait_for(timeout=200ms) のようなタイムアウトを噛ませ、超過したら諦めて Claude API に行くだけ、という割り切りが有効です。
観測性 — 何を測れば「効いている」と分かるのか
セマンティックキャッシュの効果は、5つの指標で測ります。
- ヒット率(Hit Rate):
(exact_hits + semantic_hits) / total_requests — 30〜60% を目標
- 偽陽性率(False Positive Rate): ユーザーフィードバックや A/B テストで推定 — 2% 以下を死守
- 応答時間 P50/P95/P99: 完全一致は 5ms 以下、セマンティックは 80ms 以下が目安
- コスト削減率:
(cache_hits × 平均トークン単価) / 総支出
- 鮮度違反: 古いキャッシュが返って苦情になった件数(TTL 設計の指標)
OpenTelemetry でトレースを送るのが最もシンプルです。layer 属性をスパンに入れておけば、Datadog や Honeycomb で「どの層でどれだけ削れたか」が一目で分かります。観測性の実装パターンは Claude API + OpenTelemetry で AI アプリケーションの可観測性を確立するガイド でまとめています。
よくある落とし穴 — 私が踏んだ4つの地雷
実装中に何度も同じ失敗を繰り返したので、ここに集約しておきます。
地雷1: しきい値を「勘」で決める
「0.85 くらいでいいだろう」と決めて本番投入したところ、誤ヒットが頻発しました。前述のラベル付き PR 曲線で決めることをおすすめします。私は最低 100 ペア、できれば 300 ペアでチューニングしています。
地雷2: stop_reason を見ずに書き込む
max_tokens 切れの中途半端な回答がキャッシュに残ると、永遠に再現します。end_turn 以外は書き込まないルールを is_safe_to_cache に必ず入れてください。
地雷3: tenant_id を payload にしか入れず filter を忘れる
Qdrant や Pinecone では filter 条件にも tenant_id を入れないと、隣のテナントの結果が混入します。私は payload にだけ入れて検索時 filter を忘れた状態で本番に出してしまい、内部監査で発覚しました。
地雷4: TTL を一律にする
「24時間で失効」を全エントリに適用したところ、料金プランの説明が古い情報のままキャッシュに残り続けました。価格・在庫・契約条件のような頻繁に変わる情報は TTL 1時間以下、もしくはセマンティックキャッシュ対象から外す判断が必要です。
キャッシュ無効化 — いつか必ず向き合う問題
運用を続けると、必ず「このエントリを消したい」場面が来ます。私が実際に無効化を迫られたきっかけは、おおむね次のいずれかでした。
- 元データが変わった(価格改定・規約変更)
- 誤った回答が配信され、ユーザーから報告があった
- 参照している Claude モデルをアップグレードした(過去の回答が陳腐化する)
- テナントが解約・削除された
- ある機能のシステムプロンプトを変更した
元データ起点の無効化は、イベント駆動で組むのが最も素直です。上流データが変わったら tenant_id と feature_id を含むイベントを流し、Vector DB へフィルタ付き削除、Redis へ DEL を投げます。Qdrant・Pinecone・pgvector はいずれもフィルタ削除に対応しています。
モデルアップグレードの無効化は、破壊的な一括削除ではなく「名前空間のバージョニング」で解くことをおすすめします。Redis のキー接頭辞と Vector DB の payload に cache_version を持たせ、モデル差し替え時に値を上げるだけで旧エントリは到達不能になり、TTL で自然に消えていきます。
ユーザー報告起点では、キャッシュ対象の画面に「この回答を報告する」導線を置きます。報告はキューに溜め、人間が毎日レビューし、確定した不良エントリを両層から削除します。報告が特定の feature_id に偏っていれば、そのしきい値を締めるべき実証データにもなります。
コストモデリング — 期待値を低めに約束する
「ヒット率はどれくらいを見込めますか」と何度も聞かれてきました。以下は私が実際に追跡した本番の傾向値で、ベンチマーク上の理論値ではありません。
- 公開情報のFAQ(返金ポリシー等): ヒット率 60〜75%、定常でコスト 40〜60% 削減
- 社内ヘルプデスク(VPN申請の手順等): ヒット率 50〜65%、コスト 35〜50% 削減
- 商品探索の検索(症状別のおすすめ等): ヒット率 30〜45%、コスト 20〜30% 削減。ロングテールが多く低め
- 顧客固有のサポート(注文遅延の理由等): ヒット率 5〜15%。単独では導入コストに見合わないことが多い
- コード関連のQ&A: ヒット率 20〜35%。変数名やスタックトレースを正規化してから embedding するかに大きく依存
経営層への約束は、見込みより低めに置くのが健全です。1日の計測だけで「50% 削減できます」と約束し、四半期をかけて数字が目減りしていく — これはよくある落とし穴です。25〜30% と約束して上回るほうが、予算の議論は穏やかに進みます。
セマンティックキャッシュが不向きなケース
すべての LLM ワークロードをキャッシュ背後に置くべきではありません。無理に押し込むと、かえってユーザー体験を損ないます。私が見てきた注意すべきパターンです。
- 毎回の新鮮さが価値になる長文生成: 詩・広告コピー・コードの別案など、毎回違う出力を期待される用途では、完全な意味的一致でさえ「壊れている」と感じられます。再利用そのものが約束を破ります。
- 高度に個別化された助言: 特定ユーザーの口座・履歴・嗜好を踏まえる回答は、同一テナント内でも共有すべきではありません。
user_id でスコープを切ると安全ですが、その時点でヒット率は一桁に落ち、導入コストに見合わなくなりがちです。
- 副作用を伴うツール呼び出し: 書き込み・決済・外部API呼び出しを起こす応答では、言語部分だけをキャッシュして副作用を飛ばすと「起きたつもりで何も起きていない」幽霊応答になります。キャッシュするのは説明であって、行為ではありません。
- 状態を持つ会話: 「もっと詳しく」はどの会話でもほぼ同じ埋め込みになりますが、正解は直前のターンに依存します。メッセージ単位ではなくターン単位で、慎重に設計してからキャッシュします。
現場での判断基準はひとつ。「ユーザーAの回答がユーザーBに配信されて安全な理由を、非エンジニアに説明できるか」。説明は「埋め込みが近いから」より具体的である必要があります。「問いが誰にとっても変わらない公開事実で、回答が汎用的だから」— そこまで言えて初めてキャッシュ可能です。
キャッシュ自体をテストする
ここは誰も書かないので書いておきます。セマンティックキャッシュも本番コンポーネントである以上、CI で決定的にテストされるべきです。
第1に、embedding 関数と Vector DB をモックし、しきい値ロジック・テナントフィルタ・stop_reason ガードが正しく発火するかを検証するユニットテスト。高速で、毎コミット走らせます。
第2に、実際の Vector DB(Docker Compose の Qdrant や pgvector)に固定のシード埋め込みを流し込み、フィルタ付きクエリが該当テナントのデータだけを返すことを検証する結合テスト。前述したテナント間漏洩は、ここで捕まえるのが最も確実です。
第3に、Step 1 のラベル付きペア集合を、現行のしきい値と embedding モデルに対して定期的(日次・週次)に再実行し、Precision が 0.98 を割ったら通知する評価テスト。1ペアにつき embedding 1回ぶんのコストがかかり遅いですが、モデルを差し替えたときの静かな劣化を捕捉できます。
これらのテスト層を用意するコストは小さくありません。しかし、いずれ起きるキャッシュの退行が本番に滑り込んだときの損害は、それをはるかに上回ります。私はこれを「後回しでよい改善」ではなく、リリース判定の一部として扱っています。
本番チェックリストと次の一歩
ここまでで、本番投入前のチェックポイントが揃いました。以下を全てクリアしてからリリースすることをおすすめします。
- 該当エンドポイントが
cacheable: true で明示的に有効化されている
- しきい値が PR 曲線で決定済み(Precision >= 0.98)
tenant_id が payload と filter の両方に入っている
stop_reason == "end_turn" のレスポンスのみ書き込みする
- LLM-as-a-judge の安全性チェックが書き込み前に走る
- Vector DB 障害時の劣化モードがテスト済み
- ヒット率・偽陽性率・P95 レイテンシの観測ダッシュボードがある
- TTL を機能別に設計し、頻繁に変わる情報は短く設定している
最初の一歩として、まずは「最もキャッシュしやすい1つのエンドポイント」だけに導入し、A/B テストで効果を測ることをおすすめします。私の現場では、最初に FAQ ボットだけに入れた段階でヒット率 47%、月間 API コスト 38% 削減という結果が出ました。そこから対象エンドポイントを慎重に広げていけば、事故なく本番展開できるはずです。
セマンティックキャッシュは「正しく恐れて、正しく入れる」と確実に効果が出る数少ない最適化です。本記事のチェックリストを手元に置いて、皆さんの本番環境でも安全に効果を引き出してもらえたら嬉しいです。