Vespaでベクトル検索によるマルチモーダル検索システムを実装する

この記事は何?

情報検索・検索技術 Advent Calendar 2023 - Adventar の1日目の記事です。

マルチモーダル検索とは、テキストや画像、音声など複数の異なるタイプのデータを使用して情報を検索する技術です。

例えば マルチモーダル検索とは何か: 「視覚を持った LLM」でビジネスが変わる | Google Cloud 公式ブログ では、

  • 入力:検索キーワード(テキスト)
  • 検索対象:商品画像

とした検索を実現しています。 特に、商品名や商品説明文といったテキスト情報は検索対象に含まれておらず、検索キーワードと商品画像のマッチ度合いのみが考慮されて検索されているのが面白いです。

上記の例以外にも、画像を入力としてテキストを検索対象にしたり、テキストと画像の両方を入力とする検索も実現できます*1

この記事では、検索エンジンVespaを使って、テキストと画像を組み合わせたマルチモーダル検索システムの実装方法を紹介します。 実装に用いたコードは以下に載せました。

github.com

マルチモーダル検索の流れ

マルチモーダル検索には以下の2つのコンポーネントが必要です。

  • Embedding推論エンジン:テキストや画像などの入力を受け取り、機械学習モデルを利用してembedding(特徴量ベクトル)を生成し、これを出力するコンポーネントです。FastAPIなどのWebフレームワークを利用して独自に実装することもできますし、TritonTensorFlow Servingといった機械学習モデルの推論に特化した仕組みを利用することも可能です。

  • ベクトル検索エンジン:検索対象コンテンツのembeddingを格納し、検索クエリに基づいて類似するコンテンツを高速に検索し提供するコンポーネントです。ベクトル検索の需要が高まっているため、FaissAnnoyValdVertex AI Matching EnginePinecone、そしてVespaなど、さまざまなエンジンやライブラリが開発されています*2*3

以下の図は、最初に紹介したテキストによる画像検索の実装例を示しています。

  • 商品フィーダー:商品の画像を画像embedding推論エンジンに送信し、生成された画像embeddingをベクトル検索エンジンにフィードします。
  • ユーザーの検索リクエスト:ユーザーは検索APIに検索キーワード(テキスト)を送信します。検索APIはこのキーワードのembeddingを取得し、それをベクトル検索エンジンに問い合わせ、検索結果を得ます。そして、これらの検索結果をユーザーに返します。

Vespaを利用したマルチモーダル検索の実装

さて、ここからはVespaを使用してマルチモーダル検索システムを実装する方法について説明します。

実装する検索システムの概要

検索システムのデータソースとして、Amazon Berkeley Objects (ABO) Datasetを利用します。 このデータセットには、Amazonで販売されている商品のID、タイトル、説明文、画像などが含まれています。

実装するマルチモーダル検索システムでは、以下の2種類の検索が可能です*4

  • 入力:検索キーワード(テキスト)、検索対象:商品画像
  • 入力:画像、検索対象:商品タイトル(テキスト)

つまり、テキストと画像のどちらも検索入力として使用できます。

商品embeddingの管理方法

商品には、タイトル(テキスト)から計算されるembeddingと、画像から計算されるembeddingの2種類が存在します。 これら2つのembeddingを管理する方法として、以下の2つのアプローチが考えられます。

アプローチ 説明 メリット デメリット
個別フィード タイトルと画像のembeddingを個別にVespaにフィード 純粋な「キーワード-画像」や「画像-タイトル」検索が可能 インデックスサイズが大きくなる
マージフィード 2つのembeddingを1つに統合してVespaにフィード インデックスサイズが小さくなる 商品を1つのembeddingで表すため、厳密には「キーワード-タイトル&画像」のような検索になる

Vespaは1つのドキュメントに対し複数のembeddingをフィードすることが可能なので*5、タイトルと画像のembeddingを個別にフィードすることが可能です。 一方でVespa内部に、商品ごとにembeddingを2つ、ベクトル検索インデックス(HNSW)を2つ持つことになるので、インデックスサイズが大きくなるという問題があります。

