はじめに

画像をベクトル化して類似画像検索を実現するというテーマで、
今回はAmazon BedrockのAmazon Titan Multimodal Embeddingsを使った
画像ベクトル化と類似画像検索の実装について解説します。

Amazon Titan Multimodal Embeddings とは

概要

  • 提供形態: Amazon Bedrock(マネージドサービス)
  • 認証方式: IAM(AWSの標準認証)
  • 対象ユーザー: AWS利用企業、エンタープライズ
  • 特徴: AWSエコシステムとのシームレスな統合

モデルID

model_id = 'amazon.titan-embed-image-v1'

ベクトル次元

1024次元 – 高精度な画像特徴表現が可能

セットアップ方法

ステップ1: AWS IAMユーザーの作成と権限設定

# AWS CLIでIAMユーザーに必要な権限を付与
aws iam attach-user-policy \
  --user-name your-user-name \
  --policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess

# S3アクセス権限も必要な場合
aws iam attach-user-policy \
  --user-name your-user-name \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess

ステップ2: Bedrock APIの有効化

1. AWSコンソールにログイン
2. Amazon Bedrockサービスに移動
3. リージョンを選択(例: us-east-1)
4. Model Accessから「Titan Multimodal Embeddings」を有効化

ステップ3: 環境の整備

# .envファイルまたはcompose.yml
AWS_DEFAULT_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_S3_BUCKET=your-bucket-name

Docker環境での構成

# compose.yml
services:
  backend:
    build: .
    environment:
      # Amazon Bedrock設定
      - AWS_DEFAULT_REGION=us-east-1
      - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
      - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
      - AWS_S3_BUCKET=[$bucket_name]

      # PostgreSQL設定
      - DB_HOST=postgres
      - DB_NAME=image_search
      - DB_USER=postgres
      - DB_PASSWORD=postgres
      - DB_PORT=5432
    depends_on:
      - postgres
    ports:
      - "5000:5000"

  postgres:
    image: pgvector/pgvector:pg16
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=image_search
    volumes:
      - postgres_data:/var/lib/postgresql/data
    ports:
      - "5432:5432"

volumes:
  postgres_data:

ステップ4: 依存関係のインストール

requirements.txtファイルを作成し、必要なパッケージを記載します:

# requirements.txt
boto3>=1.28.0
psycopg2-binary>=2.9.9
pillow>=10.0.0
flask>=3.0.0

各パッケージの役割

  • boto3: AWS SDK(Bedrock、S3クライアント)
  • psycopg2-binary: PostgreSQL接続
  • pillow: 画像処理(リサイズ、エンコード)
  • flask: Webフレームワーク(API実装用)

Dockerfileでの構成

Dockerfileを作成して、依存関係のインストールとアプリケーションのセットアップを自動化します:
APIをFlaskで作成想定

FROM python:3.12-slim

WORKDIR /app

# requirements.txtをコピーして依存関係をインストール
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# アプリケーションコードをコピー
COPY . .

# アップロード用ディレクトリを作成
RUN mkdir -p uploads

# 環境変数を設定
ENV FLASK_APP=app.py
ENV FLASK_ENV=production

# ポートを公開
EXPOSE 5000

# アプリケーションを起動
CMD ["python", "app.py"]

Dockerイメージのビルドと起動:

# Dockerイメージをビルド
docker compose build

# コンテナを起動
docker compose up -d

ステップ5: PostgreSQLでベクトル拡張を有効化

-- pgvector拡張をインストール
CREATE EXTENSION IF NOT EXISTS vector;

