3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DynamoDBのベクトル検索に関して

3
Last updated at Posted at 2026-08-12

はじめに

2026年8月、 Amazon DynamoDBのリアルタイムベクトル検索 がGA(一般提供)されました。全ての商用リージョンとGovCloud(US)で利用できます。

これでAWSの「ベクトルを入れて検索する場所」がまた増えました。すでにこれだけあります。

  • Amazon Bedrock Knowledge Bases
  • Amazon S3 Vectors(2025年12月GA、2026年3月に31リージョンへ拡大)
  • Amazon OpenSearch Service / Serverless
  • Aurora PostgreSQL(pgvector)
  • Amazon Neptune Analytics
  • そして今回のDynamoDBベクトルインデックス

「で、結局どれを使えばいいのか」が分かりにくい。私も最初そう思いました。

しかし調べていくうちに、この混乱の原因は選択肢が多いことではなく、比較しているものの層が揃っていないことだと分かりました。
本記事ではまずその層を整理し、その上でGAしたばかりのDynamoDBベクトル検索を実際に動かして、公式ドキュメントには書かれていない挙動とコストの実測値を出します。

実測環境は東京リージョン(ap-northeast-1)、埋め込みモデルはAmazon Titan Text Embeddings V2です。検証に使ったコードはすべてGitHubに置いてあります。

層が違うのはKnowledge Basesだけ

結論から言うと、こう整理できます。

つまり、

  • Knowledge BasesとS3 Vectorsは競合しない。包含関係。 KBは「チャンク分割・埋め込み生成・データソース同期・Retrieve API」を丸ごと引き受けるRAGパイプラインで、ベクトルの置き場所は後ろに選んで付ける。S3 Vectorsはその選択肢の1つにすぎない。
  • S3 Vectors、DynamoDB、OpenSearchは同じ層。 ここが本当に比較すべき対象で、次の表もこの3つで比べる。
  • DynamoDBはKnowledge Basesのベクトルストアの選択肢に入っていない。 サポートされるベクトルストアはOpenSearch Serverless、OpenSearch Managed Cluster、S3 Vectors、Aurora PostgreSQL、Neptune Analytics、Pinecone、Redis Enterprise Cloudの7つ。

最後の点が、この記事で一番言いたいことです。DynamoDBベクトル検索はKnowledge Basesのバックエンドではなく、Knowledge Basesを使わない道です。

だから「KBとDynamoDBのどちらがいいか」という問いは、実は2つの問いが重なっています。
「マネージドRAGパイプラインに乗るか、自前で組むか」と「ベクトルをどこに置くか」 です。前者を決めてから後者を決める、という順番になります。

なおこの2層はあくまで整理のためのモデルです。実際にはOpenSearchがs3vectorエンジンでS3 Vectorsをバックエンドにできるなど、ストア層の中にも上下関係があります。埋め込みモデル(Titan、Cohereなど)も厳密には別の構成要素です。それでも「KBはパイプライン、残りは置き場所」と押さえておけば、選択肢の多さで迷わなくなります。

本当の比較対象: ストア層の3つ

S3 Vectors DynamoDBベクトルインデックス OpenSearch
位置づけ ベクトル専用の低コストストア 運用データに同居する索引 検索エンジンのベクトル機能
検索レイテンシ 低頻度で1秒以内 / 高頻度で約100ms 1桁ms(公称) 構成とデータ量次第
規模 1インデックス20億ベクトル / 1バケット1万インデックス 数兆ベクトル(公称) 十億規模の実績あり
次元数上限 4,096 4,096 16,000
距離関数 Cosine / Euclidean Cosine / Euclidean / 内積 複数 + 量子化オプションが豊富
フィルタ $eq $ne $gt $gte $lt $lte $in $nin $exists $and $or = のみ クエリDSL全般
ハイブリッド検索・集計 なし なし あり
運用データとの同居 しない する しない
同期パイプライン(自前で使う場合) 必要 不要 必要
KBのバックエンドに使えるか 使える 使えない 使える
課金 保存 / 書き込み / クエリ 書き込み / 検索 / 保存(全てバイト単位) OCU時間 + ストレージ
アイドル時のコスト 保存のみ 保存のみ NextGenは0までスケールダウン / Classicは最低2 OCU

この表で一番効くのはフィルタの行です。DynamoDBは=しか使えず、「1万円以下の商品から類似検索」ができません(後半で実測)。レイテンシを理由に選ぶ前に、ここを見るべきです。

