🐨

Amazon S3 Vectors で、ベクトル DB を立てずに画像検索 PoC を作る

に公開

こんにちは!アルダグラムでエンジニアをしているやすまさです。

建設・施工管理 SaaS「KANNA」では、現場写真が日々大量にアップロードされています。これらを「足場の写真」「配筋の写真」のように 自然言語で検索したい という要望は以前からありましたが、ベクトル DB を一台立てて運用する、というのは PoC のハードルとしては少し重めでした。

そこで、2025 年に一般提供が始まった Amazon S3 Vectors を使って、KANNA の現場写真をテキストで意味検索するプロトタイプを作ってみました。本記事ではその構成と、実際に作って分かった所感を共有します。

TL;DR

  • Amazon Bedrock の Titan Multimodal Embeddings (Image V1) で画像をベクトル化
  • S3 Vectors にベクトル + メタデータ(案件 UUID など)を格納
  • テキストクエリも同じ埋め込みモデルでベクトル化して類似検索
  • Flask API + React のシンプルな UI で動作確認
  • ベクトル DB を別途構築せずに、S3 を触る感覚で意味検索の PoC が組める

背景:現場写真の「意味検索」という課題

KANNA では案件(プロジェクト)ごとに何百枚〜何千枚という現場写真が蓄積されます。これまで提供している検索は、ファイル名・フォルダ名といった メタデータベース のものでした。

しかし実運用では、

  • 「あの足場が組まれていた頃の写真を出したい」
  • 「配筋の写真だけまとめて見たい」
  • 「外壁が出来上がっていた時期の写真を確認したい」

といった、画像の内容そのもの に対する検索ニーズが存在します。これをまともに解こうとするとベクトル検索が必要になりますが、選択肢としては OpenSearch / pgvector / Pinecone / Weaviate などがあり、いずれも「インフラを一つ増やす」決断が必要でした。

プロダクトに本採用する前に PoC で当たりを付けたい、という温度感では少し重い。そこに S3 Vectors が出てきた、というのが今回のモチベーションです。

Amazon S3 Vectors とは

S3 Vectors は、S3 にネイティブなベクトルインデックスを置けるサービスです。雑に言うと「ベクトル専用のバケットを作って、ベクトルとメタデータを put し、類似検索 API を叩く」だけで動きます。

  • Vector Bucket:ベクトル専用の S3 バケット
  • Vector Index:その中に作る検索インデックス(次元数・距離関数などを指定)
  • メタデータフィルタ:各ベクトルに付けた属性で絞り込み検索が可能

ベクトル DB を別途立てる必要がなく、IAM・課金・運用の世界観が S3 と地続きなのが大きな魅力です。一方で、レイテンシ特性や更新コストは従来のオンメモリ系ベクトル DB とは異なるため、ユースケース次第で向き不向きはあります(後述)。

仕様(次元上限・メタデータサイズ・対応リージョンなど)はアップデート頻度が高いので、最新は公式ドキュメントを確認してください。

Vector Bucket と Vector Index の作成手順は AWS 公式ドキュメント を参照してください。本記事では作成済みの前提で進めます。

作ったもの

KANNA の現場写真を対象に、自然文のテキストクエリで類似画像を返す プロトタイプです。案件単位での絞り込みもできるようにしました。

アーキテクチャ

[投入フロー]
  S3 (現場画像)
    ─▶ ローカルにダウンロード
    ─▶ Bedrock Titan Embed Image V1 でベクトル化
    ─▶ S3 Vectors にメタデータ付きで保存

[検索フロー]
  テキストクエリ
    ─▶ Titan で埋め込み
    ─▶ S3 Vectors に類似検索(案件 UUID でフィルタ)
    ─▶ Flask API ─▶ React フロント

ポイントは 画像とテキストを同じ埋め込み空間に乗せる こと。Titan Multimodal Embeddings は画像とテキストを同じベクトル空間に写像してくれるので、テキストクエリで画像を引く、という操作が成立します。

実装ハイライト

肝になるのは「画像をベクトル化して S3 Vectors に投入する部分」と「テキストで類似検索する部分」の 2 つです。順に見ていきます。

1. ベクトル化と S3 Vectors への投入

