Amazon DynamoDBの新機能で意味検索を簡単に作る

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

Amazon DynamoDB

この記事で伝えたいこと

「音楽に集中できるイヤホン」で検索したら、「ノイズキャンセリング機能付きのワイヤレスヘッドホン」が見つかる。そんな"意味で探す検索"が、専用の検索エンジンを別で立てずに、すでに使っている Amazon DynamoDB の新機能「ベクトルインデックス」だけで簡単に作れました。

これがこの記事の結論です。

ふつうの検索(キーワード検索)は、文字が一致するかどうかで探します。そのため、探している人と登録した人の「言い方」が違うと見つかりません。これを解決するのが、言葉の意味の近さで探す 意味検索(セマンティック検索) です。

これまで意味検索を作ろうとすると、専用の検索エンジン(Amazon OpenSearch Service など)を常時起動しておく構成が一般的で、使っていないときも固定費がかかっていました。最近は Amazon S3 Vectors などの従量課金で使えるサービスも増えてきており、2026年8月には DynamoDB にもベクトルインデックスという機能が追加されました。これを使うと、すでにある DynamoDB テーブルにそのまま追加でき、検索や書き込みは使った分だけ払う形で意味検索が作れます。

この記事の想定読者

DynamoDB は普段から使っているけれど、AI(埋め込みやベクトル)はあまり詳しくない方を想定しています。「DynamoDB にテーブルを作って読み書きする」ことは分かるけれど、「ベクトル?埋め込み?」というところで止まっている、という方にちょうど届く内容です。

そのため、DynamoDB の基本的な用語(テーブル・属性・パーティションキーなど)は知っている前提で進めますが、AI まわりの用語(ベクトル・埋め込み・意味の近さ)はゼロから、たとえ話を交えて説明します。

この記事では、

  • そもそも「意味検索」とは何か
  • どういう仕組みで動くのか(AI の部分をやさしく)
  • 実際の設定方法と使い方(コード付き)

を順番に説明していきます。

1. 「意味検索」ってなに?

キーワード検索は文字の一致で探します。意味検索は意味の近さで探します。

登録されている商品 検索ワード キーワード検索 意味検索
ノイズキャンセリング機能付きのワイヤレスヘッドホン 音楽に集中できるイヤホン ❌ ✅
ポアオーバー式のコーヒードリッパー 家でおいしいコーヒーをいれたい ❌ ✅

検索ワードに「ノイズキャンセリング」や「ドリッパー」という言葉が入っていなくても、言いたいことが近ければ見つけられるようにするのが今回のゴールです。

2. どういう仕組みで動くの?

カギは「ベクトル(数字の並び)」です。

たとえ話:言葉に「住所」をつける

ここに「言葉」が住んでいる場所を示す地図があると仮定します。この地図では、意味が近い言葉ほど近くに住んでいます。「ヘッドホン」の家のすぐ隣には「イヤホン」や「ノイズキャンセリング」が住んでいて、「コーヒー」はずっと遠くの別の街に住んでいる、というイメージです。

AI のモデルを使うと、文章をこの地図上の住所(数字の列)に変換できます。これをベクトルとか 埋め込み(Embedding) と呼びます。

"ノイズキャンセリングのヘッドホン" の住所イメージ(1024個の数字の列)

[-0.02129449136555195, 0.011849354021251202, -0.03400249406695366, ... ]

※ 数字は説明のための仮の値です。

実際の住所は 1024 個の数字でできているので人間には想像しにくいですが、考え方は同じです。意味が近い文章どうしは、数字の列も「近い値」になります。

検索の流れはこうです。

  1. あらかじめ、登録する文章を全部ベクトル(住所)に変換して保存しておく
  2. 検索するときは、検索ワードもベクトルに変換する
  3. 住所が一番「近い」ものを探す → それが「だいたい同じ意味のもの」

この「近い住所を高速で探す」役割をするのが、今回の主役ベクトルインデックスです。

