DynamoDBに追加されたベクトル検索を、Streamsでベクトル化してStrands Agentsから使ってみた

DynamoDBに追加されたベクトル検索を、Streamsでベクトル化してStrands Agentsから使ってみた

DynamoDBに新しく追加されたネイティブベクトル検索機能をStrands Agentsから試してみました!
2026.08.07

はじめに

こんにちは、スーパーマーケットが好きなコンサル部の神野です。

2026年8月5日、Amazon DynamoDBにネイティブなベクトル検索機能が一般提供(GA)されました!!

https://aws.amazon.com/blogs/aws/amazon-dynamodb-now-supports-real-time-vector-search-at-any-scale/

やはり気になるのが、AIエージェントのナレッジ検索用途です。今のところBedrock Knowledge BasesはデータソースとしてDynamoDBに対応していないため、DynamoDBに溜まっているデータをエージェントに検索させたい場合は自前で仕組みを作る必要があります。

そこで今回は、下記を試してみました!

  1. DynamoDB Streamsを使って、レコード登録時に自動でベクトル化するパイプライン
  2. ネイティブベクトル検索(SearchVectors API)を呼び出すStrands Agentsのツール
  3. そのエージェントをAmazon Bedrock AgentCore Runtimeへデプロイ

インフラはAWS CDKを中心に作ります!本記事のコード一式は下記リポジトリに配置したので、必要に応じてご参照ください。

https://github.com/yuu551/agentcore-strands-dynamo

構成

今回構築するアーキテクチャは下記の通りです。

アーキテクチャ図

書き込み側は、アプリケーションがDynamoDBテーブルへレコードを登録すると、DynamoDB StreamsがLambdaを起動し、Amazon Titan Text Embeddings V2でテキストをベクトル化します。ベクトル化した結果は、embeddingという名前の属性として同じレコードに保存します。属性名は任意で、どの属性をベクトル検索の対象にするかは後述のインデックス作成時に指定します(以降、この属性をembedding属性と呼びます)。ベクトルインデックスは非同期に更新されるため、書き込み側はベクトル化を一切意識する必要がありません。

検索側は、AgentCore Runtime上のStrands Agentがツール呼び出しでクエリをベクトル化し、SearchVectors APIで類似ドキュメントを取得して回答を生成します。

前提

使用した環境とバージョンは下記の通りです。

項目 バージョン
リージョン ap-northeast-1
Python 3.13
Node.js / pnpm 24.16.0 / 11.20.0
boto3 1.43.66
aws-cdk-lib 2.263.0
strands-agents 1.50.2
埋め込みモデル amazon.titan-embed-text-v2:0(1024次元)
エージェントモデル jp.anthropic.claude-haiku-4-5-20251001-v1:0

また、エージェントのモデルにはClaude Haiku 4.5を使用しています。

実装

CDKスタック

まずはインフラ全体をCDKで定義します。DynamoDBテーブル、ベクトル化Lambda、AgentCore Runtimeを1つのスタックにまとめました。

cdk/app.ts 全体
cdk/app.ts
import * as cdk from "aws-cdk-lib";
import {
  Duration,
  RemovalPolicy,
  Stack,
  StackProps,
  aws_bedrockagentcore as agentcore,
  aws_dynamodb as dynamodb,
  aws_ecr_assets as ecrAssets,
  aws_iam as iam,
  aws_lambda as lambda,
  aws_lambda_event_sources as eventSources,
} from "aws-cdk-lib";
import { Construct } from "constructs";

const EMBED_MODEL_ID = "amazon.titan-embed-text-v2:0";

class VectorSearchStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const table = new dynamodb.Table(this, "KnowledgeTable", {
      tableName: "agent-knowledge",
      partitionKey: { name: "doc_id", type: dynamodb.AttributeType.STRING },
      billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
      stream: dynamodb.StreamViewType.NEW_AND_OLD_IMAGES,
      removalPolicy: RemovalPolicy.DESTROY,
    });

    const embedder = new lambda.Function(this, "EmbedderFunction", {
      functionName: "agent-knowledge-embedder",
      runtime: lambda.Runtime.PYTHON_3_13,
      handler: "embedder.handler",
      code: lambda.Code.fromAsset("cdk/lambda"),
      timeout: Duration.seconds(60),
      environment: {
        TABLE_NAME: table.tableName,
        EMBED_MODEL_ID: EMBED_MODEL_ID,
      },
    });

    table.grantWriteData(embedder);
    embedder.addToRolePolicy(
      new iam.PolicyStatement({
        actions: ["bedrock:InvokeModel"],
        resources: [
          `arn:aws:bedrock:${this.region}::foundation-model/${EMBED_MODEL_ID}`,
        ],
      }),
    );

    embedder.addEventSource(
      new eventSources.DynamoEventSource(table, {
        startingPosition: lambda.StartingPosition.LATEST,
        batchSize: 10,
        retryAttempts: 3,
      }),
    );

    // ---- AgentCore Runtime(Strands Agent) ----
    const runtime = new agentcore.Runtime(this, "KnowledgeAgentRuntime", {
      runtimeName: "knowledge_agent",
      agentRuntimeArtifact: agentcore.AgentRuntimeArtifact.fromAsset("agent", {
        platform: ecrAssets.Platform.LINUX_ARM64,
      }),
    });

    runtime.role.addToPrincipalPolicy(
      new iam.PolicyStatement({
        actions: ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
        resources: [
          "arn:aws:bedrock:*::foundation-model/*",
          `arn:aws:bedrock:*:${this.account}:inference-profile/*`,
        ],
      }),
    );
    runtime.role.addToPrincipalPolicy(
      new iam.PolicyStatement({
        actions: ["dynamodb:SearchVectors"],
        resources: [table.tableArn, `${table.tableArn}/*`],
      }),
    );

    new cdk.CfnOutput(this, "TableName", { value: table.tableName });
    new cdk.CfnOutput(this, "AgentRuntimeArn", {
      value: runtime.agentRuntimeArn,
    });
  }
}

const app = new cdk.App();
new VectorSearchStack(app, "AgentKnowledgeVectorSearchStack", {
  env: { region: "ap-northeast-1" },
});
app.synth();

テーブルのstreamにはNEW_AND_OLD_IMAGESを指定しています。これは、Streamsに流れるイベントに変更後のレコードだけでなく変更前のレコードの内容も含める設定です。後述するLambdaで変更前後の内容を見比べてループを防ぐために、両方が必要になります。また、ベクトルインデックスはオンデマンドキャパシティモードのテーブルでのみサポートされるため、billingModeはPAY_PER_REQUESTにしています。

自動ベクトル化Lambda

DynamoDB Streamsから起動され、レコードのtext属性をベクトル化して同じレコードのembedding属性に保存するLambdaを実装します。

cdk/lambda/embedder.py
import json
import os

import boto3

TABLE_NAME = os.environ["TABLE_NAME"]
EMBED_MODEL_ID = os.environ["EMBED_MODEL_ID"]

dynamodb = boto3.client("dynamodb")
bedrock = boto3.client("bedrock-runtime")

def embed(text: str) -> list[float]:
    response = bedrock.invoke_model(
        modelId=EMBED_MODEL_ID,
        body=json.dumps({"inputText": text, "dimensions": 1024, "normalize": True}),
    )
    return json.loads(response["body"].read())["embedding"]

def handler(event, context):
    for record in event["Records"]:
        if record["eventName"] not in ("INSERT", "MODIFY"):
            continue

        new_image = record["dynamodb"].get("NewImage", {})
        old_image = record["dynamodb"].get("OldImage", {})
        text = new_image.get("text", {}).get("S")
        if not text:
            continue

        # 自分自身のembedding書き込みで再度発火した場合はスキップ(無限ループ防止)
        if "embedding" in new_image and old_image.get("text", {}).get("S") == text:
            continue

        embedding = embed(text)
        dynamodb.update_item(
            TableName=TABLE_NAME,
            Key={"doc_id": new_image["doc_id"]},
            UpdateExpression="SET embedding = :emb",
            ExpressionAttributeValues={
                ":emb": {"L": [{"N": str(v)} for v in embedding]}
            },
        )
        print(f"embedded: {new_image['doc_id']['S']} ({len(embedding)} dims)")

    return {"statusCode": 200}

