様々な埋め込みモデルのベクトル表現を2D散布図で可視化して比べてみる

# この記事について

この記事では、以下の埋め込みモデルを扱います

- Titan Text Embeddings(amazon.titan-embed-text-v1)
- Cohere Embed - Multilingual(cohere.embed-multilingual-v3)
- OpenAI Ada(text-embedding-ada-002)
- OpenAI Embedding 3(text-embedding-3-large)

# この記事を3行＋グラフで

- 埋め込みモデルのベクトル表現を2Dの散布図で可視化した
- 性能差が分かりやすく、OpenAIのEmbedding 3が圧倒的、次点でCohereが強い
- キーワードの傾向を調べると、性能の出ていないベクトル表現でもそれなりのスコアにはできる

スコアの高い埋め込みモデルは、グラフにすると以下のようになりました。クラスタの位置、クラスタに配置された単語の構成を見ると、どちらのモデルも似ていることが分かります。

|OpenAI Embedding 3|Cohere Embed - Multilingual|
|---|---|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/673cb701-40ae-59bb-ccaa-14e161564e93.png"></img>|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/46737531-bd52-f35d-52f3-7109580b276c.png"></img>|

Titan Text Embeddingsは、英語と日本語で異なるクラスタを作っているように見えます。検索単語を変えることでスコアが上がること、スコアの向上がグラフに現れることが確認できました。

|英語で日本語を検索（60%正答）|日本語で日本語を検索（75％正答）|
|---|---|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/ca076355-b8ca-ef28-70f0-3d4ce76075a9.png"></img>|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/232e1f40-2775-1e19-3a04-cd59767d0dc9.png"></img>|

## 検証したこと

このような会話を生成AIで処理するとします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/510fa5ed-d8dd-2e4a-2205-1f45add01b0a.png" width="400px"></img>

この「大丈夫です」は、「YES」の意味なのか「NO」の意味なのかを分類します。
「大丈夫です」なら分かりやすいのですが、あいまいな応答が来ることもあります。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/57597237-d019-72d7-a730-e624922d1a9e.png" width="400px"></img>

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/c061e718-7a05-edcd-f87d-67571df99cae.png" width="400px"></img>

この「YES」「NO」の判断を、生成AIの埋め込みモデルで分類することを考えます。

## なぜ埋め込みモデル？

ClaudeのようなLLMでも分類はできるのですが、埋め込みモデルを使うモチベーションやメリットには以下の表の通りです。Haikuよりも料金が安く、応答時間が早く、結果が安定しています。

また、複数件のレコードを1度のAPI実行で一括変換することもできます。

||Cohere|Claude Sonnet|Claude Haiku|
|---|---|---|---|
|1Mトークンあたりの入力料金|$0.1|$3.0|$0.25|
|1Mトークンあたりの出力料金|無料|$15.0|$1.25|
|APIが応答するまでの時間※|0.88秒|2.195秒|1.650秒|
|同じ入力への応答|必ず同じ応答が返る|設定により不定|設定により不定|

※応答時間は5回連続で実行したときの応答時間の平均で計測しています

もしAPIGateway+Lambdaで埋め込みモデルを使ったテキストの分類を実装する場合は、以下のソースで実装できます。依存ライブラリは不要です。

![api-gateway.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/0372b994-faf9-50c1-cb51-acd05ee19067.png)

デプロイしたものをcurlで実行すると、0.9秒で分類結果が返ってきます。

<details><summary>APIGateway+Lambdaで埋め込みモデルの分類を実行するソース（クリックで開く）</summary>