残り2行も補足します。

  • 同期パイプライン — 元データとベクトルが別の場所にあると、追加・削除を反映し続ける仕組み(DynamoDB Streams → Lambda → Bedrock → OpenSearchなど)が要る。ズレると「消したはずの商品が検索に出続ける」事故に。DynamoDBはベクトルが同じアイテムにあるので不要。KBならKBが管理する。
  • アイドル時のコスト — 「OpenSearchは使わなくても高い」はClassic collectionsの話。NextGenは最低OCUがなく、10分無活動で0までスケールダウンする。

判断軸

ステップ1: チャンク分割と埋め込み生成を自前で持つか

持ちたくないならKnowledge Basesです。自前で組むと、パーサ選定・チャンクサイズ・再同期の面倒を全部抱えることになります。文書RAGを一から作るなら素直にKBに乗るほうが速い。ただしこの時点でDynamoDBは選択肢から外れます。

ステップ2: ストアを選ぶ

  1. データはすでにどこにあるか。 DynamoDBに商品や会話履歴があるなら、そこにベクトル属性を足すのが圧倒的に短い道。同期パイプラインが丸ごと消える。
  2. レイテンシ要件は何msか。 ユーザーのリクエストパスに同期で入るならDynamoDB。バッチや非同期ならS3 Vectorsで十分。
  3. フィルタ要件はどこまで必要か。 範囲検索やOR条件が要るならDynamoDBは脱落。
  4. クエリ頻度と保存量のどちらが支配的か。 保存量が大きくクエリが低頻度ならS3 Vectors、クエリが高頻度ならDynamoDBかOpenSearch。

ケース別に落とすとこうなります。

やりたいこと 選ぶもの
社内文書のRAGチャットボット Knowledge Bases + S3 Vectors
ハイブリッド検索や集計が要るRAG Knowledge Bases + OpenSearch
億単位のアーカイブを時々検索 S3 Vectors単体
価格帯や日付での範囲絞り込みが要る S3 VectorsまたはOpenSearch(DynamoDBは=のみ)
商品類似検索・レコメンド DynamoDB
エージェントの会話履歴からの想起 DynamoDB

以降はDynamoDBを実際に触っていきます。

実際に触ってみる

前提: SDKのバージョンに注意

まずここでつまずきました。手元のAWS CLI 2.27.44にはsearch-vectorsもありませんし、create-tableに--vector-indexesもありません。

$ aws dynamodb search-vectors help
usage: aws [options] <command> <subcommand> [<subcommand> ...] [parameters]

GAしたばかりの機能なので、SDKとCLIを新しくするのが最初の作業です。今回は既存環境を壊さないよう、専用のvenvに最新のboto3を入れました。

uv venv --python 3.12 .venv
uv pip install --python .venv/bin/python boto3   # boto3 1.43.67

対応しているかはこう確認できます。

import boto3
c = boto3.client("dynamodb", region_name="ap-northeast-1")
print("SearchVectors" in c.meta.service_model.operation_names)  # True

テーブルとベクトルインデックスを作る

ベクトルインデックスはGSIと同じくCreateTableかUpdateTableで作ります。専用のAPIはありません。

検証のため、1つのテーブルに3つのインデックスを作りました。

インデックス 次元 パーティションキー 目的
idx-1024 1024 なし 基準
idx-256 256 なし 次元数によるコスト差の比較
idx-1024-bycat 1024 category 検索範囲の絞り込み効果の比較
VECTOR_INDEXES = [
    {
        "IndexName": "idx-1024",
        "VectorAttribute": {"AttributeName": "embedding_1024"},
        "SearchSchema": [
            {"AttributeName": "category", "SearchSchemaElementType": "INLINE_FILTER"},
            {"AttributeName": "price", "SearchSchemaElementType": "INLINE_FILTER"},
            {"AttributeName": "brand", "SearchSchemaElementType": "INLINE_FILTER"},
            {"AttributeName": "stock_status", "SearchSchemaElementType": "INLINE_FILTER"},
        ],
        "Projection": {
            "ProjectionType": "INCLUDE",
            "NonKeyAttributes": ["name", "category", "price", "brand"],
        },
        "Dimensions": 1024,
        "DistanceFunction": "COSINE",
    },
    # idx-256, idx-1024-bycat は省略
]