今回の構成では、

  • 文章 → ベクトルへの変換:Amazon Bedrock の「Amazon Titan Text Embeddings V2」という AI モデル
  • ベクトルの保存と検索:DynamoDB のベクトルインデックス

を使います。

Titan Text Embeddings V2 を選んだのは、東京リージョンで使える埋め込みモデルの中で料金が安く、DynamoDB のベクトル検索の公式チュートリアルでも使われていて試しやすいからです。

⚠️ ただし、Titan Text Embeddings V2 は英語向けに最適化されたモデルで、日本語を含む多言語対応はプレビュー扱いです。日本語データで本格的に使う場合は、多言語向けのモデル(Cohere Embed Multilingual など)とも実際の検索結果を比べてから選ぶのがおすすめです。

3. 全体の構成

意味検索に必要な AWS サービスは、実は Bedrock と DynamoDB の2つだけ です。アプリケーションは、登録のときも検索のときも「Bedrock でベクトルに変換 → DynamoDB に保存/検索」という順番で2つを呼び出します。

flowchart LR
    App["アプリケーション<br/>(登録処理・検索処理)"]

    Bedrock["Amazon Bedrock<br/>Titan Text Embeddings V2<br/>(テキスト → ベクトル)"]

    subgraph DDB["Amazon DynamoDB"]
        Table["テーブル本体"]
        Index["ベクトルインデックス"]
    end

    App -- "① テキストを渡す" --> Bedrock
    Bedrock -- "② ベクトルが返る" --> App
    App -- "③ 登録:PutItem(ベクトルを保存)<br/>検索:SearchVectors(似たものを探す)" --> DDB

    style DDB fill:#F5F5F5,stroke:#B0BEC5,color:#232F3E;
サービス 役割
Amazon Bedrock Titan Text Embeddings V2 でテキストを 1024 次元のベクトルに変換
Amazon DynamoDB テーブル本体+ベクトルインデックスで、保存と類似検索をまとめて担当

ポイントは、ベクトルの保存・検索を DynamoDB のインデックスに寄せていることです。検索専用のクラスターを別に建てないので、それを常時起動しておく費用もかかりません。

「アプリケーション」の部分は何でもかまいません。既存の Web アプリや Amazon EC2・Amazon ECS 上のサーバー、バッチ処理などから、AWS SDK で Bedrock と DynamoDB を呼び出せば同じように動きます。

構成例:Amazon API Gateway + AWS Lambda で API にする

この記事では一例として、アプリケーション部分を API Gateway(HTTP API)+ Lambda で作り、登録用の POST /items と検索用の POST /search の2つの API にしています。以降の 5・6 章のコードと curl の例は、この構成を前提にしています。

 Client ── API Gateway (HTTP API) ─┬─ POST /items  ─▶ Lambda (ingest) ─┐
                                   └─ POST /search ─▶ Lambda (search) ─┴─▶ Bedrock / DynamoDB

API Gateway と Lambda はどちらも従量課金で待機コストがかからないため、Bedrock や DynamoDB と組み合わせると、構成全体を「使った分だけ払う」形にできます。Lambda は割安な ARM(AWS Graviton)を指定、API Gateway は REST API より安価な HTTP API を採用、ログの保存期間は7日に短縮しています。

4. いちばん大事なところ:ベクトルインデックスの設定方法

ここからは、ベクトルインデックスの設定を説明します。今回は AWS CDK(TypeScript) でコードとして定義します。

ベクトルインデックスの設定は、DynamoDB テーブルを定義するところに書きます。