タイトルと画像のembeddingを個別にフィードするのではなく、うまく1つのembeddingに統合してあげて、統合後のembeddingのみをフィードする方法も考えられます。 1つのembeddingのみをベクトル検索エンジンで扱えば良いので、インデックスサイズは小さくなるものの、商品を表すembeddingにタイトルと画像の両方の情報が含まれることに注意が必要です。

embeddingの統合について、例えばCIKM 2023の論文"Unsupervised multi-modal representation learning for high quality retrieval of similar products at e-commerce scale"では、テキストと画像のembeddingを足し合わせてL2正則化を行い、1つのembeddingとして扱う手法を提案しています。

"Unsupervised multi-modal representation learning for high quality retrieval of similar products at e-commerce scale, CIKM 2023"のFigure 1より引用。STEP 3にてタイトルおよび画像のembeddingを足し算してL2正則化をかけている。

プロダクトとして実装する際には、ユースケースやコストを加味してどちらかの案を採用する必要があります。 この記事では、後に2つの案を実験するため、両方の案を実装していきます(以下図)。

この記事で実装するマルチモーダル検索

Embeddingの推論

タイトルと画像のembeddingの生成には、CLIP ("openai/clip-vit-large-patch14") を使用します。

CLIPは内部にText EncoderとImage Encoderの2つのモデルを持ち、それぞれから得られるembeddingをマルチモーダル検索に利用します。 CLIPの詳しい説明はOpenAIの紹介ページを参照してください。

Vespaのスキーマ設定

次に、ベクトル検索エンジンとして機能するVespaのスキーマ(ドキュメントの構造)を定義します。 VespaのスキーマについてはSchema ReferenceおよびSchemasが詳しいです。

この記事の実装では、以下の商品情報をVespaに格納します。

  • 商品ID
  • 商品タイトル
  • 画像パス(ABOデータセット上のパスを表します)
  • 商品タイトルから計算されるembedding
  • 商品画像から計算されるembedding
  • タイトルと画像のembeddingを統合したembedding(単純に和を取ります)

スキーマの定義

商品情報に基づいて、以下のようにスキーマを定義しました。

