Serverless Operations, inc

>_cd /blog/id_v5z0geeb5y59

title

Amazon DynamoDB ベクトル検索機能をさっそくさわってみた

2026年8月5日、Amazon DynamoDB がネイティブでベクトル検索に対応しました。これまで DynamoDB でベクトル検索をやろうとすると、OpenSearch など別のベクトルストアにデータをコピーし、その同期パイプラインを維持する必要がありました。その一連の運用負荷・データ移動コスト・ライセンスコストが、今回のアップデートで不要になります。

ベクトル埋め込みを運用データと同じテーブルに保存して、別のベクトルストアに複製することなく類似検索を直接実行できるようになり、 単桁ミリ秒のレイテンシーを実現し、数兆件規模のベクトルにも対応する設計が実現可能になります。

99%の再現率

DynamoDB のベクトル検索は、99%の再現率を採用しています。これは全件を総当たりする厳密検索ではなく、近似最近傍探索(ANN) を意味します。数兆件規模から厳密に最近傍を求めると遅くなりすぎるため、インデックスを使って探索範囲を絞り込むことで、単桁ミリ秒という速度を実現しています。

その代わり、近似ゆえにごくまれに「本当に最も近い候補」を取りこぼす可能性があります。この取りこぼしの少なさを測る指標が再現率(recall) です。再現率は「近似検索が返した結果のうち、厳密検索なら返るはずだった正解にどれだけ一致していたか」の割合を表します。

「99%以上の再現率」は、総当たりで計算した理想の結果と約99%以上が一致する、という意味です。たとえば Top100件を検索すると、厳密検索での正解100件のうち約99件を正しく拾えるイメージで、速度のために近似を使っていても精度はほぼ厳密検索と変わらない、と言えます。なお再現率は、個々の結果がどれくらい似ているか(=スコア/距離)とは別の、検索品質そのものを表す指標です。

主な仕様

仕組みとしては、埋め込みを格納した属性に対して新しい「ベクトルインデックス」を作成する形です。埋め込みは好みのモデル(Bedrock Titan Text Embeddings、Cohere Embed、OpenAI など)で生成し、float のリストとして標準の PutItem で保存します。DynamoDB は既存の List データ型を使うため、新しいデータ型やスキーマ変更は不要です。

項目

内容

最大次元数

4096次元

距離関数

Euclidean / Cosine / Dot product の3種類

検索結果

最大100件、類似度でランク付けして返却

絞り込み

非ベクトル属性での完全一致フィルタに対応(BETWEEN などの範囲条件は非対応)

リージョン

AWS GovCloud (US) を含む全ての商用リージョン amazon

さっそくやってみる

Step 1: プロジェクトの準備

ファイルは setup.mjssearch.mjs の2つだけです。まず package.json を用意します。

{
  "name": "dynamodb-vector-test",
  "version": "2.0.1",
  "type": "module",
  "scripts": {
    "setup": "node setup.mjs",
    "search": "node search.mjs",
    "cleanup": "node setup.mjs cleanup"
  },
  "dependencies": {
    "@aws-sdk/client-bedrock-runtime": "^3.658.0",
    "@aws-sdk/client-dynamodb": "^3.1103.0",
    "@aws-sdk/util-dynamodb": "^3.996.0"
  },
  "engines": { "node": ">=18" }
}

ここで一つ注意点があります。SearchVectors は比較的新しいバージョンの @aws-sdk/client-dynamodb にしか入っていません。この記事の執筆時点では 3.1104.0SearchVectorsCommand の存在を確認できました。バージョンでハマりたくなければ、@latest で入れてしまうのが確実です。

npm install @aws-sdk/client-dynamodb@latest @aws-sdk/util-dynamodb@latest @aws-sdk/client-bedrock-runtime@latest

Step 2: テーブル作成とデータ投入(setup.mjs)

setup.mjs は「テーブル作成 → ACTIVE 待ち → 埋め込み生成して投入」を1ファイルで行います。node setup.mjs cleanup でテーブル削除もできるようにしてあります。

