はじめに
この記事はClaudeと一緒に書いています
2026年8月5日、Amazon DynamoDBがネイティブでベクトル検索に対応したという情報が入ってきました。
別のベクトルDBにデータをコピーする同期パイプラインが要らなくなる、単一桁ミリ秒のレイテンシーが出る、
というかなり大きな話です。
ただ、情報が流れてきた直後に読んだ公式ブログには、コードサンプルが一切ありませんでした。List型で埋め込みを保存する、SearchVectorsというAPIがある、リコール率99%以上、という文章だけで、実際に動かせる情報がなにもない状態です。
こういうときにいきなり記事や説明を鵜呑みにすると痛い目を見るので、まず本当にAPIが存在するのか、自分の手元のSDKで確認するところから始めました。
SDKを直接調べる
手元の aws dynamodb help (CLI 2.35.15) にはベクトル関連のサブコマンドが見当たりませんでした。boto3もローカルには入っていなかったので、venvを作って最新版を入れ直し、サービスモデルを直接読みに行きました。
import botocore.session
s = botocore.session.get_session()
model = s.get_service_model('dynamodb')
[o for o in model.operation_names if 'vector' in o.lower()]
# ['SearchVectors']
botocore 1.43.64にはちゃんと入っていました。今回作るアプリはVercel上のNode.jsで動かすつもりだったので、JS SDK側も同じように確認します。
npm view @aws-sdk/client-dynamodb version # 3.1103.0
find node_modules/@aws-sdk/client-dynamodb -iname "*SearchVectors*"
# SearchVectorsCommand.d.ts が存在
こちらも実装済みでした。サービスモデルから実際のパラメータ名を取り出します。
-
CreateTableにVectorIndexesというリストが増えていて、IndexName/VectorAttribute.AttributeName/SearchSchema(HASH要素とINLINE_FILTER要素) /Projection/Dimensions/DistanceFunction(COSINE/EUCLIDEAN/DOT_PRODUCT)を持つ -
SearchVectorsはTableName・IndexName・SearchVector(通常の属性と同じList<Number>の形)・TopKが必須で、SearchConditionExpressionとその周辺パラメータで絞り込みができる
ここで一つ発見がありました。CloudFormationのAWS::DynamoDB::Tableには、まだVectorIndexesが定義されていません(GAからすぐだからか?)
# GA翌日時点でのAWS::DynamoDB::Tableのスキーマ
'VectorIndexes' in schema['properties'] # False
普段CloudFormationでインフラを組んでいる場合、この機能はまだIaCから作れません。boto3かCLIで直接作るしかない状態のようです。これは実際に手を動かすまで気づけなかった制約でした。
実際にアプリを作ってみる
APIが存在することは確認できましたが、curlを1回叩いて終わりにするのも味気ないので、小さなアプリを作ることにしました。
カンファレンス会場向けのアイスブレイクツールで、ニックネームと「今気になっていること」を一言書くと、Bedrockで埋め込みを作ってDynamoDBに保存し、同じ部屋にいる意味的に近い人をその場で検索して表示する
という仕組みです。6秒ごとにポーリングして、新しく入ってきた人も反映されます。
テーブル設計は以下のようにしました。
- パーティションキーが
eventId、ソートキーがattendeeId - ベクトルインデックスの
SearchSchemaにeventIdをHASH要素として指定し、検索が部屋をまたがないようにする -
Dimensions: 1024、DistanceFunction: COSINE。Bedrockのamazon.titan-embed-text-v2:0を実際にinvoke-modelで叩いて1024次元が返ってくることを先に確認してから決めました
アプリを書く前に、3人分のダミーデータで手動検証をしています。
0.3638 「AIエージェントに承認ゲートをつける仕組みを作っています」
0.4453 「生成AIエージェントの権限設計と人間承認フローに興味があります」
0.8907 「週末は登山とキャンプが趣味です」
「AIエージェントの自律実行と承認の設計について話したい」というクエリに対して、承認ゲート系の2文が近く、登山の文が一番遠いスコアになりました。ブログ記事の説明文よりも、このprintoutを見た瞬間の方が「ちゃんと動いている」と実感できました。
このスコアには一つ注意が必要で、COSINEのスコアは類似度ではなく距離のようです。0が完全一致、2が正反対で、結果は昇順(スコアが小さいほど上位)に並びます。0.36という数字を見て最初「36%似ている」と読んでしまいましたが、JS SDKの型定義コメントに明記されていて、そこで気づきました。表示用には 類似度% = (1 - スコア / 2) * 100 という変換を使っています。
本番デプロイで実際にハマった話
ローカルではすぐ動きました。fromNodeProviderChain()がaws loginのセッションを拾ってくれるので、localhostでは問題なくマッチングできています。ところが本番は違いました。
最初のデプロイでは、POST /api/joinが全部500エラーになりました。Vercelのログはこうです。
Error [CredentialsProviderError]: Could not load credentials from any providers
コードはprocess.env.VERCEL_OIDC_TOKENを読み、なければfromNodeProviderChain()にフォールバックする作りにしていました。以前のプロジェクトで動いていたのと同じパターンです。まずOIDC Federationが本当に有効になっているか確認しました。
curl -s "https://api.vercel.com/v9/projects/resonance-match?teamId=$TEAM" \
-H "Authorization: Bearer $TOKEN" | jq .oidcTokenConfig
# {"enabled": true, "issuerMode": "team"}
有効にはなっています。それでもデバッグ用のエンドポイントで確認すると、VERCEL_OIDC_TOKENが実行時のprocess.envに存在していませんでした。静的な環境変数として注入されるわけではなく、Vercel側のSDKを通して取得する必要があるようです。手書きのfromWebToken()呼び出しを、公式の@vercel/oidc-aws-credentials-providerパッケージに置き換えたら1行で直りました。
import { awsCredentialsProvider } from "@vercel/oidc-aws-credentials-provider";
const credentials = process.env.AWS_ROLE_ARN
? awsCredentialsProvider({ roleArn: process.env.AWS_ROLE_ARN, roleSessionName: "resonance-match-web" })
: fromNodeProviderChain();
再デプロイ後、まずブラウザを介さずcurlで直接叩いて成功を確認し、そのあとブラウザ2枚で別々の文章を送信してみました。73%の類似度で正しくマッチし、6秒ポーリングでお互いの画面にも反映されています。動くはずと信じて出すのではなく、実際に確認できて初めて安心できました。
確認できていないこと
今回の検証で分かっていないことも正直に書いておきます。
実際のイベント会場での同時アクセスは試していません。
ブラウザタブ2枚は会場の人数とは全く違いますし、PAY_PER_REQUESTが急なスパイクでどう振る舞うかも未検証です。
また、ニックネームや一言テキストには文字数制限しかかけておらず、内容のモデレーションはありません。
個人のデモとしては割り切っていますが、不特定多数が自由に書き込める場で使うにはそのままでは足りないと思われます。
SearchSchemaのINLINE_FILTER(完全一致の絞り込み)もAPI上は用意されていますが、今回はeventIdのHASH要素しか使っておらず、試せていません。既存のOpenSearchゼロETL方式とのコスト比較もできていないので、ネイティブベクトル検索が実際に安くつくのかは分からないままです。
あくまで「GAから1日経った機能を1日触ってみた」の範囲での話として読んでいただけるとありがたいです。
最後まで読んでいただきありがとうございました。
