理系学生日記

おまえはいつまで学生気分なのか

OpenAI(GPT)のEmbeddingsを利用して原始的なChatBotを作る

会社の中の雑談で、Embeddings APIを使ってChatBot作れるよね、という話をしていました。 僕はこのあたり初心者なのですが、まずは動かすもの作ったらイメージが沸くだろうと、とりあえず実装してみます。

Embeddingsとは何か

Embeddingsとは、単語や文章をある種のベクトルに変換することを指します。例えば2つの文章を表現する2つのベクトルを生成すると、その間の類似度がコサイン類似度によって求められます。 このように、扱いづらい自然言語をベクトルで表現することによって、さまざまなことが可能になります。OpenAIの公式ページでは、以下のような応用例が示されています。

  • 検索
  • クラスタリング
  • 推薦
  • 異常検知
  • 多様性の測定
  • 分類

Embeddings API

ここでは、Embeddings APIを実際に利用してみましょう。 OpenAIの提供するAPIはシンプルであるため、curlで容易に呼び出せます。ここでは、結果となるベクトルの先頭5つを抽出してみます。

$ curl -s https://api.openai.com/v1/embeddings \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input": "今日はいい天気ですね",
    "model": "text-embedding-ada-002"
  }' | jq '.data[].embedding | limit(5;.[])'
0.0022655425
0.0025153372
-0.0050211884
-0.02542214
0.008973636

当たり前ですが、上記のように文章がベクトルに変換できていることがわかります。 ベクトルの次元は1536です。

$ curl https://api.openai.com/v1/embeddings -H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" -d '{
    "input": "今日はいい天気ですね",
    "model": "text-embedding-ada-002"
  }' | jq '.data[].embedding | length'
1536

コスト

OpenAIの他のAPIと同様に、Embeddingsのコストについても以下の2つの要素で決まります。

  • 利用するモデル
  • 入出力トークン数

6/24現在、ほぼ全てのユースケースに対してモデルtext-embedding-ada-002が推奨されています。このモデルで定義されるコストは$0.0001/1K Tokenです。

入力トークン数

text-embedding-ada-002の最大の入力トークン数は8,191となっています。 日本語にすると、およそ8,000文字程度になるでしょうか。長大な文章をベクトル化しようとすると、分割せざるを得ないようです。

ChatBotを作ってみる

では、このEmbeddings APIを用いて、ChatBotを作ってみましょう。

ChatBotの作り方は以下の公式チュートリアルに載っていますが、少し凝った内容になっています。この記事ではもう少しシンプルにすることで、方法論のところを示したいです。

実行の様子

まず最初に、実行結果を示します。

ChatBotは自分の認識している情報を文章として保持しており、 与えられた質問に対する類似度を文章ごとに判断しています。 ここではコサイン類似度を用いています。

Chatbotが認識している文章のうち、類似している文章と質問をGPTに渡すことで、その回答を得ると言うのが基本的な動きです。

$ rye run python src/embeddings/main.py "明後日の天気を教えて"
    類似度:0.102131, テキスト:明後日は台風のようです
    類似度:0.133388, テキスト:明日は雨です。土砂降りですね。
    類似度:0.163842, テキスト:今日はいい天気です
    類似度:0.203095, テキスト:昨日は雪でした。寒かったですね。
    類似度:0.244429, テキスト:私はkiririmodeです
質問:
    明後日の天気を教えて
回答:
    明後日は台風が接近しているため、大雨や強風が予想されます。外出の際はくれぐれもお気をつけください。

$ rye run python src/embeddings/main.py "明日の天気を予報してくれると嬉しいな〜〜〜〜"
    類似度:0.116179, テキスト:明日は雨です。土砂降りですね。
    類似度:0.130837, テキスト:明後日は台風のようです
    類似度:0.134058, テキスト:今日はいい天気です
    類似度:0.192421, テキスト:昨日は雪でした。寒かったですね。
    類似度:0.247638, テキスト:私はkiririmodeです
質問:
    明日の天気を予報してくれると嬉しいな〜〜〜〜
回答:
    明日は雨の予報です。傘を持って出かけることをおすすめします。

GPTに渡すコンテキストの作成

コンテキストは以下のメソッドで作成しています。

まず最初に、Chatbotが保持している文章ごとにEmbeddings APIを呼び出し、1,536次元のベクトルを得ます。同様に質問に対応するベクトルも作成した上で、質問と保持文章それぞれのコサイン類似度を計算します。

コサイン類似度の高い文章が、質問に対する回答を得るための有力な情報源です。このため、これをコンテキストとして抽出しています。

もちろん長大なコンテキストをGPTに渡せれば良いのですが、前述の通りコンテキスト長にはトークン長の制限があるため、類似度の高いものを優先する必要があります。トークン長を得るためにはtiktokenを使って計算する必要があるのですが、そこは今回割愛しています。実装自体はあまり難しくはありません。

