はじめに
AWS から低価格のベクトルストレージ Amazon S3 Vectors が登場しました!
今までは Aurora や OpenSearch を使う必要がありましたが、本当に従量課金で使えるようになります
Aurora Serverless や OpenSearch Serverlss は「サーバーレス」と言いつつ、起動している時間で課金されるため、小規模なデータベースであったとしてもそれなりの料金がかかってしまいます
S3 Vectors はストレージ料金とリクエスト量の従量課金なので、小規模であればかなりの低価格化が可能です
本記事では Elixir の Livebook から S3 Vectors を使用し、ベクトル検索を実行します
具体的には、以前の記事で PostgreSQL に登録していたベクトルデータを S3 Vectors に登録します
本記事では Elixir の Livebook を使用します
Livebook については以下の記事を参照してください
API の呼び出し方については以下の公式ドキュメントを参考にしました
実装したノートブックはこちら
セットアップ
必要なモジュールをインストールします
Mix.install([
{:kino, "~> 0.15"},
{:evision, "~> 0.2.13"},
{:aws, "~> 1.0"},
{:hackney, "~> 1.24"}
])
aws-elixir の最新版(2025年7月20日時点で 1.0.7)では S3 Vectors に対応しておらず、 GitHub 上の最新コードでもうまく動作しなかったため、今回は別途 S3 Vectors 用の関数を作成します
画像の準備
顔が写っている写真を読み込みます
本記事では以下の場所に配置している画像を使用しました
image_files = Path.wildcard("/home/livebook/evision/test-images/*.{jpg,png}")
images =
image_files
|> Enum.map(fn image_file ->
Evision.imread(image_file)
end)
Kino.Layout.grid(images, columns: 4)
実行結果
上半分(others-000.jpg 〜 others-007.jpg)は AI が生成した適当な顔です
下半分(ryo-000.jpg 〜 ryo-007.jpg)は私の顔(加工含む)です
顔特徴量の取得
Evision (OpenCV)を使って、顔の特徴をベクトルとして取得します
recognizer =
Evision.Zoo.FaceRecognition.SFace.init(:default_model,
backend: Evision.Constant.cv_DNN_BACKEND_OPENCV(),
target: Evision.Constant.cv_DNN_TARGET_CPU(),
distance_type: :cosine_similarity,
cosine_threshold: 0.363,
l2_norm_threshold: 1.128
)
detector =
Evision.Zoo.FaceDetection.YuNet.init(:default_model,
backend: Evision.Constant.cv_DNN_BACKEND_OPENCV(),
target: Evision.Constant.cv_DNN_TARGET_CPU(),
nms_threshold: 0.3,
conf_threshold: 0.8,
top_k: 5
)
[feature_list, visualized_list] =
images
|> Enum.reduce([[], []], fn image, [feature_acc, visualized_acc] ->
results = Evision.Zoo.FaceDetection.YuNet.infer(detector, image)
bbox = Evision.Mat.to_nx(results, Nx.BinaryBackend)[0][0..-2//1]
feature =
recognizer
|> Evision.Zoo.FaceRecognition.SFace.infer(image, bbox)
|> Evision.Mat.to_nx()
|> Evision.Mat.from_nx()
visualized = Evision.Zoo.FaceDetection.YuNet.visualize(image, results[0])
[[feature | feature_acc], [visualized | visualized_acc]]
end)
|> Enum.map(&Enum.reverse/1)
Kino.Layout.grid(visualized_list, columns: 4)
実行結果
顔の特徴量を取得するついでに、顔の位置とランドマーク(両目、はな、口の両端)を視覚化しています
先頭の顔(others-000.jpg)の顔特徴量を見てみましょう
feature_list
|> hd()
|> Evision.Mat.to_nx()
実行結果
#Nx.Tensor<
f32[1][128]
Evision.Backend
[
[0.44662198424339294, -0.18196798861026764, -1.4434093236923218, 0.8574541211128235, 0.1395811140537262, 0.7469102740287781, -1.0085504055023193, 1.634568691253662, 1.2835875749588013, -0.3873269557952881, -0.23642760515213013, 0.18337981402873993, -0.9161995053291321, -0.7120431065559387, 1.2634904384613037, -2.0566511154174805, -0.6620227098464966, 0.795756459236145, -0.5798499584197998, -1.2061798572540283, 0.41009998321533203, 1.9834741353988647, 0.9995343685150146, -0.7854088544845581, -0.1552244871854782, 1.87942373752594, 1.1083965301513672, 1.136807918548584, 0.7649863958358765, 0.8868155479431152, 0.9097082614898682, 3.014265537261963, 1.3832037448883057, -0.2159992903470993, 1.6671949625015259, -0.7780312895774841, -0.09871964156627655, 0.7701786160469055, 0.9242772459983826, 1.5758417844772339, 0.22983834147453308, 2.8381900787353516, -1.2160191535949707, -1.2613626718521118, 0.1288629174232483, -0.3132745623588562, 2.095221996307373, -0.5830097794532776, -0.9680026173591614, -0.24778619408607483, ...]
]
>
128 次元のベクトルになっています
ryo-000.jpg と ryo-007.jpg の顔特徴量を比較してみます
Evision.Zoo.FaceRecognition.SFace.match_feature(
recognizer,
Enum.at(feature_list, 8),
Enum.at(feature_list, 15)
)
実行結果
%{matched: true, measure: "cosine_score", retval: 0.8235997036008484}
コサイン類似度は 0.82 で、同一人物と判定されました
今度は ryo-000.jpg と others-000.jpg を比較してみましょう
Evision.Zoo.FaceRecognition.SFace.match_feature(
recognizer,
Enum.at(feature_list, 8),
Enum.at(feature_list, 0)
)
実行結果
%{matched: false, measure: "cosine_score", retval: -0.1412325658275222}
コサイン類似度は -0.14 で、別人と判定されました
ちゃんと人物を見分けることができています
ベクトルがメモリ上にあれば、この方法で似ている顔を検索できますが、データはデータベースに保存しておきたいものです
以前の記事では PostgreSQL や Pinecone に保存しましたが、今回は S3 Vectors を使ってみましょう
アクセス用関数の準備
まずは AWS の認証情報を画面から入力しましょう
access_key_id_input = Kino.Input.password("ACCESS_KEY_ID")
secret_access_key_input = Kino.Input.password("SECRET_ACCESS_KEY")
region_input = Kino.Input.text("REGION")
[
access_key_id_input,
secret_access_key_input,
region_input
]
|> Kino.Layout.grid(columns: 3)
実行結果
表示されたテキスト入力に AWS の認証情報を入力します
REGION について、現状、 S3 Vectors は一部のリージョンでしか使用できません
- us-east-1
- us-east-2
- us-west-2
- eu-central-1
- ap-southeast-2
特にこだわりがなければ us-east-1 を使いましょう
認証情報を AWS API へのアクセス用クライアントにセットします
client =
AWS.Client.create(
Kino.Input.read(access_key_id_input),
Kino.Input.read(secret_access_key_input),
Kino.Input.read(region_input)
)
実行結果
#AWS.Client<
region: "us-east-1",
service: nil,
endpoint: nil,
proto: "https",
port: 443,
http_client: {AWS.HTTPClient.Hackney, []},
json_module: {AWS.JSON, []},
xml_module: {AWS.XML, []},
...
>
そのうち AWS.S3Vectors
モジュールで簡単に実行できるようになると思いますが、現状まだうまく動いていないので、アクセス用の関数を定義します
request_to_s3_vectors = fn (client, path, payload) ->
now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second)
encoded_payload = AWS.Client.encode!(client, payload, :json)
headers = [
{"Host", "s3vectors.us-east-1.api.aws"},
{"Content-Type", "application/json"}
]
url = "https://s3vectors.us-east-1.api.aws#{path}"
siged_headers =
AWS.Signature.sign_v4(
%{client | service: "s3vectors"},
now,
:post,
url,
headers,
encoded_payload
)
{:ok, %{body: body}} =
AWS.Client.request(client, :post, url, encoded_payload, siged_headers, [])
JSON.decode!(body)
end
ベクトルバケットの作成
まず、現状はバケットがないことを確認します
request_to_s3_vectors.(client, "/ListVectorBuckets", %{"maxResults" => 20})
実行結果
%{"vectorBuckets" => []}
S3 Vectors のバケットを作成します
バケットは単なる入れ物(バケツ)なので、適当な名前で作成してください
ただし、バケット名には以下の制限があります
- 全世界で一意になる名前であること
- 最小3文字、最大64文字
- 使える文字は英字小文字、数字、ピリオド、ハイフンのみ
詳細は以下のドキュメントを参照してください
request_to_s3_vectors.(client, "/CreateVectorBucket", %{
"vectorBucketName" => "face-vectors",
})
実行結果
%{}
作成されたバケットを取得します
vectorBucket =
client
|> request_to_s3_vectors.("/GetVectorBucket", %{"vectorBucketName" => "face-vectors"})
|> Map.get("vectorBucket")
実行結果
%{
"creationTime" => 1752981108,
"encryptionConfiguration" => %{"sseType" => "AES256"},
"vectorBucketArn" => "arn:aws:s3vectors:us-east-1:xxx:bucket/face-vectors",
"vectorBucketName" => "face-vectors"
}
インデックスの作成
インデックスを作成します
Evision で作成した顔特徴量のベクトルに合うよう、Float32 型、次元数 128、距離タイプはコサインを指定します
request_to_s3_vectors.(client, "/CreateIndex", %{
"vectorBucketName" => "face-vectors",
"indexName" => "face-vectors-index",
"dataType" => "float32",
"dimension" => 128,
"distanceMetric" => "cosine"
})
実行結果
%{}
作成したインデックスを取得します
vectorIndex =
client
|> request_to_s3_vectors.("/GetIndex", %{
"vectorBucketName" => "face-vectors",
"indexName" => "face-vectors-index"
})
|> Map.get("index")
実行結果
%{
"creationTime" => 1752981490,
"dataType" => "float32",
"dimension" => 128,
"distanceMetric" => "cosine",
"indexArn" => "arn:aws:s3vectors:us-east-1:xxx:bucket/face-vectors/index/face-vectors-index",
"indexName" => "face-vectors-index",
"vectorBucketName" => "face-vectors"
}
データの登録
ベクトルデータをインデックスに登録しましょう
まず、ベクトルデータを登録用に変換します
1件ずつではなく、一気に複数件登録できるため、まとめて変換しておきます
vaectors =
feature_list
|> Enum.zip(image_files)
|> Enum.map(fn {feature, image_file} ->
%{
"key" => Path.basename(image_file),
"data" => %{
"float32" => feature |> Evision.Mat.to_nx() |> Nx.to_flat_list(),
},
"metadata" => %{
"name" => image_file |> Path.basename() |> String.split("-") |> hd()
}
}
end)
キーとしてファイル名、メタデータとして「誰なのか」を「name」として設定しています
実行結果
[
%{
"data" => %{
"float32" => [0.44662198424339294, -0.18196798861026764, -1.4434093236923218, ...]
},
"key" => "others-000.jpg",
"metadata" => %{"name" => "others"}
},
%{
"data" => %{
"float32" => [0.34519702196121216, 0.28847435116767883, 1.5731010437011719, ...]
},
"key" => "others-001.jpg",
"metadata" => %{"name" => "others"}
},
%{
"data" => %{
"float32" => [0.796004593372345, -1.378779649734497, -0.6996304392814636, ...]
},
"key" => "others-002.jpg",
"metadata" => %{"name" => "others"}
},
...
]
データを登録します
request_to_s3_vectors.(client, "/PutVectors", %{
"indexArn" => Map.get(vectorIndex, "indexArn"),
"vectors" => vaectors
})
実行結果
%{}
登録してデータを確認します
request_to_s3_vectors.(client, "/ListVectors", %{
"indexArn" => Map.get(vectorIndex, "indexArn"),
"maxResults" => 20,
"returnData" => true,
"returnMetadata" => true
})
実行結果
%{
"nextToken" => "AiHedFqm9_1uU8JQkfvKt7BgjlfZJA_FFGjJu0_ZGgsGHndK-pcs9TMAEmlkUUAqf5BlaYiSXwv_9uuvMeYRDDfFxMJ5Knl-XBhHedhdy56EbcmndWmlncZ6PSwXSHWHzD5QKVGjxIOI2Xc",
"vectors" => [
%{
"data" => %{
"float32" => [0.042828191071748734, -0.3125211298465729, 1.9609209299087524, ...]
},
"key" => "ryo-002.jpg",
"metadata" => %{"name" => "ryo"}
},
%{
"data" => %{
"float32" => [0.6338018178939819, 0.9195507764816284, -1.7577342987060547, ...]
},
"key" => "others-003.jpg",
"metadata" => %{"name" => "others"}
},
%{
"data" => %{
"float32" => [0.37515249848365784, -0.605842113494873, 0.7162454128265381, ...]
},
"key" => "ryo-001.jpg",
"metadata" => %{"name" => "ryo"}
},
...
]
}
順序は key 順になりませんが、取得できました
データの検索
似ている顔を検索したい顔(ryo-000.jpg)の特徴量を配列に変換します
query_vector =
feature_list
|> Enum.at(8)
|> Evision.Mat.to_nx()
|> Nx.to_flat_list()
実行結果
[-0.8045313954353333, -1.1093559265136719, 1.6812481880187988, ...]
似ている(コサイン距離が小さい順に)5件取得するように指定します
request_to_s3_vectors.(client, "/QueryVectors", %{
"indexArn" => Map.get(vectorIndex, "indexArn"),
"queryVector" => %{
"float32" => query_vector
},
"returnDistance" => true,
"returnMetadata" => true,
"topK" => 5
})
実行結果
%{
"vectors" => [
%{"distance" => 3.153085708618164e-4, "key" => "ryo-000.png", "metadata" => %{"name" => "ryo"}},
%{"distance" => 0.17822492122650146, "key" => "ryo-007.jpg", "metadata" => %{"name" => "ryo"}},
%{"distance" => 0.48675572872161865, "key" => "ryo-003.jpg", "metadata" => %{"name" => "ryo"}},
%{"distance" => 0.4868904948234558, "key" => "ryo-001.jpg", "metadata" => %{"name" => "ryo"}},
%{"distance" => 0.48874151706695557, "key" => "ryo-002.jpg", "metadata" => %{"name" => "ryo"}}
]
}
ちゃんと、私の顔だけを取得してきました
一番上は同一顔なので、誤差分の限りなく 0 に近い距離になっています
データの削除
余計な費用を発生させないように、全て削除しておきましょう
request_to_s3_vectors.(client, "/DeleteVectors", %{
"indexArn" => Map.get(vectorIndex, "indexArn"),
"keys" => image_files |> Enum.map(&Path.basename(&1))
})
削除したことを確認します
request_to_s3_vectors.(client, "/ListVectors", %{
"indexArn" => Map.get(vectorIndex, "indexArn"),
"maxResults" => 20
})
実行結果
%{"vectors" => []}
インデックスの削除
インデックスを削除します
request_to_s3_vectors.(client, "/DeleteIndex", %{
"indexArn" => Map.get(vectorIndex, "indexArn")
})
削除したことを確認します
request_to_s3_vectors.(client, "/ListIndexes", %{
"maxResults" => 20,
"vectorBucketArn" => Map.get(vectorBucket, "vectorBucketArn")
})
実行結果
%{"indexes" => []}
バケットの削除
バケットを削除します
request_to_s3_vectors.(client, "/DeleteVectorBucket", %{
"vectorBucketArn" => Map.get(vectorBucket, "vectorBucketArn")
})
削除したことを確認します
request_to_s3_vectors.(client, "/ListVectorBuckets", %{"maxResults" => 20})
実行結果
%{"vectorBuckets" => []}
まとめ
S3 をベクトルデータベースとして扱えるようになったことで、小規模であれば、かなり気軽にベクトル検索が使えるようになりました
次は RAG を構築してみます