ddb.create_table(
    TableName="vector-search-demo-products",
    AttributeDefinitions=[
        {"AttributeName": "item_id", "AttributeType": "S"},
        {"AttributeName": "category", "AttributeType": "S"},
        {"AttributeName": "price", "AttributeType": "N"},
        {"AttributeName": "brand", "AttributeType": "S"},
        {"AttributeName": "stock_status", "AttributeType": "S"},
    ],
    KeySchema=[{"AttributeName": "item_id", "KeyType": "HASH"}],
    BillingMode="PAY_PER_REQUEST",   # オンデマンド専用
    VectorIndexes=VECTOR_INDEXES,
)

SearchSchemaの2種類: HASHとINLINE_FILTER

SearchSchemaに並べる属性には2つの型があります。この違いが後のコストに直結します。

型 役割 いつ効くか
HASH ベクトルインデックスのパーティションキー 探す前。どこを見るかを決める
INLINE_FILTER 結果を絞り込む条件に使える属性 探した後。何を残すかを決める

HASHはテーブルのパーティションキー(KeyType: HASH)と同じ言葉で、パーティションキーと読み替えて構いません。上のコードでは省略しましたが、idx-1024-bycatだけはcategoryをHASHにしています。

{"AttributeName": "category", "SearchSchemaElementType": "HASH"},

こうするとインデックスがカテゴリごとに分かれ、1万件の箱を探す代わりに1,679件の箱を1つ選んで探すようになります。

ハマりどころ1: SearchSchemaの属性もAttributeDefinitionsに書く

最初AttributeDefinitionsにitem_idだけ書いて失敗しました。

ValidationException: One or more parameter values were invalid:
One element in SearchSchema is not defined in attribute definitions

SearchSchemaに出てくる属性は全てAttributeDefinitionsで宣言が要ります。

ハマりどころ2: インラインフィルタにBOOLは使えない

上の制約から自動的に導かれる、地味に効く制限です。AttributeTypeに指定できるのはS(文字列) / N(数値) / B(バイナリ)の3つだけ。アイテム自体はBOOLやLも持てますが、インデックスの対象にできるのはこの3つです。

つまり在庫フラグをBOOLで持っているとインラインフィルタに使えません。今回はstock_statusという文字列("in_stock" / "out_of_stock")に変えて回避しました。

しかもベクトルインデックスの設定は作成後に変更できません。次元数も距離関数もProjectionもインラインフィルタもパーティションキーも、変えたければ作り直しです。設計時に気づかないと後から効いてきます。 ただしUpdateTableでインデックスを足す場合、バックフィルは無料です。

同じベクトル属性を複数のインデックスで共有できる

idx-1024とidx-1024-bycatは同じembedding_1024属性を指しています。これは問題なく作成できました。
パーティションキーの有無だけが違うインデックスを2つ持ち、クエリによって使い分ける、という設計が可能です。

作成後、3つとも数分でACTIVEになりました。

TableStatus: ACTIVE
  idx-1024         status=ACTIVE  dims=1024 attr=embedding_1024
  idx-256          status=ACTIVE  dims=256  attr=embedding_256
  idx-1024-bycat   status=ACTIVE  dims=1024 attr=embedding_1024

データを投入する

DynamoDBは埋め込みを生成しません。ここがKnowledge Basesとの一番大きな差です。自分でBedrockなりを呼んでベクトルを作り、普通のPutItemで入れます。

ベクトルは数値のリスト(Lの中にN) として格納します。

def embed(text: str, dimensions: int) -> list[float]:
    r = rt.invoke_model(
        modelId="amazon.titan-embed-text-v2:0",
        body=json.dumps({"inputText": text, "dimensions": dimensions, "normalize": True}),
    )
    return json.loads(r["body"].read())["embedding"]

resp = ddb.put_item(
    TableName=TABLE,
    Item={
        "item_id": {"S": "A001"},
        "category": {"S": "オーディオ"},
        "name": {"S": "SilentWave NC-900"},
        "price": {"N": "48800"},
        "stock_status": {"S": "in_stock"},
        "embedding_1024": {"L": [{"N": str(x)} for x in v1024]},
        "embedding_256": {"L": [{"N": str(x)} for x in v256]},
    },
    ReturnConsumedCapacity="INDEXES",
)

日本語の家電商品60件を投入しました。商品名もブランド名もすべて架空で、類似検索の結果が直感的に納得できるよう作ったダミーデータです。

ここでReturnConsumedCapacity="INDEXES"を指定している点が重要です。ConsumedCapacityは「このリクエストが資源をどれだけ使ったか」をレスポンスに同梱してくれるフィールドで、通常はWCU/RCUで返ります。しかしベクトルインデックスはバイト数で返ります。

  • VectorWriteRequestBytes — 書き込み時にインデックスへ複製されたバイト数
  • VectorSearchRequestBytes — 検索1回が処理したバイト数