// setup.mjs
// DynamoDB ベクトル検索の準備を1ファイルで行う。
//   node setup.mjs            → テーブル作成 + 埋め込み投入
//   node setup.mjs cleanup    → テーブル削除(課金停止用・元に戻せません)

import {
  DynamoDBClient,
  CreateTableCommand,
  DescribeTableCommand,
  PutItemCommand,
  DeleteTableCommand,
} from "@aws-sdk/client-dynamodb";
import { marshall } from "@aws-sdk/util-dynamodb";
import {
  BedrockRuntimeClient,
  InvokeModelCommand,
} from "@aws-sdk/client-bedrock-runtime";

// ===== 設定 =====================================================
export const REGION = process.env.AWS_REGION ?? "ap-northeast-1";
export const TABLE_NAME = process.env.DDB_TABLE ?? "VectorSearchDemo";
export const VECTOR_INDEX = "descriptionVectorIndex";
export const VECTOR_ATTRIBUTE = "descriptionEmbedding";
export const EMBED_MODEL = process.env.BEDROCK_EMBED_MODEL ?? "amazon.titan-embed-text-v2:0";
export const DIMENSIONS = Number(process.env.BEDROCK_EMBED_DIMENSIONS ?? 1024);

const PRODUCTS = [
  { productId: "p001", name: "サマーランニングシューズ", category: "footwear", price: 8900,
    description: "通気性の高いメッシュ素材で、暑い季節のジョギングに最適な軽量スニーカー" },
  { productId: "p002", name: "ウィンターハイキングブーツ", category: "footwear", price: 15800,
    description: "保温性のある裏地付きで、雪道でも滑りにくい防水のトレッキングブーツ" },
  { productId: "p003", name: "マラソンレーシングシューズ", category: "footwear", price: 21000,
    description: "反発性のあるフォームを使った、記録を狙うランナー向けの超軽量シューズ" },
  { productId: "p004", name: "ステンレスボトル", category: "kitchen", price: 3200,
    description: "750ml入る、真空断熱で保冷保温に優れたステンレス製ウォーターボトル" },
  { productId: "p005", name: "キャンバススニーカー", category: "footwear", price: 5400,
    description: "普段使いに合わせやすい、キャンバス地のカジュアルシューズ" },
];

// ===== Bedrock 埋め込み(search.mjs と共通のロジック)===============
const bedrock = new BedrockRuntimeClient({ region: REGION });

export async function embed(text) {
  const isV2 = EMBED_MODEL.startsWith("amazon.titan-embed-text-v2");
  const payload = isV2
    ? { inputText: text, dimensions: DIMENSIONS, normalize: true }
    : { inputText: text };
  const res = await bedrock.send(
    new InvokeModelCommand({
      modelId: EMBED_MODEL,
      contentType: "application/json",
      accept: "application/json",
      body: JSON.stringify(payload),
    })
  );
  return JSON.parse(new TextDecoder().decode(res.body)); // { embedding, inputTextTokenCount }
}

// ===== DynamoDB =================================================
const ddb = new DynamoDBClient({ region: REGION });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function tableExists() {
  try {
    await ddb.send(new DescribeTableCommand({ TableName: TABLE_NAME }));
    return true;
  } catch (err) {
    if (err?.name === "ResourceNotFoundException") return false;
    throw err;
  }
}

async function createTable() {
  console.log(`テーブル作成中: ${TABLE_NAME} (region: ${REGION})`);
  await ddb.send(
    new CreateTableCommand({
      TableName: TABLE_NAME,
      BillingMode: "PAY_PER_REQUEST",
      // ベクトル属性はキーではないので AttributeDefinitions には含めない
      AttributeDefinitions: [{ AttributeName: "productId", AttributeType: "S" }],
      KeySchema: [{ AttributeName: "productId", KeyType: "HASH" }],
      // ▼ 新機能: ベクトルインデックス(まず最小構成)
      VectorIndexes: [
        {
          IndexName: VECTOR_INDEX,
          VectorAttribute: { AttributeName: VECTOR_ATTRIBUTE },
          Dimensions: DIMENSIONS,
          DistanceFunction: "COSINE", // COSINE / EUCLIDEAN / DOT_PRODUCT
          Projection: { ProjectionType: "ALL" },
          // 絞り込みを使う場合はここに SearchSchema(HASH / INLINE_FILTER)を追加
        },
      ],
    })
  );
}

