Amazon Aurora PostgreSQL+pgvectorで、今から学ぶベクトルデータベース

記事タイトルとURLをコピーする

こんにちは。
アプリケーションサービス本部、DevOps担当の兼安です。
今回は、今から学ぶベクトルデータベースの基礎知識と題して、ベクトルデータベースの基本的な概念や基礎用語を紹介します。

本記事のターゲット

本記事はベクトルデータベースの初心者をターゲットにしています。
LLM や RAG にベクトルデータベースが関係することは知っているが、ベクトルデータベースがどういうものかはよくわからない・・・。
そんな気持ちをお持ちの方をイメージして書いていると思ってください。

ベクトルデータベースとは

ベクトルデータベースとは、データをベクトル(数値の配列)として保存し、ベクトル間の「距離」や「類似度」を使って検索するデータベースです。

従来のリレーショナルデータベースでは「完全一致」や「部分一致」(LIKE検索)でデータを探しますが、ベクトルデータベースでは意味的に近いものを探すことができます。
例えば、「東京の天気」で検索すると、「東京都の気温」や「関東地方の天候」といった、文字列としては異なるが意味的に関連するデータがヒットします。

ベクトル空間のイメージ

ベクトルデータベースでは、すべてのデータが多次元空間上の点として表現されます。 検索時には、クエリもベクトル化し、空間上で「距離が近い」データを探します。

graph LR
    subgraph ベクトル空間
        Q["🔍 東京の天気<br/>(検索クエリ)"]
        A["☀️ 東京都の気温"]
        B["🌧️ 関東地方の天候"]
        C["🗼 東京タワーの歴史"]
        D["🐍 Pythonの使い方"]
    end

    Q ---|"距離: 近い ✅(意味が似ている)"| A
    Q ---|"距離: 近い ✅(意味が似ている)"| B
    Q -.-|"距離: やや遠い(東京は共通だが話題が違う)"| C
    Q -.-|"距離: 遠い ❌(まったく無関係)"| D

こちらの図では二次元で表現していますが、本当のベクトルデータベースでは距離の近い・遠いが多次元的に定義されています。

ベクトルデータベースの用途

ベクトルデータベースは、以下のような幅広い用途で活用されています。

用途 説明
RAG(検索拡張生成) LLM に外部知識を与えるためのナレッジベース検索。 社内ドキュメントや最新情報を LLM の回答に反映できる
セマンティック検索 キーワードではなく意味で検索する社内文書検索や FAQ 検索。 表記揺れや同義語にも対応可能
レコメンデーション ユーザーの嗜好ベクトルに近い商品・コンテンツを推薦。 協調フィルタリングの代替・補完として利用
画像検索 類似画像の検索(顔認識、商品画像マッチング)。 画像を Embedding モデルでベクトル化して比較
異常検知 正常パターンのベクトルから離れたデータを異常として検出。 ログ分析やセキュリティ監視に活用
重複検出 類似文書・類似コードの検出。 コピペ検出やコンテンツの重複排除に利用

一番メジャーな用途は RAG ですね。

回答精度を向上させる技術:RAG

RAG とは

RAG(Retrieval-Augmented Generation)は、LLM が回答を生成する前に外部データソースから関連情報を検索し、その情報をプロンプトに含めることで回答精度を向上させる手法です。

LLM は学習データに含まれない情報(社内ドキュメント、最新のニュース、専門的な技術情報など)については正確に回答できません。
RAG を使うことで、ベクトルデータベースに格納した外部知識を LLM に参照させ、より正確で最新の回答を生成させることができます。

RAG を構築するにあたり、LLM に Amazon Bedrock を使用するならば Knowledge Bases というフルマネージドの RAG 機能があります。
Knowledge Bases を使うと、S3 などに置いたドキュメントを登録するだけで、ベクトル化・ベクトルデータベースの構築・検索の仕組みをすべて AWS が管理してくれます。
ベクトルデータベースを自分で用意する必要がないため、RAG を素早く試したい・インフラ管理を最小化したいケースに向いています。

RAG の基盤モデル – Amazon Bedrock ナレッジベース – AWS

今回はベクトルデータベースそのものの方に着目しているので、Knowledge Basesは使用せずに進めていきます。

RAG の処理フロー

RAG の処理は以下の流れで行われます。

  1. ユーザーが質問を入力する(例:「AWSのLambdaって何?」)
  2. アプリケーションが質問テキストを Embedding モデルでベクトル化する
  3. ベクトル化したクエリでベクトルデータベースを検索し、関連ドキュメントを取得する
  4. この時、何件の関連ドキュメントを取得するかを指定(top_k=3 など)
  5. 取得した関連ドキュメントをコンテキストとして LLM のプロンプトに含めて送信する
  6. LLM が検索結果を参照しながら回答を生成する
  7. ユーザーに回答を返す