DynamoDBはベクトル操作をバイト単位で課金するので、この値はそのリクエストの請求額そのものです。おかげで「たぶんこのくらい」と見積もる必要がなく、条件を変えながらコストを直接測れます。後半の実測はすべてこのフィールドに乗っています。

1件目のConsumedCapacityがこれです。

{
  "TableName": "vector-search-demo-products",
  "CapacityUnits": 6.0,
  "VectorIndexes": {
    "idx-256":        { "VectorWriteRequestBytes": 1135.0 },
    "idx-1024":       { "VectorWriteRequestBytes": 4208.0 },
    "idx-1024-bycat": { "VectorWriteRequestBytes": 4208.0 }
  }
}

ここで読み取れることが2つあります。

  1. 1024次元は1件あたり約4,209バイト。 1024 × 4バイト(f32) = 4,096バイトにProjection分が乗った値。256次元だと1,136バイトで、ほぼ次元数に比例する。
  2. ベーステーブル側も6WCU消費している。 ベクトルはインデックスだけでなくアイテム本体にも入るため、アイテムが約6KBに膨らむ。ベクトルインデックスの課金とは別に、この分のベーステーブル書き込みが発生する。ベクトルを2種類持たせたのは検証の都合だが、1種類でも構造は同じ。

検索する

SearchVectorsを呼びます。ここでも2つ小さな罠がありました。

resp = ddb.search_vectors(
    TableName=TABLE,
    IndexName="idx-1024",
    # SearchVectorは L でラップせず、AttributeValueのリストをそのまま渡す
    SearchVector=[{"N": str(x)} for x in query_vec],
    TopK=5,
    ReturnConsumedCapacity="INDEXES",
)
for it in resp["SearchResults"]:      # "Items" ではない
    print(it["Score"], it["Item"]["name"]["S"])   # Score は素の float
  • 格納時は{"L": [...]}だが、SearchVectorはLでラップせずリストを直接渡す。
  • レスポンスのキーはItemsではなくSearchResults。スコアはAttributeValueではなく素のfloat。

COSINEを指定した場合、Scoreは距離なので小さいほど近いです。実際の結果がこちら。

Q: 朝の忙しい時間においしいコーヒーを飲みたい   (27ms)
    0.7709  BrewMaster Drip 6            (キッチン家電, ¥34,800)
    0.8060  BrewMaster Espresso E5       (キッチン家電, ¥78,000)
    0.8486  BrewMaster Hand Grinder      (キッチン家電, ¥8,800)
    0.8787  RiceMaster 圧力IH 5合          (キッチン家電, ¥68,000)
    0.8795  BlendForce 高速ミキサー         (キッチン家電, ¥14,800)

Q: 梅雨の時期に部屋干しした洗濯物を早く乾かしたい   (40ms)
    0.6709  MistHome 除湿機 コンプレッサー     (生活家電, ¥32,800)
    0.7676  WashPro ドラム式洗濯乾燥機        (生活家電, ¥198,000)
    0.7720  DryFast 布団乾燥機             (生活家電, ¥15,800)
    0.8124  WarmNest オイルヒーター          (生活家電, ¥28,800)
    0.8742  MistHome 気化式加湿器           (生活家電, ¥19,800)

「部屋干し」から除湿機と洗濯乾燥機と布団乾燥機を引いてくるので、キーワード検索ではないことがちゃんと分かります。

実測: 何がコストとレイテンシを動かすか

ここからが本題です。数字を並べる前に、読み方の枠組みを先に置きます。鍵になるのはこの1文です。

DynamoDBは「クエリの回数」ではなく「触ったバイト数」で課金する。

だから「いくらかかるか」という問いは、「検索1回が何バイト読むか」 という問いに変わります。そしてそのバイト数を動かすつまみは3つしかありません。

つまみ 何が決めるか 実測の結果
① ベクトル1個の大きさ 次元数 × 4バイト 1024次元4KB → 256次元1KB(0.31倍)
② 何個を見に行くか 探索範囲 パーティションキーで絞る(0.54倍)
③ 何を返すか ProjectionExpression、TopK ベクトルまで返す(1.47倍)

以降の表はすべて、この3つのつまみのどれかを回した記録です。そしてこの枠組みがあると、後で出てくる「意外な発見」がなぜ意外なのかもすぐ分かります。インラインフィルタは②に見えて、②ではないのです。

測定は1万件で行います