const cfnTable = new dynamodb.CfnTable(this, "DocumentsTable", {
  // ベクトルインデックスは「オンデマンド課金」が必須
  billingMode: "PAY_PER_REQUEST",

  attributeDefinitions: [
    { attributeName: "PK", attributeType: "S" }, // 主キー(各データの ID)
    { attributeName: "Category", attributeType: "S" }, // 絞り込み用のカテゴリ
  ],
  keySchema: [{ attributeName: "PK", keyType: "HASH" }],

  // ↓↓↓ ここがベクトルインデックスの設定 ↓↓↓
  vectorIndexes: [
    {
      indexName: "SemanticSearchIndex",
      dimensions: 1024, // ベクトルの次元数(=数字の個数)
      distanceFunction: "COSINE", // 「近さ」の測り方
      vectorAttribute: { attributeName: "Embedding" }, // ベクトルを入れる列名
      searchSchema: [
        // Category で検索範囲を絞れるようにする
        { attributeName: "Category", searchSchemaElementType: "HASH" },
      ],
      projection: { projectionType: "ALL" }, // 検索結果に全項目を含める
    },
  ],
});

設定項目を1つずつ説明します。

dimensions: 1024(次元数)

ベクトル=数字の列の「長さ」です。今回使う AI モデル(Titan V2)は標準で 1024 個の数字を出すので 1024 にします。この数字は後から変更できず、変えたいときはインデックスの作り直しになります。 使う AI モデルに合わせて最初に決めましょう。

💡 コスト削減のコツ:Titan V2 は 512 や 256 も選べます。数字が少ないほど保存・検索のコストが下がります(そのぶん検索精度は落ちることがあります)。

distanceFunction: "COSINE"(近さの測り方)

2つのベクトルが「どれくらい近いか」を測る計算方法です。COSINE(コサイン距離)は、地図のたとえで言うと「どちらの方角にあるか(向き)」だけで比べる方法で、文章の意味を比べる用途の定番です。こちらも後から変更できないので注意してください。

vectorAttribute: { attributeName: "Embedding" }

ベクトル(数字の列)を保存する列の名前です。ここでは Embedding という名前にしています。

searchSchema(検索の絞り込み条件)

Category を HASH(ベクトルインデックス用のパーティションキー)に指定すると、検索を「このカテゴリの中だけ」に絞れます。たとえば「家電カテゴリの中だけで似たものを探す」ができます。検索で調べるデータが減るので、速くもなりますし安くもなります。

ここで使う属性(Category)は、attributeDefinitions にも宣言しておく必要があります。

projection: { projectionType: "ALL" }

検索結果に、どの項目を含めて返すかの設定です。ALL は「全項目を返す」という意味です(ベクトル本体は、指定しない限り検索結果には含まれません)。

5. 使い方:登録する

次に、データを登録する処理です(構成例では ingest 用の Lambda の中身にあたります)。やることは2ステップだけです。

  1. 文章を Bedrock に送ってベクトルに変換する
  2. そのベクトルを DynamoDB に保存する

ステップ1:文章をベクトルに変換する(Bedrock を呼ぶ部分)

import {
  BedrockRuntimeClient,
  InvokeModelCommand,
} from "@aws-sdk/client-bedrock-runtime";

const bedrock = new BedrockRuntimeClient({});

async function embedText(text: string): Promise<number[]> {
  const res = await bedrock.send(
    new InvokeModelCommand({
      modelId: "amazon.titan-embed-text-v2:0", // 使う AI モデルの指定
      contentType: "application/json",
      accept: "application/json",
      body: JSON.stringify({
        inputText: text,
        dimensions: 1024, // インデックスの次元数と合わせる
      }),
    }),
  );

  // AI からの返事(ベクトル)を取り出す
  const payload = JSON.parse(new TextDecoder().decode(res.body));
  return payload.embedding; // → [0.12, -0.03, ...] のような数字の列
}

ステップ2:ベクトルを DynamoDB に保存する

import { randomUUID } from "node:crypto";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb";

// TABLE_NAME の定義と、リクエストボディ req の取り出しは省略

// 検索したい項目をまとめて1本のベクトルにする
const contentToEmbed = [req.title, req.text].filter(Boolean).join("\n");
const embedding = await embedText(contentToEmbed);

const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

const id = randomUUID();
const category = req.category ?? "default";

await ddb.send(
  new PutCommand({
    TableName: TABLE_NAME,
    Item: {
      PK: id,
      Category: category,
      Title: req.title ?? "",
      Text: req.text,
      Embedding: embedding, // ← ここに変換したベクトルを保存
    },
  }),
);