注意として、このLambda自身がupdate_itemでembeddingを書き込むと、それがまたStreamsに流れてLambdaが再度起動されます。そこで、変更後のレコード(NewImage)にembeddingがあり、かつtextが変更前のレコード(OldImage)と同じ場合は、自分自身の書き込みと判断して何もせず次へ進めています。逆にtextが書き換わっていれば再度ベクトル化されるので、レコードの更新にも追従できます。

ベクトルの格納形式は数値のリスト型(L型の中にN型)です。DynamoDBに専用のベクトル型が追加されたわけではなく、通常の属性としてベクトルを格納し、そこにインデックスを張るイメージですね。

ベクトルインデックスの作成

前述の通りCloudFormation未対応のため、UpdateTable APIでベクトルインデックスを追加します。

scripts/create_vector_index.py
import time

import boto3

TABLE_NAME = "agent-knowledge"
INDEX_NAME = "embedding-index"

dynamodb = boto3.client("dynamodb", region_name="ap-northeast-1")

def main():
    dynamodb.update_table(
        TableName=TABLE_NAME,
        AttributeDefinitions=[
            {"AttributeName": "category", "AttributeType": "S"}
        ],
        VectorIndexUpdates=[
            {
                "Create": {
                    "IndexName": INDEX_NAME,
                    "VectorAttribute": {"AttributeName": "embedding"},
                    "Dimensions": 1024,
                    "DistanceFunction": "COSINE",
                    "SearchSchema": [
                        {
                            "AttributeName": "category",
                            "SearchSchemaElementType": "INLINE_FILTER",
                        }
                    ],
                    "Projection": {"ProjectionType": "ALL"},
                }
            }
        ],
    )

    while True:
        table = dynamodb.describe_table(TableName=TABLE_NAME)["Table"]
        indexes = table.get("VectorIndexes", [])
        status = indexes[0]["IndexStatus"] if indexes else "NOT_FOUND"
        print(f"IndexStatus: {status}")
        if status == "ACTIVE":
            break
        time.sleep(10)

    print("ベクトルインデックスの作成が完了しました")

if __name__ == "__main__":
    main()

主なパラメータは下記の通りです。

設定項目 設定値 説明
VectorAttribute embedding ベクトルを格納する属性名
Dimensions 1024 ベクトルの次元数。Titan Text Embeddings V2の出力に合わせる
DistanceFunction COSINE 距離関数。COSINE / DOT_PRODUCT / EUCLIDEAN から選択
SearchSchema category(INLINE_FILTER) 検索時にフィルタ条件として使える属性
Projection ALL 検索結果に含める属性

SearchSchemaに指定する属性は、GSIと同様にAttributeDefinitionsへの定義が必要です。

SearchSchemaの種類はINLINE_FILTERとHASHの2つがあります。今回使ったINLINE_FILTERは、検索時に絞り込み条件として任意で使える属性です。もう1つのHASHは、その属性の値ごとに検索空間そのものを分割する指定で、検索時はその属性の等値条件をSearchConditionExpressionに必ず含める必要があります。テナントIDをHASHにして、テナント間のデータが検索で絶対に混ざらないようにする、といった使い方ができます。

なお、Dimensions と DistanceFunction はインデックス作成後に変更できません。
埋め込みモデルを差し替える場合はインデックスの作り直しになるため、モデル選定は先に固めておくのがよさそうです。

実行すると、6件程度のデータなら1分ほどでACTIVEになりました。

実行結果
IndexStatus: CREATING
IndexStatus: CREATING
...
IndexStatus: ACTIVE
ベクトルインデックスの作成が完了しました

Strands Agentsのツール実装

エージェント側の実装は、クエリをベクトル化してSearchVectors APIを呼び出すツールを@toolデコレータで定義します。

agent/agent.py
import json
import sys

import boto3
from strands import Agent, tool
from strands.models import BedrockModel

REGION = "ap-northeast-1"
TABLE_NAME = "agent-knowledge"
INDEX_NAME = "embedding-index"
EMBED_MODEL_ID = "amazon.titan-embed-text-v2:0"

bedrock_runtime = boto3.client("bedrock-runtime", region_name=REGION)
dynamodb = boto3.client("dynamodb", region_name=REGION)