```python: app.py
import json
import boto3


def distance_pow(vec: list[float]) -> float:
    """
    距離の二乗を取得する
    vec: 取得する対象のベクトル
    """
    return sum([v * v for v in vec])


def cosign_distance(vec1: list[float], vec2: list[float]):
    """
    コサイン距離を取得する(似ているほど-1に近い、似ていないほど大きくなる)
    """
    # ノルム同士をかけてL2を取る(0.5乗してルートを取る)
    l2_length = (distance_pow(vec1) ** 0.5) * (distance_pow(vec2) ** 0.5)
    if l2_length == 0.0:
        # L2が異常値になるなら0を返す
        return 0.0
    # 内積を取る
    dot_product = sum([v1 * v2 for v1, v2 in zip(vec1, vec2)])
    # コサイン類似度を計算する（変化の方向をユークリッド距離に合わせたいので、1.0から引いてコサイン距離とする）
    return 1.0 - (dot_product / l2_length)


def nearest(
    base_point: list[float],
    reference_points: list[list],
    topk: int = 1,
):
    """
    基準点から見て、最も距離の近いデータ点を取得する
    """
    knn = sorted(
        # 距離のリストに、インデックスをつけてソートする
        enumerate(
            [
                # 基準点から対象の点までの距離を求めて、距離リストに追加する
                cosign_distance(blue_point, base_point)
                for blue_point in reference_points
            ]
        ),
        # enumerateで、インデックスと値の配列になるので、x[1]で値を取得する
        key=lambda x: x[1],
        # 距離の昇順でソート、距離が小さいものを先頭に置く
        reverse=False,
    )
    return [{"index": k[0], "score": k[1]} for k in knn[:topk]]


def lambda_handler(event, context):
    """
    エントリポイント
    """

    # リクエストからテキストを取得
    text = json.loads(event.get("body", "{}")).get("text", "")

    # BedrockでCohereを実行する
    bedrock = boto3.client("bedrock-runtime")
    response = bedrock.invoke_model(
        body=json.dumps(
            {
                # 対象のテキスト
                "texts": [
                    text,
                    "Positive",
                    "Negative",
                ],
                # 埋め込みモデルの利用想定
                "input_type": "clustering",
                # トークンの最大長を超えたとき、どのように処理をするか
                # None -> 何もしない
                "truncate": "NONE",
            }
        ),
        modelId="cohere.embed-multilingual-v3",
        accept="application/json",
        contentType="application/json",
    )

    # Cohereからレスポンスを取得する
    response_body = json.loads(response.get("body").read())

    # それぞれのベクトル表現を取得する
    input_text_vector = response_body.get("embeddings")[0]
    positive_vector = response_body.get("embeddings")[1]
    negative_vector = response_body.get("embeddings")[2]

    # ポジティブ、ネガティブのどちらに近いかを判定する
    result = nearest(input_text_vector, [positive_vector, negative_vector])
    nearest_index = result[0]["index"]
    result_text = ""

    # ポジティブに近いのならPositiveを返す
    if nearest_index == 0:
        result_text = "Positive"

    # ネガティブに近いのならNegativeを返す
    if nearest_index == 1:
        result_text = "Negative"

    return {
        "statusCode": 200,
        "body": json.dumps({"result": result_text}),
    }
```

</details>

## 検証手順

入力として、20個のキーワードを用意しました。

|グループ1|グループ2|
|---|---|
|はい|いいえ|
|良い|良くない|
|素敵です|望ましくない|
|素晴らしい|考え直してください|
|ありがたい|残念|
|その形でお願いします|嬉しくない|
|肯定的|変えてください|
|進めてください|否定的|
|よろしくお願いします|避けてください|
|そうしましょう|ダメです|

それぞれの入力に選んだキーワードが、「Negative」と「Positive」の単語のどちらに似ているのかを判定します。グループ1はPositiveになることを期待して選んだ単語、グループ2はNegativeになることを期待して選んだ単語です。

![doc-embed-3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/e37d1443-a403-db7c-7657-4755693a9918.png)

期待通りの分類結果になった割合を正答率とします。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/f02db3e3-fec9-5c50-e149-53eea576e632.png" width="350px"></img>

上記の手順を4つのLLMのモデルに対して実施、それぞれの正答率を出して、2Dグラフで可視化しました。

## 可視化の手順

可視化の流れは下図の通りです。例として「ヨークシャーテリア」の単語を可視化します。
※厳密ではなく、かなり簡略化した図です

![doc-embed-4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/1264bb63-e177-5123-e1a7-6b042474e502.png)

ベクトル表現の一部から、関係する情報のベクトル表現だけを抜き出します。抜き出したベクトル表現をPCA（主成分分析）で次元削減すると、情報を壊さないまま2次元前後まで次元を減らすことができます。

この方法で、可視化が上手く動くことは以下の記事で検証しました。

https://qiita.com/ShotaOki/items/ab0a80851b7627a2c741

ソースコードはこちらにあります。
今回の検証も同じソースコードを使っています。

https://github.com/ShotaOki/vector-image-compare/tree/master

## 検証結果：正答率

まず、グループ1、グループ2の分類に使う単語を「Positive」と「Negative」にして検証しました。
検証の結果、それぞれのモデルの正答率は以下のようになりました。

|モデル|正答率|次元数|グループ1の単語|グループ2の単語|
|---|---|---|---|---|
|text-embedding-3-large|100%|3072|Positive|Negative|
|Cohere Embed - Multilingual|95%|1024|Positive|Negative|
|text-embedding-ada-002|90%|1536|Positive|Negative|
|Titan Text Embeddings|60%|1536|Positive|Negative|

Titan Text Embeddingsのグラフを見ると、言語をまたいだ検索が難しいように見えたため、分類に使う単語を日本語に変更して、再度実施しました。
日本語同士の比較の検証の結果、それぞれのモデルの正答率は以下のようになりました。