sequenceDiagram
    participant User as ユーザー
    participant App as アプリケーション
    participant Emb as Embeddingモデル
    participant VDB as ベクトルDB
    participant LLM as LLM

    User->>App: 「AWSのLambdaって何?」
    App->>Emb: テキストをベクトル化
    Emb-->>App: [0.023, -0.014, 0.057, ...]
    App->>VDB: 類似ドキュメント検索(top_k=3)
    VDB-->>App: 関連ドキュメント3件
    App->>LLM: プロンプト + 検索結果をコンテキストとして送信
    LLM-->>App: 検索結果を参照した回答を生成
    App-->>User: 「AWS Lambdaはサーバーレスの...」

このように、ベクトルデータベースは RAG における「外部知識の検索エンジン」として中核的な役割を果たします。
ここから、この中の「ベクトルデータベースを検索」を詳しく説明していきます。

ベクトルデータベースの検索

検索の流れ

ベクトルデータベースの検索の流れは以下の通りです。

  1. ユーザーが「東京の天気」で検索する
  2. アプリケーションが「東京の天気」を Embedding モデルでベクトル化する(例:1024次元のベクトル)
  3. ベクトルデータベース内のデータ(事前に同じモデルでベクトル化済み)とコサイン距離を計算する
  4. 距離が近い順に上位 k 件を返却する
sequenceDiagram
    participant User as ユーザー
    participant App as アプリケーション
    participant Emb as Embeddingモデル
    participant DB as ベクトルDB

    User->>App: 「東京の天気」で検索
    Note over App: ★ ここが肝 ★<br/>検索クエリもベクトル化する
    App->>Emb: 「東京の天気」をEmbedding
    Emb-->>App: クエリベクトル [0.023, -0.014, ...] (1024次元)
    Note over App,DB: DB内のデータも事前に同じモデルで<br/>ベクトル化して保存済み
    App->>DB: コサイン距離で近傍検索 (top_k=5)
    DB-->>App: 類似度が高い上位5件を返却
    App-->>User: 「東京都の気温」「関東の天候」...

ベクトルデータベースには、データが多次元の数値で表現されています。
したがって、データや検索クエリは投入時に数値化します。
これをベクトル化・またはEmbeddingと呼びます。

ベクトルデータベースの検索で重要なポイントは、検索クエリ自体もベクトル化するということです。
テキストのまま検索するのではなく、Embedding モデル(後述)でベクトルに変換し、ベクトル空間上で距離が近いデータを探します。

ここから、Amazon Aurora PostgreSQL + pgvector(以下、Aurora PostgreSQL + pgvectorと略します)とPythonのコードによる実装例を用いて説明をしていきます。

AWS でベクトルデータベースを構築する手段は複数ありますが、現状最初に手に取るなら Aurora PostgreSQL + pgvector が一番扱いやすく感じるのと、一般的な関係データベースとベクトルデータベースの違いを感じるのにちょうどよいと思います。

検索の実装コード

以下は、Aurora PostgreSQL + pgvector を使った検索の実装例です。

# ① テキストクエリをベクトル化(handler.py)
embedding_result = generate_embedding(query)
query_embedding = embedding_result.embedding  # 1024次元のベクトル
  
# ② ベクトル化したクエリでDBを検索(logic.py)
with connection.cursor() as cur:
    cur.execute(
        # クエリベクトルとDB内ベクトルのコサイン距離を計算し
        # 距離が近い順に top_k 件を返す
        "SELECT content, embedding <=> %s::vector AS distance "
        "FROM embeddings ORDER BY distance LIMIT %s;",
        (query_embedding, top_k),
    )
    results = cur.fetchall()

ここで使っている <=> は pgvector のコサイン距離演算子です。
値が小さいほど類似度が高いことを意味します。
今回は、Aurora PostgreSQL + pgvector を使っているので、ベクトル DB へのクエリに SQL が使用できます。
このコードではプリペアドステートメントを利用し、SQLの %s の部分にベクトル化(Embedding)した検索テキストと、取得件数(top_k)を安全に渡しています。

シンプルな検索でも色々な用語が出てきたので、これらを解説します。

Embedding(=ベクトル化)

Embedding(エンベディング)とは、テキストや画像などのデータを数値ベクトルに変換する処理のことです。 「ベクトル化」とも呼ばれます。

人間には「東京の天気予報」と「東京都の気温」が似ていると直感的に分かりますが、コンピュータは文字列の比較しかできません。 Embeddingによって意味を数値化することで、コンピュータも「意味の近さ」を数学的に計算できるようになります。