ダウンロード済みの画像を 1 枚ずつ Bedrock の amazon.titan-embed-image-v1 に投げ、案件メタデータを付けて S3 Vectors に保存します。まずは Bedrock と S3 Vectors のクライアントを用意します。S3 Vectors は boto3s3vectors クライアントとして提供されています。

import boto3

REGION = "us-east-1"
MODEL_ID = "amazon.titan-embed-image-v1"
DIMENSION = 1024
VECTOR_BUCKET_NAME = "image-search-prototype-xxxxx"
VECTOR_INDEX_NAME = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

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

画像をベクトル化する処理は、Bedrock の invoke_model に base64 化した画像を渡すだけです。

import base64, json

image_bytes = open(path, "rb").read()
response = bedrock.invoke_model(
    modelId=MODEL_ID,
    body=json.dumps({
        "inputImage": base64.b64encode(image_bytes).decode("utf-8"),
        "embeddingConfig": {"outputEmbeddingLength": DIMENSION},
    }),
)
embedding = json.loads(response["body"].read())["embedding"]

得られたベクトルに、案件 UUID などのメタデータを付けて put_vectors でインデックスに投入します。

vectors.append({
    "key": path,
    "data": {"float32": embedding},
    "metadata": {
        "source_path": path,
        "project_uuid": project_uuid,
        "project_title": project_title,
    },
})

s3vectors.put_vectors(
    vectorBucketName=VECTOR_BUCKET_NAME,
    indexName=VECTOR_INDEX_NAME,
    vectors=batch_vectors,
)
vectors.append({
    "key": path,
    "data": {"float32": embedding},
    "metadata": {
        "source_path": path,
        "project_uuid": project_uuid,
        "project_title": project_title,
    },
})

s3vectors.put_vectors(
    vectorBucketName=VECTOR_BUCKET_NAME,
    indexName=VECTOR_INDEX_NAME,
    vectors=batch_vectors,
)

メタデータに project_uuid を入れておくのが後段で効いてきます。S3 Vectors はクエリ時にメタデータでフィルタが書けるため、「案件 A の中で『足場』に似た写真」のような検索が、アプリ側で後フィルタするのではなく インデックス側で絞り込める。これは権限制御の観点でも嬉しい性質です。

2. 検索 API

検索側は Flask の小さな API にし、POST /search でテキストクエリを受け付けるようにしました。中身は、クエリ文字列を同じく Titan で埋め込みベクトル化し、S3 Vectors の query_vectors を呼ぶだけです。

# テキストクエリをベクトル化
response = bedrock.invoke_model(
    modelId=MODEL_ID,
    body=json.dumps({
        "inputText": query_text,
        "embeddingConfig": {"outputEmbeddingLength": DIMENSION},
    }),
)
query_vector = json.loads(response["body"].read())["embedding"]

# S3 Vectors に類似検索
search_params = {
    "vectorBucketName": VECTOR_BUCKET_NAME,
    "indexName": VECTOR_INDEX_NAME,
    "queryVector": {"float32": query_vector},
    "topK": top_k,
    "returnDistance": True,
    "returnMetadata": True,
}

# 案件単位の絞り込みはここ
if project_uuid:
    search_params["filter"] = {"project_uuid": project_uuid}

result = s3vectors.query_vectors(**search_params)

ポイントは 3 つあります。

  • 画像とテキストが同じ埋め込み空間に乗っている ので、テキストクエリで画像を引ける(Titan Multimodal Embeddings の効能)
  • filter パラメータでメタデータ絞り込みがインデックス側で完結する ので、アプリ層で後フィルタする必要がない
  • returnDistance / returnMetadata を true にするだけで、スコアと付帯情報がそのまま返ってくる

レスポンスからは distancemetadata.source_path を取り出し、フロントに返します。実装では score = 1 - distance として表示用の類似度に変換しました。

curl -X POST http://localhost:5000/search \
  -H "Content-Type: application/json" \
  -d '{"query": "建築現場", "top_k": 10, "project_uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"}'

3. 動かしてみる

POST /search を叩くと、以下のような JSON レスポンスが返ってきます(案件名・UUID はサニタイズ済み)。