ここからのコスト測定は、合成データを足して10,060件(1カテゴリ約1,679件)に増やしたインデックスに対する値です。前節までの検索品質の例は手で書いた60件のときのものなので、そこだけ母集団が違います。

規模を上げたのは、60件ではパーティションキーの効果がまったく出なかったからです。8回測って0.78〜1.08倍とばらつき、「絞っても減らない」という結果になりました。1カテゴリ12件では絞る前からインデックスが小さすぎます。小規模なPoCでこの機能を評価すると、効果なしと誤判定します。

レイテンシ

10,060件のインデックスに対し、同一クエリを50回。埋め込み生成を除いたSearchVectorsのみの時間です。p50は中央値(半分がこれより速い=普段の体感)、p95は95%がこれより速い値(遅い5%=ユーザーが不満を言う側)を表します。平均を使わないのは、外れ値1つで平均が引っ張られて「普段の体感」を表さなくなるからです。

インデックス p50 p90 p95 min
idx-1024 15.8ms 20.3ms 22.5ms 13.6ms
idx-256 13.6ms 18.1ms 22.5ms 10.8ms
idx-1024-bycat(HASH絞り込み) 28.2ms 33.7ms 111.3ms 22.0ms

注意: 自宅の端末から東京リージョンへのインターネット往復を含んだ値です。AWS公称の「1桁ms」はサーバ側の処理時間なので、直接は比較できません。

読み取れることが2つあります。

  1. 1万件でもp50は15.8ms。 60件のときが17.8msだったので、データが166倍になってもレイテンシは変わっていない。ANN検索らしい素直な挙動。
  2. HASHで絞ったほうが遅い(28.2ms)。 後で見るようにコストは半分になるのに、レイテンシは逆に増えた。コストとレイテンシは連動しない。

検索コストは何で変わるか

10,060件のインデックスに対する1クエリあたりのVectorSearchRequestBytesです(7回計測の中央値)。

条件 bytes 基準比
1024次元 / 全件検索 45,799 1.00x
256次元 / 全件検索 14,374 0.31x
1024次元 / パーティションキーで絞り込み 24,727 0.54x
1024次元 / インラインフィルタのみ(brand = :b) 43,688 0.95x
1024次元 / パーティションキー + インラインフィルタ 22,228 0.49x
1024次元 / TopK=100 57,473 1.25x
1024次元 / ProjectionExpressionにベクトルを含める 67,366 1.47x

ここに、ドキュメントを読んだだけでは気づきにくい重要な事実が出ています。

インラインフィルタは検索コストを下げない

同じ「絞り込む」でも、2つの効き方はまったく違いました。

絞り方 消費バイト 基準比
絞らない 45,799 1.00倍
インラインフィルタ(brand = :b) 43,688 0.95倍
パーティションキー(HASH) 24,727 0.54倍

理由は前に見た箱の話そのものです。

  • パーティションキーは探す前に効く — 小さい箱しか開けないので、コストが減る
  • インラインフィルタは探した後に効く — 大きい箱を全部見てから捨てるだけなので、減らない

そのためマルチテナントのRAGでテナントIDを持たせるなら、インラインフィルタではなくパーティションキーにすべきです。

ただしこれはセキュリティ境界ではありません。SearchVectorsにはFGACが効かないので、権限さえあれば他のテナントのパーティションキーも検索できます。IAMで分離したいならテーブルごと分けてください。

ベクトルを返させない

ProjectionExpressionにembedding_1024を含めた途端、45,799 → 67,366バイトと1.47倍になりました。デフォルトで結果にベクトルが含まれないのは、この課金を避けるための設計です。意図的に必要な場面以外では絶対に含めないことです。

データが10倍になっても検索コストは変わらない

2026-08-21 訂正
公開時点でこの節は「データが10倍になっても検索コストは変わらない」としていましたが、
誤りでした。コメントでのご指摘を受けて条件を揃えて10万件まで測り直した結果です
(1024次元 / TopK=1 / median)。

アイテム数 pkなし pkあり
1,000 26,266 10,672
10,000 52,479 10,672
100,000 66,709 24,471

区間内は誤差6バイトで平ら、境界で跳ねます。ただし1万→10万件は10倍のデータで1.27倍と、
包絡線はおおむね log N。パーティションキーありは1万件まで不動で、段を後ろへずらします。

以下の表は公開時の計測のまま残しますが、待ち時間もTopKも今回と揃っておらず、
比較可能な計測ではありませんでした(平らに見えた原因は特定できていません)。
手順と全チェックポイント、生データは追試コードのREADMEにあります。

