概要
用意した1万枚の画像(今回はUnsplash APIを使用)に対して検索ワードを入力すると類似画像を取得できるようにする。
完成系
「犬 寝ている」で検索し、ヒットした画像(画像提供:Unsplash)
上位2件の犬は確実に寝ている(それ以外は?だが...)ため、精度の高い検索機能といえる。
実装
実装の手順としては
①画像キャプションの生成(画像情報を言語化)
②埋め込みの生成(言語化したキャプションを数値化)
③コサイン類似度による検索
となる。
①画像キャプションの生成
gpt-4oモデルを使い、画像にキャプションを付けさせる。DBには画像URLしかなく、gptは画像URLからキャプションをつけることはできない(URLによっては可能?)ため、一度URLから画像を適当なディレクトリに自動ダウンロード(API送信用にBase64に変換)し、キャプションを付け終わったら自動削除する、という流れで行った。
この一連の流れを/lib/tasks/caption_generator.rbに記述し、ターミナルから
rails runner lib/tasks/caption_generator.rb
を打つと実行されるようにした。
以下が/lib/tasks/caption_generator.rbの記述
〇画像のダウンロード
require "open-uri"
require "fileutils"
require "base64"
require "net/http"
require "json"
API_KEY = ENV["OPENAI_API_KEY"]
MAX_IMAGES = 1000
TEMP_DIR = "tmp/images"
# 画像保存用の一時ディレクトリ作成
FileUtils.mkdir_p(TEMP_DIR)
def download_image(image_url, file_path)
URI.open(image_url) do |image|
File.open(file_path, "wb") do |file|
file.write(image.read)
end
end
puts "画像ダウンロード完了: #{file_path}"
end
# API送信用に画像データをBase64にエンコード
def encode_image_to_base64(file_path)
Base64.strict_encode64(File.read(file_path))
end
〇Open AIへの指示
def generate_caption(file_path)
image_data = encode_image_to_base64(file_path)
uri = URI("https://api.openai.com/v1/chat/completions")
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Authorization"] = "Bearer #{API_KEY}"
request.body = {
model: "gpt-4o",
messages: [
{ role: "system", content: "あなたは画像の内容を日本語で簡潔に説明するAIです。100トークン以内で、重要な情報のみを要約してください。" },
{
role: "user",
content: [
"この画像の内容を100トークン以内で簡潔に説明してください。",
{
type: "image_url",
image_url: {
url: "data:image/jpeg;base64,#{image_data}"
}
}
]
}
],
max_tokens: 100,
temperature: 0.3
}.to_json
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(request)
end
if response.code == "200"
result = JSON.parse(response.body)
caption = result["choices"][0]["message"]["content"]
caption.strip
else
puts "キャプション生成エラー: #{response.body}"
nil
end
end
〇一連の関数実行&画像の削除
def process_images
# まだcaptionがついていない画像を取得
images = Image.where(caption: nil).limit(MAX_IMAGES)
# 先ほど作った一時ディレクトリを画像の保存先に指定
images.each_with_index do |image, index|
file_path = "#{TEMP_DIR}/image_#{index + 1}.jpg"
# 画像の保存
download_image(image.url, file_path)
# captionの生成
caption = generate_caption(file_path)
if caption
image.update(caption: caption)
puts "キャプション生成完了: #{image.url} → #{caption}"
else
puts "キャプション生成失敗: #{image.url}"
end
# 画像の削除
File.delete(file_path) if File.exist?(file_path)
puts "画像削除完了: #{file_path}"
end
puts "1000枚分のキャプション生成 & 画像削除完了!"
end
process_images
以上の記述により、用意した画像URLにキャプションを付けることができる
②埋め込みの生成
先ほどのキャプションを使って画像検索を実装することもできるが、精度が低い。例えば”犬が眠っている”というキャプションに対して”寝ている犬”と検索しても「眠っている」と「寝ている」を類似ワードと判別できないため、画像がヒットしない。
そこで活躍するのが埋め込み(embedding)の生成。
ここで扱う画像の埋め込みは、あらかじめ用意した画像のキャプション(説明文)をコンピュータが扱いやすい「ベクトル」に落とし込む(数値化する)ことであり、これにより「眠っている」と「寝ている」といった同じ言葉でなくても数値が近い(意味的に似た)言葉を探すことができる。
ということで先ほどのキャプションから画像の埋め込みを生成していく。
➊pgvectorのインストール
➋pgvectorの有効化
➌埋め込みの生成
という手順で行う。
➊まずはpgvectorをインストールする
今回、Ruby on Railsを使用しており、DBのPostgresはデフォルトではvector(埋め込みベクトルを扱える型)を持っていないので、拡張機能としてpgvectorをインストールする必要がある。ローカルのUbuntu環境でのpgvectorの導入方法を一例として紹介する
sudo apt install postgresql-server-dev-all
git clone https://github.com/pgvector/pgvector.git
cd pgvector
make && sudo make install
➋次にインストールしたpgvectorを有効化する
gemfileにgem 'pgvector’を記述し、bundle installを行う。
Imageテーブル(例)に埋め込みとなるembeddingカラムを加えるマイグレーションファイルを作成し、マイグレーションを実行
def change
enable_extension 'vector'
add_column :images, :embedding, :vector, limit: 1536
end
limitはベクトルの次元数(OpenAIの場合、1536が多い)
ここでしっかりとembeddingカラムがvector型になっているか確認する。
rails dbconsole # DBコンソールに入る
\d images # imagesのテーブル定義を確認
embeddingがvectorになっていたらOK
➌準備ができたらついに埋め込みを生成していく
今回はtext-embedding-ada-002というモデルを使用した。
services/embedding_generator.rbファイルを作成し、以下のように記述。
require "net/http"
require "json"
class EmbeddingGenerator
API_KEY = ENV["OPENAI_API_KEY"]
def self.generate_embedding(text)
client = OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"])
response = client.embeddings(
parameters: {
model: "text-embedding-ada-002",
input: text
}
)
response["data"][0]["embedding"]
end
end
コンソールで以下を実行することでimagesテーブルのembeddingカラムに埋め込みが保存される
images = Image.where.not(caption: [nil, ""]).where(embedding: nil)
images.each_with_index do |image, index|
begin
puts "(#{index + 1}/#{images.count}) ID: #{image.id} の embedding を生成中..."
embedding = EmbeddingGenerator.generate_embedding(image.caption) # embedding 生成
image.update!(embedding: embedding) # データベースを更新
puts "ID: #{image.id} の embedding を更新しました"
rescue => e
puts "ID: #{image.id} の更新に失敗: #{e.message}"
end
end
③コサイン類似度による検索
最後に、ここまで作成してきた画像の埋め込みを使って画像検索を実装する。
手順としては
➊検索ワードの埋め込み生成
➋コサイン類似度による検索
➌検索結果画像の表示
➊入力した検索ワードも同じように埋め込み(数値化)する必要がある。
@query_text = params[:query]
@query_embedding = EmbeddingGenerator.generate_embedding(@query_text)
入力した検索ワード(queryパラメーター)を先ほどのembedding_generator.rbファイルの記述を使い、ベクトル化し、@query_embeddingとしておく。
➋コサイン類似度による検索
今回はimage.rbファイルに検索ロジックを記述した
def self.rank_all_images(query_embedding)
query_embedding = JSON.parse(query_embedding) if query_embedding.is_a?(String)
query_vector = query_embedding.join(",")
subquery = Image
.select("id, url, embedding <-> '[#{query_vector}]' AS distance")
.where.not(embedding: nil)
.to_sql
from("(#{subquery}) AS images_with_distance")
.select("*")
.order("distance")
.limit(100)
end
embedding <-> '[#{query_vector}]' AS distanceの部分で画像埋め込みと検索ワード埋め込みの距離を測定している。
そして、.order("distance").limit(100)の部分で距離が近い順(類似度が高い順)に並べ、上位100件の画像を取得している
➌検索結果画像の表示
@all_ranked_images = Image.rank_all_images(@query_embedding)
先ほど記述したrank_all_imagesメソッドにより取得した画像を@all_ranked_imagesとし、このインスタンス変数をビューで使うことで、検索結果画像(この場合は上位100件)が表示されるようになる。
以上がOpenAI APIを使った画像検索機能の実装です。
ここまで読んでいただきありがとうございました。