2
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】GAされたベクトル検索、15,000件のデータで OpenSearch と比べてみた

2
Posted at

はじめに

2026年8月5日に GA された DynamoDB Vector Search について、実際にありそうな在庫データ 15,000 レコードで試してみました。

比較対象として OpenSearch Serverless のベクトル検索にも同一データを投入、同条件で突き合わせることで DynamoDB ベクトル検索がどんなもんかと評価してみました。

その結果も踏まえて、「実運用で使えるか?」の判断材料を整理してみようと思います。

まずは結論から

  • DynamoDBのベクトル値は通常の属性としてテーブルに入る
  • ベクトル検索には Bedrock 等の外部モデルが必要
  • DynamoDB の範囲フィルタとベクトル検索は併用できない
  • 日本語での検索精度は実用レベル

想定される読者

  • DynamoDB に意味検索を追加したいが、制約を把握してから判断したい方
  • 「ベクトル検索」が実務でどう効くのかイメージできてない方
  • DynamoDB の検索をどこまで拡張できるか興味がある方

今回の題材

KiroRoasters.png

今回も架空のコーヒー焙煎メーカー「Kiro Roasters」の在庫管理(5,000 SKU × 3倉庫 = 15,000レコード)を題材にしています。

取扱商品(約5,000 SKU)

以下のような商品を扱っており、品種・産地・焙煎度などの組み合わせで SKU が爆発的に増えます。

カテゴリ SKU数 内容
生豆 約 30 SKU エチオピア、ブラジル、コロンビア、グアテマラ、ケニア、インドネシア、コスタリカ、パナマの8産地。各産地に2〜3品種 × 2グレード
焙煎豆 約 900 SKU 30生豆 × 6焙煎度 × 5容量(100g/200g/500g/1kg/5kg)
ブレンド 約 1,500 SKU 自社ブレンド約50種 × 6焙煎度 × 5容量
包装材 約 500 SKU ドリップバッグ・ギフトセット等
資材 約 2,000 SKU 包装袋、段ボール、ラベル、バルブ等

倉庫x出荷比率

拠点 倉庫ID 出荷比率 役割
東京本社 WH-TOKYO 70% メイン倉庫。焙煎工場併設。関東圏のカフェチェーン向け出荷の中心
大阪 WH-OSAKA 20% 関西エリアのカフェ向け。EC出荷の一部も担当
福岡 WH-FUKUOKA 10% 九州エリア + アジア向け輸出(台湾・シンガポール)

「現状はメイン倉庫である東京一極集中が続いている」という設定です。

5,000 SKU x 3倉庫 で 15,000レコードが今回の検索対象となります。

そもそもベクトル検索とは

ベクトル検索で何が変わるのか

従来の検索は「キーワードが一致するかどうか」でした。ベクトル検索は「意味が近いかどうか」で探します。RAG(検索拡張生成)を構築する際によく使われるため、ベクトル検索という言葉自体はお馴染みになってきましたよね。

クエリ例: フルーティで酸味のある焙煎豆

このクエリを投げると、以下のような商品がヒットします。部分一致で検索するわけではなく、説明文の意味が近しいレコードが選出されます。

  • 商品名:グアテマラ SHB G2 シティ 1kg
  • 説明:グアテマラ産の豆をシティで焙煎した焙煎豆。 チョコレート オレンジの風味。

ベクトル化の仕組み

このベクトル検索の仕組みを簡単に説明すると、次のようになります。

  1. 商品の説明文を AI モデル(Bedrock)に渡して「N個の数値の列(例えば1,024個)」に変換する(これが「ベクトル」)
  2. 検索クエリも同じように数値の列に変換する
  3. 2つの数値列がどれくらい似ているかを計算して、近い順に返す

1,024個(=1,024次元)と言われてもピンと来ないと思いますが、要するに商品1つを1,024個の特徴量で表した指紋のようなものです。人間には読めませんが、機械はこれで「この2つは意味的に近い」を判定できます。

実際の検索画面がこちらです。DynamoDB と OpenSearch の2つのベクトル検索を比較しています。左が DynamoDB、右が OpenSearch で、同じクエリ・同じデータに対する結果を並べて表示します。
DynamoDBVectorIndexScreen.png

DynamoDB Vector Search とは

既存テーブルにベクトルインデックスを追加するだけで、SearchVectors API による類似検索が使えるようになる機能です。

項目
対応次元数 最大 4,096次元
距離関数 COSINE / Euclidean / 内積
TopK 上限 100件
課金モード オンデマンド専用
テーブルあたりのベクトルインデックス上限 5本(GSI / LSI とは別枠)

