ChatGPT: Function Callingとベクトル検索を組み合わせて、独自データを利用した回答を生成する


GPTのAPIのアップデートによりFunction Callingという機能が追加されました。今回は、自社データを利用するシステムを想定して、ベクトルデータ検索と組み合わせて、Function Callingを使ってみます。

![diagramGPTfunction.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2661777/5a3b63f2-5843-d318-a9cf-2e49220db798.png)

以下の会話例のように、学習データにない質問への回答を検索したデータに基づいて行います。

```
[会話例]
ユーザー：
スティービーの武道館公演の日程と回数を教えてください。

AI：
スティービー・ワンダーの日本武道館での公演は以下の通りです：

- 1975年：1月29日、30日
- 1981年：3月31日、4月1日
- 1982年：11月4日、5日
- 1988年：4月25日、26日、27日
- 1995年：2月21日、22日
- 1996年：9月18日、19日、20日

以上の情報から、スティーヴィー・ワンダーは日本武道館で合計11回の公演を行っています。 
```

# Embeddingsを準備する
https://qiita.com/kuromiya123/items/01c2807288c055f017b9

:::note warn
上記の記事内で作成した、[Wikipedia(スティーヴィー・ワンダー）](https://ja.wikipedia.org/wiki/%E3%82%B9%E3%83%86%E3%82%A3%E3%83%BC%E3%83%B4%E3%82%A3%E3%83%BC%E3%83%BB%E3%83%AF%E3%83%B3%E3%83%80%E3%83%BC)を元にしたベクトルデータベース使用し、またそのデータベースへのクエリを行う関数をfunction callingから呼び出します。本ページに記載のコードだけでは動作しません。
:::

```Python
### query_collectionは、chromaを検索して、queryと近い文章を探し出し、テキストと関連度を返却する関数
# OpenAIのAPIを使用したEmbeddingの作成から、Chromaへの保存まで以下の記事に記載している
# https://qiita.com/kuromiya123/items/01c2807288c055f017b9

def query_collection(
    query: str,
    collection: chromadb.api.models.Collection.Collection, 
    max_results: int = 100)-> tuple[list[str], list[float]]:
    results = collection.query(query_texts=query, n_results=max_results, include=['documents', 'distances'])
    strings = results['documents'][0]
    relatednesses = [1 - x for x in results['distances'][0]]
    return strings, relatednesses

strings, relatednesses = query_collection(
    collection=stevie_collection,
    query="スティーヴィーは日本武道館で何回公演している？",
    max_results=3,
)

for string, relatedness in zip(strings, relatednesses):
    print(f"{relatedness=:.3f}")
    display(string)
```
この関数の実行結果は以下のようになります。ユーザーの`スティーヴィーは日本武道館で何回公演している？`という質問に対して、関連度の高い順にテキストを返却しています。
![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f323636313737372f30616334653635302d376466642d623539302d653363612d3565663462303535353665312e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2661777/4d0c1567-1ca7-88dd-c898-03111e6df204.png)

それでは、このようにベクトル検索を行う関数をGPTにfunctionとして渡して実行してみましょう。

# Function Callingを使って、ベクトルデータベースを検索する
全体のコードは以下の通りです。OpenAIのドキュメントに掲載されているサンプルコードと、npaka氏のnoteを元に改変しています。

次の項で流れを追って、解説をします。

https://note.com/npaka/n/n917463f55b8a

https://platform.openai.com/docs/guides/gpt/function-calling


```Python
import tiktoken
import json

def num_tokens(text: str, model: str = "gpt-3.5-turbo") -> int:
    encoding = tiktoken.encoding_for_model(model)
    return len(encoding.encode(text))

def run_conversation(question):
    # STEP1: モデルにユーザー入力と関数の情報を送る
    first_response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo-0613",
        messages=[{"role": "user", "content": question}],
        functions=[
            {
                "name": "query_collection",
                "description": "スティーヴィー・ワンダーに関する記事をベクターストアから検索する",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "query": {
                            "type": "string",
                            "description": "ユーザーの質問文から知りたいと考えている内容を補完してテキストにする",
                        },
                         "collection": {
                            "type": "string",
                            "description": "検索対象のchromaコレクションを指定"},
                    },
                    "required": ["query"],
                },
            }
        ],
        function_call="auto",
    )
    message = first_response["choices"][0]["message"]
    print("message>>\n", message, "\n\n")

    # STEP2: モデルが関数を呼び出したいかどうかを確認
    if message.get("function_call"):
        function_name = message["function_call"]["name"] #呼び出す関数名
        arguments = json.loads(message["function_call"]["arguments"]) # その時の引数dict

        # STEP3: 関数を呼び出す
        query=arguments.get("query")
        strings, relatedness = query_collection(question, collection=stevie_collection, max_results=5)

        token_budget=4096-500
        text = "" #token budgetまでレスポンスを切り詰める
        for string in strings:
          next_article = f'{string}\n---\n'
          if (
            num_tokens(text + next_article)
            > token_budget
            ):
            break
          else:
            text += next_article

        print(f"APIが生成した引数：{query}")
        print(f"データベースの検索結果：{text}")

        # STEP4: モデルにユーザー入力と関数の実行結果を送る
        second_response = openai.ChatCompletion.create(
            model="gpt-4-0613",
            messages=[
                {"role": "user", "content": question},
                message,
                {
                    "role": "function",
                    "name": function_name,
                    "content": text,
                },
            ],
            temperature=0,
        )
        return second_response["choices"][0]["message"]["content"]
    else:
        #関数を呼び出さなかった場合、ファーストレンポンスの内容をそのまま戻す
        return first_response["choices"][0]["message"]["content"]
```

# 実行
それでは、質問に対する答えが生成されるまでの流れを追っていきましょう。

```Python
question = "スティービーの武道館公演の日程と回数を教えてください。"
print("response>>\n", run_conversation(question))
```
## 1回目のAPI呼び出し
１回目のAPIコールでユーザーの入力と関数の情報を渡します。

```Python
    first_response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo-0613",
        messages=[{"role": "user", "content": question}],
        functions=[
            {
                "name": "query_collection",
                "description": "スティーヴィー・ワンダーに関する記事をベクターストアから検索する",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "query": {
                            "type": "string",
                            "description": "ユーザーの質問文から知りたいと考えている内容を補完してテキストにする",
                        },
                         "collection": {
                            "type": "string",
                            "description": "検索対象のchromaコレクションを指定"},
                    },
                    "required": ["query"],
                },
            }
        ],
        function_call="auto",
    )
```

１回目のAPIコールで、GPTは質問に対し与えられたfunctionを使用するかどうか、また使用する場合、どのような引数を設定するかを決めます。

今回は、`スティービーの武道館公演の日程と回数を教えてください。`というユーザーの質問に対して、functionを使用して回答を生成することを決定。そして、引数であるqueryの値を生成します。以下のようになりました。

```
APIが生成した引数：スティービー・ワンダー 武道館 公演 日程
```

次に、この値を使って、関数を呼び出します。

```python
        query=arguments.get("query")
        strings, relatedness = query_collection(question, collection=stevie_collection, max_results=5)

        token_budget=4096-500
        text = "" #token budgetまでレスポンスを切り詰める
        for string in strings:
          next_article = f'{string}\n---\n'
          if (
            num_tokens(text + next_article)
            > token_budget
            ):
            break
          else:
            text += next_article
```

データベースからは以下の結果が得られました。処理できるテキストの限界量は、token_budgetにあるため、バジェットいっぱいまで変数textに入れています。

```
データベースの検索結果：日本公演 
~~~~~
 1975年
 1月29日、30日 日本武道館、31日、2月3日 大阪厚生年金会館、2月1日 静岡駿府会館
 1981年
 3月31日,4月1日 日本武道館
 1982年
~~~~
```
## 2回目のAPI呼び出し
ユーザーからの質問と、データベースの検索結果を与えて、２回目のAPIコールを行います。
```Python
        second_response = openai.ChatCompletion.create(
            model="gpt-4-0613",
            messages=[
                {"role": "user", "content": question},
                message,
                {
                    "role": "function",
                    "name": function_name,
                    "content": text,
                },
            ],
            temperature=0,
        )
        return second_response["choices"][0]["message"]["content"]
```

もし、GPTが1回目のAPIコールでfunctionを使わないと判断した場合、1回目のレスポンスに含まれる、`first_response["choices"][0]["message"]["content"]`が最終的なGPTの返答になります。2回目のAPIコールは行われません。

# 結果の確認

最終的に以下のような結果が得られました。

```
スティービー・ワンダーの日本武道館での公演は以下の通りです：

- 1975年：1月29日、30日
- 1981年：3月31日、4月1日
- 1982年：11月4日、5日
- 1988年：4月25日、26日、27日
- 1995年：2月21日、22日
- 1996年：9月18日、19日、20日

以上の情報から、スティービー・ワンダーは日本武道館で合計11回の公演を行っています。
```
ん、11回？

# 何が良いのか
LangchainではLLMを使った処理を行うtoolを用意して、toolを実行するかどうかをまたLLMが判断していました。そのようなライブラリで実装していたようなことがオフィシャルで実装されて、より安定して動くようになったという印象です。LLMによる処理を多段階で重ねて、結果を正規表現で抜き出し、次の処理に繋げて・・・ということをしていくと、どこかで意図しない回答が発生し、全体の処理が不安定化するのは避けられませんでした。今回のアップデートでオフィシャルに処理を連結できるようになったのはとても大きな変化だと思います。

また、embeddingで独自データを利用する手法では、これまでIn-Context Learningと呼ばれるように検索したテキストを最終的にはプロンプトに詰め込んでLLMに渡していました。今回のアップデートで、role:userによる発言ではなく、role:functionによる発言(?)として、messageのリストに入れることができるようになりました。ユーザーの発言とシステム上も区別できるという点から、こちらの方が無理矢理感が薄くていいですね。

```Python
            messages=[
                {"role": "user", "content": question},
                message,
                {
                    "role": "function",
                    "name": function_name,
                    "content": text,
                },
            ],
```

functionを使うかどうかをAPIが判断してくれるという点も効いています。スティーヴィー・ワンダーについての質問にとどまらず、ユーザーが他のアーティストについて問い合わせたり、世間話をしても、返答につまらず答えてくれるでしょう。（ノーマルのGPTとして）

```
ユーザー：スポンジボブに登場するクラリネットの名手の名前は？
AI： スポンジボブに登場するクラリネットの名手の名前はスクイッドワード・テンタクルズです。

※ 日本語版での役名はイカルド、吹替は納谷六朗さんでした。余談。
```