@tool
def search_knowledge(query: str) -> list[dict]:
    """社内ナレッジベースをベクトル検索して、関連するドキュメントを返す。

    経費、勤怠、IT関連の社内規程・手続きに関する質問に回答する際に使用する。

    Args:
        query: 検索したい内容(自然言語)
    """
    response = bedrock_runtime.invoke_model(
        modelId=EMBED_MODEL_ID,
        body=json.dumps({"inputText": query, "dimensions": 1024, "normalize": True}),
    )
    embedding = json.loads(response["body"].read())["embedding"]

    results = dynamodb.search_vectors(
        TableName=TABLE_NAME,
        IndexName=INDEX_NAME,
        SearchVector=[{"N": str(v)} for v in embedding],
        TopK=3,
    )
    return [
        {
            "title": r["Item"]["title"]["S"],
            "text": r["Item"]["text"]["S"],
            "category": r["Item"]["category"]["S"],
            "score": r["Score"],
        }
        for r in results["SearchResults"]
    ]

def create_agent() -> Agent:
    return Agent(
        model=BedrockModel(
            model_id="jp.anthropic.claude-haiku-4-5-20251001-v1:0",
            region_name=REGION,
        ),
        tools=[search_knowledge],
        system_prompt=(
            "あなたは社内ヘルプデスクのアシスタントです。"
            "社内規程に関する質問には必ず search_knowledge ツールで検索し、"
            "検索結果に基づいて回答してください。"
            "検索結果にない情報は推測せず、わからないと答えてください。"
        ),
    )

if __name__ == "__main__":
    agent = create_agent()
    agent(sys.argv[1] if len(sys.argv) > 1 else "経費精算の締め日はいつ?")

SearchVectorsの呼び出しは、検索クエリを同じ埋め込みモデル・同じ次元数でベクトル化してSearchVectorに渡します。TopKで取得件数を指定でき、最大100件まで返せます。検索結果にはItem(Projectionで指定した属性)とScoreが含まれるため、別途GetItemで検索する必要はありません。

AgentCore Runtime用のエントリーポイントはこのエージェントをラップするだけです。

agent/main.py
from agent import create_agent
from bedrock_agentcore.runtime import BedrockAgentCoreApp

app = BedrockAgentCoreApp()

@app.entrypoint
def invoke(payload, context):
    agent = create_agent()
    result = agent(payload.get("prompt", ""))
    return {"result": str(result)}

if __name__ == "__main__":
    app.run()

コンテナイメージのDockerfileも載せておきます。

agent/Dockerfile
FROM public.ecr.aws/docker/library/python:3.13-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY agent.py main.py ./

EXPOSE 8080
CMD ["python", "main.py"]
agent/requirements.txt
strands-agents
bedrock-agentcore
boto3>=1.43.66

requirements.txtでboto3 1.43.66以降を下限指定しています。コンテナ内のboto3が古いとsearch_vectorsが呼べないためです。

動作確認

セットアップとデプロイ

まずはリポジトリを取得して依存パッケージをインストールします。CDK関連(aws-cdk-lib / tsx など)はpnpm、Pythonスクリプト側(boto3 / strands-agents)はuvで管理しています。

実行コマンド
git clone https://github.com/yuu551/agentcore-strands-dynamo.git
cd agentcore-strands-dynamo
pnpm install
uv sync

続いてCDKでデプロイします。

実行コマンド
npx cdk deploy

デプロイが完了したら、ベクトルインデックスを作成します。

実行コマンド
uv run python scripts/create_vector_index.py

続いて、サンプルの社内ナレッジを投入します。投入するとベクトル化はStreams経由でLambdaが勝手にやってくれます。

実行コマンド
uv run python scripts/seed_items.py
実行結果
put: doc-001 経費精算の締め日
put: doc-002 出張旅費の上限
put: doc-003 リモートワーク規程
put: doc-004 有給休暇の申請方法
put: doc-005 貸与PCの交換サイクル
put: doc-006 社内Wi-Fiの接続方法

20秒ほど待ってから、各レコードにembeddingが付与されたかを確認してみます。テーブルをスキャンして、embedding属性の有無と次元数を出力するスクリプトです。