{
  "query": "車椅子に乗っている写真はありますか",
  "project_uuid": null,
  "top_k": 5,
  "results_count": 5,
  "results": [
    {
      "score": 0.5250,
      "distance": 0.4750,
      "project_title": "案件A",
      "project_uuid": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
      "source_path": "data/<index>/<project>/xxxxxxxx1.jpg"
    },
    {
      "score": 0.5154,
      "distance": 0.4846,
      "project_title": "案件B(電動グラインダ案件)",
      "project_uuid": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
      "source_path": "data/<index>/<project>/xxxxxxxx2.jpg"
    },
    {
      "score": 0.5083,
      "distance": 0.4917,
      "project_title": "案件B(電動グラインダ案件)",
      "project_uuid": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
      "source_path": "data/<index>/<project>/xxxxxxxx3.jpg"
    },
    {
      "score": 0.5083,
      "distance": 0.4917,
      "project_title": "案件B(電動グラインダ案件)",
      "project_uuid": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
      "source_path": "data/<index>/<project>/xxxxxxxx4.jpg"
    },
    {
      "score": 0.5073,
      "distance": 0.4927,
      "project_title": "案件C(車いす修理案件)",
      "project_uuid": "cccccccc-cccc-cccc-cccc-cccccccccccc",
      "source_path": "data/<index>/<project>/xxxxxxxx5.jpg"
    }
  ]
}

フロントでは source_path を画像表示に、score(= 1 - distance)を類似度の表示に使う、というシンプルな使い方をしています。

なお、この API の上に薄い React UI(テキスト入力 + 案件選択 + グリッド表示 + 類似度スコア表示)も作って、社内デモではそちらを使っています。

動かしてみての所感

良かった点

  • PoC の立ち上がりが速い。ベクトル DB を一台立てる意思決定をスキップできるのは、検証段階では圧倒的に効く
  • メタデータフィルタが強力。案件単位の絞り込みがインデックス側で完結する。アプリで後フィルタする必要がないので、ページネーションやスコア順序の整合性で悩まない
  • IAM/監査の世界観が S3 と地続き。社内で S3 の運用ルールが整っていれば、そのまま延長線上で扱える
  • マルチモーダル埋め込みの精度 が、現場写真というドメインでも想像以上に実用的だった。日本語クエリでもそこそこ引ける

気になった点・ハマりどころ

  • リアルタイム検索のレイテンシ特性 は、要件によっては評価が必要。ユーザー操作と直結する検索 UI に乗せる場合は本番データ規模での計測が必須
  • 更新・削除の運用設計。画像が日々追加・削除される実プロダクトでは、S3 → ベクトル化 → S3 Vectors の差分同期パイプラインを別途用意する必要がある
  • Bedrock の埋め込みコスト。画像 1 枚あたりの単価は小さいが、現場写真の総量を掛けると無視できない金額になる。バッチ化・キャッシュ戦略は早めに検討したい
  • メタデータサイズ・件数の上限。設計時点で何をメタデータに載せるかは絞っておくのが安全

本番投入を考えたときの宿題

プロトタイプから先に進めるなら、最低限以下を詰める必要があります。

  • 同期パイプライン:S3 イベント → Lambda → 埋め込み生成 → S3 Vectors 投入。失敗時のリトライとデッドレターキュー
  • メタデータ設計:案件 UUID、撮影日、アップロード者、権限スコープなど。後から増やしにくいので最初に設計を固めたい
  • 認可:「ユーザーが閲覧権限を持つ案件の画像しか検索結果に出さない」を、S3 Vectors のフィルタでどこまで実現するか。アプリ層との責務分割
  • コスト試算:ベクトル件数 × ストレージ単価、埋め込み生成回数 × Bedrock 単価、検索回数 × クエリ単価。3 つの軸で見積もる
  • 品質評価:日本語クエリでの再現率・適合率を、現場ドメインのテストセットで定量評価する

まとめ

S3 Vectors を使うと、「S3 にベクトルを置く」感覚で画像意味検索のプロトタイプが数時間で組めました。ベクトル DB の選定で止まっていたチームの最初の一歩 として、かなり良い選択肢だと感じています。

一方で、本番運用に乗せるには更新パイプライン・認可・コスト・品質評価といった通常のベクトル検索基盤と同じ宿題が残ります。S3 Vectors は 検証フェーズの摩擦を最小化する のが最大の価値、というのが今のところの結論です。

もっとアルダグラムエンジニア組織を知りたい人、ぜひ下記の情報をチェックしてみてください!

アルダグラム Tech Blog

Discussion