変換前: "東京の天気予報"
変換後: [0.0231, -0.0142, 0.0567, ..., 0.0412]  ← 1024個の数値

Embeddingの実装コード

以下は、Amazon Bedrock の Titan Embeddings V2 を使ったEmbeddingの実装例です。
ここで実装しているgenerate_embedding関数を、先述の「検索の実装コード」の①で呼んでいます。

def generate_embedding(text: str) -> EmbeddingResult:
    """Bedrock Titan Embeddings V2 でテキストをベクトル化."""
    client = _get_bedrock_client()
    body = json.dumps({
        "inputText": text,        # ← 変換前: テキスト
        "dimensions": 1024,       # ← 出力次元数
        "normalize": True,        # ← 正規化(ベクトルの長さを1に揃える)
    })
  
    response = client.invoke_model(
        modelId="amazon.titan-embed-text-v2:0",
        body=body,
    )
  
    response_body = json.loads(response["body"].read())
    embedding = response_body["embedding"]  # ← 変換後: [float] × 1024
    return EmbeddingResult(embedding=embedding, time_ms=elapsed_ms)

normalize=True を指定すると、出力ベクトルの長さが 1 に正規化されます。
これにより、コサイン類似度の計算が内積の計算と等価になり、検索が効率化されます。

次元数

Embeddingの実装コードで、「次元数」というキーワードがあります。
次元数とは、1つのベクトルが持つ数値の個数です。

3次元ベクトル:    [0.5, -0.3, 0.8]           ← 3個の数値
1024次元ベクトル: [0.023, -0.014, ..., 0.041] ← 1024個の数値

次元数が多いほど「意味」をきめ細かく表現できますが、その分ストレージ消費も増えます。

次元数 1ベクトルのサイズ 10万件のサイズ
256次元 1 KB 約 100 MB
1024次元 4 KB 約 400 MB
1536次元 6 KB 約 600 MB
3072次元 12 KB 約 1.2 GB

次元数は「どのEmbeddingモデルを使うか」で決まります。 Titan Embeddings V2 は 256 / 512 / 1024 から選択可能で、用途に応じて精度とコストのバランスを取ることができます。

Embeddingモデル

テキストをベクトルに変換する専用モデルは、LLM(生成系モデル)とは別物です。
Embeddingモデルは「意味の類似度を計算するための表現」を生成することに特化しています。

モデル 提供元 次元数 特徴
Titan Embeddings V2 AWS Bedrock 256/512/1024 AWS ネイティブ。 正規化オプションあり。 AWS 環境との親和性が高い
Cohere Embed v3 AWS Bedrock 1024 多言語対応。 日本語の精度が高いと評価されている
text-embedding-3-small OpenAI 256〜1536 軽量・低コスト。 多言語対応。 コスト重視の用途に最適
text-embedding-3-large OpenAI 256〜3072 高精度・多言語対応。 次元数を柔軟に選択可能

重要な注意点として、検索時と登録時で必ず同じモデルを使う必要があります。
異なるモデルで生成したベクトル同士は、同じ空間上にないため距離の計算が意味を持ちません。

Amazon Titan Text Embeddings models - Amazon Bedrock

コサイン類似度、コサイン距離

コサイン類似度は、2つのベクトルが「どれくらい同じ方向を向いているか」を -1〜1 の数値で表したものです。
1 に近いほど意味が似ており、0 に近いほど無関係、-1 に近いほど意味が逆方向です。

コサイン距離は 1 - コサイン類似度 で定義され、0〜2 の範囲をとります。
値が小さいほど類似度が高いという性質があり、pgvector の <=> 演算子はこのコサイン距離を返します。
「距離」と「類似度」は符号が逆になるだけで、同じ概念の別表現です。

指標 範囲 「似ている」方向 使いどころ
コサイン類似度 −1〜1 値が大きい(1に近い) 閾値判定(「0.95以上ならヒット」など)
コサイン距離 0〜2 値が小さい(0に近い) ORDER BY で近い順に並べる SQL・KNN 検索

検索の実装コード(embedding <=> %s::vector)がコサイン距離でソートしているのに対し、後述のセマンティックキャッシュの閾値判定(similarity >= 0.95)にはコサイン類似度を使っています。

top_k

top_k は検索結果として返す上位 k 件の数です。 用途に応じて適切な値を設定します。

  • top_k が小さい(1〜5): 最も関連性の高い結果のみを返す。 RAG で LLM に渡すコンテキストを絞りたい場合に適している
  • top_k が大きい(10〜100): 幅広い候補を返す。 レコメンデーションや候補一覧の表示に適している