今回は 1,024次元 を選びました。理由は埋め込みモデル(Titan Text Embeddings V2)の最大出力が1,024次元だからです。DynamoDB は最大4,096次元まで対応していますが、モデル側の上限に合わせています。次元数が多いほど表現力は上がりますが、1 item あたりのサイズ(≒コスト)も比例して大きくなるため、モデルの出力に合わせるのが素直な選択です。

┌─────────────────────────────────────────┐
│  既存の DynamoDB テーブル                  │
│  PK=itemId, SK=warehouseId              │
│  ├── GSI byWarehouse(既存)             │
│  ├── GSI byLocation(既存)              │
│  ├── GSI byUnitPrice(既存)             │
│  └── Vector Index byEmbeddingJa(新規)  │ ← これを追加するだけ
└─────────────────────────────────────────┘

特徴的なのは、ベクトルと業務データが同一テーブルに同居する点です。1回の SearchVectors で商品名・在庫数・単価まで取得でき、OpenSearch のようにデータ同期のパイプラインを別途構築・運用する必要がありません。

検証環境

アーキテクチャ図

検証環境の全体像です。

DynamoDBVectorIndexArch.png

ポイントは Amazon Bedrock(Titan Text Embeddings V2)が全経路に共通して登場する点です。

DynamoDB も OpenSearch も、ベクトルの生成機能は持っていません。テキストを数値の列(ベクトル)に変換するのは外部のモデルの仕事で、今回は Bedrock を使っています。この変換は2つのタイミングで発生します。

  1. データ投入時 — 商品説明文を Bedrock でベクトル化し、DynamoDB と OpenSearch の両方に同じベクトルを格納する
  2. 検索時 — 検索クエリを Bedrock で同じモデルを使ってベクトル化し、そのベクトルを DynamoDB と OpenSearch にそれぞれ投げる

つまり検索するたびに Bedrock への1往復(約200ms)が必ず発生します。 この点は後述のレイテンシ比較で効いてきます。

技術スタック

レイヤー 技術
フロントエンド Next.js 15 + TypeScript(Amplify Hosting)
API Amazon API Gateway REST API
コンピュート AWS Lambda(Node.js 20)
データベース Amazon DynamoDB(オンデマンド)
ベクトル検索(比較対象) Amazon OpenSearch Serverless VECTORSEARCH(NextGen / scale-to-zero)
埋め込み生成 Amazon Bedrock — Titan Text Embeddings V2(1,024次元)
IaC AWS Amplify Gen 2 + CDK

Lambda は役割ごとに分かれています:

Lambda 役割
vector-query-embed 検索クエリを Bedrock でベクトル化する
vector-search-ddb DynamoDB の SearchVectors を呼ぶ
vector-search-aoss OpenSearch の k-NN _search を呼ぶ
vector-embed-batch 初回データ投入時に全 SKU のベクトルを一括生成する
vector-capabilities 両バックエンドの機能制約メタデータを返す(UI の比較表用)

検索ボタンを押してから結果が返るまでの流れです。クエリのベクトル化を1回行い、そのベクトルを DynamoDB と OpenSearch の両方に投げて結果を並べています。

ポイントは、クエリのベクトル化は1回だけ行い、同じベクトルを DynamoDB と OpenSearch の両方に投げている点です。これで「同じ条件での純粋な検索エンジンの比較」が成立します。

検証の構成

検証データ

架空のコーヒー焙煎メーカー「Kiro Roasters」の在庫データ 15,000レコード(5,000 SKU × 3倉庫)を使います。

各 SKU には商品の特徴を表す説明文があり、これを Bedrock に渡してベクトルを生成しました。例えば次のような商品です。

商品名 カテゴリ 説明(ベクトル化の入力)
ブレンド リッチ フレンチ 200g 焙煎豆 チョコレートのような甘みとコクが特徴の深煎りブレンド
エチオピア イルガチェフェ G1 シティ 500g 焙煎豆 フローラルな香りと明るい酸味のスペシャルティ
資材 袋 1kg用 バルブ付 包装資材 コーヒー豆の鮮度を保つバルブ付きアルミ袋

日英2本のベクトルを SKU ごとに独立生成しました。 日本語の商品名・説明から生成したベクトル(embeddingJa)と、英語の商品名・説明から生成したベクトル(embeddingEn)の2本です。同じ商品でも言語が違えばベクトルは異なります。