ポイントは、保存するときに Embedding という列(=さっきインデックスで指定した列)にベクトルを入れることです。これだけで、DynamoDB が自動的にベクトルインデックスに登録してくれます。

もう1つ大事なのが Category です。searchSchema でパーティションキーに指定した属性が抜けているアイテムは、エラーにならずにインデックスから外れてしまいます。このコードでは未指定のときに "default" を入れているので、その心配はありません。

違いが分かるように、種類の違う商品をいくつか登録しておきましょう。

for item in \
  '{"title":"ワイヤレスヘッドホン","text":"ノイズキャンセリング機能付きのワイヤレスヘッドホン"}' \
  '{"title":"ワイヤレスイヤホン","text":"充電ケース付きの小型イヤホン。通勤中の通話にも便利"}' \
  '{"title":"Bluetoothスピーカー","text":"防水仕様でアウトドアでも使える充電式スピーカー"}' \
  '{"title":"コーヒードリッパー","text":"マグカップの上に置いて1杯ずついれるポアオーバー式のドリッパー"}' \
  '{"title":"ランニングシューズ","text":"クッション性の高い、毎日のジョギング向けの軽量シューズ"}'
do
  curl -s -X POST "$API_URL/items" \
    -H 'content-type: application/json' \
    -d "$(echo "$item" | jq '. + {"category":"demo"}')"
done

6. 使い方:検索する

検索も2ステップです。

  1. 検索ワードを Bedrock でベクトルに変換する(登録のときと同じ AI モデル・同じ次元数を使うのが鉄則)
  2. DynamoDB の SearchVectors で、似たベクトルを探す
import { SearchVectorsCommand } from "@aws-sdk/lib-dynamodb";

// ddb・TABLE_NAME・embedText は5章と同じものを使います

// リクエストから値を取り出す
const category = req.category ?? "default";
const topK = Math.max(req.topK ?? 1, 1);

// 1. 検索ワードをベクトルに変換
const queryVector = await embedText(req.query);

// 2. 似ているものを探す
const res = await ddb.send(
  new SearchVectorsCommand({
    TableName: TABLE_NAME,
    IndexName: "SemanticSearchIndex",
    SearchVector: queryVector, // 検索ワードのベクトル
    TopK: topK, // 返す件数
    // Category で検索範囲を絞る(searchSchema で指定した項目)
    SearchConditionExpression: "Category = :cat",
    ExpressionAttributeValues: { ":cat": category },
  }),
);

// 結果は「似ている順」で返ってくる
const results = (res.SearchResults ?? []).map((r) => ({
  title: r.Item?.Title,
  text: r.Item?.Text,
  score: r.Score, // ← レスポンスの results[].score(COSINE では小さいほど似ている)
}));

return jsonResponse(200, {
  query: req.query,
  count: results.length,
  results,
});

この return で組み立てた JSON が、そのまま API のレスポンスとして返ります。つまり下の結果の query・count・results は、上のコードの各行とそれぞれ対応しています。

それでは検索してみましょう。

curl -X POST "$API_URL/search" \
  -H 'content-type: application/json' \
  -d '{
    "query": "音楽に集中できるイヤホン",
    "category": "demo",
    "topK": 3
  }'

返ってくる結果:

{
  "query": "音楽に集中できるイヤホン",
  "count": 3,
  "results": [
    {
      "title": "ワイヤレスイヤホン",
      "text": "充電ケース付きの小型イヤホン。通勤中の通話にも便利",
      "score": 0.6375558376312256
    },
    {
      "title": "ワイヤレスヘッドホン",
      "text": "ノイズキャンセリング機能付きのワイヤレスヘッドホン",
      "score": 0.6735330820083618
    },
    {
      "title": "Bluetoothスピーカー",
      "text": "防水仕様でアウトドアでも使える充電式スピーカー",
      "score": 0.7652348875999451
    }
  ]
}