一番驚いたのがこれです。投入の途中でチェックポイントを置いて測りました。

アイテム数 全件検索 HASH絞り込み HASH/全件 p50
1,000 41,786 22,226 0.53 36.7ms
2,500 43,668 24,717 0.57 27.4ms
5,000 43,668 22,219 0.51 22.5ms
10,000 41,788 17,813 0.43 24.5ms

この計測では1万件までほぼ横ばい(約42KB)に見えましたが、上記の通り誤りでした。

  • 検索コストはデータ量とともに上がる。ただし log N なので増え方は緩やか。 1万→10万件で1.27倍
  • 効いてくるのはクエリ回数と、1回あたりのバイト数。つまり次元数、Projection、パーティションキーの使い方
  • 一方書き込みと保存はアイテム数に比例するので、こちらは素直に件数で見積もれる

「ベクトルDBは規模が上がると急に高くなる」という直感は当てはまりませんが、
「まったく変わらない」でもありません。小規模なPoCの数値をそのまま外挿すると過小評価になります。

次元数: 69%安くなるが、品質は落ちる

256次元は検索コストが0.31倍、書き込みが1,136 / 4,209 = 0.27倍。かなり安い。ではそれでいいのか、というと話はそう単純ではありませんでした。

同じクエリでの上位5件を比較します。

Q: 静かな環境で音楽に集中したい
  1024: [TypeCraft K87 静音, StudioMonitor M70 Open, StudioMonitor M40x, BassRoom S200, BassRoom Mini]
   256: [TypeCraft K87 静音, StudioMonitor M70 Open, BassRoom S200, SilentWave NC-900, BassRoom Mini]
  一致: 4/5

Q: 在宅勤務でオンライン会議が多い
  1024: [ClearVoice Headset H1, TypeCraft K87 静音, StreamCam 4K, StudioMonitor M70 Open, DockHub 11in1]
   256: [ClearVoice Headset H1, GlowLamp デスクライト, MistHome 気化式加湿器, TypeCraft K87 静音, LumenOne 望遠 70-200mm F4]
  一致: 2/5

Q: 梅雨の時期に部屋干しした洗濯物を早く乾かしたい
  1024: [MistHome 除湿機, WashPro ドラム式洗濯乾燥機, DryFast 布団乾燥機, WarmNest オイルヒーター, MistHome 気化式加湿器]
   256: [MistHome 除湿機, TravelTripod Carbon, BassRoom S200, BlendForce 高速ミキサー, PodMic USB-C]
  一致: 1/5

3つ目が分かりやすい。256次元は1位こそ除湿機を当てていますが、2位以降は三脚・スピーカー・ミキサー・マイクと、完全に破綻しています。

ただし1つ目のクエリでは逆に、256次元のほうが本命のノイズキャンセリングヘッドホンを4位に入れています(1024次元は6位)。次元数の影響は一様ではありません。

コスト最適化の文脈で「次元数を下げましょう」という話をよく見ますが、だからこそ下げた次元で検索品質が保てるかは自分のデータで測るしかありません。TopK=5で使うつもりなら、5件全部が意味を持つかを確認すべきです。

検索品質を決めるのはDynamoDBではない

最初のクエリ「静かな環境で音楽に集中したい」の1位は静音キーボードでした。本命のノイズキャンセリングヘッドホンは6位。デモデータでは、その商品にクエリとほぼ同じ表現の説明文を持たせてあるのにです。

SilentWave NC-900 — 業界最高クラスのノイズキャンセリングを備えた密閉型ワイヤレスヘッドホン。飛行機や電車の騒音を大幅に低減し、静かな環境で音楽に没入できる。最大40時間再生。

原因は2つ考えられます。

  1. DynamoDBのインデックスが、近いはずのものを取りこぼした
  2. 埋め込みモデルが、そもそも両者を近くに置いていない

切り分け方は簡単です。商品説明そのものをクエリにする。 インデックスが正しければ、その商品自身が距離0で1位に来るはずです。

query = SilentWave NC-900(NCヘッドホン)の説明文そのもの
  0.00000  SilentWave NC-900     <- 自分自身が距離0で1位
  0.23628  SilentWave NC-500     <- 同じNCヘッドホンの軽量モデル

自分自身が距離0で1位、2位も同系統のヘッドホン。TopK=60でも60件すべてが返りました。インデックスは正しい。原因は2でした。