async function waitActive() {
  process.stdout.write("テーブルが ACTIVE になるのを待機");
  for (let i = 0; i < 60; i++) {
    const { Table } = await ddb.send(new DescribeTableCommand({ TableName: TABLE_NAME }));
    process.stdout.write(".");
    if (Table?.TableStatus === "ACTIVE") return console.log(" OK");
    await sleep(3000);
  }
  throw new Error("テーブルが時間内に ACTIVE になりませんでした");
}

async function seed() {
  console.log("埋め込みを生成して投入中...");
  for (const p of PRODUCTS) {
    const { embedding } = await embed(p.description);
    await ddb.send(
      new PutItemCommand({
        TableName: TABLE_NAME,
        // marshall が number[] を List<Number> ({L:[{N},...]}) に変換
        Item: marshall({ ...p, [VECTOR_ATTRIBUTE]: embedding }, { removeUndefinedValues: true }),
      })
    );
    console.log(`  投入: ${p.productId} ${p.name} (${embedding.length}次元)`);
  }
}

async function runSetup() {
  if (await tableExists()) {
    console.log(`テーブル ${TABLE_NAME} は既に存在します。作成をスキップします。`);
  } else {
    await createTable();
    await waitActive();
  }
  await seed();
  console.log("\n完了しました。次に: npm run search");
}

async function runCleanup() {
  try {
    await ddb.send(new DeleteTableCommand({ TableName: TABLE_NAME }));
    console.log(`削除リクエストを送信しました: ${TABLE_NAME} (${REGION})`);
  } catch (err) {
    if (err?.name === "ResourceNotFoundException") {
      return console.log(`テーブル ${TABLE_NAME} は存在しません(すでに削除済み)。`);
    }
    throw err;
  }
}

// ===== エントリポイント ==========================================
const isDirectRun = import.meta.url === `file://${process.argv[1]}`;
if (isDirectRun) {
  const mode = process.argv[2];
  const task = mode === "cleanup" ? runCleanup() : runSetup();
  task.catch((err) => {
    console.error("\n失敗しました:", err?.name, err?.message);
    console.error("→ フィールド名の不一致が原因の場合、上のメッセージに正しい名前が含まれています。");
    process.exit(1);
  });
}

① ベクトルインデックスは CreateTableVectorIndexes で作ります。 これが今回の新機能の中核です。VectorAttribute に埋め込みを入れる属性名、Dimensions に次元数、DistanceFunction に距離関数(COSINE/EUCLIDEAN/DOT_PRODUCT)を指定します。まずは絞り込みなしの最小構成にしています。

埋め込みはキーではなく普通の List 属性として保存されるので、VectorAttribute.AttributeName で直接参照しています。

③ 埋め込みの保存は marshall が JavaScript の数値配列 number[] を、DynamoDB の List<Number>{L:[{N:...}]})に自動変換してくれます。専用のデータ型は不要です。

実行するとこうなります。

npm run setup
テーブル作成中: VectorSearchDemo (region: ap-northeast-1)
テーブルが ACTIVE になるのを待機...... OK
埋め込みを生成して投入中...
  投入: p001 サマーランニングシューズ (1024次元)
  投入: p002 ウィンターハイキングブーツ (1024次元)
  投入: p003 マラソンレーシングシューズ (1024次元)
  投入: p004 ステンレスボトル (1024次元)
  投入: p005 キャンバススニーカー (1024次元)

Step 3: ベクトル検索(search.mjs)

search.mjs は自然文クエリを埋め込み化して SearchVectors を叩きます。

// search.mjs
// 自然文クエリを埋め込みにして SearchVectors でベクトル検索する。
//   node search.mjs "夏に走るための軽いシューズ" [TopK]