なぜ2本かというと、Titan Text Embeddings V2 の日本語はプレビュー扱いで「実用に足るのか?」を確認したかったためです。英語を正解のベースラインとして横に置くことで、日本語がどの程度劣るかを定量的に比較できます。また、日本語と英語を1本のベクトルに混ぜると「コーヒー豆」と「coffee beans」が同じ空間に同居し、クエリ言語と格納言語がずれて精度が落ちるため、検索時に言語を指定できるよう byEmbeddingJa / byEmbeddingEn の2本に分けています。

5,000 SKU × 2言語 = 10,000回の Bedrock 呼び出しで、所要時間は約95分(レート上限 120リクエスト/分に律速)。DynamoDB と OpenSearch の両方に同一のベクトル値を格納しています。

ベクトルの持ち方 — DynamoDB ならではの構造

ここが DynamoDB Vector Search の設計上のポイントです。ベクトルは専用のストレージに入るのではなく、通常の属性として item に直接入ります。

DynamoDB コンソールで実際の item を見るとこうなっています:

DynamoDBVectorEmbeddingJaEn.png

embeddingJaembeddingEn は DynamoDB の L(リスト)型の中に N(数値)型を1,024個並べたもので、itemNamequantity と同じ階層の普通の属性です。特別な型やストレージエンジンはありません。

この「同居」設計がもたらす強みと制約は表裏一体です:

同居の強み 同居の制約
SearchVectors 1回で業務属性まで取得できる(別サービスへの通信不要) 1,024次元 × 4バイト ≒ 約4KB が item サイズに加算される
テーブルの一部なので Cold Start が軽い(400ms) GSI が ProjectionType: ALL だと全 GSI にベクトルがコピーされる
同期パイプラインが不要(書いた瞬間に検索対象) ベクトルインデックスは SearchVectors 専用。通常の Query / Scan では読めない

OpenSearch 側も構造は同じで、レコードの _sourceembeddingJa / embeddingEn が同じ数値配列として入っています。イメージはこうです:

{
  "_source": {
    "itemName": "ブラジル サントス G2 ライト 1kg V425",
    "warehouseId": "WH-FUKUOKA",
    "embeddingJa": [0.021, -0.113, 0.008, "... 1,024個"],
    "embeddingEn": [0.019, -0.108, 0.011, "... 1,024個"]
  }
}

つまり両バックエンドとも「同じ数値配列」を持っているだけで、違うのはその配列をどうインデックス化して探すかだけです。今回はこの格納値が全1,024次元一致することを確認した上で比較しています。

なお、DynamoDB は先ほどの「項目の編集」画面でレコードをそのまま覗けたのに対し、OpenSearch 側は中身を確認するだけでも OpenSearch UI Application の作成やデータアクセスポリシーの設定が必要でした。この手間の差は、DynamoDB の同居設計(テーブルにインデックスを足すだけ)が運用面で軽いことの表れでもあります。

テーブルとインデックス

検証に使ったテーブルとベクトルインデックスの構成です。

項目
テーブル kiro-roasters-inventory-vector(PK=itemId / SK=warehouseId)
ベクトルインデックス byEmbeddingJa(日本語用)/ byEmbeddingEn(英語用)の2本
埋め込みモデル Amazon Bedrock — Titan Text Embeddings V2(1,024次元)
距離関数 COSINE(ベクトル同士の角度で類似度を測る方式)
比較対象 OpenSearch Serverless VECTORSEARCH
データ 同一の15,000レコード。両方に同じベクトルを格納して条件を揃えた

格納値の一致は 10,000組(5,000 SKU × 2言語)すべてで全1,024次元が完全一致することを確認済みです。したがって以降の差はデータの差ではなく、検索エンジンの差です。

検証結果 — 実際に検索してみる

DynamoDB と OpenSearch の数値比較に入る前に、まずベクトル検索が実データで何を返すのかを見ておきます。両バックエンドで結果はほぼ一致するので、以降は DynamoDB 側の画面で示します。

在庫担当者になったつもりで、自然言語で検索してみます。「こういう商品が欲しい」と入力して、期待通りの結果が返るでしょうか。

ケース1.「焙煎していない生豆が欲しい」

生豆(焙煎前のコーヒー豆)だけを一覧したい、という場面です。

🔍 焙煎していない生豆

DynamoDBVectorSearchCondition1.png

返ってきた30件は、すべて生豆でした。焙煎済みの豆も、袋やラベルのような資材も、1件も混ざっていません。「生豆」という一つのまとまったカテゴリを指すクエリは、意味検索が最も素直に効きます。

DynamoDBVectorSearchResult1.png

ケース2.「エチオピア産の生豆はある?」