つまり検索品質を決めるのはベクトルDBではなく、埋め込みモデルと入力テキストの作り方です。DynamoDBに移しても検索はよくなりません。打つ手はモデルの変更、埋め込む文章の作り直し、リランカーの追加など。Knowledge Basesならこの辺りが一通り用意されている分やりやすく、自前で組むなら改善サイクルも自前で持つことになります。

制約: 実際にエラーを出して確認した

ドキュメントの記述と実挙動が食い違っている箇所があったので、全部叩いて確かめました。

やったこと 結果
price < :p(範囲比較) ValidationException: Invalid comparator used in SearchConditionExpression
category IN (:a, :b) ValidationException: Invalid operator used in SearchConditionExpression
SearchSchemaにない属性でフィルタ ValidationException: SearchConditionExpression must not contain any attributes that is not in SearchSchema
HASH付きインデックスをキー指定なしで検索 ValidationException: SearchConditionExpression must be provided when SearchSchema has a HASH key
TopK=101 ValidationException: Provided TopK value '101' is out of valid range. The value must be between 1 and 100 inclusive

SearchConditionExpressionで使えるのは=だけです。 <, <=, >, >=, <>, INはすべて拒否されます。

ここは注意が必要で、SDKリファレンスには「INLINE_FILTER属性には比較演算子と範囲演算子が使える」と書かれていますが、実際には使えませんでした。開発者ガイドの「等価演算子のみサポート、比較・範囲・集合演算子はまだ利用できない」という記述の方が実挙動と一致します。

これは設計に直撃します。「1万円以下の商品の中から類似検索」ができません。 価格帯をprice_band = "under_10000"のような文字列属性に離散化して持たせる、といった前処理が必要になります。

その他の主な制約もまとめておきます。

  • オンデマンドキャパシティモード専用。 プロビジョンドのテーブルには作れない。
  • ベクトルはf32精度で格納される。 より高精度な値を入れても丸められる。
  • SearchVectorsにFGACが効かない。
  • レスポンス上限16MB、ページネーションなし。 Projectionが広くTopKが大きいとここに当たる。
  • ベクトルインデックスにQuery / Scan / PartiQLは使えない。 SearchVectorsのみ。
  • テーブルあたり5インデックス、最大4,096次元、TopK最大100、インラインフィルタ18個。
  • パーティションキーあたり検索1GBps / 書き込み10MBps。 768次元 + 1KBのアイテムなら、毎秒約25万ベクトルのスキャンと約2,500書き込みが目安。
  • ProjectionType: ALLのGSI/LSIに注意。 ベクトル属性が自動的にコピーされ、GSIのストレージと書き込みコストが跳ね上がる。ベクトルが要らないGSIは必要な属性だけをProjectionに指定する。

結果整合性はどのくらい遅れるか

ベクトルインデックスは非同期に更新されるので、書いた直後に検索でヒットするとは限りません。ここで遅れるのはベクトルを作る時間ではなく、アイテムに入っている出来上がったベクトルをインデックスへ複製する時間です(埋め込みの生成はPutItemより前に自分で済ませています)。これがどのくらいなのかを測りました。

PutItemの直後から、その商品の説明文自体をクエリにして(=インデックスにあれば必ず1位に来る)ポーリングします。

Z001 EchoGuard NC-Ultra    PutItem= 114ms  強整合GetItem=HIT  インデックス反映まで= 104ms (1回目の検索でヒット)
Z002 AquaSilent 静音加湿器    PutItem=  66ms  強整合GetItem=HIT  インデックス反映まで=  25ms (1回目の検索でヒット)
Z003 ZenPad 静音マウスパッド     PutItem=  46ms  強整合GetItem=HIT  インデックス反映まで=  20ms (1回目の検索でヒット)

反映ラグ: min=20ms / max=104ms / avg=49ms

3件とも1回目の検索ですでにヒットしました。ラグは20〜104ms、つまり1往復以内に追いついています。

ただしこれは60件しかない小さなインデックスでの値です。大規模なテーブルや高い書き込みレートでは変わり得ます。「書いた直後に必ず読める」ことを前提にした設計はしないでください。ベーステーブル側はConsistentRead=Trueで強整合読み取りができるので、書き込み直後の確認はGetItem、検索はSearchVectorsと使い分けるのが素直です。

料金を実際に計算する

Price List APIから取得した東京リージョン(Standardテーブルクラス)の実単価です。

項目 単価
ベクトル書き込み $0.59488 / GB
ベクトル検索 $0.00228 / GB
ベクトルインデックス保存 $0.285 / GB-月(最初の25GBは無料)
書き込みリクエスト(オンデマンド) $0.000000715 / WRU