RAG においては、top_k で取得した結果一式を LLM のコンテキストとして渡すのが一般的です。
top_k を大きくしすぎるとコンテキストが長くなり、LLM のトークン消費やレイテンシが増加する点に注意が必要です。

正規化

正規化(normalize)とは、ベクトルの長さ(ノルム)を 1 に揃える処理です。
Titan Embeddings V2 では normalize=True を指定することで、出力ベクトルが自動的に正規化されます。
正規化されたベクトル同士のコサイン類似度は、単純な内積(ドット積)と等価になります。
内積はコサイン類似度よりも計算コストが低いため、検索の効率化につながります。
また、ベクトルの長さが統一されることで、距離の比較が純粋に「方向の違い」だけを反映するようになり、検索結果の品質も安定します。

ベクトルデータベースへのデータ登録

ベクトルデータベースの検索をするためには、当然事前にデータ登録をしておく必要があります。
今度はベクトルデータベースへのデータ登録を説明します。

データ登録の流れ

ベクトルデータベースへのデータ登録は、以下の流れで行います。

  1. HNSW インデックスを削除する(登録を高速化するため)
  2. テキストデータを Embedding モデルでベクトル化し、バッチ単位(例:500件ずつ)でデータベースに INSERT する
  3. 全データの登録が完了したら、HNSW インデックスを一括で作成する

検索と同じように、Aurora PostgreSQL + pgvector と Python のコードによる実装例を用いて説明をしていきます。

Aurora PostgreSQL + pgvector 上のテーブル定義

pgvector 拡張を有効にした Aurora PostgreSQL 上に、以下のテーブルとインデックスを作成しています。

-- pgvector 拡張を有効化
CREATE EXTENSION IF NOT EXISTS vector;

-- embeddings テーブル(ベクトルデータの格納先)
CREATE TABLE IF NOT EXISTS embeddings (
    id SERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    embedding vector(1024) NOT NULL
);

-- HNSW インデックス(ANN 検索を高速化)
CREATE INDEX IF NOT EXISTS idx_embeddings_embedding
    ON embeddings
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

embeddingsテーブルのcontentカラムにテキストデータを格納し、embeddingカラムにテキストをベクトル化して格納します。
そして、embeddingカラムにHNSW インデックスを張ります。

HNSW(検索アルゴリズムとインデックスアルゴリズム)

ベクトルデータベースにもインデックスがあり、Aurora PostgreSQL + pgvector においては、通常のインデックス同様に CREATE INDEX 文でインデックスを作ります。
この時、ON embeddings USING hnsw でインデックスアルゴリズムというものを指定しています。
インデックスアルゴリズムは検索アルゴリズムと密接な関係があり、ベクトルデータベースはこの2つのアルゴリズムが重要です。

検索アルゴリズム

ベクトルデータベースにおける検索手法は大きく2種類あります。

検索手法 正式名称 特徴
KNN K-Nearest Neighbor 全データと総当たりで比較する。 精度は完全だが、データ量が増えると計算コストが線形に増加するため遅い
ANN Approximate Nearest Neighbor 近似的に探索する。 精度は若干落ちるが、大量データでも高速に検索できる

実用的なシステムでは ANN が使われることがほとんどです。
数千件程度の小規模データであれば KNN でも問題ありませんが、数万件以上のデータを扱う場合は ANN が必須になります。

インデックスアルゴリズム

ANN を実現するためのデータ構造をインデックスアルゴリズムと呼び、いくつかの種類があります。

アルゴリズム 仕組み 特徴
HNSW 階層的なグラフ構造を構築し、上位層から下位層へと段階的に探索範囲を絞り込む 高精度かつ高速。 メモリ消費は多めだが、現在最もよく使われている
IVF データをクラスタリングし、クエリに近いクラスタのみを部分探索する メモリ効率が良い。 大規模データに向いているが、HNSW より精度が落ちる場合がある

現状ベクトルデータベースを構築するなら ANN + HNSW の組み合わせがメジャーです。
AWS にはベクトルデータベースを構築する手段が複数ありますが、Aurora PostgreSQL+pgvector、OpenSearch、MemoryDB は HNSW をサポートしています。

HNSW インデックスのパラメータ

-- HNSW インデックス(ANN 検索を高速化)
CREATE INDEX IF NOT EXISTS idx_embeddings_embedding
    ON embeddings
    USING hnsw (embedding vector_cosine_ops)
    WITH (m = 16, ef_construction = 64);

インデックス作成用SQLの WITH 句では、 HNSW インデックスのパラメータを指定しています。
HNSW インデックスのパラメータの意味は以下の通りです。