import * as ddbPkg from "@aws-sdk/client-dynamodb";
import { unmarshall } from "@aws-sdk/util-dynamodb";
import { embed, REGION, TABLE_NAME, VECTOR_INDEX } from "./setup.mjs";

const { DynamoDBClient, SearchVectorsCommand } = ddbPkg;

if (!SearchVectorsCommand) {
  console.error(
    "SearchVectorsCommand が見つかりません。SDK を更新してください:\n" +
      "  npm install @aws-sdk/client-dynamodb@latest @aws-sdk/util-dynamodb@latest"
  );
  process.exit(1);
}

const ddb = new DynamoDBClient({ region: REGION });

// COSINE は「距離」(0=完全一致, 2=正反対, 小さいほど近い)。表示用に類似度%へ変換。
const toSimilarityPct = (d) => ((1 - d / 2) * 100).toFixed(1);
// SearchVector は List<Number> の「中身」(Number属性値の配列)。{L:...} では包まない。
const toVectorAttr = (embedding) => embedding.map((n) => ({ N: String(n) }));

async function main() {
  const queryText = process.argv[2] ?? "夏に走るための軽くて涼しいシューズ";
  const topK = Number(process.argv[3] ?? 5);
  console.log(`クエリ: 「${queryText}」  TopK=${topK}\n`);

  const { embedding } = await embed(queryText);

  let response;
  try {
    response = await ddb.send(
      new SearchVectorsCommand({
        TableName: TABLE_NAME,
        IndexName: VECTOR_INDEX,
        SearchVector: toVectorAttr(embedding),
        TopK: topK,
        // 絞り込み(HASHスコープ / INLINE_FILTER)は SearchConditionExpression 等をここに追加
      })
    );
  } catch (err) {
    console.error("検索に失敗しました:", err?.name, err?.message);
    process.exit(1);
  }

  // 生レスポンスを見たいときは DEBUG=1 を付けて実行
  if (process.env.DEBUG) {
    console.log("=== 生レスポンス(デバッグ用)===");
    console.log(JSON.stringify(response, null, 2));
    console.log("================================\n");
  }

  // 実レスポンス: { SearchResults: [{ Item: <marshall済み>, Score: <距離> }] }
  const rows = response.SearchResults ?? [];
  if (rows.length === 0) {
    console.log("結果が空でした。");
    return;
  }

  console.log("類似度が高い順(Score=距離, 小さいほど近い):");
  rows.forEach((row, i) => {
    const attrs = unmarshall(row.Item);
    const score = row.Score;
    console.log(
      `  ${i + 1}. [dist=${score.toFixed(4)} / 類似度${toSimilarityPct(score)}%] ${attrs.name}`
    );
  });
}

main();

SearchVectors の必須パラメータは TableName / IndexName / SearchVector / TopKです。 クエリ埋め込みを SearchVector に、返す件数を TopK に渡します。

②レスポンスは SearchResults[].ItemSearchResults[].Scoreです。 Item は marshall された形なので unmarshall で普通のオブジェクトに戻します。Score はベクトル検索の距離(類似度)です。

npm run search "夏に走るための軽いシューズ"
クエリ: 「夏に走るための軽いシューズ」  TopK=5

類似度が高い順(Score=距離, 小さいほど近い):
  1. [dist=0.6140 / 類似度69.3%] サマーランニングシューズ
  2. [dist=0.6238 / 類似度68.8%] マラソンレーシングシューズ
  3. [dist=0.7306 / 類似度63.5%] キャンバススニーカー
  4. [dist=0.8955 / 類似度55.2%] ウィンターハイキングブーツ
  5. [dist=1.0086 / 類似度49.6%] ステンレスボトル

ちゃんと 夏に走るための軽いシューズ の検索結果で サマーランニングシューズ が一番近い候補として出てきています。

Written by
編集部

亀田 治伸

Kameda Harunobu

  • Facebook->
  • X->
  • GitHub->

Share

Facebook->X->
Back
to list
<-