OpenAI APIを使った画像検索機能の実装

## 概要
用意した1万枚の画像（今回はUnsplash APIを使用）に対して検索ワードを入力すると類似画像を取得できるようにする。

## 完成系
「犬　寝ている」で検索し、ヒットした画像（画像提供：Unsplash）

![スクリーンショット 2025-05-09 165314.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4080728/a5c3f60c-9a98-4559-af64-d0d3a0c25963.png)


上位2件の犬は確実に寝ている（それ以外は？だが．．．）ため、精度の高い検索機能といえる。

## 実装
実装の手順としては

①画像キャプションの生成（画像情報を言語化）

②埋め込みの生成（言語化したキャプションを数値化）

③コサイン類似度による検索

となる。

### ①画像キャプションの生成
gpt-4oモデルを使い、画像にキャプションを付けさせる。DBには画像URLしかなく、gptは画像URLからキャプションをつけることはできない（URLによっては可能？）ため、一度URLから画像を適当なディレクトリに自動ダウンロード（API送信用にBase64に変換）し、キャプションを付け終わったら自動削除する、という流れで行った。

この一連の流れを/lib/tasks/caption_generator.rbに記述し、ターミナルから

```bash
rails runner lib/tasks/caption_generator.rb
```

を打つと実行されるようにした。

以下が/lib/tasks/caption_generator.rbの記述

〇画像のダウンロード

```ruby
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への指示

```ruby
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
```

〇一連の関数実行＆画像の削除

```ruby
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の導入方法を一例として紹介する

```bash
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カラムを加えるマイグレーションファイルを作成し、マイグレーションを実行

```bash
def change
  enable_extension 'vector'

  add_column :images, :embedding, :vector, limit: 1536
end
```

limitはベクトルの次元数（OpenAIの場合、1536が多い）

ここでしっかりとembeddingカラムがvector型になっているか確認する。

```bash
rails dbconsole # DBコンソールに入る
\d images # imagesのテーブル定義を確認
```

embeddingがvectorになっていたらOK

➌準備ができたらついに埋め込みを生成していく

今回はtext-embedding-ada-002というモデルを使用した。

services/embedding_generator.rbファイルを作成し、以下のように記述。

```ruby
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
```
### ③コサイン類似度による検索
最後に、ここまで作成してきた画像の埋め込みを使って画像検索を実装する。

手順としては

➊検索ワードの埋め込み生成
➋コサイン類似度による検索
➌検索結果画像の表示

➊入力した検索ワードも同じように埋め込み（数値化）する必要がある。

```ruby
    @query_text = params[:query]
    @query_embedding = EmbeddingGenerator.generate_embedding(@query_text)
```

入力した検索ワード（queryパラメーター）を先ほどのembedding_generator.rbファイルの記述を使い、ベクトル化し、@query_embeddingとしておく。

➋コサイン類似度による検索

今回はimage.rbファイルに検索ロジックを記述した

```ruby
  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件の画像を取得している

➌検索結果画像の表示

```ruby
    @all_ranked_images = Image.rank_all_images(@query_embedding)
```

先ほど記述したrank_all_imagesメソッドにより取得した画像を@all_ranked_imagesとし、このインスタンス変数をビューで使うことで、検索結果画像（この場合は上位100件）が表示されるようになる。

以上がOpenAI APIを使った画像検索機能の実装です。
ここまで読んでいただきありがとうございました。