scripts/check_embeddings.py
import boto3

TABLE_NAME = "agent-knowledge"

dynamodb = boto3.client("dynamodb", region_name="ap-northeast-1")

def main():
    paginator = dynamodb.get_paginator("scan")
    for page in paginator.paginate(
        TableName=TABLE_NAME, ProjectionExpression="doc_id, embedding"
    ):
        for item in sorted(page["Items"], key=lambda i: i["doc_id"]["S"]):
            emb = item.get("embedding", {}).get("L")
            status = f"あり({len(emb)}次元)" if emb else "なし"
            print(f"{item['doc_id']['S']}: embedding={status}")

if __name__ == "__main__":
    main()
実行コマンド
uv run python scripts/check_embeddings.py
実行結果
doc-001: embedding=あり(1024次元)
doc-002: embedding=あり(1024次元)
doc-003: embedding=あり(1024次元)
doc-004: embedding=あり(1024次元)
doc-005: embedding=あり(1024次元)
doc-006: embedding=あり(1024次元)

DynamoDBにデータ投入しただけで自動でベクトル化されていますね!

SearchVectors APIを直接叩いてみる

エージェントに組み込む前に、検索結果の傾向を確認しておきます。クエリをTitan Text Embeddings V2でベクトル化し、SearchVectors APIに渡して上位2件を表示するスクリプトです。

scripts/search_test.py(抜粋)
def embed(text: str) -> list[float]:
    response = bedrock_runtime.invoke_model(
        modelId=EMBED_MODEL_ID,
        body=json.dumps({"inputText": text, "dimensions": 1024, "normalize": True}),
    )
    return json.loads(response["body"].read())["embedding"]

for query in queries:
    vector = [{"N": str(v)} for v in embed(query)]
    results = dynamodb.search_vectors(
        TableName=TABLE_NAME,
        IndexName=INDEX_NAME,
        SearchVector=vector,
        TopK=2,
    )
    hits = [
        (round(r["Score"], 4), r["Item"]["title"]["S"])
        for r in results["SearchResults"]
    ]
    print(f"{query} -> {hits}")
実行コマンド
uv run python scripts/search_test.py
実行結果
経費の締め切りはいつ? -> [(0.4065, '経費精算の締め日'), (0.9086, '出張旅費の上限')]
ホテル代はいくらまで出る? -> [(0.6383, '出張旅費の上限'), (0.913, 'リモートワーク規程')]
パソコンが壊れたらどうすればいい? -> [(0.6795, '貸与PCの交換サイクル'), (0.8679, 'リモートワーク規程')]

いずれも意図したドキュメントが1位に来ていますね!

ScoreはCOSINEを指定した場合コサイン距離であり、値が小さいほど類似しています。類似度スコア(大きいほど類似)の感覚でしきい値を設定すると真逆になるのでご注意ください。

INLINE_FILTERに指定したcategory属性でのフィルタも試してみます。SearchConditionExpressionに条件を渡すだけです。

scripts/search_test.py(抜粋)
vector = [{"N": str(v)} for v in embed("社内ネットワークにつなぎたい")]
results = dynamodb.search_vectors(
    TableName=TABLE_NAME,
    IndexName=INDEX_NAME,
    SearchVector=vector,
    TopK=3,
    SearchConditionExpression="category = :c",
    ExpressionAttributeValues={":c": {"S": "IT"}},
)
実行結果
--- category=IT フィルタ ---
0.5044 社内Wi-Fiの接続方法
0.8222 貸与PCの交換サイクル

ITカテゴリのドキュメントだけに絞った上でのベクトル検索が問題なくできました。
レコードに登録した属性はこのように絞り込みができて便利ですね。

AgentCore Runtime上のエージェントに質問してみる

デプロイしたエージェントを呼び出してみます。RuntimeのARNはCDKデプロイ時の出力(AgentRuntimeArn)に表示されるので、引数で渡す形にしています。

scripts/invoke_runtime.py
import json
import sys

import boto3

client = boto3.client("bedrock-agentcore", region_name="ap-northeast-1")

