TL:DR
- ベクトル検索対応したDynamoDBでセマンティックキャッシュは機能した
- コストは実質「ミス率 × クエリ数」で決まる。間欠トラフィックならDynamoDBが圧倒的に安い
- サーバーレス構成ならDynamoDB、常時高トラフィックや最速レイテンシ狙いならValkeyという使い分け
📚はじめに
DynamoDBでベクトル検索対応しましたね。
※機能自体の解説は本記事の主旨ではないのでこちらの素晴らしい記事をご参照ください。
ということは"セマンティックキャッシュ"としても使えるんじゃね?ということでやってみました。
🧐セマンティックキャッシュとは
普通のキャッシュと何が違うの?
普通のキャッシュはキーの完全一致で動きます。例えば「返品したい」というキーで回答を保存しても「商品を送り返したいんですが」ではヒットしません。人間にとっては同じ質問でも文字列としては別物だからですね。
セマンティックキャッシュ(意味的キャッシュ)はこの判定を「文字が同じか」ではなく「意味が近いか?」で行います。言い回しが違っても意図が同じなら保存済みの回答を返せるので「同じことを違う言葉で聞かれる」のがLLMアプリと相性が良いのです。
仕組み(ざっくり)
予め、質問文のベクトルと回答文をキャッシュとして保持しておいて。質問する際に質問を埋め込みモデルでベクトル化し、キャッシュ側のベクトルと類似検索します。十分に近いものが見つかれば(ヒット)保存済みの回答を返し、見つからなければ(ミス)通常のRAG検索を行った後に、質問と回答のペアをキャッシュに書き戻します。
キャッシュ検索時に、あらかじめ設定したスコアの範囲に満たないものはミスとなります。
ポイントは「十分に近い」の線引きをする類似度閾値です。緩すぎると別の質問に別人の回答を返す事故が起き、厳しすぎると誰もキャッシュにヒットしません。
何が嬉しいの?
RAG検索のLLM呼び出しは数秒かかるうえ、従量課金です。同じような質問が繰り返し来るユースケースなら、2回目以降をキャッシュから返すことで応答は数秒から数百ミリ秒になり、LLM代もまるごと浮きます。カスタマーサポートのFAQのように「聞かれることが大体決まっている」領域とは特に相性が良いのです。
👑定番はElastiCache Valkey
2025年10月にElastiCacheのベクトル検索がGAしてからは、AWSでセマンティックキャッシュといえばValkeyが定番ですね。
ベクトル検索はサーバーレス非対応という罠
ところがElastiCacheにはサーバーレスオプションがあるのにベクトル検索が使えるのはノードベースクラスタだけ(2026年8月時点)。つまりキャッシュのために24時間365日起動しておく必要があります。最小の cache.t4g.micro でも月10ドル前後、公式検証で使われている cache.r7g.large だと月130ドル前後です(US East基準)。誰も質問していない深夜3時もノードは元気に稼働しています。![]()
アクセスが疎らなら従量課金で組みたいっ(願望)
常時トラフィックのあるサービスなら固定費で全然ペイします。悩ましいのは社内ツールやFAQ、検証段階のLLMアプリのような間欠的なワークロードで、1日数百クエリのためにノードを立てるのはどうにも気が引けますよね。使った分だけ課金のDynamoDBがベクトル検索に対応した今、ここを置き換えられないか?というのが本記事の主旨になります。
⛏️DynamoDBでセマンティックキャッシュを作ってみた
全体構成
題材はカスタマーサポートにしました。テーブルは2つ作りました(ナレッジ用/キャッシュ用)。
| テーブル | 役割 |
|---|---|
cs-support-manual(ナレッジ層) |
応対マニュアル10件。キャッシュミス時のRAG検索対象 |
cs-semantic-cache(キャッシュ層) |
質問文とそのベクトル + LLMが生成した回答文 + 出典マニュアルID |
顧客の質問が来たら、まずキャッシュ層を類似検索します。閾値内にヒットすれば保存済みの回答と出典を即返し、ミスならナレッジ層をRAG検索してLLM(Claude Haiku 4.5)に回答を作らせ、質問+回答+出典をキャッシュ層に書き戻す構成です。
大事なのは「キャッシュ層に埋め込むのは質問文」という点です。似ているかどうかを判定したい相手は過去の質問だからです。回答文はベクトル化せずヒットしたら返すだけです。
テーブルとベクトルインデックスを作る
テーブル作成時に --vector-indexes を指定します。
aws dynamodb create-table \
--region ap-northeast-1 \
--table-name cs-support-manual \
--attribute-definitions AttributeName=ManualId,AttributeType=S \
--key-schema AttributeName=ManualId,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--vector-indexes '[
{
"IndexName": "ManualVectorIndex",
"VectorAttribute": { "AttributeName": "ContentVector" },
"Dimensions": 1024,
"DistanceFunction": "COSINE",
"Projection": { "ProjectionType": "ALL" }
}
]'
ナレッジを書き込む
応対マニュアルを10件用意しました。
| ManualId | Category | Title | Content(先頭のみ) |
|---|---|---|---|
| MANUAL-001 | 返品・交換 | 商品の返品および交換の受付手順 | お客様から返品のお申し出があった場合、まず注文番号と... |
| MANUAL-002 | 配送遅延 | 配送の遅れに関するお問い合わせ対応 | 配送状況のお問い合わせを受けたら、注文番号から配送業... |
| MANUAL-003 | 初期不良 | 初期不良品の交換対応フロー | 商品が動作しない、破損しているとのご連絡を受けた場合... |
| MANUAL-004 | パスワードリセット | ログインできない場合のパスワード再設定案内 | ログイン不可のお問い合わせでは、まず登録メールアドレ... |
| MANUAL-005 | 請求・支払い | 請求金額に関するお問い合わせ対応 | 請求金額に相違があるとのご指摘を受けたら、該当月の注... |
| MANUAL-006 | 解約手続き | 有料会員プランの解約受付手順 | 解約のお申し出があった場合、まず解約理由を任意でお伺... |
| MANUAL-007 | クーポン利用 | クーポンコードが使用できない場合の対応 | クーポンが適用できないとのお問い合わせでは、まずコー... |
| MANUAL-008 | 会員情報変更 | 登録情報の変更手続き案内 | 住所や電話番号の変更はマイページの「会員情報編集」か... |
| MANUAL-009 | 領収書発行 | 領収書および適格請求書の発行対応 | 領収書のご依頼を受けたら、マイページの注文履歴からP... |
| MANUAL-010 | 営業時間・店舗案内 | 営業時間と実店舗に関する問い合わせ対応 | カスタマーサポートの受付時間は平日の午前9時から午後... |
Titan Text Embeddings V2 で Title + Content を1024次元にベクトル化して PutItem します。
まずナレッジ検索を単体で試す
キャッシュ層を作る前に、ナレッジ検索がちゃんと「意味」で拾えるかを確認しておきます。マニュアル本文とは違う言い回しの顧客質問を5件投げてみました(TopK=3)。
| # | 顧客質問 | 期待 | 1位 | 判定 | 1位スコア(距離) |
|---|---|---|---|---|---|
| 1 | 買った商品が壊れてたんですけど | 初期不良 | 初期不良 | OK | 0.694855 |
| 2 | 引き落とし金額がおかしい気がします | 請求・支払い | 請求・支払い | OK | 0.806849 |
| 3 | サイズが合わなかったので別のものと取り替えたいです | 返品・交換 | 請求・支払い | NG | 0.806700 |
| 4 | ログインのパスワードを忘れてしまいました | パスワードリセット | パスワードリセット | OK | 0.529474 |
| 5 | 来週で月額プランをやめたいのですがどうすればいいですか | 解約手続き | 解約手続き | OK | 0.604598 |
3問目だけNGでしたが、1位とのスコア差はわずか0.011の接戦で、正解も2位にちゃんと入っています。RAGでは上位3件をLLMに投げるので実害はない、という判断です。
キャッシュテーブルを作る
キャッシュ層は別のテーブル cs-semantic-cache にしました。
持たせる属性はこちらです。
| 属性 | 中身 |
|---|---|
| CacheId (PK) | エントリのID |
| QueryText | 質問文そのもの |
| QueryVector | 質問文の埋め込み。ここにベクトルインデックスを張る |
| Answer | LLMが生成した回答文 |
| SourceManualIds / SourceTitles | 回答生成時にRAGで使ったマニュアルの出典 |
| ExpireAt | TTL用のepoch秒 |
ヒット判定と類似度閾値
回答フローの本体がこちらです。
THRESHOLD = 0.7
def answer(question: str):
vector = embed(question) # Titanで質問を埋め込み
hit = search_cache(vector) # キャッシュ層をSearchVectors(TopK=1)
if hit and hit["score"] <= THRESHOLD:
return hit["answer"], hit["sources"] # ヒット: 保存済み回答+出典を即返す
manuals = search_manuals(vector) # ミス: ナレッジ層をRAG検索(TopK=3)
result = generate_answer(question, manuals) # LLM(Haiku 4.5)で回答生成
put_cache(question, vector, result, manuals) # 質問+回答+出典を書き戻し
return result, manuals
キャッシュ側の検索は最類似の過去質問1件だけ見ればよいので TopK=1 で十分です。
動かしてみる
まずキャッシュを空にして顧客質問5件を投げると想定どおり5/5がミスになり、RAG検索+LLM生成が走って回答と出典がキャッシュに書き戻されました。所要は1件あたり2.7〜3.4秒です。(haikuだとやはり速いな...)
続いてウォームラン(キャッシュあり状態)。同じ意図の言い換え質問5件を投げます。
| # | 言い換え質問 | 判定 | スコア(距離) | ターンアラウンド |
|---|---|---|---|---|
| 1 | 届いた品物が不良品でした | HIT | 0.644852 | 490.5ms |
| 2 | カード引き落としの金額に誤りがあるようです | HIT | 0.513193 | 190.0ms |
| 3 | 注文したサイズが違ったので交換をお願いしたいです | HIT | 0.443936 | 201.0ms |
| 4 | アカウントにサインインできず、パスワードが分かりません | HIT | 0.508777 | 202.1ms |
| 5 | 有料会員プランを今月中に解約したいです | HIT | 0.404004 | 184.4ms |
全件(5/5)ヒットです。回答文と出典は保存済みのものがそのまま返り、LLMは一度も呼ばれていませんでした。Haikuの出番はゼロです。
無関係な質問を投げてみる
「今日の東京の天気は?」はスコア0.920、「おすすめのラーメン屋さんはありますか?」は0.887で、どちらも閾値0.7に遠く及ばず正しくミス。無関係なキャッシュを拾って答えることはなく通常のRAG検索処理を行いました。
今回登場した計12テキストの全ペア間距離を計測して、分布を見てみました。
| ペアの種類 | n | min | max | avg |
|---|---|---|---|---|
| 同じ意図の言い換え同士 | 5 | 0.4040 | 0.6449 | 0.5030 |
| 意図が違うサポート質問同士 | 30 | 0.7569 | 0.9798 | 0.8764 |
| 無関係な質問 vs サポート質問 | 20 | 0.8682 | 0.9837 | 0.9298 |
同じ意図の最大値0.6449と、違う意図の最小値0.7569の間。この間あたりがキャッシュヒット/ミスを切り分ける閾値になってきます。今回はとりあえず0.7を閾値に設定しました。とはいえ件数はまだ少なく、境界事例を増やせば分布は変わりうるので実運用ではしっかりとしたチューニングする必要があります。
ヒット時と未ヒット時を測り比べる
ヒット時と未ヒット時をそれぞれ20回ずつ、3つの観点で測りました(ネットワーク往復込み)。
| 指標 | ヒット時 p50 | ヒット時 p95 | 未ヒット時 p50 | 未ヒット時 p95 |
|---|---|---|---|---|
| ベクトルデータ抽出時間(小計) | 185.6ms | 218.2ms | 191.4ms | 236.4ms |
| ├ 埋め込み生成(Titan V2) | 146.9ms | 178.3ms | 115.3ms | 155.3ms |
| ├ キャッシュ検索(SearchVectors) | 38.7ms | 39.9ms | 38.2ms | 40.1ms |
| └ ナレッジRAG検索(SearchVectors) | なし | なし | 37.9ms | 41.1ms |
| LLM生成時間(Haiku 4.5) | 0ms(発生せず) | 0ms(発生せず) | 2258.5ms | 2832.2ms |
| ターンアラウンドタイム | 185.7ms | 214.8ms | 2542.7ms | 3097.9ms |
差を生んでいるのはほぼLLM生成時間です。セマンティックキャッシュが削っているのは「LLM呼び出し」という一番重い処理そのもので、DynamoDBのベクトル検索はどちらの経路でも誤差レベルの脇役でした。
未ヒット時にはキャッシュへの書き戻し(約48ms)もターンアラウンドに含めています。回答を先に返して書き戻しを非同期にすれば、ここはさらに削れます。
キャッシュの鮮度管理
キャッシュエントリに出典( SourceManualIds )を持たせたのには、表示以外にもう一つ狙いがあります。マニュアルが改訂されたとき「そのマニュアルを出典に持つキャッシュだけ」を狙い撃ちで消すことが可能になります。マニュアルが変更されたのに同じ質問をして古いマニュアルの結果がキャッシュヒットしたら嫌ですよね?キャッシュを削除することで次回の検索では通常のRAG検索とLLM回答生成が行われ、最新ナレッジで回答を生成できます。
古いキャッシュの削除
古いキャッシュはTTLで自動削除できます。DynamoDBの特性として削除までのラグはあリマスがこのワークロードではデメリットになりません。
よくヒットするキャッシュは残したいっ!
ヒットするキャッシュを残したい場合はTTL対象のExpireAtを更新すれば良いです。(寿命を伸ばす)
Valkeyの Least Recently Used (LRU) アルゴリズムにちょっと似てますね。
⚖️Valkeyとの比較
では気になる王道ValkeyとDynamoDBのセマンティックキャッシュを比べてみましょう。
応答速度
ElastiCache for Valkey(cache.t4g.micro、Valkey 9.0)を実際に立てて同じ計測をしました。キャッシュデータは cs-semantic-cache の5件を保存済みベクトルごと移植(埋め込みの再生成なし)、ミス時のRAG+LLM生成もDynamoDB版と同一です。
ElastiCacheはVPC内からしか繋がらないため、Valkey側の計測は踏み台EC2経由のSSHトンネルを掘って、DynamoDB側と同じくローカルPCから行っています(計測位置を揃えるため)。
3指標の実測結果(p50、各20回)を並べます。
| 指標 | DynamoDB ヒット | Valkey ヒット | DynamoDB 未ヒット | Valkey 未ヒット |
|---|---|---|---|---|
| ベクトルデータ抽出時間(小計) | 185.6ms | 170.5ms | 191.4ms | 197.3ms |
| ├ 埋め込み生成(Titan V2) | 146.9ms | 142.8ms | 115.3ms | 130.1ms |
| ├ キャッシュ検索 | 38.7ms | 27.7ms | 38.2ms | 27.5ms |
| └ ナレッジRAG検索 | なし | なし | 37.9ms | 39.7ms |
| LLM生成時間 | 0ms | 0ms | 2258.5ms | 2381.0ms |
| ターンアラウンドタイム | 185.7ms | 170.8ms | 2542.7ms | 2638.7ms |
ターンアラウンドはどちらの構成でも未ヒット時2.5〜2.6秒がヒット時0.17〜0.19秒になっており、「LLMを呼ばない分だけ速くなる」という同じ結論に落ち着きました。
コスト構造と損益分岐点
単価を並べても構造が違いすぎるのでまず構造から簡単に整理します。Valkeyはノード時間課金で使っても使わなくても毎月同じ額。代わりに検索は叩き放題です。DynamoDBは完全従量でちょっと複雑なので棚卸しするとこうなります。
| 処理 | 課金されるもの | 実測値 |
|---|---|---|
| キャッシュ検索(SearchVectors) | ベクトル検索 $0.002/GB | 28.3KB/回(数件時)〜54.5KB/回(1万件時) |
| ナレッジRAG検索(ミス時のSearchVectors) | 同上 | 29.5KB/回 |
| 書き戻し(ミス時のPutItem) | 通常のWRU + ベクトル書き込み$0.52/GBの二階建て | 5WRU + 4,822バイト/回 |
| ストレージ | テーブル+ベクトルインデックス 各$0.25/GB-月 | 数十KB(誤差) |
ナレッジRAG検索はProjection ALLで返るアイテム本体もベクトル検索課金の内数なので、ヒット時に別途 GetItem で引き直す必要がなく、読み取り側はベクトル検索課金だけ見ればOKです。一方の書き戻しはアイテム本体の通常WRU(実測5WRU)とベクトルインデックスへの書き込み(実測4,822バイト)の両方がかかります。これを全部積んで試算します。前提は、WRU単価$0.625/100万WRU(US East)ストレージは無視できる規模、埋め込み生成コストは両構成共通のため除外。キャッシュ件数は実運用を想定して1万件とします。
キャッシュ検索の単価は、質問文を実際に1万件投入して実測した54.5KB/回(TopK=1)を使っています。
キャッシュミス率は読みにくいので、30% / 50% / 70% の3パターンで試算します。
| 月間クエリ数 | DynamoDB(ミス30%) | DynamoDB(ミス50%) | DynamoDB(ミス70%) | Valkey (t4g.micro) | Valkey (r7g.large) |
|---|---|---|---|---|---|
| 1万 | 約$0.02 | 約$0.03 | 約$0.04 | 約$9.3 | 約$128 |
| 100万 | 約$1.8 | 約$3.0 | 約$4.1 | 約$9.3 | 約$128 |
| 1000万 | 約$18 | 約$30 | 約$41 | 約$9.3 | 約$128 |
| 1億 | 約$182 | 約$296 | 約$410 | 約$9.3 | 約$128 |
DynamoDBセマンティックキャッシュのコストは実質「ミス率 × クエリ数」で決まり、ヒット率が上がるほど安くなる、キャッシュらしい性質を持っています。損益分岐に直すとこうなります。
『1億クエリはt4g.microじゃ捌けんやろ!』というツッコミが聞こえてきそうですが...無視します![]()
損益分岐
| ミス率 | 100万クエリあたり | t4g.microとの損益分岐 | r7g.largeとの損益分岐 |
|---|---|---|---|
| 30% | 約$1.8 | 月510万クエリ | 月7,000万クエリ |
| 50% | 約$3.0 | 月310万クエリ | 月4,300万クエリ |
| 70% | 約$4.1 | 月230万クエリ | 月3,100万クエリ |
運用と機能
速度とコスト以外の差も表にまとめます。
| 観点 | DynamoDB | ElastiCache Valkey |
|---|---|---|
| 課金 | 従量(GB単位) | 固定(起動時間) |
| トラフィックゼロ時 | ストレージ代のみ | ノード代が満額 |
| 接続経路 | VPC設計不要 | VPC内限定 |
| 検索レイテンシ | 1桁ms(公称)、実測38〜41ms(往復込み) | 実測27.7ms(往復込み)、VPC内からは0.31ms |
| エビクション | TTLのみ | TTLに加えLRU等 |
| 検索時フィルタ | 等価条件のみ | 豊富 |
| 運用 | なし | ノード管理、バージョンアップ |
ElastiCacheにはサーバーレスオプションもありますが、ベクトル検索が非対応のためセマンティックキャッシュ用途では使えません。そのため接続経路もVPC内に限定されます。また上記の試算には表れていませんが、アーキテクチャ構成によってはVPC関連のコストや運用の手間も考慮する必要が出てきます。
🧭使い分け指針
DynamoDBが向くのは、トラフィックが間欠的なアプリです。社内ツール、PoC、夜間はほぼ無風のサービスなど、「ノードを24時間立てるほどではない」規模なら従量課金が素直に効きます。すでにDynamoDBで会話履歴などを管理していてキャッシュを同居させたい場合や、キャッシュのためだけに運用対象を増やしたくない場合も有効な選択肢となります。Lambda中心のサーバーレス構成なら相性は抜群です。VPC設計なしのLambdaからHTTPSで呼ぶだけでセマンティックキャッシュが完結します。埋め込みのBedrockも回答生成のLLMも同じくHTTPSで呼べるので、キャッシュを足してもアーキテクチャに「VPC」が登場しません。ここはVPC住まいのValkeyには真似できないところですね。
Valkeyが向くのはその逆です。常時高トラフィックで検索単価を固定費に押し込んだほうが安くつく規模やLRU戦略/ハイブリッド検索(全文 + ベクトル)が必要な要件なら素直にValkeyを選ぶのが良いと思います。
ちなみに...ManagedKBのセマンティックキャッシュとしても使えます。ManagedKBのemmbeddingモデルは公開されてませんが、セマンティックキャッシュのemmbeddingモデルと合わせる必要はない為実現可能です。ただManagedKBを使うモチベーションとして多いのが「手放しで手軽にRAG検索したい!」という部分だと思われます。セマンティックキャッシュのようなアプリ側でゴリゴリ制御するのはちょっと相性悪いかなと思ったりしました。
🍵締め
DynamoDBのベクトル検索対応でアーキテクチャの選択肢がいろいろ広がってきたように思います。今後のDynamoDBのアップデートやもしかしたらValkeyサーバーレスのベクトル検索対応もあるかもしれないので動向をチェックしていきたいですね。