🚀

SpotifyのAnnoyを用いた画像とテキストを使った類似商品検索システムについて

2025/01/04に公開

概要


近年、Eコマースサイトやコンテンツ配信サービス(tiktok,spotify)などで、レコメンドシステムの重要性がますます高まっています。ユーザーの好みや行動履歴に基づいて、関連性の高い商品を推薦することで、ユーザー満足度の向上や売上アップにつなげることができます。

レコメンドシステムを実現する方法はいくつかありますが、その中でも、類似アイテム検索は、ユーザーが閲覧している商品や、過去に購入した商品と似た商品を提示する効果的な手法です。

類似アイテムを検索するためには、まず、商品の特徴をベクトルで表現する必要があります。例えば、商品の説明文を単語の出現頻度でベクトル化したり、画像からCNNで特徴量を抽出したりすることが考えられます。そして、ベクトル間の距離を計算することで、商品間の類似度を測ることができます。

しかし、商品数が多くなると、すべての商品間の距離を計算するのは非常に時間がかかります。そこで、近似最近傍探索 (Approximate Nearest Neighbor Search, ANNS) と呼ばれる手法が用いられます。ANNSは、厳密な最近傍を求める代わりに、それに近いアイテムを高速に検索する手法です。

本記事では、ANNSアルゴリズムを実装したライブラリの一つである Annoy (Approximate Nearest Neighbors Oh Yeah) を使って、画像とテキスト情報から類似商品を検索するシステムを構築する方法を紹介します。

Annoyとは?

Annoy は、Spotifyが開発したオープンソースのC++ライブラリで、Pythonバインディングも提供されています。高速な近似最近傍探索を実現するために、以下の特徴を持っています。

  • メモリ効率: インデックスをメモリマップすることで、メモリ使用量を抑えつつ、高速な検索を実現しています。
  • 複数の距離指標をサポート: コサイン類似度、ユークリッド距離、マンハッタン距離など、様々な距離指標に対応しています。
  • シンプルで使いやすいAPI: Pythonから簡単に利用できるAPIが提供されています。
  • c++による高速化: 本体はc++で書かれており高速に処理を行うことができます。

事前準備

このプログラムの実行に必要なライブラリ

pip install annoy torch torchvision transformers Pillow requests pandas

また、画像とテキストの情報を統合してベクトル化するために、今回はOpenAIのCLIPモデルを使用します。transformers ライブラリから、事前学習済みのCLIPモデルを簡単に利用できます。

ClIPとは


OpenAIのCLIP(Contrastive Language–Image Pretraining) は、画像とテキストの関連性を学習し、それを基に幅広いタスクに適応できるよう設計されたモデルです。
画像生成のモデルなどでも利用されているそうです。

CLIPの特徴

  • 画像とテキストのペアで学習
    CLIPは、大量の画像とその説明文のペアを用いて学習されました。これにより、画像と言語の間の共通表現空間を学習します。

  • ゼロショット推論が可能
    CLIPは、新しいタスクに特化した追加の学習なしに、ゼロショットで優れた性能を発揮できます。例えば、特定の画像に関する説明文やラベルがなくても、テキストプロンプトを利用して画像を分類できます。

  • テキストと画像のマッチング
    CLIPは、テキストがどの画像に最も関連しているかをスコアで評価できます。これにより、画像検索や画像生成の評価に利用されています。

実装手順

それでは、Annoyを使って類似商品検索システムを構築する手順を説明します。

1. 商品データの準備

まず、商品データを準備します。今回は、デモ用に3つの商品データを用意します。各商品は、画像URLと説明文を持っています。

import pandas as pd
from PIL import Image
import requests
from io import BytesIO

# 商品データ (画像URL, 説明文)
data = {
    'item_id': [1, 2, 3],
    'image_url': [
        'https://images.unsplash.com/photo-1593642532454-e138e28a63f4?&w=500',  # ノートPC
        'https://images.unsplash.com/photo-1583394838336-acd977736f90?&w=500',  # スマホ
        'https://images.unsplash.com/photo-1555949963-ff9fe0c870eb?&w=500'   # ヘッドフォン
    ],
    'description': [
        '高性能なノートパソコン。最新のCPUと大容量メモリを搭載しています。',
        '最新のスマートフォン。高画質なカメラと長持ちするバッテリーが特徴です。',
        'ノイズキャンセリング機能を備えたワイヤレスヘッドフォン。快適な装着感です。'
    ]
}

df = pd.DataFrame(data)

# 画像をダウンロードしてバイト列として変数に格納する関数
def display_image(url):
  response = requests.get(url, stream=True)
  img = Image.open(BytesIO(response.content))
  return img

# 例:
display_image(df['image_url'][0])

2. CLIPモデルのロード