def main():
    runtime_arn = sys.argv[1]
    prompt = sys.argv[2] if len(sys.argv) > 2 else "経費精算の締め日はいつ?"

    response = client.invoke_agent_runtime(
        agentRuntimeArn=runtime_arn,
        qualifier="DEFAULT",
        payload=json.dumps({"prompt": prompt}),
    )
    print(json.loads(response["response"].read())["result"])

if __name__ == "__main__":
    main()
実行コマンド
uv run python scripts/invoke_runtime.py \
  arn:aws:bedrock-agentcore:ap-northeast-1:xxxxxxxxxxxx:runtime/knowledge_agent-xxxxxxxxxx \
  "出張のホテル代っていくらまで経費で落とせる?新幹線のグリーン車は乗っていい?"
実行結果
社内規程に基づいて回答いたします。

## 出張費用について

**ホテル代(宿泊費)**
- 国内出張の場合、**1泊12,000円まで**が上限となります。

**新幹線のグリーン車**
- 新幹線は**普通車指定席まで**利用可能です。
- グリーン車の利用は認められていません。

なお、海外出張の場合は事前に部門長の承認が必要となりますので、ご注意ください。

ベクトル検索の結果を踏まえて回答できていますね!出張旅費の上限という1つのドキュメントに含まれるホテル代とグリーン車の情報を、どちらも正確に拾って回答しています。

追加でデータ投入するケースも確認する

新しいナレッジを投入した直後に、エージェントがそのナレッジで回答できるかを試します。

新しいナレッジを追加
table.put_item(Item={
    "doc_id": "doc-007",
    "category": "福利厚生",
    "title": "書籍購入補助制度",
    "text": "業務に関連する書籍は月5,000円まで会社負担で購入できます。購入申請は総務部のフォームから行い、電子書籍も対象です。",
})

投入から15秒後に、先ほどのスクリプトで質問してみると・・・

実行コマンド
uv run python scripts/invoke_runtime.py \
  arn:aws:bedrock-agentcore:ap-northeast-1:xxxxxxxxxxxx:runtime/knowledge_agent-xxxxxxxxxx \
  "技術書を会社のお金で買いたいんだけど、何か制度ある?"
実行結果
はい、ございます!

**書籍購入補助制度**があります。

## 制度の内容
- **上限額**: 月5,000円まで会社負担
- **対象**: 業務に関連する書籍
- **申請方法**: 総務部のフォームから申請
- **電子書籍**: 対象に含まれます

技術書は業務に関連する書籍として対象になりますので、総務部のフォームから購入申請を行ってください。

無事、追加したばかりのナレッジで回答できました!今回の環境では、投入してからDynamoDB StreamsによるLambda起動→ベクトル化→インデックス反映まで含めて約15秒で検索可能になりました(非同期処理のため所要時間は変動します)。

補足(長い文書は分割してから登録する)

Knowledge Basesであれば、データの取り込み時に長い文書を検索しやすい単位へ自動で分割してくれますが、今回の構成にはその仕組みがないため、分割は自前で行う必要があります。

