画像類似検索における Vertex AI の Vector Search と Search and Conversation 比較

<!-- 
# 誰に何を受け取ってもらうか
- LLM 初心者
- 画像検索で抑えるべきポイントと how。

# ストーリー
1. やりたいこと
2. 画像類似検索の流れ。
  - 画像->embedding->LLMに取り込み
3. 2つのサービスの違い。特徴
  - 特徴比較
  - 結局判断基準は？
3. 具体的な実現方法
  3-1. vertex ai search and conversation
  3-2. vertex vector search
-->
# はじめに
### やりたいこと
この記事では、Goolgle Cloudを使った画像類似検索の構築方法についてまとめたい。 
画像類似検索自体は以前から実現は可能だが、最近 Vertex AI 系サービスが充実してきておりその敷居がかなり下がっているように思える。
データ準備含めなるべく構築・運用コストを抑えてお手軽に画像類似検索を立ち上げられないかを調査・整理する。

### 画像類似検索について
本題に入る前に、まずは画像類似検索自体どのような実現手段があるかの全体像から見ていきたい。
下記記事が分かりやすいので一部引用して紹介する。

https://www.nogawanogawa.com/entry/image_search_2
>![link](https://cdn-ak.f.st-hatena.com/images/fotolife/n/nogawanogawa/20210411/20210411170744.jpg)

テキスト・画像どちらに基づいて検索をかけるかで大別される。
画像の中でも、類似度を評価するための特徴量の抽出方法によってさらに分類でき、 
Vertex AI Vector Search, Search and Conversation を利用した画像類似検索は主に Embedding を特徴量とする手法に分類される。

### Vertex AI の Vector Search と Search and Conversation とは？
Google Cloud が提供する機械学習系のサービスの一つである。

Google Cloud は Vertex AI と呼ばれる機械学習モデルの学習・構築・デプロイ・利用するプラットフォームを提供している。
最近だとその中の LLM 系サービス(PaLM2 API)が急成長しており、 

Vector Search と Search and Conversation は Vertex AI の一部として位置づけられ、Google Cloud が提供するマネージドサービスのひとつである。
- [Vector Search](https://cloud.google.com/vertex-ai/docs/vector-search/overview): Google 検索等で使われるようなベクトル検索を実現するためのマネージドサービス。以前は Matching Engine と呼ばれていた。
- [Search and Conversation](https://cloud.google.com/generative-ai-app-builder/docs/introduction): 生成 AI を使用した検索と会話を構築するためのマネージドサービス。以前は Enterprise Search on Generative AI App Builder と呼ばれていた。

# 画像類似検索の構築方法
ここから本題である。
Vector Search と Search and Conversation いずれでも、画像類似検索の構築は下記手順を踏むことになる。
1. 検索対象にしたい画像を収集する
2. 画像から embedding を作成する
3. モデルに embedding を取り込む
4. API を立ち上げ、利用可能にする

API が呼び出されると、検索のインプット情報を元に embedding の類似度を算出して結果が返却される。

### Vector Search と Search and Conversation の違い
では画像類似検索にそれぞれのサービスを利用した時の違いは何か？を下記に記載する。

あくまで個人的な意見としてだが、**検索方法はテキストのみでも問題なく、素早い立ち上げ・初期コストを重視**するなら Search and Conversation。
**画像での検索やその調整、性能にこだわる**なら Vector Search を選択すると良いと思われる。


|  |Vector Search  |   Search and Conversation|
|---|---|---|
|ユースケース  |画像、音声、ビデオ、ユーザー設定等の類似検索  | 独自データの検索・レコメンデーション |
|取り扱うデータの種類 | ベクトル  | ウェブサイト・構造化データ・非構造化データ  |
|サービスの利用方法  | API  | API, Widget  |
|検索のインプット  |ベクトル |テキスト   |
|画像検索の主なアウトプット  |検索ベクトルに対する画像のIDと類似度(高い順) |検索テキストを元に取得されたデータに近い類似度の画像(設定された類似度閾値以上のもの)   |
|検索データの制約 |なし|embedding のサイズ: 1~768    |
|課金方式  | 検索 API サーバ稼働に対する課金 | 検索 API 呼び出しごと  |
|ランニングコスト| 最小で$67.68/月(利用インスタンスタイプ・数による) | 	$4 / 1000クエリ |

# Vector Search での構築

Vector Search は次の要素で成り立っている。
インデックスをインデックスエンドポイントにデプロイすることで、autoscale する GCE が立ち上がり ベクトル検索 API にアクセス可能となる。
- インデックス: embedding をモデルにロードし、高速に検索可能にするための要素
- インデックスエンドポイント: ベクトル検索 API 自体の設定(公開設定など)を行うための要素

以下具体的な構築手順を紹介するが、再現のためにはまず共通となる環境変数を設定する。
```sh
PROJECT_ID=<project id>
LOCATION=<us-central1 等>
```

### embedding の作成
まずは embedding の作成を行う。
embedding の作成方法は何を選んでも問題ないが、Google Cloud に[画像の embedding 作成を行うための API](https://cloud.google.com/vertex-ai/docs/generative-ai/embeddings/get-multimodal-embeddings?hl=ja)が用意されているためそれを利用する。

詳細は [マルチモーダルエンベディング API](https://cloud.google.com/vertex-ai/docs/generative-ai/model-reference/multimodal-embeddings?hl=ja)に書かれているが、例えば下記コマンドを実行すると対象の画像の embedding を取得できる。

```sh
IMAGE_FILE_PATH=<local の画像ファイルパス>

echo "{\"parameters\":{\"dimension\":1408}, \"instances\": [{\"image\": {\"bytesBase64Encoded\": \"$(base64 $IMAGE_FILE_PATH -w 0)\"}}]}" | \
 curl -X POST \
 -H "Authorization: Bearer $(gcloud auth print-access-token)"\
 -H "Content-Type: application/json; charset=utf-8" \
 -d @-  \
 https://us-central1-aiplatform.googleapis.com/v1/projects/$PROJECT_ID/locations/us-central1/publishers/google/models/multimodalembedding@001:predict |\
 jq '.predictions[].imageEmbedding'
```

Vector Search でこの生成した embedding を利用するためには、下記のような ndjson ファイルを作成する必要がある。
`id` はユニークな文字列(ベクトル検索結果で返却される情報)、 `embedding` は上記で生成した embedding をそのまま登録する。

```json
{"id": "1", "embedding": [-0.0232418478,0.0302745607,...]}
{"id": "2", "embedding": [-0.00336091942,0.0222148206,...]}
```

これでデータの準備は完了。

### API のデプロイ
まず、API にデータを取り込むために GCS 上にファイルを登録し、それを元にインデックスを作成するための metadata ファイルを準備する。
metadata ファイルは[設定内容](https://cloud.google.com/vertex-ai/docs/matching-engine/configuring-indexes?hl=ja)を参考に適切に修正して問題ないが、 `config.dimensions` を embedding のベクトルサイズと同じに設定しないとインデックス作成時にエラーになるので注意。

```sh
GCS_DIR=<アップロード先の bucket 名 + ディレクトリパス>
EMBEDDING_FILE_PATH=<local で作成済みの embedding ファイル>

gsutil cp $EMBEDDING_FILE_PATH gs://$GCS_DIR

cat << EOF > ./metadata.json
{
    "contentsDeltaUri": "gs://$GCS_DIR",
    "config": {
      "dimensions": 1408,
      "approximateNeighborsCount": 150,
      "distanceMeasureType": "DOT_PRODUCT_DISTANCE",
      "shardSize": "SHARD_SIZE_SMALL",
      "algorithm_config": {
        "treeAhConfig": {
          "leafNodeEmbeddingCount": 5000,
          "leafNodesToSearchPercent": 3
        }
      }
    }
  }
EOF
```

次にインデックス・インデックスエンドポイントを作成する。ここまでの手順であればランニングコストははかからない。

```sh
INDEX_NAME=<適当なインデックス名>

gcloud ai indexes create \
  --metadata-file=metadata.json \
  --display-name=$INDEX_NAME \
  --project=$PROJECT_ID \
  --region=$LOCATION

gcloud ai index-endpoints create \
  --display-name=${INDEX_NAME}_endpoint \
  --public-endpoint-enabled \ 
  --project=$PROJECT_ID \
  --region=$LOCATION
```

上記コマンドを実行した結果、下記フォーマットの ID が生成される。このインデックスIDとインデックスエンドポイントIDを使い、インデックスエンドポイントにインデックスをデプロイすることができる。
- インデックス: `projects/<プロジェクト番号>/locations/<ロケーション>/indexes/<インデックスID>`
- インデックスエンドポイント: `projects/<プロジェクト番号>/locations/<ロケーション>/indexEndpoints/<インデックスエンドポイントID>`

最安構成でインデックスをデプロイするために、下記コマンドを実行する。
また、現状(2023/12時点)ではデフォルトで立ち上げる GCE の replica 数を1にしないと、project の quota 制限に引っかかり、` 'The following quotas are exceeded: MatchingEngineDeployedIndexNodes'` エラーとなる。
replica を2以上に設定したい場合は Google Cloud の営業担当への連絡が必要。

```sh
INDEX_ID=<インデックスID>
INDEX_ENDPOINT_ID=<インデックスエンドポイントID>

gcloud ai index-endpoints deploy-index ${INDEX_ENDPOINT_ID} \
  --deployed-index-id=${INDEX_NAME}_id \
  --display-name=${INDEX_NAME} \
  --index=${INDEX_ID} \
  --enable-access-logging \
  --machine-type=e2-standard-2 \
  --max-replica-count=1 --min-replica-count=1 \
  --project=$PROJECT_ID \
  --region=$LOCATION
  ```
この手順からわかる通り、インデックスエンドポイントには複数のインデックスを登録可能になっており、API呼び出し側でインデックスを指定してベクトル検索を使い分けるようになっている。

ちなみにデプロイをやめてランニングコストを抑えたい場合は、 `gcloud ai index-endpoints undeploy-index ...` コマンドを実行する。

### 検索APIの利用方法

下記コマンドを実行すると、 `neighbor_count` で指定した数の類似度が高い画像上位3つが取得される。
```sh
PUBLIC_DOMAIN_NAME=<公開ドメイン名: ~~~.vdb.vertexai.goog>
INPUT_EMBEDDING=<検索したい画像を embedding した配列>
curl \
  -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $(gcloud auth print-access-token)" \
   https://$PUBLIC_DOMAIN_NAME/v1/projects/$PROJECT_ID/locations/$LOCATION/indexEndpoints/$INDEX_ENDPOINT_ID:findNeighbors \
  -d '{deployed_index_id: "${INDEX_NAME}_id", queries: [{datapoint: { feature_vector: $INPUT_EMBEDDING}, neighbor_count: 3}]}'
```

具体的な API からの応答結果は下記のようになっており、 `distaince` が類似度、 `datapointId` が GCS にアップロードしたファイルに入力した ID 情報である。
```json
{"nearestNeighbors":[{"neighbors":[{"datapoint":{"datapointId":"1","crowdingTag":{"crowdingAttribute":"0"}},"distance":1},{"datapoint":{"datapointId":"2","crowdingTag":{"crowdingAttribute":"0"}},"distance":0.689365804195404},{"datapoint":{"datapointId":"3","crowdingTag":{"crowdingAttribute":"0"}},"distance":0.51652371883392334}]}]}
```
クライアントはこのIDから、どの画像が得られたのかを判別することができるようになっている。
ちなみ、ID 以外の情報を response に含めることは難しいので、この ID から実際の画像データや他メタデータを取得するデータソースを別途準備する必要がある。



# Search and Conversation での構築



Search and Conversation で画像類似検索を実現するためには、[カスタム embedding](https://cloud.google.com/generative-ai-app-builder/docs/bring-embeddings)を利用することになる。
このドキュメントにある通り構造化データ・メタデータを含む非構造データの2種類のデータソースを利用できるが、ここでは前者の構造化データを使った構築手順を紹介する。

以下具体的な構築手順を紹介するが、Vector Search 同様下記環境変数の設定と、 (embedding の作成)[./embedding の作成]と同様の手順が必要になる。
ただし、 embedding の次元は718次元以内に抑える必要があるため、 embedding 作成の API 呼び出しをする際に次元を 512 次元等718次元以下に指定する必要がある。

```sh
PROJECT_ID=<project id>
LOCATION=<us-central1 等>
```

### データの準備
構造化データのスキーマは自由に決められる。例えば下記のようなスキーマでデータを登録し、 `embedding` フィールドがカスタム embedding を指定するフィールドとする。
```json
{"id": "1","title":"title1","uri":"image uri path1", "embedding": [-0.0232418478,0.0302745607,...]}
{"id": "2","title":"title2","uri":"image uri path2", "embedding": [-0.00336091942,0.0222148206,...]}
...
```

まずはデータソースを作成し、どのカラムをカスタム embedding とするか指定する必要がある。
`embedding_vector` というプロパティを指定したものがカスタム embedding となるため、下記コマンドを実行する。
```sh
DATA_STORE_ID="<データソースID>"
EMBEDDING_FIELD_NAME="embedding"
EMBEDDING_DIMENSION=512

curl -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
-H "X-Goog-User-Project: $PROJECT_ID" \
"https://discoveryengine.googleapis.com/v1alpha/projects/$PROJECT_ID/locations/global/collections/default_collection/dataStores?dataStoreId=$DATA_STORE_ID" \
-d "{
  \"displayName\": \"${DATA_STORE_ID}_name\",
  \"industryVertical\": \"GENERIC\",
}"

echo "{\"structSchema\":{\"\$schema\": \"https://json-schema.org/draft/2020-12/schema\", \"type\": \"object\", \"properties\": {\"$EMBEDDING_FIELD_NAME\": {\"type\": \"array\", \"keyPropertyMapping\": \"embedding_vector\", \"dimension\": $EMBEDDING_DIMENSION, \"items\": {\"type\": \"number\"}}}}}' | \
curl -X PATCH \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
"https://discoveryengine.googleapis.com/v1beta/projects/$PROJECT_ID/locations/global/collections/default_collection/dataStores/$DATA_STORE_ID/schemas/default_schema" \
-d @-
```
ちなみに、データのインポートを行う前にこの指定を実行する必要があり、この手順が前後すると下記エラーが発生して更新できない。
```
Schema update doesn't support adding the key property annotation for schema with active documents. Key property mapping mismatch for field "embedding".
```

次に構造化データをアップロードし、それをもとにデータをインポートする。
```sh
GCS_FILE_PATH=<アップロード先の bucket 名 + ファイルパス>
STRUCTURED_JSON_FILE_PATH=<local で作成済みの embedding ファイル>

gsutil cp $STRUCTURED_JSON_FILE_PATH gs://$GCS_FILE_PATH

curl -X POST \
  -H "Authorization: Bearer $(gcloud auth print-access-token)" \
  -H "Content-Type: application/json" \
  "https://discoveryengine.googleapis.com/v1/projects/$PROJECT_ID/locations/global/collections/default_collection/dataStores/$DATA_STORE_ID/branches/0/documents:import" \
  -d "{
    \"gcsSource\": {
      \"inputUris\": [\"gs://${GCS_FILE_PATH}\"],
      \"dataSchema\":\"custom\"
    },
    \"reconciliationMode\": \"INCREMENTAL\",
    \"autoGenerateIds\": \"true\"
  }"
```


# デプロイ

次に作成したデータソースを元にエンジン(検索API) を立ち上げ、その類似度検索設定を更新する。
```sh
ENGINE_ID=<エンジン名>
curl -X POST \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json" \
-H "X-Goog-User-Project: $PROJECT_ID" \
"https://discoveryengine.googleapis.com/v1alpha/projects/$PROJECT_ID/locations/global/collections/default_collection/engines?engineId=$ENGINE_ID" \
-d "{
  \"name\": \"projects/$PROJECT_ID/locations/global/collections/default_collection/engines/$ENGINE_ID\",
  \"displayName\": \"${ENGINE_ID}_name\",
  \"dataStoreIds\": [\"$DATA_STORE_ID\"],
  \"commonConfig\": {\"companyName\": \"aaa\"},
  \"searchEngineConfig\": {
    \"searchTier\": \"SEARCH_TIER_ENTERPRISE\",
    \"searchAddOns\": [\"SEARCH_ADD_ON_LLM\"]},
  \"solutionType\": \"SOLUTION_TYPE_SEARCH\"
}"

curl -X PATCH \
-H "Authorization: Bearer $(gcloud auth print-access-token)" \
-H "Content-Type: application/json; charset=utf-8" \
-d "{
      \"name": \"projects/$PROJECT_ID/locations/global/collections/default_collection/dataStores/$DATA_STORE_ID/servingConfigs/default_search\",
      \"embeddingSpec\":
      {
        \"fieldPath\": \"$EMBEDDING_FIELD_NAME\"
      }
      \"ranking_expression\": \"0.5 * relevance_score\"
    }" \
"https://discoveryengine.googleapis.com/v1alpha/projects/$PROJECT_ID/locations/global/collections/default_collection/dataStores/$$PROJECT_ID/servingConfigs/default_search?updateMask=embeddingSpec,rankingExpression'
```

ウィジェットを利用する場合は Google Console から表示設定を更新した上で html を自分のアプリに埋め込むと良い。