Amazon ElastiCache でのセマンティックキャッシュ検証 (Valkey 8.2)
はじめに
LLM の応答生成は、モデル推論の処理時間と外部 API 呼び出しの待ち時間がボトルネックになりやすいです。過去と同じ、または意味が近い問合せでは、生成済みの応答をキャッシュから返すことで推論と外部呼び出しを省略できます。本記事では、Amazon ElastiCache の Valkey 8.2 を使い、セマンティックキャッシュの導入を検証しました。
セマンティックキャッシュとは
セマンティックキャッシュは、質問文が完全一致しなくても、意味が近い質問でキャッシュをヒットさせる方式です。質問文を埋め込み (embedding) というベクトルに変換し、ベクトル検索で近いデータを探します。近さの評価には、コサイン類似度 (cosine similarity) のような指標を使います。
検証環境
- リージョン: ap-northeast-1
- ElastiCache: Valkey 8.2
- Node.js: v22.16.0
- iovalkey: 0.3.1
- 埋め込み: Titan Text Embeddings v2 (Bedrock)
全体の流れ
アプリケーションは入力文を埋め込みへ変換し、ElastiCache に保存した埋め込みと KNN 検索で比較します。閾値を超えた場合は、キャッシュ済みの応答を返します。
検証
ElastiCache (Valkey 8.2) の準備
Terraform の最小例を示します。サブネットグループやセキュリティグループは省略し、要点だけ記載します。
resource "aws_elasticache_parameter_group" "valkey8" {
family = "valkey8"
name = "REPLACE_ME-valkey8-params"
parameter {
name = "reserved-memory-percent"
value = "50"
}
}
resource "aws_elasticache_replication_group" "semantic_cache" {
replication_group_id = "REPLACE_ME-semantic-cache"
description = "Valkey 8.2 for semantic cache"
engine = "valkey"
engine_version = "8.2"
node_type = "cache.t4g.micro"
num_cache_clusters = 1
parameter_group_name = aws_elasticache_parameter_group.valkey8.name
subnet_group_name = "REPLACE_ME"
security_group_ids = ["sg-REPLACE_ME"]
transit_encryption_enabled = true
}
Node.js から TLS で接続する
Node.js から iovalkey で ElastiCache (Valkey) に TLS 接続し、あわせて Bedrock Runtime を呼び出します。
mkdir test-semantic-cache
cd test-semantic-cache
npm init -y
必要なパッケージをインストールします。
npm install iovalkey @aws-sdk/client-bedrock-runtime
次のようなテストスクリプトを配置します。
test-vector-search.js
import Valkey from 'iovalkey';
import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime';
// 設定
const config = {
elasticacheEndpoint: process.env.ELASTICACHE_ENDPOINT || 'localhost',
elasticachePort: parseInt(process.env.ELASTICACHE_PORT || '6379'),
awsRegion: process.env.AWS_REGION || 'ap-northeast-1',
embeddingModelId: 'amazon.titan-embed-text-v2:0',
indexName: 'idx:test_cache',
similarityThreshold: 0.45,
};
// Bedrock クライアント
const bedrockClient = new BedrockRuntimeClient({ region: config.awsRegion });
/**
* テキストから Embedding を生成
*/
async function generateEmbedding(text) {
const command = new InvokeModelCommand({
modelId: config.embeddingModelId,
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify({ inputText: text }),
});
const response = await bedrockClient.send(command);
const parsed = JSON.parse(new TextDecoder().decode(response.body));
return parsed.embedding;
}
/**
* Float32Array を Buffer に変換(Valkey 用)
*/
function embeddingToBuffer(embedding) {
return Buffer.from(new Float32Array(embedding).buffer);
}
/**
* メインテスト
*/
async function main() {
console.log('='.repeat(60));
console.log('ElastiCache Valkey Vector Search Test');
console.log('='.repeat(60));
console.log(`Endpoint: ${config.elasticacheEndpoint}:${config.elasticachePort}`);
console.log(`Index: ${config.indexName}`);
console.log(`Similarity Threshold: ${config.similarityThreshold * 100}%`);
console.log('');
// Valkey 接続
console.log('[1] Connecting to Valkey...');
const valkey = new Valkey({
host: config.elasticacheEndpoint,
port: config.elasticachePort,
tls: config.elasticacheEndpoint !== 'localhost' ? {} : undefined,
maxRetriesPerRequest: 3,
});
valkey.on('error', (err) => {
console.error('Valkey connection error:', err.message);
});
try {
// 接続確認
const pong = await valkey.ping();
console.log(` PING response: ${pong}`);
// Valkey バージョン確認
const info = await valkey.info('server');
const versionMatch = info.match(/valkey_version:(\S+)/);
const version = versionMatch ? versionMatch[1] : 'unknown';
console.log(` Valkey version: ${version}`);
if (version !== 'unknown' && parseFloat(version) < 8.2) {
console.error(' ERROR: Valkey 8.2 or higher is required for vector search');
process.exit(1);
}
console.log('');
// [2] クリーンアップ & インデックス作成
console.log('[2] Creating vector index...');
try {
await valkey.call('FT.DROPINDEX', config.indexName);
console.log(' Dropped existing index');
} catch (err) {
// インデックスが存在しない場合は無視
}
// ベクトルフィールドのみのスキーマ(TEXT フィールドは別途 HSET で保存)
await valkey.call(
'FT.CREATE', config.indexName,
'ON', 'HASH',
'PREFIX', '1', 'test:',
'SCHEMA',
'embedding', 'VECTOR', 'FLAT', '6',
'TYPE', 'FLOAT32',
'DIM', '1024',
'DISTANCE_METRIC', 'COSINE'
);
console.log(' Index created successfully');
console.log('');
// [3] サンプルデータ登録
console.log('[3] Storing sample data with embeddings...');
const sampleData = [
{
key: 'test:q1',
question: '営業時間は何時から何時までですか?',
answer: '営業時間は平日9時から18時までです。土日祝日はお休みをいただいております。',
},
{
key: 'test:q2',
question: '返品はできますか?',
answer: '商品到着後7日以内であれば、未使用品に限り返品を承っております。',
},
{
key: 'test:q3',
question: '配送料はいくらですか?',
answer: '5,000円以上のご購入で送料無料です。5,000円未満の場合は全国一律500円です。',
},
];
for (const item of sampleData) {
console.log(` Generating embedding for: "${item.question.substring(0, 30)}..."`);
const embedding = await generateEmbedding(item.question);
const embeddingBuffer = embeddingToBuffer(embedding);
await valkey.hset(item.key, {
question: item.question,
answer: item.answer,
embedding: embeddingBuffer,
});
console.log(` Stored: ${item.key}`);
}
console.log('');
// [4] ベクトル検索テスト
console.log('[4] Testing vector search...');
const testQueries = [
'何時に開いていますか?', // 類似: 営業時間
'返品の条件を教えてください', // 類似: 返品
'送料はかかりますか?', // 類似: 配送料
'クレジットカードは使えますか?', // 非類似: 新規
];
let cacheHits = 0;
for (const query of testQueries) {
console.log(`\n Query: "${query}"`);
const queryEmbedding = await generateEmbedding(query);
const queryBuffer = embeddingToBuffer(queryEmbedding);
// 今回の検証では SORTBY がエラーになったため、使用しません
const results = await valkey.call(
'FT.SEARCH', config.indexName,
'*=>[KNN 1 @embedding $vec AS score]',
'PARAMS', '2', 'vec', queryBuffer,
'DIALECT', '2'
);
// 結果のパース
const numResults = results[0];
if (numResults > 0) {
const docId = results[1];
const docData = results[2];
// docData は [field, value, field, value, ...] 形式
const dataMap = {};
for (let i = 0; i < docData.length; i += 2) {
if (docData[i] !== 'embedding') {
dataMap[docData[i]] = docData[i + 1];
}
}
const score = parseFloat(dataMap.score);
const similarity = 1 - score; // COSINE 距離から類似度へ変換
// question と answer を取得(インデックスには含まれないので別途取得)
const storedData = await valkey.hgetall(docId);
console.log(` -> Best match: ${docId}`);
console.log(` -> Similarity: ${(similarity * 100).toFixed(2)}%`);
console.log(` -> Threshold: ${config.similarityThreshold * 100}%`);
if (similarity >= config.similarityThreshold) {
console.log(` -> CACHE HIT`);
console.log(` -> Answer: "${storedData.answer.substring(0, 50)}..."`);
cacheHits++;
} else {
console.log(` -> CACHE MISS: Similarity below threshold`);
}
} else {
console.log(' -> No results found');
}
}
console.log('');
// [5] クリーンアップ
console.log('[5] Cleaning up test data...');
for (const item of sampleData) {
await valkey.del(item.key);
}
await valkey.call('FT.DROPINDEX', config.indexName);
console.log(' Test data and index removed');
console.log('');
console.log('='.repeat(60));
console.log('Test completed successfully!');
console.log(`Cache hits: ${cacheHits} / ${testQueries.length}`);
console.log('='.repeat(60));
} catch (error) {
console.error('\nError:', error.message);
if (error.stack) {
console.error(error.stack);
}
process.exit(1);
} finally {
await valkey.quit();
}
}
main();
環境変数を設定します。
export ELASTICACHE_ENDPOINT=REPLACE_ME
export ELASTICACHE_PORT=6379
export AWS_REGION=ap-northeast-1
実行し、結果を確認します。
node test-vector-search.js
test-vector-search.js の詳細解説
test-vector-search.js の詳細解説
最初に、ベクトル検索用のインデックスを作成します。今回の検証では、スキーマは embedding のみを定義し、question と answer は HASH に保存するだけにしました。
await valkey.call(
'FT.CREATE', config.indexName,
'ON', 'HASH',
'PREFIX', '1', 'test:',
'SCHEMA',
'embedding', 'VECTOR', 'FLAT', '6',
'TYPE', 'FLOAT32',
'DIM', '1024',
'DISTANCE_METRIC', 'COSINE'
);
次に、質問文から埋め込みを生成し、HASH に保存します。埋め込みは Float32 の配列を前提とし、Valkey 用に Float32 のバイナリへ変換して保存します。
function embeddingToBuffer(embedding) {
return Buffer.from(new Float32Array(embedding).buffer);
}
await valkey.hset(item.key, {
question: item.question,
answer: item.answer,
embedding: embeddingBuffer,
});
埋め込み生成は Bedrock Runtime の InvokeModel を使います。
async function generateEmbedding(text) {
const command = new InvokeModelCommand({
modelId: config.embeddingModelId,
contentType: 'application/json',
accept: 'application/json',
body: JSON.stringify({ inputText: text }),
});
const response = await bedrockClient.send(command);
const parsed = JSON.parse(new TextDecoder().decode(response.body));
return parsed.embedding;
}
次に、入力文の埋め込みを作り、KNN 検索で最も近い 1 件を取得します。今回の環境では SORTBY がエラーになったため、スクリプトでは SORTBY を使っていません。
const results = await valkey.call(
'FT.SEARCH', config.indexName,
'*=>[KNN 1 @embedding $vec AS score]',
'PARAMS', '2', 'vec', queryBuffer,
'DIALECT', '2'
);
返却された docId を使い、HGETALL で answer を取得しています。インデックスのスキーマに answer を含めない方針にしたためです。
最後に、score を similarity に変換し、閾値でキャッシュヒットを判定します。今回の検証では、score は COSINE 距離として扱い、similarity = 1 - score で変換しています。
const score = parseFloat(dataMap.score);
const similarity = 1 - score; // COSINE 距離から類似度へ変換
日本語では similarityThreshold を 0.45 に設定しました。これは後述の実測値から決めた値です。
結果
| 項目 | 結果 |
|---|---|
| ElastiCache Valkey 8.2 | ベクトル検索動作確認 |
| Node.js クライアント | iovalkey で動作 |
| インデックス作成 (FT.CREATE) | 成功 |
| ベクトル検索 (FT.SEARCH) | 成功 |
| 完全一致時の類似度 | 100% |
日本語テキストの類似度
Titan Text Embeddings v2 で、日本語テキストの類似度を実測しました。保存データは 営業時間は何時から何時までですか? です。
| クエリ | 保存データ | 類似度 |
|---|---|---|
| 営業時間は何時から何時までですか? | 同じ | 100% |
| 営業時間を教えてください | 営業時間は何時から何時までですか? | 48.67% |
| 何時に開いていますか | 営業時間は何時から何時までですか? | 37.56% |
| 返品はできますか | 営業時間は何時から何時までですか? | 15.01% |
追実験: キャッシュヒット時の応答時間 (ECS Fargate 実測)
本検証とは別に、ECS Fargate (ap-northeast-1) 上の Node.js 実行環境で、セマンティックキャッシュによる応答時間の改善を実測しました。1 回目の問合せではキャッシュが空のためミスとなり、2 回目の問合せでは同一または近い入力によりヒットするシナリオです。
シナリオ
- 1 回目:
AWS支援について教えてで回答生成し、結果をキャッシュへ保存 - 2 回目: 同一質問で回答キャッシュがヒットし、応答をキャッシュから返却
あわせて、不満判定 (NEG 判定) も同様にキャッシュ対象とし、近い不満発話でヒットすることを確認しました。
応答時間の比較
| 処理 | キャッシュなし | キャッシュあり | 備考 |
|---|---|---|---|
| 回答生成 | 3,571 ms | 133 ms | 2 回目は回答をキャッシュから返却 |
| 不満判定 | 約 1,500 ms | 約 90 ms | 2 回目は判定結果をキャッシュから返却 |
同一入力の 2 回目では、回答生成が 3,571 ms から 133 ms へ短縮しました。セマンティックキャッシュは、LLM 呼び出しの待ち時間を省略できるため、会話テンポに直結する改善が見込めます。
Bedrock API 呼び出し回数の削減
| シナリオ | 呼び出し回数 | 内訳 |
|---|---|---|
| 1 回目 (キャッシュなし) | 3 回 | Embedding + 回答生成 + 不満判定 |
| 2 回目 (キャッシュあり) | 1 回 | Embedding のみ |
2 回目は Embedding のみを実行し、回答生成と不満判定の呼び出しを省略できました。
考察
今回の検証は、セマンティックキャッシュ導入判断の根拠を作れた点に意味があると考えています。音声 IVR やチャットでは、応答の間が UX を左右します。したがって、「速いはず」という期待ではなく、定量値として説明できたことが重要です。上記の結果から、同じ質問が繰り返される窓口では、会話テンポを守る手段としてセマンティックキャッシュを採用しやすいと考えられます。
導入時に迷いやすいのが類似度閾値の設定だと思います。閾値が高すぎるとヒットせず、低すぎると誤ヒットが増えるためです。今回の実測では、日本語では 0.40 から 0.50 程度が出発点として現実的でした。本番では、想定される文章の言語や内容に応じて、閾値を再評価し調整すべきでしょう。
1 回の入力で複数回 LLM を呼び出す設計では、待ち時間とコストが増えやすいです。セマンティックキャッシュは、ヒット時にそれらの呼び出しをまとめて省略できるため、有効な抑制策になりえます。ただし、ElastiCache の利用料が追加で発生するため、費用対効果はヒット率とトラフィックを前提に評価すべきでしょう。
まとめ
本記事では、Amazon ElastiCache の Valkey 8.2 でベクトル検索を有効化し、セマンティックキャッシュを検証しました。Node.js (iovalkey) から TLS で接続し、FT.CREATE と FT.SEARCH による KNN 検索が動作することを確認しました。日本語の実測では、言い換え表現の類似度が 0.40 から 0.50 程度になることが分かりました。追実験ではキャッシュヒットにより応答時間が 3,571 ms から 133 ms に短縮し、会話テンポ改善の根拠を定量値として示せました。