パラメータ 意味 大きくすると 一般的な値
m ノードあたりの接続数 検索精度↑ / メモリ消費↑ / 構築時間↑ 16
ef_construction 構築時の探索幅 検索精度↑ / 構築時間↑ 64〜200

データ登録の実装コード

Python を使ってそれなりのデータを Aurora PostgreSQL+pgvector に登録するコードは、以下のようになります。

class AuroraIngester:
    """Aurora pgvector へのバッチ INSERT によるデータ投入.
  
    500件単位のバッチ INSERT で効率的にベクトルデータを投入する。  
    """
    def __init__(self, connection: psycopg2.extensions.connection) -> None:
        """AuroraIngester を初期化する.
 
        Args:
            connection: psycopg2 コネクション
        """
        self._connection = connection
  
    def ingest_batch(self, start_index: int, end_index: int) -> int:
        """指定範囲のレコードをバッチ INSERT で投入する.
  
        Args:
            start_index: 開始インデックス(含む)
            end_index: 終了インデックス(含まない)

        Returns:
            投入したレコード数
        """
        values_parts: list[str] = []
        params: list[str | list[float]] = []
        for i in range(start_index, end_index):
            values_parts.append("(%s, %s::vector)")
            params.append(f"doc-{i}")
            params.append(generate_vector(seed=i))
 
        sql = f"INSERT INTO embeddings (content, embedding) VALUES {', '.join(values_parts)};"
        with self._connection.cursor() as cur:
            cur.execute(sql, params)
        self._connection.commit()
        return end_index - start_index
   
    def ingest_all(self, record_count: int, batch_size: int = 500) -> int:
        """全レコードをバッチ単位で Aurora に投入する.
  
        Args:
            record_count: 投入するレコード総数
            batch_size: 1バッチあたりのレコード数(デフォルト500件)
  
        Returns:
            投入したレコード総数
        """
        log = logger.bind(database="aurora_pgvector")
        total_inserted = 0
  
        for start in range(0, record_count, batch_size):
            end = min(start + batch_size, record_count)
            for attempt in range(1, MAX_RETRIES + 1):
                try:
                    count = self.ingest_batch(start, end)
                    total_inserted += count
                    break
                except Exception as e:
                    log.warning("batch_insert_retry", start=start, end=end, attempt=attempt, error=str(e))
                    if attempt == MAX_RETRIES:
                        log.error("batch_insert_failed", start=start, end=end, error=str(e))
                        break
                    time.sleep(RETRY_DELAY_SECONDS)
  
        log.info("ingest_all_complete", total_inserted=total_inserted)
        return total_inserted
def _run_database_ingestion(index_manager, ingester, record_count):
    """データベースへの大量データ投入を実行する.
  
    Args:
        index_manager: インデックスの削除・作成を管理するオブジェクト(実装は割愛)
        ingester: バッチ単位でデータを投入するオブジェクト(上述)
        record_count: 投入するレコード総数
    """
    # ① インデックス削除(登録を高速化)
    index_manager.drop_index()
    # 内部で実行される SQL:
    # DROP INDEX IF EXISTS embeddings_hnsw_idx;
    # TRUNCATE TABLE embeddings;
  
    # ② バッチ登録(500件単位)
    ingester.ingest_all(record_count, batch_size=500)
  
    # ③ インデックス一括作成
    index_manager.create_index()
    # 内部で実行される SQL:
    # CREATE INDEX embeddings_hnsw_idx
    #   ON embeddings USING hnsw (embedding vector_cosine_ops) -- HNSWを指定
    #   WITH (m = 16,              -- ノードあたりの接続数(多いほど精度↑・メモリ↑)
    #         ef_construction = 64); -- 構築時の探索幅(多いほど精度↑・構築速度↓)

このコードで、インデックスを最初に削除してその後データ登録、最後にインデックスを再作成しているのは、インデックスを張った状態でデータ登録をすると、データ登録の処理時間が読めなくなるからです。
この手法は関係データベースにおいてよく用いられる手法ですが、Aurora PostgreSQL+pgvector でも当てはまります。
詳しくはこちらをご覧ください。

blog.serverworks.co.jp

セマンティックキャッシュ

検索というかデータ取得処理を高速化する手法として、キャッシュの活用があります。
ベクトルデータベースの場合は、一般的なキャッシュと少し違うセマンティックキャッシュという技術が存在します。

セマンティックキャッシュとは?

セマンティックキャッシュは、クエリの Embedding ベクトルをキーとして過去の検索結果や FM(Foundation Model)の応答をキャッシュし、意味的に類似したクエリに対してキャッシュから高速に返す仕組みです。

従来のキャッシュとの違いを比較すると、その特徴がよく分かります。

