1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-04-07

この記事について

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

  • 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

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

英語で日本語を検索(60%正答) 日本語で日本語を検索(75%正答)

検証したこと

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

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

この「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

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

APIGateway+Lambdaで埋め込みモデルの分類を実行するソース(クリックで開く)
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}),
    }

検証手順

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

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

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

doc-embed-3.png

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

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

可視化の手順

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

doc-embed-4.png

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

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

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

検証結果:正答率

まず、グループ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

Cohere Embed - Multilingual

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

cohere-Positive-Negative-clustering.png

Embedding Ada 002

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

openai-embedding-ada-Positive-Negative.png

Titan Text Embeddings

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

Positive-Negative-res.png

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

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

Embedding 3 large

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

openai-embedding-v3-ありがたい-望ましくない.png

Cohere Embed - Multilingual

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

cohere-素敵です-嬉しくない-clustering.png

Titan Text Embeddings

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

よろしくお願いします-よくない-res.png

結果

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

  • Embedding 3、Cohereは性能が高く、万能

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

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

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

Embedding 3 Cohere

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

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

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

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

おまけ

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

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

cohere-Positive-Negative-clustering.png

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

cohere-Positive-Negative-classification.png

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

cohere-Positive-Negative-search-query.png

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

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?