産地とカテゴリの2つを同時に指定してみます。

🔍 エチオピア産の未焙煎の生豆

期待される回答はエチオピア産の生豆が4レコード。結果、上位4件にしっかりと入ってきました。「産地 + カテゴリ」のように条件が具体的だと、狙った商品がきれいに上位に揃います。
DynamoDBVectorSearchResult2.png

もう少し細かく確認します。ベクトルの類似度を測る正規化距離で見てみると、4位までのエチオピアと5位のケニアの間に 0.113 の段差があります。距離が小さいほど「意味が近い」ので、この段差にラインを引けば「エチオピアの生豆だけ4件」を正確に取り出せます。

順位 距離 商品名
1 0.3853 エチオピア イルガチェフェ 生豆
2 0.3891 エチオピア サントス 生豆
3 0.4203 エチオピア スプレモ 生豆
4 0.4283 エチオピア SHB 生豆
5 0.5413 ケニア イルガチェフェ 生豆

ケース3.「ナッツとチョコの中煎りが飲みたい」

今度は商品カテゴリではなく、味の好みで探します。

🔍 ナッツとチョコレートの風味でバランスの良い中煎り

上位はブラジル産のミディアムロースト(中煎り)で埋まりました。狙い通りです。
DynamoDBVectorSearchResult3.png

ただ、説明文を見てみると、 「ナッツ チョコレートの風味」という語がそのまま入っていました。
DynamoDBVectorSearchDiscription3.png

キーワード検索でも同じ結果が取れるので、これは「意味を理解した成果」ではありません。
ですが、「ナッツ」「チョコレート」「中煎り」を同時に満たしていたブラジル産の焙煎豆が上位に揃ったのは素晴らしい。一部のキーワード(チョコレート)だけ一致するグアテマラ産の商品などは上位に来ませんでした。

ケース4.「グレープフルーツのような酸味の浅煎り」

🔍 グレープフルーツのような酸味が強い浅煎り

結果はこのようになっており、複数産地が入り混じって返ってきました。
DynamoDBVectorSearchResult4.png

想定していた回答は1位に入ったケニアの浅煎り焙煎豆。説明文には次のように入っています。

カシス グレープフルーツの風味。 ボディは軽い、酸味は強い。

2位のグアテマラの説明文は次の通り。

チョコレート オレンジの風味。 ボディは軽い、酸味は強い。

「チョコレート・オレンジ」系が、柑橘の酸味として近いと判定されて上位に入っています。これが単なる文字検索ではないベクトル検索の特徴と言えそうです。30件返して、狙い通りのケニアの浅煎りは約3分の2でした。

ただ、使い方の想定として、「グレープフルーツっぽい酸味の浅煎り、この辺にない?」と投げて、返ってきた候補を人が眺めて『あ、これこれ』と選ぶ ——そんなイメージを持っているので、この結果は十分実用的です。1位も3位も狙い通りで、混ざったグアテマラも「柑橘系の酸味」という意味では的外れではありません。

ケース5.「重いボディの深煎り」

🔍 土の香りとスパイスのある重いボディの深煎り

「フレンチロースト(深煎り)」を期待したのに、上位30件すべてが「ダークロースト」で埋まりました。産地はすべてインドネシアで正解なのに、焙煎度だけが1件も合っていません
DynamoDBVectorSearchResult5.png

原因は、焙煎度がライト → シティ → フレンチ → ダークという連続した段階だからです。クエリの「重いボディ」が、フレンチ(重い)より一段強いダーク(非常に重い)に、より強く反応してしまいました。

説明文を見比べるとよく分かります。

フレンチロースト

インドネシア産の豆をフレンチで焙煎した焙煎豆。 スパイス ハーブ 土の香りの風味。 ボディは重い、酸味はごく弱い。

ダークロースト

インドネシア産の豆をダークで焙煎した焙煎豆。 スパイス ハーブ 土の香りの風味。 ボディは非常に重い、酸味はほとんどない。

こういう「段階を厳密に区切りたい」検索は、焙煎度 = フレンチ のような等価フィルタで補えます。DynamoDB のベクトル検索は等価条件での絞り込みに対応しているので、ここは解決できます。

ケース6.「コーヒー豆」で検索したら袋が出た(おまけ)

最後に、設計の落とし穴を1つ。

🔍 コーヒー豆

結果はこちら。
DynamoDBVectorSearchResult6.png

肝心のコーヒー豆が1件も出ず、上位が全部「袋」 になりました。理由は、袋の説明文が「コーヒー豆の保存と持ち運びに使う包装袋」だったから。「コーヒー豆」という語が袋の説明にそのまま入っているため、意味的に近いと判定されたのです。