従来のキャッシュ セマンティックキャッシュ
キー 文字列の完全一致 ベクトルの類似度
ヒット条件 完全に同じクエリのみ 意味的に類似したクエリもヒット
「東京の天気」のみヒット 「東京都の天気予報」「東京の今日の天気は?」もヒット

従来のキャッシュでは「東京の天気」と「東京都の天気予報」は別のキーとして扱われるため、キャッシュヒット率が低くなりがちです。 セマンティックキャッシュでは意味的に同じクエリをまとめてキャッシュできるため、ヒット率が大幅に向上します。

Amazon MemoryDBを用いたセマンティックキャッシュの処理フロー

AWS 上でセマンティックキャッシュを実装する場合、Amazon ElastiCache や Amazon MemoryDB が選択肢にあがります。
今回は以下の記事を参考に Amazon MemoryDB(以下、MemoryDB)を用いたセマンティックキャッシュを紹介します。

ユースケース - Amazon MemoryDB

ベクトルデータベースによる RAG は一旦おいといて、基盤モデルに対する問い合わせに対してセマンティックキャッシュを導入したとしたら、処理フローは以下のようになります。

  1. アプリケーションがクエリを Bedrock Titan Embeddings V2 でベクトル化する
  2. MemoryDB(キャッシュストア)に対して FT.SEARCH KNN でコサイン類似度検索を実行する
  3. 類似度が閾値以上の結果が見つかった場合(キャッシュヒット)→ キャッシュ済みの FM(基盤モデル) 応答を返却する
  4. 類似度が閾値未満の場合(キャッシュミス)→ FM を呼び出して推論し、結果をキャッシュに保存する(HSET + EXPIRE)
sequenceDiagram
    participant App as アプリケーション
    participant Emb as Bedrock<br/>Titan Embeddings V2
    participant Cache as MemoryDB<br/>(キャッシュストア)
    participant FM as Bedrock FM<br/>(Claude)

    App->>Emb: クエリをベクトル化
    Emb-->>App: クエリベクトル(1024次元)
    App->>Cache: FT.SEARCH KNN(コサイン類似度で検索)
    alt 類似度 >= 閾値(キャッシュヒット)
        Cache-->>App: キャッシュ済みの FM 応答を返却
    else 類似度 < 閾値(キャッシュミス)
        Cache-->>App: ヒットなし
        App->>FM: FM 呼び出し(推論)
        FM-->>App: FM 応答
        App->>Cache: FM 応答をキャッシュに保存(HSET + EXPIRE)
    end

MemoryDB 上のインデックス定義

補足: MemoryDB は Redis 互換のキーバリューストアであり、RDB のような「テーブル」は存在しません。 データは Hash 型のキーに格納し、検索用のスキーマは FT.CREATE コマンドで「インデックス」として定義します。

本リポジトリでは、以下の FT.CREATE コマンドでセマンティックキャッシュ用のインデックスを作成しています。

FT.CREATE semantic_cache_idx
  ON HASH
  PREFIX 1 cache:
  SCHEMA
    embedding    VECTOR HNSW 10
                   TYPE FLOAT32
                   DIM 1024
                   DISTANCE_METRIC COSINE
                   M 16
                   EF_CONSTRUCTION 512
    query_text   TAG
    result       TEXT
    created_at   NUMERIC
    ttl          NUMERIC
フィールド 説明
embedding VECTOR (HNSW) クエリの embedding ベクトル(1024次元)。 KNN 検索の対象
query_text TAG 元のクエリテキスト。 完全一致フィルタ用
result TEXT FM の応答結果(キャッシュされた回答)
created_at NUMERIC キャッシュエントリの作成日時(UNIX タイムスタンプ)
ttl NUMERIC キャッシュの有効期間(秒)
  • PREFIX 1 cache: により、キー名が cache: で始まる Hash のみがインデックス対象になります
  • HNSW パラメータの EF_CONSTRUCTION=512 は Aurora pgvector(64)より大きく設定しています。 MemoryDB はインメモリで動作するため構築コストが相対的に低く、精度を優先した設定です

セマンティックキャッシュの閾値

セマンティックキャッシュの閾値は、キャッシュヒットの判定基準となるコサイン類似度の値です。

閾値 特徴 推奨ケース
0.95〜1.0 ほぼ完全一致のクエリのみヒット 精度重視。 誤ったキャッシュ応答を返すリスクを最小化したい場合
0.80〜0.90 同義の表現違い(「天気」と「天気予報」など)もヒット 実用的なバランス。 多くのユースケースで推奨
0.70〜0.80 関連するクエリも広くヒット ヒット率重視。 ただし無関係な結果を返すリスクが高まる