def generate_context_from_texts(df, question, engine):
    """
    このメソッドは、テキストと質問から、GPTに提供するコンテキストを生成します。
    df: 各テキストを含むDataFrame
    question: 質問
    engine: 使用するEmbedding用エンジン
    """
    # 各テキストについてEmbeddingsを生成します
    df["embeddings"] = np.array(
        df.text.apply(
            lambda x: openai.Embedding.create(input=x, engine=engine)["data"][0][
                "embedding"
            ]
        )
    )

    # 質問に対応するベクトルを生成します
    q_embeddings = openai.Embedding.create(input=question, engine=engine)["data"][0][
        "embedding"
    ]

    # 各テキストのエンコーディングと質問のエンコーディング間の距離を計算します
    df["distances"] = distances_from_embeddings(
        q_embeddings, df["embeddings"].values, "cosine"
    )

    # 質問からの距離に基づいてテキストをソートします
    sorted_texts = []

    for i, row in df.sort_values("distances", ascending=True).iterrows():
        print("\t類似度:{:f}, テキスト:{}".format(row["distances"], row["text"]))
        # TODO: 本来は、ここでトークン長を制限しなければならない
        sorted_texts.append(row["text"])

    # ソートされたテキストを結合します
    return "\n".join(sorted_texts)

回答を得る

コンテキストさえ作成できれば、あとはChatCompletion APIを呼び出すだけです。 この辺りは初めてのGPTのAPI(OpenAI API):コストの理解とTypeScriptでの呼び出し - 理系学生日記と同じですね。

def generate_response(model, context, question):
    """
    このメソッドは、指定されたモデルを使用して、質問に対する応答を生成します。
    model: 使用するOpenAIのモデル
    context: モデルに供給するコンテキスト
    question: モデルに供給する質問
    """
    return openai.ChatCompletion.create(
        model=model,
        messages=[
            {
                "role": "system",
                "content": "あなたは天気予報をするキャスターです。以下の街の声のみを基にして、あなたの予測としての、信頼性のある天気予報を提供してください。ただし、晴れなのか雨なのか曇りなのか、といったことのみをシンプルに示してください。気温等の情報は不要です"
                + context,
            },
            {"role": "user", "content": question},
        ],
        temperature=0,
    )

実装ソース全文

import pandas as pd
import openai
import numpy as np
from openai.embeddings_utils import distances_from_embeddings
import sys

EMBEDDING_ENGINE = "text-embedding-ada-002"
CHAT_ENGINE = "gpt-3.5-turbo"


def generate_context_from_texts(df, question, engine):
    """
    このメソッドは、テキストと質問から、GPTに提供するコンテキストを生成します。
    df: 各テキストを含むDataFrame
    question: 質問
    engine: 使用するEmbedding用エンジン
    """
    # 各テキストについて埋め込みを生成します
    df["embeddings"] = np.array(
        df.text.apply(
            lambda x: openai.Embedding.create(input=x, engine=engine)["data"][0][
                "embedding"
            ]
        )
    )

    # 質問に対応するベクトルを生成します
    q_embeddings = openai.Embedding.create(input=question, engine=engine)["data"][0][
        "embedding"
    ]

    # 各テキストのエンコーディングと質問のエンコーディング間の距離を計算します
    df["distances"] = distances_from_embeddings(
        q_embeddings, df["embeddings"].values, "cosine"
    )

    # 質問からの距離に基づいてテキストをソートします
    sorted_texts = []

    for i, row in df.sort_values("distances", ascending=True).iterrows():
        print("\t類似度:{:f}, テキスト:{}".format(row["distances"], row["text"]))
        # TODO: 本来は、ここでトークン長を制限しなければならない
        sorted_texts.append(row["text"])

    # ソートされたテキストを結合します
    return "\n".join(sorted_texts)


def generate_response(model, context, question):
    """
    このメソッドは、指定されたモデルを使用して、質問に対する応答を生成します。
    model: 使用するOpenAIのモデル
    context: モデルに供給するコンテキスト
    question: モデルに供給する質問
    """
    return openai.ChatCompletion.create(
        model=model,
        messages=[
            {
                "role": "system",
                "content": "あなたは天気予報をするキャスターです。以下の街の声のみを基にして、あなたの予測としての、信頼性のある天気予報を提供してください。ただし、晴れなのか雨なのか曇りなのか、といったことのみをシンプルに示してください。気温等の情報は不要です"
                + context,
            },
            {"role": "user", "content": question},
        ],
        temperature=0,
    )


# テキストのリストを辞書形式で定義します
texts = {
    "text": [
        "昨日は雪でした。寒かったですね。",
        "今日はいい天気です",
        "明日は雨です。土砂降りですね。",
        "明後日は台風のようです",
        "私はkiririmodeです",
    ],
}
df = pd.DataFrame.from_dict(texts)

question = sys.argv[1]
context = generate_context_from_texts(df, question, EMBEDDING_ENGINE)
response = generate_response(CHAT_ENGINE, context, question)

print("質問:\n\t{}\n回答:\n\t{}".format(question, response.choices[0].message.content))