schema item {
    document item {
        field item_id type string {
            indexing: summary | attribute
        }

        field item_name_en_us type string {
            indexing: summary | index
            index: enable-bm25
        }

        field path type string {
            indexing: summary | attribute
        }

        field text_embedding type tensor<float>(x[768]) {
            indexing: attribute | index
            attribute {
                distance-metric: angular
            }
            index {
                hnsw {
                    max-links-per-node: 16
                    neighbors-to-explore-at-insert: 50
                }
            }
        }

        field image_embedding type tensor<float>(x[768]) {
            indexing: attribute | index
            attribute {
                distance-metric: angular
            }
            index {
                hnsw {
                    max-links-per-node: 16
                    neighbors-to-explore-at-insert: 50
                }
            }
        }

        field synthetic_embedding type tensor<float>(x[768]) {
            indexing: attribute | index
            attribute {
                distance-metric: angular
            }
            index {
                hnsw {
                    max-links-per-node: 16
                    neighbors-to-explore-at-insert: 50
                }
            }
        }
    }
...

Embeddingの定義について補足します。

  • text_embedding, image_embedding, synthetic_embeddingは、それぞれ商品のテキストと画像から計算されたembeddingおよびマージ後のembeddingを保持します。
  • 各embeddingフィールドでは、attributeにおいてdistance-metric: angularを指定しています。これは、ベクトル間の角度に基づいて距離を計算することを表します。他に指定可能な距離計算式はSchema Referenceに挙げられています。
  • index項目において、fieldで指定されたベクトルをベクトル検索インデックス(HNSW)で管理するように指定しています。field定義時にHNSWのパラメータ(max-links-per-nodeneighbors-to-explore-at-insert)を指定することができます*6。詳しくはApproximate Nearest Neighbor Search using HNSW Indexを確認してください。

ランキングロジックの定義

Vespaではランキングで利用するロジックをrank-profileという項目で定義できます。

今回は単純にembedding間の類似度順でランキングします*7

    rank-profile text_embedding_closeness {
        match-features: distance(field, text_embedding)

        inputs {
            query(query_embedding) tensor<float>(x[768])
        }

        first-phase {
            expression: closeness(field, text_embedding)
        }
    }

    rank-profile image_embedding_closeness {
        match-features: distance(field, image_embedding)

        inputs {
            query(query_embedding) tensor<float>(x[768])
        }

        first-phase {
            expression: closeness(field, image_embedding)
        }
    }

    rank-profile synthetic_embedding_closeness {
        match-features: distance(field, synthetic_embedding)

        inputs {
            query(query_embedding) tensor<float>(x[768])
        }

        first-phase {
            expression: closeness(field, synthetic_embedding)
        }
    }

Vespaへのデプロイとデータフィード

スキーマ設定が完了したら、Vespaのアプリケーションをデプロイし、商品情報をフィードします。

$ vespa deploy --wait 300

商品データはJSONでVespaにフィードします。以下はその一例です。

{
  "put": "id:item:item::B074J5TWYL",
  "fields": {
    "item_id": "B074J5TWYL",
    "item_name_en_us":  "365 Everyday Value, Organic Black Tea (70 Tea Bags), 4.9 oz",
    "path": "03/03fde183.jpg",
    "text_embedding": [0.04524907, 0.00058629, ...
}

マルチモーダル検索の実行

Vespaのデプロイ&商品情報のフィードまで出来たとして、実際にマルチモーダル検索を試してみます。

Vespaでのベクトル検索の方法

Vespaでベクトル検索を行うには、以下のようにクエリを投げます。

$ vespa query \
  'yql=select * from item where {targetHits:100, approximate:true}nearestNeighbor(image_embedding, query_embedding)' \
  'ranking=image_embedding_closeness' \
  'input.query(query_embedding)=[0.1, 0.2, ...]'
  • VespaではYQLというクエリ言語で検索クエリを組み立てます
  • wherenearestNeighborにおいてベクトル検索を行うように指定します。詳しくはドキュメントを参照してください。
  • input.query(query_embedding)=...で、検索で利用する入力ベクトルを指定します。

入力:検索キーワード、検索対象:画像

検索キーワード"short modern cabinet"に対する画像検索結果の上位10件を以下に示します。

3件目と4件目の黒いキャビネットは"short"かと言われると怪しいですが、おおよそ検索キーワードの意図に沿った商品が得られています。 特に、商品タイトル(item_name_en_us)に必ずしも"short"・"modern"・"cabinet"という単語が含まれないことに注目してください。 商品のテキストフィールドに明示的に含まれていないが、商品画像から推定される情報を利用して検索を行えるのは、マルチモーダル検索の強みと言えるでしょう。


次に、検索キーワードに"black"という単語を含め、"black short modern cabinet"で検索してみます。

追加された"black"という単語を考慮し、黒いキャビネットが検索結果に掲出されるようになりました。 ファッション商品や家具商品は、商品の色情報が商品選択における重要な要素になるため、マルチモーダル検索の強みが活かされるかもしれません。


これまでは検索対象:画像(item_embedding)としていましたが、タイトルと画像のembeddingを足し合わせたembedding(synthetic_embedding)を検索対象として検索してみます。

検索キーワード"short modern cabinet"

検索キーワード"black short modern cabinet"

検索対象:画像(item_embedding)として検索した時とは大きく異なる検索結果となりました。 どちらが良い検索結果かと言われると難しいですが、チューニングの余地はありそうです*8

入力:画像、検索対象:商品タイトル

次に、画像を入力として検索対象をタイトルとしたマルチモーダル検索を試してみます。

入力は以下の商品の画像としてみました。

商品ID:B0853Q7C48

タイトルから計算されたembedding(text_embedding)を対象に検索してみると、以下の結果が得られました。

意図通り、キャビネットやチェスト商品が検索結果に含まれています。


さらに、タイトルと画像のembeddingをマージ(和)したembedding(synthetic_embedding)を検索対象として検索してみます。

先の商品タイトルのみを検索対象とした検索と異なり、よりキャビネットの形を捉えた検索結果になりました。

応用:ベクトル検索とキーワード検索の組み合わせ

これまでは、入力のベクトルと類似したベクトルの商品を検索するという、単純なベクトル検索を行ってきました。 応用事例として、キーワード検索やフィルタリングなどの通常の検索をベクトル検索と組み合わせる検索を紹介します。

例えば、ベクトル検索を行いつつ商品タイトル(item_name_en_us)に"black"という単語が含まれる商品を検索するには、以下のように検索クエリを組み立てます。

$ vespa query \
  'yql=select * from item where userQuery() and {targetHits:100, approximate:true}nearestNeighbor(image_embedding, query_embedding)' \
  'ranking=image_embedding_closeness' \
  'query=item_name_en_us:black' \
  'input.query(query_embedding)=[0.1, 0.2, ...]'

上記のように、通常の検索をベクトル検索と組み合わせる検索:ハイブリッド検索がVespaでは可能です*9。 例えば商品検索においては、カテゴリなどの商品属性を指定してキーワード検索できると便利でしょう。 ハイブリッド検索はこのような検索ケースに対応できます。

実際に、入力:検索キーワード"short modern cabinet"、検索対象:画像、商品タイトルに"black"を含む商品を検索してみましょう。

意図した通り、商品タイトルに"black"を含むかつ、検索キーワードに沿った画像の商品を検索できています。

おわりに

この記事ではVespaでマルチモーダル検索を実装する流れを紹介しました。

記事中で紹介したように、Vespaでは1つのドキュメントに複数のベクトルを紐付けることができます。 そのため、柔軟性の高いマルチモーダル検索を実装することができます。

また、Vespaはベクトル検索をキーワード検索やフィルタリングと組み合わせたハイブリッド検索もサポートしており、「◯◯な検索も実現できちゃうのかな~~??」と夢が広がります!

*1:例えば、「赤いドレス」の画像と「結婚式用」というテキストを入力とすることで、フォーマルな赤いドレスを探せるかもしれません。

*2:Vespaは、単にベクトル検索エンジンというよりは、様々な機能を持つすごい検索エンジンというのが正確かもしれません。

*3:なお、VespaにはEmbedding推論エンジンを内包する機能もあります。詳しくはEmbeddingをご確認ください。

*4:もちろん、「入力:検索キーワード(テキスト)、検索対象:商品タイトル(テキスト)」「入力:画像、検索対象:商品画像」というように、テキスト間・画像間の検索も自然と実装されます。

*5:Revolutionizing Semantic Search with Multi-Vector HNSW Indexing in Vespa

*6:HNSWの主要なパラメータとしてグラフのレイヤー数がありますが、現時点でVespaではレイヤー数を指定することはできないようです。VespaのHNSW実装は追えていませんが、おそらく内部で決め打ちの数値が利用されているのだと想像しています。

*7:rank-profileはかなり自由度があります。例えばベクトル間類似度と他属性値を組み合わせてランキング式を作ったり、ブースティング木などの機械学習ランキングモデルの特徴量にベクトル間類似度を利用することもできます。

*8:例えば、CLIPモデルを商品データでfine tuningする方法が考えられます。CLIPモデルの学習データは、ウェブサイトからクローリングした情報を利用しており、特に商品情報に特化しているわけではありません。検索キーワードや商品タイトルは、他のウェブサイトのテキストに比べて短いなど、異なる特徴を持ちます。そのため、商品情報に特化するメリットは大きいかもしれません。

*9:ここではキーワード検索とベクトル検索の結果のANDを取っていますが、ORを取ることも可能です。ハイブリッド検索というと、キーワード検索とベクトル検索のORを取った結果を返すことを指すのが一般的かもしれません。