適切な閾値は業務要件次第なので、最初は 0.95 程度の高い閾値から始めて、キャッシュヒット率を見ながら徐々に下げていくアプローチが安全だと思います。

HSET + EXPIRE

これらはベクトルデータベースまたはセマンティックキャッシュ特有のキーワードではなく、MemoryDB のエンジンである Redis のコマンドです。

HSET

Hash 型のキーにフィールドと値のペアをまとめて保存するコマンドです。
embeddingquery_textresultcreated_at などの複数フィールドを1つのエントリとして格納できます。

Redis / MemoryDB ではキー名に cache:abc123 のようにコロン区切りの命名規則を使うのが慣例です。
これは「cache というカテゴリの abc123 というエントリ」を意味するただの命名ルールで、コロン自体に特別な機能はありません。
インデックス定義の PREFIX 1 cache: は、このプレフィックスで始まるキーだけを検索対象にする設定です。

HSET | Docs

EXPIRE

キーに有効期限(TTL)を設定するコマンドです。 指定した秒数が経過すると、キーが自動的に削除されます。 古くなったキャッシュが溜まり続けるのを防ぎます。

EXPIRE | Docs

実装コード

ちょっと実装コードが長くなってしまいました。
ただし、やっていることは一般的なキャッシュを用いたデータ取得処理と同じで、キャッシュがあればそれを使用し、なければ検索したのちにキャッシュを保存です。
三段階で実装コードを紹介します。

クエリのベクトル化とキャッシュルックアップ

def handler(event, context):
    query = event["query"]  # "AWSのS3とは何ですか?"
  
    # ① クエリをベクトル化(Bedrock Titan V2)
    embedding_result = generate_embedding(query)
    query_embedding = embedding_result.embedding
  
    # ② MemoryDB でキャッシュルックアップ → FM 呼び出し
    cache_result = process_query(
        query_text=query,
        query_embedding=query_embedding,
        redis_client=redis_client,
        threshold=0.95,   # 環境変数 SIMILARITY_THRESHOLD
        ttl_seconds=3600, # 環境変数 CACHE_TTL
    )
  
    # ③ レスポンス返却(メトリクス付き)
    return {"statusCode": 200, "body": {...}}

キャッシュルックアップ処理

def process_query(query_text, query_embedding, redis_client,
                  threshold, ttl_seconds):
    # ① MemoryDBへのキャッシュ問い合わせ(FT.SEARCH KNN)
    search_results = search_similar(redis_client, query_embedding)
  
    if search_results:
        key, similarity, fields = search_results[0]

        # ② キャッシュヒット → キャッシュから結果返却(FM 呼び出しなし)
        # 閾値を超えていればそれを採用するとして返却
        if similarity >= threshold:
            return CacheResult(hit=True, source="cache",
                               result=fields["result"])
  
    # ③ キャッシュミス → FM を直に問い合わせて結果を得る
    fm_result = _invoke_fm(query_text)
  
    # ④ 結果をキャッシュに保存(HSET + EXPIRE)
    _store_cache_entry(redis_client, query_text,
                       query_embedding, fm_result, ttl_seconds)
  
    return CacheResult(hit=False, source="fm", result=fm_result)

MemoryDBへのキャッシュ問い合わせ

def search_similar(redis_client, query_embedding, top_k=1):
    """FT.SEARCH で KNN ベクトル検索を実行する."""
    query_vec = struct.pack(f"<{len(query_embedding)}f", *query_embedding)
  
    query = (
        Query(f"*=>[KNN {top_k} @embedding $query_vec AS score]")
        .return_fields("query_text", "result", "created_at", "score")
        .sort_by("score", asc=True)
        .paging(0, top_k)
        .dialect(2)
        .timeout(3000)  # 3秒タイムアウト
    )
  
    results = redis_client.ft("semantic_cache_idx").search(
        query, query_params={"query_vec": query_vec}
    )
  
    # コサイン距離 → 類似度に変換して返す(距離 = 1 - 類似度)
    return [(doc.id, 1.0 - float(doc.score), fields) for doc in results.docs]

MemoryDB の FT.SEARCH コマンドは Redis の RediSearch モジュールと互換性があり、KNN ベクトル検索をネイティブにサポートしています。
score はコサイン距離(1 - コサイン類似度、理論上は0〜2の範囲)で返されます。 1.0 - score でコサイン類似度に変換できます。
Titan V2 の normalize=True により出力ベクトルは正規化済みで、実際のスコアは0〜1に収まるため、変換後の類似度も0〜1の範囲になります。

セマンティックキャッシュの効果

以下の条件で実測した結果を示します。