キャパシティユニットのプロビジョニングはなく、全てバイト単位の従量課金です。ただし書き込み・検索ともに最小課金単位は1KBなので、極端に小さいベクトルでも1KB分は取られます。

実測バイト数を当てはめて、100万件を1024次元で投入した場合を計算します。

項目 計算 コスト
ベクトル書き込み 4,209B × 100万 = 4.209GB $2.50(初回投入時の1回)
ベクトルインデックス保存 4.209GB $1.20 / 月
ベーステーブル書き込み 6WRU × 100万 $4.29(初回投入時の1回)

256次元にすると、これがさらに下がります。

項目 1024次元 256次元
ベクトル書き込み $2.50 $0.68
ベクトルインデックス保存 $1.20 / 月 $0.32 / 月

検索バイト数は件数とともに上がるので、規模ごとに分けます(1024次元 / TopK=1 / 実測)。

規模 pkなし 100万クエリ pkあり 100万クエリ
1万件 52,479B $0.120 10,672B $0.024
10万件 66,709B $0.152 24,471B $0.056

100万件は測っていません。log Nの傾きを延ばすと約80,000B / $0.18 ですが、これは外挿です。
見積もりの根拠にするなら、ご自身のデータでVectorSearchRequestBytesを実測してください。

初回投入と保存は件数に比例するので、こちらは素直に外挿できます。

項目 100万件 / 100万クエリでのコスト
初回投入(ベクトル書き込み + ベーステーブル) $6.79
ベクトルインデックス保存 $1.20 / 月
検索100万回(全件検索) 約$0.18(外挿)
検索100万回(パーティションキーで絞る) 10万件の実測で$0.056

規模感としては、「ベクトルを持つこと自体は驚くほど安い」「効いてくるのはクエリ回数と、1回の検索が触るバイト数」 です。だからこそ次元数・Projection・パーティションキーの3点が重要になります。

まとめ

長くなったので、要点だけ。

層の整理について

  • 層が違うのはKnowledge Basesだけ。 KBはRAGパイプラインで、S3 Vectors・DynamoDB・OpenSearchはその下のストア層。KBとS3 Vectorsは競合ではなく包含関係。
  • 同じ層で比べるべきはS3 Vectors / DynamoDB / OpenSearchの3つ。 ここでの最大の差はレイテンシではなくフィルタの表現力で、DynamoDBは=しかない。
  • DynamoDBはKnowledge Basesのベクトルストアの選択肢に入っていない。KBを使わないである。
  • 決め方は2段階。まず「埋め込み生成を自前で持つか」でKBに乗るか決め、次に「データが既にどこにあるか」「レイテンシ要件」「フィルタ要件」「クエリ頻度と保存量のどちらが支配的か」でストアを決める。
  • 「OpenSearchはアイドルでも高い」はClassic collectionsの話。NextGenは最低OCUがなく0までスケールダウンする。

DynamoDBベクトル検索を使うなら

  • SDKを更新する。AWS CLI 2.27では動かない。
  • インラインフィルタに使う属性はS / N / Bのみ。BOOLは使えない。
  • フィルタは=のみ。 範囲検索ができないので、価格帯などは離散化して持つ設計にする。
  • コストを下げるのはパーティションキー(0.54倍)。インラインフィルタでは下がらない(0.95倍)。 マルチテナントのテナントIDはパーティションキーに置く。ただしセキュリティ境界ではない。
  • ただしパーティションキーで絞るとレイテンシは逆に増えた(15.8ms → 28.2ms)。安い = 速い、ではない。
  • 結果にベクトルを含めない。含めると1.47倍になった。
  • 検索コストは階段状に上がるが、包絡線は log N。 1万→10万件で1.27倍。パーティションキーは段を後ろへずらす(1万件まで不動)。効くのはクエリ回数と1回あたりのバイト数。
  • 次元数を下げるとコストは大きく下がるが、品質は自分のデータで検証する。今回は256次元で明確に破綻した。
  • 検索品質を決めるのは埋め込みモデル。DynamoDBに移しても検索はよくならない。
  • インデックスは結果整合性。実測では20〜104msで追いついたが、これに依存した設計はしない。

個人的な結論としては、すでにDynamoDBに運用データがあって、リクエストパスで類似検索したいなら迷わずこれです。同期パイプラインが丸ごと消える価値は大きい。逆に文書RAGを一から作るなら、チャンク分割から自前で組むことになるので、素直にKnowledge Basesに乗るほうが速いと思います。

参考

3
1
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?