これはモデルや DynamoDB の問題ではなく、どのテキストをベクトル化するかという設計の問題です。ベクトル検索の品質は、投入するデータの作り方で決まります。

得意なこと・苦手なこと

検索の種類 結果
まとまったカテゴリ 生豆 ✅ 得意
産地 + カテゴリ エチオピアの生豆 ✅ 得意(距離でも切り分け可)
説明文と語が一致 ナッツとチョコの中煎り ✅ 当たる(ただしキーワード検索でも可)
微妙な風味の混ざり グレープフルーツの浅煎り △ 候補は出るが混ざる
連続する段階 深煎り/フレンチの区別 ❌ 苦手

色々試してみて分かったのは、ベクトル検索は「意味を魔法のように理解する」わけではないということです。意味的にまとまった塊(カテゴリ・産地)は得意な一方、連続する段階の区別は苦手です。

苦手な部分(焙煎度のような段階)は、焙煎度 = フレンチ のような等価フィルタで補えます。ただし「価格3,000円以下」のような範囲条件になると、DynamoDB のベクトル検索では使えません。DynamoDB インデックス検索の制約条件として効いてきます。

比較結果

DynamoDB と OpenSearch の検証結果をいくつかの観点で比較してみます。

レイテンシ(応答速度)

検索1回の所要時間です。それぞれのサービスに検索クエリを投げてから結果が返るまでの「検索区間」のみを測っています。

区間 DynamoDB OpenSearch 比率
中央値(普段の体感) 19.5 ms 213 ms OpenSearch が10.9倍遅い
最大(Cold Start除く) 240 ms 978 ms
Cold Start(しばらく放置後) 398 ms 約18秒 scale-to-zero の場合

実際には、この時間に検索の前段でクエリをベクトルに変換する処理(Bedrock 呼び出し)が約200msかかります。利用者の体感としては「200ms + 検索時間」となるので、DynamoDB なら約220ms、OpenSearch なら約413ms。どちらも「パッと返ってくる」の範囲です。

唯一、決定的に差が出るのは「しばらく使っていなかった後の初回」です。 今回も OpenSearch Serverless の scale-to-zero を採用(DynamoDB とコスト面での条件をなるべく合わせるための設定)しているので、再開時に18〜19秒かかります。DynamoDB も若干遅くなりますが、それでも 400ms 程度なので無視できる範囲です。「アイドルコストを0にする」と「初回応答を速くする」を同時に満たせるのは DynamoDB だけと言えます。

Recall(検索精度)

recall とは — 「正解がN件あるとき、そのうち何件を正しく返せたか」の指標です。recall 0.99 なら「正解100件のうち99件を返せた」という意味で、1.000 が満点です。

「正解」はどう決めるのか? ベクトル検索(近似最近傍探索)は、速度のために多少の取りこぼしを許容して近いものを探す仕組みです。そこで「正解」は、5,000商品すべてとの距離を1つずつ総当たりで厳密計算し、近い順に並べた真の上位K件とします。総当たりは遅くて本番では使えませんが、検証時にオフラインで1回計算しておけば正解表が作れます。この正解表と、近似検索が返した結果を突き合わせて「取りこぼし率」を測っています。

検索精度の検証はバックエンドのバッチ処理で進めました。60個の検索クエリ × 2言語 × 2バックエンド × K3種 = 720回の検索を実施しています。閾値は0.99で設定。

バックエンド 言語 K=1 K=10 K=33 判定
DynamoDB 日本語 1.000 0.995 0.993 全群合格
DynamoDB 英語 1.000 1.000 1.000 全群合格
OpenSearch 日本語 1.000 0.993 0.987 K=33のみ未達
OpenSearch 英語 1.000 1.000 0.993 合格

DynamoDB は全ケースで合格。検索精度は問題ありません。むしろ DynamoDB の方が OpenSearch より若干良いという結果でした(ただし差は微小で、実務上は両方とも十分な水準です)。

また、日本語が英語よりわずかに低い結果となりました。

日本語での検索精度

前提として、日本語の精度は DynamoDB や OpenSearch の性能とは無関係です。精度を決めるのはテキストをベクトルに変換する Bedrock だけで、DynamoDB も OpenSearch も完成したベクトル(数値の列)を受け取って近いものを探すだけです。ベクトルが日本語由来か英語由来かは関知しません。