項目
FM(基盤モデル) Claude 3 Haiku(anthropic.claude-3-haiku-20240307-v1:0
Embedding モデル Titan Embeddings V2(1024次元)
キャッシュストア Amazon MemoryDB
類似度閾値 0.95
テストクエリ 「AWSのS3とは何ですか?」(同一クエリで2回実行)

閾値は 0.95 と高めにしています。
この計測結果はセマンティックキャッシュが一定の効果があることを示すための参考値だと捉えてください。

指標 キャッシュミス(初回) キャッシュヒット(2回目) 削減率
合計レスポンス時間 4,573ms 279ms 94%
Embedding 生成 194ms 192ms
キャッシュルックアップ 4ms 3ms
FM 呼び出し 4,375ms 0ms 100%

キャッシュヒット時は FM 呼び出しが完全にスキップされるため、レスポンス時間が 94% 削減されています。
Embedding 生成(約 190ms)とキャッシュルックアップ(約 3ms)のみで応答が完了するため、ユーザー体験が大幅に向上します。
また、FM 呼び出しがスキップされることで、API 利用料金の削減にも直結します。

ベクトル DB によるRAG + セマンティックキャッシュの処理フローイメージ

セマンティックキャッシュは、例えば RAG システムに組み込むことができます。
その場合の処理フローは以下のイメージです。

  1. クエリをベクトル化する
  2. MemoryDB(キャッシュ)で類似クエリを検索する
  3. キャッシュヒット → キャッシュ済みの応答を即座に返す
  4. キャッシュミス → Aurora pgvector(ベクトル DB)で RAG 用コンテキストを検索する
  5. 取得したコンテキストとともに FM を呼び出して推論する
  6. FM の応答をキャッシュに保存し、ユーザーに返す

キャッシュヒット時

sequenceDiagram
    participant App as アプリケーション
    participant Emb as Bedrock<br/>Titan Embeddings V2
    participant Cache as MemoryDB<br/>(キャッシュストア)
    participant DB as Aurora pgvector<br/>(ベクトルDB)
    participant FM as Bedrock FM<br/>(Claude)

    App->>Emb: クエリをベクトル化
    Emb-->>App: クエリベクトル(1024次元)
    App->>Cache: FT.SEARCH KNN(コサイン類似度で検索)
    Cache-->>App: キャッシュ済みの FM 応答を返却

キャッシュミス時

sequenceDiagram
    participant App as アプリケーション
    participant Emb as Bedrock<br/>Titan Embeddings V2
    participant Cache as MemoryDB<br/>(キャッシュストア)
    participant DB as Aurora pgvector<br/>(ベクトルDB)
    participant FM as Bedrock FM<br/>(Claude)

    App->>Emb: クエリをベクトル化
    Emb-->>App: クエリベクトル(1024次元)
    App->>Cache: FT.SEARCH KNN(コサイン類似度で検索)
    Cache-->>App: ヒットなし
    App->>DB: ベクトル検索(RAG 用コンテキスト取得)
    DB-->>App: 関連ドキュメント
    App->>FM: プロンプト + コンテキストで推論
    FM-->>App: FM 応答
    App->>Cache: FM 応答をキャッシュに保存(HSET + EXPIRE)

まとめ

本記事では、ベクトルデータベースの基本概念から AWS 上での実装、セマンティックキャッシュによる最適化までを解説しました。

  • ベクトルデータベースの基本
    • ベクトルデータベースは「意味で検索する」データベース。 従来のキーワード検索では拾えなかった、表記揺れや同義表現にも対応できる
    • データも検索クエリも同じEmbeddingモデルでベクトル化し、コサイン類似度で「意味の近さ」を計算する
    • ベクトルデータベースはANN+HNSWがメジャー
  • ベクトルデータベースの AWS での構築
    • AWS には Aurora PostgreSQL + pgvector、OpenSearch、S3 Vectors、MemoryDB など複数の選択肢がある
    • SQL で操作でき、既存スキルが活かせる Aurora PostgreSQL + pgvector が最初の一歩としておすすめ
    • 大量データ投入時は「インデックス削除 → データ投入 → インデックス一括作成」のパターンが鉄板
  • セマンティックキャッシュ
    • 意味的に類似したクエリをキャッシュさせるのに活用できる

今回は以上です。
長い記事を読んでいただきありがとうございました!

参考リンク

兼安 聡(執筆記事の一覧)

アプリケーションサービス本部 DS3課
2025 Japan AWS Top Engineers (AI/ML Data Engineer)
2025 Japan AWS All Certifications Engineers
2026 AWS Community Builders(2年目)
Certified ScrumMaster
PMP
広島在住です。今日も明日も修行中です。
X(旧Twitter)