次に、CLIPモデルとプロセッサをロードします。CLIPは、画像とテキストを同じベクトル空間に埋め込むことができるモデルです。

import torch
from transformers import CLIPProcessor, CLIPModel

# CLIPモデルとプロセッサをロード
device = "cuda" if torch.cuda.is_available() else "cpu"
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32").to(device)
processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")

3. Annoyインデックスの作成

Annoyのインデックスを作成します。インデックスには、商品の特徴ベクトルを格納します。

from annoy import AnnoyIndex

# 特徴ベクトルの次元数 (CLIPの出力次元に合わせる)
vector_dim = 512

# Annoyインデックスを作成
annoy_index = AnnoyIndex(vector_dim, 'angular')

4. 商品データをインデックスに追加

商品データを一つずつ処理し、画像とテキストから特徴ベクトルを抽出して、Annoyインデックスに追加します。

for index, row in df.iterrows():
    try:
        # 画像をダウンロード
        response = requests.get(row['image_url'], stream=True)
        response.raise_for_status()  # エラーがあれば例外を発生させる
        image = Image.open(BytesIO(response.content))

        # 画像とテキストをエンコード
        inputs = processor(text=[row['description']], images=image, return_tensors="pt", padding=True).to(device)
        with torch.no_grad():
            outputs = model(**inputs)

        # 画像とテキストの特徴ベクトルを結合
        image_vector = outputs.image_embeds.cpu().numpy()[0]
        text_vector = outputs.text_embeds.cpu().numpy()[0]
        combined_vector = image_vector # image_vectorとtext_vectorを結合する場合は以下のようにする
        # combined_vector = (image_vector + text_vector) / 2

        # インデックスに追加
        annoy_index.add_item(row['item_id'], combined_vector)

    except requests.exceptions.RequestException as e:
        print(f"Error downloading image for item_id {row['item_id']}: {e}")
    except Exception as e:
        print(f"Error processing item_id {row['item_id']}: {e}")

5. インデックスの構築と保存

インデックスにデータを追加したら、build メソッドでツリーを構築します。その後、save メソッドでインデックスをファイルに保存します。

# ツリーを構築
annoy_index.build(10)

# インデックスを保存
annoy_index.save('product_index.ann')

6. 類似商品の検索

保存したインデックスをロードし、get_nns_by_item メソッドを使って、指定した商品IDに最も近い商品を取得します。

# 検索関数
def find_similar_items(item_id, n=5):
    # インデックスをロード
    loaded_index = AnnoyIndex(vector_dim, 'angular')
    loaded_index.load('product_index.ann')

    # 指定されたアイテムIDのベクトルを取得
    # item_vector = loaded_index.get_item_vector(item_id)

    # 類似アイテムを検索 (距離も取得)
    similar_items, distances = loaded_index.get_nns_by_item(item_id, n, include_distances=True)

    # 結果を表示
    print(f"Item {item_id} に類似した商品:")
    for i, item in enumerate(similar_items):
        print(f"  {i+1}. Item ID: {item}, Distance: {distances[i]}")
        print(f"     Description: {df[df['item_id'] == item]['description'].iloc[0]}")
        display_image(df[df['item_id'] == item]['image_url'].iloc[0])

# 類似商品を検索 (例: item_id=1の商品に類似した商品を検索)
find_similar_items(1, n=3)

実行結果 (例)

Item 1 に類似した商品:
  1. Item ID: 1, Distance: 0.0
     Description: 高性能なノートパソコン。最新のCPUと大容量メモリを搭載しています。
     (ノートPCの画像が表示される)
  2. Item ID: 2, Distance: 1.2147496938705444
     Description: 最新のスマートフォン。高画質なカメラと長持ちするバッテリーが特徴です。
     (スマホの画像が表示される)
  3. Item ID: 3, Distance: 1.2492777109146118
     Description: ノイズキャンセリング機能を備えたワイヤレスヘッドフォン。快適な装着感です。
     (ヘッドフォンの画像が表示される)

まとめ

本記事では、Annoyを使って画像とテキスト情報から類似商品を検索するシステムを構築する方法を紹介しました。Annoyは高速かつメモリ効率に優れた近似最近傍探索ライブラリであり、大規模なデータセットにも対応できます。

今回紹介したサンプルコードは基本的なものですが、これをベースに、様々な応用が可能です。

  • 特徴量ベクトルの生成方法を工夫する (画像とテキストのベクトルをただ平均するのではなく、より良い結合方法を検討する) -> 連結, 重み付き平均, Attention Mechanismなど
  • ユーザーの行動履歴を考慮する
  • 検索結果をフィルタリングする

上記のような改善を行うことで、より精度の高いレコメンドシステムを構築することができると思います。

Discussion