一方で、今回ベクトル変換に利用している Titan Text Embeddings V2 の日本語サポートは公式にはプレビュー扱いです。これがどの程度精度に影響するのかを計測する目的で、日英両方でのベクトル検索を試してみました。

結果は上述の通りで、日本語は一貫して英語より劣りますが、差は小数第3位です。 「最も近い1件を返す(K=1)」用途では差が出ず、上位33件の並びまで見て初めて微小な差が現れるレベルです。

新サービスの DynamoDB だから日本語が弱いのでは?」と心配になりますが、今回のケースではそもそも関係ありませんし、本検証の結果だけ見ると日本語の精度も問題なさそうでした。

コスト

本検証で実際に発生した費用と、社内ツール規模での月額推定です。

項目 DynamoDB OpenSearch
検証で発生した実額(約730検索) 0.00012 USD 2.56 USD
社内ツール想定(月2,000検索) 約0.0003 USD 7〜8 USD 程度
ストレージ(月額) 約0.07 USD 約0.002 USD
初回データ投入(1回だけ) 0.07 USD OCU課金に含まれる

DynamoDB のベクトル検索は実質タダです。 単価は 0.002 USD/GB(Cost Explorer の実請求から確定)。1検索あたり約82KBなので、月2,000回でも0.0003 USD。社内ツール規模では検索コストを意識する必要がありません。

OpenSearch 側のコストは「検索回数」ではなく「OCU が起動している時間」で決まります。検索が終わっても OCU が0に戻るまで数分かかるため、散発利用では1検索ごとに起動時間分の課金が付きます。本検証では約730検索(数日間に散発)で2.56 USD でした。

ただし、コストは採否を決めません。 DynamoDB が圧倒的に安いのは事実ですが、いくつか制約事項があります。制約をクリアした上で、コストは追い風になる——という順番です。

では、今回の検証で明らかになった DynamoDB ベクトル検索の制約について見ていきます。

採否を分ける4つの制約

ここまで見た通り、速度・精度・コストのどれも DynamoDB は十分な水準でした。それでも「使えない」ケースがあるのは、機能面の制約が原因です。 ここが採否の分かれ目になります。

制約1: 範囲フィルタが使えない

これが最大の分岐点です。ベクトル検索に条件を組み合わせることはできるのですが、使えるのは完全一致(=)のみです。

ベクトル検索の絞り込みは GSI ではなく「インラインフィルタ」で行います。 ベクトルインデックスを作るとき、SearchSchema に絞り込み用の属性(categorywarehouseId など)を登録しておくと、SearchVectors 呼び出し時に category = 焙煎豆 のような条件を添えられます。GSI は不要で、今回のテーブルも GSI は1本も作っていません。

ただし2つ注意点があります。登録できる属性は最大18個で、しかもインデックス作成時に固定(後から追加できない) です。「あとで別の属性でも絞りたい」場合はインデックスの作り直し(=全ベクトル再投入)になります。

そして本題ですが、このインラインフィルタで使える演算子について、公式ドキュメント間で記述が矛盾しています。 開発者ガイド(トラブルシューティング)は「インラインフィルタ属性も等価演算子のみサポート」と明記する一方、CLI / API リファレンスの --search-condition-expression の説明は「INLINE_FILTER 属性には比較演算子および範囲演算子が使える」と書いてあります。どちらが正しいのかを実機で確認しました。

この矛盾は本記事執筆時点(2026年8月末)でも解消されていません。CLI リファレンスには今も「Comparison and range operators are supported for INLINE_FILTER attributes」と残っています。以下の実測の通り、正しいのは開発者ガイド(等価のみ) です。

具体例でいうと、、

  • 倉庫 = 東京 で絞ってからベクトル検索 → できる
  • 単価 <= 3000 で絞ってからベクトル検索 → できない
  • カテゴリ IN (焙煎豆, ドリップバッグ) で絞ってから → できない

実際に8パターンの条件式を投げて確認したところ、次のようになりました。

条件式 HTTP 結果
#f = :eq(完全一致) 200 受理
#f > :lo(より大きい) 400 Invalid comparator
#f >= :lo AND #f <= :hi(範囲) 400 Invalid comparator
#f BETWEEN :lo AND :hi 400 Invalid operator
#f IN (:in0, :in1, :in2) 400 Invalid operator

範囲演算子が使えると活用の幅がグッと広がるので、ぜひ早期に対応してほしいところです。

制約2: TopK 上限100件・ページネーション不可

1回の検索で返せるのは最大100件。101件目以降を取得する手段がありません。「20件ずつスクロールして読み込む」ような UX は構築できません。

さらに、データモデルの設計によってはこの100件が実質的に目減りします。