1位は「イヤホン」という単語が共通するワイヤレスイヤホンでした。注目したいのは2位で、検索ワードと共通する単語がないノイズキャンセリングヘッドホンが入っています。ドリッパーやシューズは上位に入っていません。

比較のために、まったく違う言葉でも検索してみます。

curl -X POST "$API_URL/search" \
  -H 'content-type: application/json' \
  -d '{ "query": "家でおいしいコーヒーをいれたい", "category": "demo", "topK": 3 }'

返ってくる結果:

{
  "query": "家でおいしいコーヒーをいれたい",
  "count": 3,
  "results": [
    {
      "title": "コーヒードリッパー",
      "text": "マグカップの上に置いて1杯ずついれるポアオーバー式のドリッパー",
      "score": 0.772449791431427
    },
    {
      "title": "Bluetoothスピーカー",
      "text": "防水仕様でアウトドアでも使える充電式スピーカー",
      "score": 0.9089715480804443
    },
    {
      "title": "ワイヤレスヘッドホン",
      "text": "ノイズキャンセリング機能付きのワイヤレスヘッドホン",
      "score": 0.9183297753334045
    }
  ]
}

今度はコーヒードリッパーが一番上に来ます。同じデータでも、検索ワードの意味に合わせて順位が入れ替わるのが分かります。

score は「近さ」を表す数字です。今回の COSINE という設定では、小さいほど似ているという意味になります(0 がまったく同じ向き、2 が正反対)。

7. つまずきやすいポイント

実際に動かすうえで知っておくとよい注意点です。

  • 似たものがなくても topK 件返ってくる:近い順に上から返す仕組みなので、カテゴリ内のデータが topK 以上あれば指定した件数が返ってきます。「本当に似ているか」は件数ではなく score で判断しましょう。
  • 登録と検索で同じ AI モデルを使う:違うモデルで作ったベクトルは比べても意味がありません。必ず同じモデル・同じ次元数でそろえます。
  • 作った直後は少し待つ:ベクトルインデックスは、IndexStatus が ACTIVE になるまで検索できません。ACTIVE になった直後もしばらくエラー(ValidationException)が出ることがあるので、数分おいて再実行します。既存のテーブルに後から追加した場合は、すでにあるデータを取り込む「バックフィル」の時間もかかります。
  • 書き込んだ直後は検索に出てこないことがある:インデックスへの反映は非同期なので、少し遅れて検索できるようになります。
  • 文章を書き換えたらベクトルも作り直す:たとえば商品の説明文を変えても、ベクトルは自動では更新されません。内容を変えたら、変換し直して保存し直す必要があります(インデックスへの反映は自動です)。
  • 次元数・距離関数は後から変えられない:変えるにはインデックスの作り直しが必要です。最初の設計で決めましょう。コスト重視なら次元数 512 や 256 も検討の価値があります。
  • 万能ではない:意味検索は「意味の近さ」で探すので、商品コードや型番のような"意味を持たない文字列の完全一致"は苦手です。表記ゆれも確実に吸収できるとは限りません。用途によっては、ふつうのキーワード検索と組み合わせるのがおすすめです。

8. まとめ

  • 言い換えても見つかる意味検索が、DynamoDB のベクトルインデックスで作れるようになりました。専用の検索エンジンを別で立てる必要がありません。
  • 登録は「文章をベクトルにして保存」、検索は「検索ワードをベクトルにして SearchVectors で探す」という、どちらも2ステップのシンプルな流れです。
  • すでに DynamoDB を使っているシステムなら、データを別サービスに移さず、そのまま意味検索を追加できるのが大きな利点です。

「意味検索の導入は難しい」と思っていた方こそ、ぜひ一度試してみてください。想像よりずっと手軽に作れるはずです。


参考

池内 真奈美 (記事一覧)

アプリケーションサービス本部

新しい技術って、初めて触れたときは難しく見えますよね。『なんだ、そういうことか!』と読んだ後に納得してもらえる記事を書くことが目標です。