今回の実装では、Lambdaがtext属性を丸ごと1本のベクトルにしています。社内FAQのような短い文章ならこれで十分ですが、長い文書を扱う場合は下記のどちらかの方式になります。

  1. 登録前に分割する方式。アプリ側で文書を小さな単位に区切り、それぞれ別レコード(doc_idを doc-001#chunk-0 のように分ける)として投入します。Lambdaは今回のままでよく、検索結果も区切った単位で返ります
  2. Lambda内で分割する方式。Streamsで受けたtextをLambdaが区切り、子レコードとして登録します。登録する側は手を入れずに済みますが、元の文書を更新したときに古い子レコードを消すなど、親子関係の管理が必要になってきます。

実際に前者の方式を検証してみます。3つの節からなる情報セキュリティ規程を、節ごとに区切って投入してみました。

scripts/seed_chunked_doc.py(抜粋)
document = {
    "doc_id": "doc-008",
    "category": "IT",
    "title": "情報セキュリティ規程",
    "chunks": [
        "パスワードは12文字以上で、英大文字・英小文字・数字・記号のうち3種類以上を...",
        "社外への資料送付は、機密区分がconfidential以上の場合、上長の承認と...",
        "セキュリティインシデントを発見した場合は、30分以内に情報システム部の...",
    ],
}

for i, chunk in enumerate(document["chunks"]):
    table.put_item(
        Item={
            "doc_id": f"{document['doc_id']}#chunk-{i}",
            "category": document["category"],
            "title": document["title"],
            "text": chunk,
        }
    )
実行コマンド
uv run python scripts/seed_chunked_doc.py
実行結果
put: doc-008#chunk-0 (97文字)
put: doc-008#chunk-1 (84文字)
put: doc-008#chunk-2 (90文字)

20秒ほど待つと3レコードすべてにembeddingが付与されていました。この状態で検索してみると、質問に対応する節のレコードがそれぞれ1位でヒットします。

実行結果
パスワードは何文字必要? -> [(0.3302, 'doc-008#chunk-0'), (0.8678, 'doc-005')]
セキュリティ事故を見つけたらどこに連絡する? -> [(0.5501, 'doc-008#chunk-2'), (0.7785, 'doc-006')]

エージェントに「パスワードのルールを教えて。あとセキュリティ事故を見つけたときの連絡先も」と2つの節にまたがる質問をしても、chunk-0とchunk-2の内容を両方拾って正しく回答できました!

なお、Titan Text Embeddings V2の入力上限は8,192トークンで、これを超えると埋め込み自体が失敗します。実際に運用する際はチャンキング戦略も考えていきたいですね・・・!
この辺りは今後、DynamoDBがKnowledge Basesのデータソースに対応してくれることにも期待したいところです。

補足(このやり方だとベクトル化には時間差がある)

この構成のベクトル化は、レコードの登録とは別のタイミングで裏側で実行されます。さらにベクトルインデックスへの反映も非同期です。そのため、登録してから検索で見つかるようになるまで、今回の環境では15秒ほどの時間差がありました。この時間はデータ量や状況によって変わります。

登録した直後に必ず検索へ反映されていることをこの構成では保証できません。たとえば「保存」ボタンを押した直後の画面でそのデータを検索して表示する、といった使い方には向かないので、登録から検索までの時間差を許容できる要件かどうかは事前に確認しておいてください。時間差を許容できない場合は、Streamsに任せず、登録処理の中でベクトル化まで同期的に済ませてしまうのが1つの手かと思います!

他のベクトル検索サービスとの使い分け

最後に、S3 VectorsやOpenSearchと比べてどうなのかを整理しておきます。

サービス 検索機能 向いているケース
OpenSearch 全文検索・ハイブリッド検索・集約まで揃っていて最も高機能 検索品質を追求したい。検索が主役のワークロードだがコストは相応にかかる
S3 Vectors ベクトル検索+メタデータフィルタ。応答は遅め 大量のナレッジを安く保存して検索する
DynamoDBベクトル検索 ベクトル検索+属性での絞り込み。応答は1桁ミリ秒 業務データが既にDynamoDBにあり、更新が多く、すぐ検索に反映したい

DynamoDBでのベクトル検索が嬉しいのは、業務データの置き場とセットでベクトル検索ができる点なのかな?と感じました。

DynamoDBのデータをベクトル化したいケースを考えた時に、OpenSearchやS3 Vectorsを使う場合、DynamoDBのデータを複製する仕組みと、複製先のメタデータ設計・同期の運用を考慮する必要があるかと思います。
今回の構成なら、テーブルにインデックスを追加することで、複製も同期の運用もありません。検索時の絞り込みも、categoryのような運用データの属性をそのまま使えます。

また、スキーマでHASHを指定すると、その属性の値ごとに検索空間そのものを分けられます。マルチテナントのアプリで、テナントIDをHASHにしてテナント間のデータが検索で混ざらないようにする、といった使い方もできそうです。

正直まだどう言ったケースにすごく有効かな・・・?と思っているので、またいいアイデアが思いついたら共有します!

おわりに

DynamoDBだけでベクトル検索が完結し、Streamsと組み合わせることで書き込み側に手を入れずにベクトル化までできました!
今後Knowledge Basesのデータソースとして対応するとより幅が広がるのかなと感じました!

本記事が少しでも参考になりましたら幸いです。最後までご覧いただきありがとうございました!

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事