「同じ商品が3行ある」と何が起きるか

本検証のデータは1つの SKU が3倉庫分の行を持ちます(東京・大阪・福岡)。3行とも同一のベクトルなので、検索すると3行セットで返ってきます

TopK 30 で検索した結果(実測):
  1位: ブレンド リッチ フレンチ 200g(東京)  ← 同じ商品
  2位: ブレンド リッチ フレンチ 200g(大阪)  ← 同じ商品
  3位: ブレンド リッチ フレンチ 200g(福岡)  ← 同じ商品
  4位: ブレンド エスプレッソ フレンチ 200g(東京)
  5位: ブレンド エスプレッソ フレンチ 200g(大阪)
  ...

TopK 30 を要求しても、ユーザーに見せられる一意の商品は約10件にしかなりません。TopK の上限100で割ると、表示できる商品は最大33件です。

解決策

今回のケースでは、制約1で触れたインラインフィルタで倉庫を絞れば解決します。 warehouseId = WH-TOKYO を付ければ1商品1行になり、TopK 30 がそのまま30商品に対応します。

より本質的には、ベクトルは「1エンティティ = 1行」に配置するのが鉄則です。今回のような「SKU × 倉庫」の構造なら、ベクトルは SKU テーブル(1行)に持たせ、倉庫別の在庫は別レコードにする。こうすれば複製自体が起きず、TopK 100 がそのまま100商品に対応します。

制約3: GSI ProjectionType: ALL との相性問題

見落としやすく、コスト的な影響が最も大きい制約です。

DynamoDB の GSI に ProjectionType: ALL(全属性をコピー)を設定していると、ベクトル属性も全 GSI にコピーされます。今回の在庫テーブル(GSI 3本)で試算すると次の通り。

項目 現状 ベクトル追加後(推定)
テーブル + GSI 合計 約12.3 MB 約512 MB(約42倍)

1,024次元のベクトルは1つで約4KB(日英2本で約8KB)。1レコードあたり数百バイトだった在庫データが、ベクトルを持つと一気に約8KB に膨れます。これが基底テーブルと GSI 3本の計4箇所にコピーされるので、合計で約42倍という膨張になります。

膨張するのはストレージだけではありません。在庫一覧のような既存の GSI Query も、1レコードあたりの読み取りバイト数が跳ね上がる分消費 RCU が増え、コストが悪化します。

対策は2つ。

  • GSI の射影を KEYS_ONLYINCLUDE に変更する(既存クエリへの影響確認が必要)
  • ベクトルを持つテーブルを業務テーブルと分ける

既存テーブルにベクトルを「ちょい足し」するなら、まず GSI の射影設定を確認してください。

GSI とベクトル検索は連携しません。 「GSI で カテゴリ = 焙煎豆 に絞ってからベクトル検索する」という2段階の動作は存在せず、SearchVectors は最初から最後までベクトルインデックスだけを見ます(絞り込みは制約1のインラインフィルタで行う)。

制約4: 検索の権限分離ができない

従来 DynamoDB でよくやるテナント分離が、ベクトル検索には通用しません。

DynamoDB でマルチテナントやアクセス制御を組むとき、定番の手法が IAM の dynamodb:LeadingKeys 条件です。パーティションキーにテナントIDを入れ、「自分のテナントのパーティションしか読めない」を IAM ポリシーで強制します。

ところが SearchVectors には、この dynamodb:LeadingKeys をはじめとするファイングレインアクセスコントロールの条件キーが一切効きません。 つまり SearchVectors の権限を持っていれば、全テナント・全倉庫のベクトルを検索できてしまいます。「東京倉庫の担当者は東京だけ」を IAM で縛れないです。

分離できる粒度はインデックス単位のみです。テナントごとにベクトルインデックスを分ければ IAM で縛れますが、インデックスはテーブルあたり上限5本なので、少数のテナントにしか使えません。

「既存テーブルは LeadingKeys でテナント分離してあるから安心」と考えてベクトルを足すと、検索経路だけ分離が抜け落ちます。

機能制約の対比まとめ

ここまでの制約1〜4に、距離関数・次元数などの細かい制約も加えた一覧です。

制約 DynamoDB OpenSearch 実務への影響
フィルタ演算子 完全一致のみ 完全一致 + 範囲 価格帯・在庫数で絞れない
TopK 上限 100件 上限なし 「もっと見る」が作れない
GSI ALL との相性 ベクトルが全 GSI に複製され膨張 該当なし 既存 GSI のストレージ・コスト増
権限分離 IAM で不可(インデックス単位のみ) 可能 テナント分離が抜け落ちる
距離関数の変更 インデックス再作成が必要 同じく再作成 両方とも同じ
課金モード オンデマンド必須 プロビジョンドのテーブルでは使えない
次元数上限 4,096 16,000 現行モデルは最大1,024なので問題なし
Query/Scan での読み取り 不可 全件エクスポートなどで制約になる