-- 画像テーブルの作成(1024次元)
CREATE TABLE IF NOT EXISTS images (
    id SERIAL PRIMARY KEY,
    file_path TEXT NOT NULL,
    file_name TEXT NOT NULL,
    description TEXT,
    embedding VECTOR(1024),  -- Titan Multimodalは1024次元
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- ベクトル検索用のインデックス作成(高速化)
CREATE INDEX ON images USING ivfflat (embedding vector_cosine_ops);

実装コード詳解

BedrockImageVectorizerクラス

import base64
import os
import boto3
import psycopg2
from io import BytesIO
from PIL import Image
import json
import logging

class BedrockImageVectorizer:
    def __init__(self, aws_region=None):
        """初期化: Bedrockクライアントとデータベース接続を設定"""
        self.aws_region = aws_region or os.environ.get('AWS_DEFAULT_REGION')

        # Bedrock Runtimeクライアントの初期化
        self.bedrock_runtime = boto3.client(
            service_name='bedrock-runtime',
            region_name=self.aws_region
        )

        # S3クライアントの初期化
        self.s3_client = boto3.client(
            service_name='s3',
            region_name=self.aws_region
        )

        self.s3_bucket = os.environ.get('AWS_S3_BUCKET')
        self.model_id = 'amazon.titan-embed-image-v1'

        # PostgreSQL接続情報
        self.db_config = {
            'host': os.environ.get('DB_HOST'),
            'database': os.environ.get('DB_NAME'),
            'user': os.environ.get('DB_USER'),
            'password': os.environ.get('DB_PASSWORD'),
            'port': os.environ.get('DB_PORT', 5432)
        }

画像エンコード処理

def _encode_image(self, image_source):
    """画像をBase64エンコードする(ファイルパスまたはBytesIOに対応)"""
    try:
        with Image.open(image_source) as img:
            # 画像が大きすぎる場合はリサイズ
            max_size = 1024
            if max(img.width, img.height) > max_size:
                ratio = max_size / max(img.width, img.height)
                new_size = (int(img.width * ratio), int(img.height * ratio))
                img = img.resize(new_size, Image.Resampling.LANCZOS)

            # JPEG形式でBase64エンコード
            buffer = BytesIO()
            img.save(buffer, format="JPEG")
            return base64.b64encode(buffer.getvalue()).decode('utf-8')
    except Exception as e:
        logger.error(f"画像のエンコードに失敗しました: {e}")
        raise

ベクトル化処理

def vectorize_image(self, image_source):
    """Bedrock Titan Multimodalを使用して画像をベクトル化する"""
    try:
        base64_image = self._encode_image(image_source)

        # Bedrock Titan Multimodalモデルへのリクエスト
        request_body = json.dumps({
            "inputImage": base64_image
        })

        response = self.bedrock_runtime.invoke_model(
            modelId=self.model_id,
            body=request_body
        )

        response_body = json.loads(response['body'].read())
        embedding = response_body['embedding']  # 1024次元のベクトル

        return embedding
    except Exception as e:
        logger.error(f"画像のベクトル化に失敗しました: {e}")
        raise

S3のパスをテーブルに格納(統合処理)

def store_image_embedding_from_s3(self, file_key, image_data, bucket_name, description=None):
    """S3から取得した画像データをベクトル化してPostgreSQLに格納"""
    conn = None
    cursor = None
    try:
        # 画像をベクトル化
        embedding = self.vectorize_image(image_data)

        # S3のフルパスを作成
        s3_file_path = f"s3://{bucket_name}/{file_key}"
        file_name = os.path.basename(file_key)

        # PostgreSQLに格納
        conn = psycopg2.connect(**self.db_config)
        cursor = conn.cursor()

        cursor.execute("""
            INSERT INTO images (file_path, file_name, description, embedding)
            VALUES (%s, %s, %s, %s)
            RETURNING id;
        """, (s3_file_path, file_name, description, embedding))

        image_id = cursor.fetchone()[0]
        conn.commit()

        return image_id
    finally:
        if cursor:
            cursor.close()
        if conn:
            conn.close()

類似画像検索

def find_similar_images(self, query_image_source, limit=5):
    """類似画像を検索する(コサイン距離ベース)"""
    conn = None
    cursor = None
    try:
        # クエリ画像をベクトル化
        query_embedding = self.vectorize_image(query_image_source)

        # PostgreSQLで類似度検索(コサイン距離)
        conn = psycopg2.connect(**self.db_config)
        cursor = conn.cursor()

        cursor.execute("""
            SELECT 
                id, 
                file_path, 
                file_name, 
                description, 
                embedding <-> %s::vector(1024) AS distance
            FROM images
            ORDER BY distance
            LIMIT %s;
        """, (query_embedding, limit))

        results = cursor.fetchall()

        similar_images = []
        for row in results:
            image_data = {
                "id": row[0],
                "file_path": row[1],
                "file_name": row[2],
                "description": row[3],
                "distance": row[4]
            }

            # S3パスの場合は署名付きURLを生成
            if row[1].startswith('s3://'):
                s3_path = row[1].replace('s3://', '')
                parts = s3_path.split('/')
                bucket = parts[0]
                key = '/'.join(parts[1:])

                image_data['presigned_url'] = self.get_presigned_url(bucket, key)

            similar_images.append(image_data)

        return similar_images
    finally:
        if cursor:
            cursor.close()
        if conn:
            conn.close()

ポイント

  • pgvectorの<->オペレーターでユークリッド距離(L2距離)を計算
  • 距離が小さいほど類似度が高い
  • S3画像には自動的に署名付きURL生成
  • セキュアなアクセス制御

バッチ処理スクリプト

S3バケット内の大量の画像を一括でベクトル化するスクリプト:

#!/usr/bin/env python

import boto3
from io import BytesIO
import logging
from services.bedrock_image_service import BedrockImageVectorizer

S3_BUCKET_NAME = '[$bucket_name]'
S3_FILES_PREFIX = 'files/'

def get_s3_image_files(bucket_name, prefix, patterns):
    """S3バケットから画像ファイル一覧を取得"""
    s3_client = boto3.client('s3')
    image_files = []

    response = s3_client.list_objects_v2(Bucket=bucket_name, Prefix=prefix)

    if 'Contents' not in response:
        return []

    import fnmatch
    for obj in response['Contents']:
        file_key = obj['Key']
        file_name = os.path.basename(file_key)

        if not file_name:
            continue

        for pattern in patterns:
            if fnmatch.fnmatch(file_name.lower(), pattern.lower()):
                image_files.append(file_key)
                break

    return image_files

def main():
    vectorizer = BedrockImageVectorizer()

    # S3から画像ファイル一覧を取得
    patterns = ['*.jpg', '*.jpeg', '*.png']
    image_files = get_s3_image_files(S3_BUCKET_NAME, S3_FILES_PREFIX, patterns)

    # 各画像を処理
    for idx, file_key in enumerate(image_files, 1):
        print(f"[{idx}/{len(image_files)}] 処理中: {file_key}")

        # S3から画像をメモリにダウンロード
        s3_client = boto3.client('s3')
        response = s3_client.get_object(Bucket=S3_BUCKET_NAME, Key=file_key)
        image_data = BytesIO(response['Body'].read())

        # ベクトル化してデータベースに格納
        vectorizer.store_image_embedding_from_s3(
            file_key=file_key,
            image_data=image_data,
            bucket_name=S3_BUCKET_NAME
        )

if __name__ == '__main__':
    main()

API処理(今回はFlaskを想定)

from flask import request, jsonify
from io import BytesIO
from services.bedrock_image_service import BedrockImageVectorizer

bedrock_vectorizer = BedrockImageVectorizer()

@app.route('/api/search-bedrock', methods=['POST'])
@login_required
def search_similar_images_bedrock():
    """Bedrockを使った類似画像検索API"""
    try:
        if 'image' not in request.files:
            return jsonify({'error': '画像ファイルが提供されていません'}), 400

        image_file = request.files['image']

        # 画像データをメモリに読み込む
        image_data = BytesIO(image_file.read())

        # 検索数を取得(デフォルト6件)
        limit = int(request.form.get('limit', 6))
        if limit < 6 or limit > 10:
            limit = 6

        # 類似画像を検索
        similar_images = bedrock_vectorizer.find_similar_images(
            image_data, 
            limit=limit
        )

        return jsonify({
            'success': True,
            'similar_images': similar_images,
            'count': len(similar_images)
        })

    except Exception as e:
        return jsonify({
            'success': False,
            'error': f'検索エラー: {str(e)}'
        }), 500

料金とコスト

Amazon Titan Multimodal Embeddingsの料金

2025年10月時点の料金:

画像エンベディング: $0.020 / 1,000画像

月間処理量ごとのコスト試算

月間処理画像数 月額コスト
10,000枚 $0.20
100,000枚 $2.00
1,000,000枚 $20.00
10,000,000枚 $200.00

追加コスト

  • S3ストレージ: $0.023 / GB / 月(スタンダードストレージ)
  • S3データ転送: 無料(同一リージョン内)
  • PostgreSQL(RDS使用時): インスタンスタイプによる

まとめ

Amazon Bedrockの Amazon Titan Multimodal Embeddings を使えば、画像の類似検索システムを簡単に構築できます。
画像をBase64エンコードしてAmazon Bedrockに送信するだけで、1024次元の高精度ベクトルが取得でき、pgvectorを使ったPostgreSQLで効率的に類似度検索が可能です。
S3に保存された大量の画像も、バッチ処理スクリプトで一括ベクトル化できます。

既にAWSを利用している場合は、IAM認証やS3ストレージとシームレスに統合でき、追加の認証設定やインフラ構築が不要です。
マネージドサービスなので、スケーリングやメンテナンスの手間もかかりません。

本記事で紹介した実装コードをベースに、ぜひ画像検索システムの構築にチャレンジしてみてください。