|モデル|正答率|グループ1の単語|グループ2の単語|
|---|---|---|---|
|text-embedding-3-large|95%|ありがたい|望ましくない|
|Cohere Embed - Multilingual|90%|素敵です|嬉しくない|
|Titan Text Embeddings|75%|よろしくお願いします|良くない|

※Adaは可視化できる下限まで次元を削り込めていないため、単語の選定ができず、対象から除外しました。

## 検証結果：可視化した画像

それぞれのモデルの可視化画像は以下の通りです。

- 青丸：グループ1の単語（Positiveが期待結果）です
- 赤丸：グループ2の単語（Negativeが期待結果）です
- オレンジ色の枠：分類に使う単語です

正しく分類されていれば、オレンジの枠の単語を中心に、青色の丸と赤色の丸が2つのグループに分かれます。グループの間にははっきりとした層が出ます。

「Positive」「Negative」の分類の可視化結果は以下のようになりました。

### Embedding 3 large

> 正答率：100%
誤答した単語は一つもありませんでした

![openai-embedding-v3-Positive-Negative.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/673cb701-40ae-59bb-ccaa-14e161564e93.png)

### Cohere Embed - Multilingual

> 正答率：95%
誤答した単語：「いいえ」

![cohere-Positive-Negative-clustering.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/46737531-bd52-f35d-52f3-7109580b276c.png)

### Embedding Ada 002

> 正答率：90%
誤答した単語：「いいえ」「変えてください」
備考：分類単語の距離が近く、可視化できる次元数まで削減しきれていないように見えます

![openai-embedding-ada-Positive-Negative.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/af436d32-ccba-9281-4285-abdd607bc87c.png)

### Titan Text Embeddings

> 正答率：60%
誤答した単語：「そうしましょう」「はい」「よろしくお願いします」「肯定的」「進めてください」「いいえ」「嬉しくない」「望ましくない」
備考：英語と日本語のクラスタが分かれていて、正しく分類できていないように見えます

![Positive-Negative-res.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/ca076355-b8ca-ef28-70f0-3d4ce76075a9.png)

## 検証結果：分類単語を日本語に変更

分類に使う単語を日本語に変更した結果は、以下の通りになりました。

### Embedding 3 large

> 正答率：95%
誤答した単語：「進めてください」

![openai-embedding-v3-ありがたい-望ましくない.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/a6efb061-cb90-a3f5-74f7-9ed031eb6866.png)

### Cohere Embed - Multilingual

> 正答率：90%
誤答した単語：「いいえ」「変えてください」

![cohere-素敵です-嬉しくない-clustering.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/8b0335a1-68bc-a6ea-8187-11948b2f7aac.png)

### Titan Text Embeddings

> 正答率：75%
誤答した単語：「そうしましょう」「はい」「いいえ」「変えてください」「残念」
備考：日本語で分類することで、結果が改善しました

![よろしくお願いします-よくない-res.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/232e1f40-2775-1e19-3a04-cd59767d0dc9.png)

## 結果

検証の結果から分かったことを列挙します。

- **Embedding 3、Cohereは性能が高く、万能**

性能差は大きく、この2つのモデルはどの条件でも高い性能を出しました。

- **Embedding 3とCohereの傾向は似ている**

グラフを良く見ると、Embedding 3とCohereは似ていることが分かります。

|Embedding 3|Cohere|
|---|---|
|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/673cb701-40ae-59bb-ccaa-14e161564e93.png"></img>|<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/46737531-bd52-f35d-52f3-7109580b276c.png"></img>|

素晴らしい、素敵ですは「Positive」の近くに集まってクラスタを作っていて、嬉しくない、良くないは「Negative」の近くに集まってクラスタを作っています。図の下のほうでは、よろしくお願いします、そうしましょうが3つ目のクラスタを作っています。

基本的な点の配置、類似だと判定する傾向は似ています。

- **Titan Embeddingは得意不得意の傾向が強い**

Embedding 3、Cohereはどの条件でも精度が出ましたが、Titan Embeddingは得意不得意が見られました。
得意不得意がはっきりしているため、精度を出しやすいキーワード、精度の落ちるキーワードがあります。

## おまけ

Cohere Embed - Multilingualは、引数として利用目的を指定することができます。
引数を変えて、それぞれベクトルを可視化してみます。

Clusteringを指定すると以下のようになりました。

![cohere-Positive-Negative-clustering.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/4e08c09b-e96d-3e05-21d9-67374f2589c3.png)

Classificationを指定すると以下のようになりました。

![cohere-Positive-Negative-classification.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/ff30e7a0-02ae-8301-d41b-f6a36a473dda.png)

Search Queryを指定すると以下のようになりました。

![cohere-Positive-Negative-search-query.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/233180/6538bad6-e2ff-f9de-3dc6-dfb31df5b1f8.png)

一緒に見えます。単語だと影響がないのかもしれないです。