採否チェックリスト — DynamoDB Vector Search を選べる条件

ここまでの検証をふまえて、自分のワークロードで判定できるチェックリストにまとめました。次の質問すべてに「はい」と答えられるなら、DynamoDB Vector Search を積極的に選べます。

確認すること 関連する制約
絞り込みは等価条件で足りるか(価格帯・在庫数などの範囲指定は不要か) 制約1
上位100件で足りるか(ページ送り・無限スクロールは不要か) 制約2
ベクトルを1エンティティ1行で配置できるか(同一ベクトルの複製が起きない設計か) 制約2
対象テーブルの GSI が ProjectionType: ALL でないか、または射影を変更できるか 制約3
検索の権限分離は不要か、インデックス単位(上限5本)で足りるか 制約4
利用が散発的で、アイドル後の初回応答の速さが重要か

1つでも「いいえ」があるなら、OpenSearch の併用か別の設計を検討した方が早いです。 とくに範囲フィルタ(制約1)は後から追加できないので、ここだけは設計に入る前に必ず確認してください。

逆に全部「はい」なら、意味検索のために OpenSearch という別システムを立てる必要がなくなります。運用対象が1つ減る利得は大きいです。

GA から2週間強の時点で遭遇したこと

DynamoDB Vector Search の GA は 2026-08-05 で、本検証は 2026-08-18〜22 に実施しました。新しいサービスに特有の落とし穴をいくつか記録しておきます。

事象 何が起きるか
CloudFormation に VectorIndexes プロパティがない IaC で管理するにはカスタムリソースの自作が必要
SDK 3.1103.0 未満だとインデックス情報が消える DescribeTable の応答からベクトルインデックスがエラーも警告もなく消える。気づかずに「インデックスが存在しない」と誤判定する
VectorSearchUnits が SDK の型定義にない コスト計測に必要な値が型で読めず、生応答を直接パースする必要あり
範囲フィルタの可否がドキュメント間で矛盾 開発者ガイドは「等価のみ」、CLI / API リファレンスは「範囲も可」と書いてある(執筆時点でも未解消)。正しいのは開発者ガイド(等価のみ)
インデックス作成に約18分 2本逐次で ACTIVE 到達まで 546秒 / 542秒。空テーブルでもこの時間がかかる

今回も Kiro をフル活用して検証を進めましたが、これらは Kiro でもすんなり解けず、検証メモにハマりどころとして残っていた内容です。同じような検証に取り組む際は、お使いのコーディング AI にあらかじめこれらのポイントを渡しておくと、無駄な寄り道を減らせるはずです。

まとめ

今回は、最近 GA された DynamoDB Vector Search について、実際に使う場面を想定しながら検証を進めてみました。

検証結果としては検索の速度も精度も十分で、既存テーブルへの追加も容易。割と使えそうなイメージを持てました。ただ、採否チェックリストにまとめた通り、現在の制約事項が業務要件と合わないようであれば採用は難しい、という結論です。


検証に使ったコードと全測定値は以下のリポジトリにあります。全数値と測定条件は docs/vector-search-comparison.md(約85KB)に記録しており、本記事で扱わなかった TopK 依存のキャパシティ分析、スコア-距離変換式の同定、日英60クエリの全件リストなども含まれています。

このリポジトリは、これまで書いてきた DynamoDB 検証シリーズ全体の検証コードをまとめたものです。同じ「Kiro Roasters」の15,000レコードを共有していて、今回のベクトル検索だけでなく、過去記事のテーマも同じアプリ上で切り替えて再現できます。

テーマ 記事
ホットパーティション(キー設計) ホットパーティションを起こしたい
ホットパーティション(コスト・解決編) ホットパーティションを起こせました。コストもしっかりかかりました【解決編】
GSI 設計と検索パターン ユーザーが欲しがる検索条件、DynamoDB でどこまで対応できるか試してみた
全文検索の比較 検索が苦手な DynamoDB に OpenSearch を足してみた
ベクトル検索(意味検索)の比較 本記事

同一データ・同一条件で複数の検索方式を並べられるので、「この要件ならどの方式か」を比べる土台として使えます。

参考リンク

2
1
0

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
2
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?