OpenAIのAssistants APIでRAG(検索拡張生成)を実装してみた

[Supership](https://supership.jp/)の名畑です。[ドラゴンボール](https://dragon-ball-official.com/)も[ちびまる子ちゃん](https://chibimaru.tv/)も子どもの頃からずっとそばにいてくれました。

## はじめに

**Retrieval-Augmented Generation (RAG)** という技術があります。

> Retrieval-Augmented Generation (RAG) は、大規模言語モデル（LLM）によるテキスト生成に、外部情報の検索を組み合わせることで、回答精度を向上させる技術のこと。
> 
> 「検索拡張生成」、「取得拡張生成」などと訳されます。外部情報の検索を組み合わせることで、大規模言語モデル（LLM）の出力結果を簡単に最新の情報に更新できるようになる効果や、出力結果の根拠が明確になり、事実に基づかない情報を生成する現象（ハルシネーション）を抑制する効果などが期待されています。
>
> 参考：[RAG | 用語解説 | 野村総合研究所(NRI)](https://www.nri.com/jp/knowledge/glossary/lst/alphabet/rag)

[OpenAI](https://openai.com/)の[Assistants API](https://platform.openai.com/docs/assistants/overview)を用いて**RAG**を実装しようというのが今回の記事となります。
あらかじめ用意しておいた資料（テキストファイル）に書かれた内容を元にした回答を**GPT4**にしてもらいます。

[GPTs](https://openai.com/blog/introducing-gpts)と同じことを**Assistants API**経由で行ったとも言えます。**Assistants API**も**GPTs**も同一タイミング(2023年11月)でリリースされたものです。

**GPTs**について詳しくは過去記事「[ChatGPTの新機能であるGPTsを使って私のコピーを作ってみた](https://qiita.com/nabata/items/44550f673d6f973b6dc4)」をご覧ください。

## 注意

**Assistants API**をはじめとして、今回使用する**API**の多くはまだ**Beta**扱いとなります。

## 料金

[Pricing](https://openai.com/pricing)のページをご覧いただければと思いますが、モデル利用に加えて**Code interpreter**と**Retrieval**の料金がかかります。

## 私の環境

OSは[macOS 14 Sonoma](https://www.apple.com/jp/macos/sonoma/)です。

今回の使用言語は**Python**です。
バージョンは**3.12.2**です。

```shell-session
$ python --version
Python 3.12.2
```

**openai**のライブラリもインストール済みです。

```shell-session
$ openai --version
openai 1.13.3
```

入っていない人は**install**しておきましょう。

```shell-session
$ pip install openai
```

API Keyは[OpenAIのAPI keysのページ](https://platform.openai.com/api-keys)で発行済みで、**OPENAI\_API\_KEY**という環境変数名で保存してあるとします。

```shell-session
export OPENAI_API_KEY=ここに取得したAPI Keyを書く
```

## テキストファイルの準備

今回はお手製のテキストファイルを**RAG**におけるデータソースとします。

下記を**company.txt**というファイル名で保存しておきます。

```text
会社名：株式会社あいうえおほげ
社員数：65536人
事業内容：アニメと漫画についての話し相手になること
所在：東京都港区
社長：漫画好木
社風：静か
休日：水曜
```

## import

ここからがPythonのコードとなります。

まずは必要なモジュールの**import**を行います。

```python
from openai import OpenAI
import os
```

## OpenAIのインスタンス生成

**OpenAI**のインスタンス生成を行います。

```python
client = OpenAI()
client.api_key = os.environ['OPENAI_API_KEY']  # 環境変数から取得
```

## ファイルのアップロード

用意しておいた**company.txt**をOpenAIのサーバ上に[Files API](https://platform.openai.com/docs/api-reference/files)でアップロードします。

```python
my_file = client.files.create(
  file=open("company.txt", "rb"),
  purpose="assistants"
)
file_id = my_file.id
```

**purpose**には**assistants**を指定しています。

次に使うため、レスポンスの**id**を保持しています。

アップロードしたファイルはOpenAIのサーバ上に保存されますので、使い回し可能です。

## アシスタントの生成

[Assistants API](https://platform.openai.com/docs/api-reference/assistants)を用いて**アシスタント**を生成します。**file_ids**として先ほどファイルアップロード時に保持していた**id**を指定します。今回は一つですが、複数指定可能です。

```python
my_assistant = client.beta.assistants.create(
    instructions="添付されたファイルは日本語のテキスト形式です。このファイルを開いて質問に答えてください。",
    name="secretary",
    model="gpt-4-turbo-preview",
    tools=[{"type": "code_interpreter"}],
    file_ids=[file_id]
)
assistant_id = my_assistant.id
```

**model**はここでは**gpt-4-turbo-preview**としました。以下の記載があったためです。

> The Retrieval tool requires at least gpt-3.5-turbo-1106 (newer versions are supported) or gpt-4-turbo-preview models.

**instructions**や**name**はオプションですが書いておきました。「**添付されたファイルは日本語のテキスト形式です。このファイルを開いて質問に答えてください。**」という指示を与えてあります。

以降で使うため、生成されたアシスタントの**id**を保持しておきます。

生成したアシスタントはファイル同様にOpenAIのサーバ上に保存されますので、使い回し可能です。

## スレッドの生成

**アシスタント**との一連の流れを処理するために[Threads API](https://platform.openai.com/docs/api-reference/threads)を用いて**スレッド**を生成します。

スレッドに対してアシスタントとのやりとりを紐づけ、連続したものとして扱うことができます。

```python
thread = client.beta.threads.create()
thread_id = thread.id
```

やはり**id**を保持しておきます。

生成したスレッドも使い回し可能です。

## スレッドに紐づけたメッセージの生成

[Messages API](https://platform.openai.com/docs/api-reference/messages)を用いて**メッセージ**を生成します。この際、生成済みのスレッドを紐づけています。こうすることでやり取りが保持されます。

```python
message = client.beta.threads.messages.create(
    thread_id= thread_id,
    role="user",
    content="会社名を教えてください。"
)
```

今回は「会社名を教えてください。」と聞いています。

## スレッドの実行

[Runs API](https://platform.openai.com/docs/api-reference/runs/createRun)を用いてスレッド上で処理を投げます。保持しておいたアシスタントとスレッドの**id**を指定します。

**status**が**completed**になるのを待ち、**messages**から結果を取得します。今回のコードでは**queued**、**in_progress**、**completed**の3つのみを判定材料としていますが、他にもあります。

```python
run = client.beta.threads.runs.create(
  thread_id=thread_id,
  assistant_id=assistant_id,
)
run_id = run.id

while run.status in ["queued", "in_progress", "completed"]:
    run = client.beta.threads.runs.retrieve(
        thread_id=thread_id,
        run_id=run_id
    )
    if run.status == "completed":
        messages = client.beta.threads.messages.list(
            thread_id=thread_id
        )
        print(messages.data[0].content[0].text.value)
        break
```

出力結果は以下でした。

> 会社名は「株式会社あいうえおほげ」です。

無事にファイルの内容が取得できています。

ここまでで目的の実装は完了となります。

## ファイル等の削除

実務では使い回すことの方が多いかと思いますが、今回はファイルやインスタンスを削除しておくことにします。

```python
print(client.files.delete(file_id=file_id))  # ファイルの削除
print(client.beta.assistants.delete(assistant_id))  # アシスタントの削除
print(client.beta.threads.delete(thread_id=thread_id))  # スレッドの削除
```

以下が出力されましたので、きちんと削除されていそうです。

> FileDeleted(id='file-hogehoge', deleted=True, object='file')
> AssistantDeleted(id='asst\_hogehoge', deleted=True, object='assistant.deleted')
> ThreadDeleted(id='thread\_hogehoge', deleted=True, object='thread.deleted')

余談ですが、**files**と**assistants**はlist取得のAPIが見つかるのですが、**threads**はありませんでした。[OpenAI Developer Forum](https://community.openai.com/)でも[List and delete all threads](https://community.openai.com/t/list-and-delete-all-threads/505823)などの議論が行われているのが見つかります。

## 今回のコードをまとめると

今回のコードをまとめると以下になります。

```python
from openai import OpenAI
import os
client = OpenAI()
client.api_key = os.environ['OPENAI_API_KEY']  # 環境変数から取得

# ファイルのアップロード
my_file = client.files.create(
  file=open("company.txt", "rb"),
  purpose="assistants"
)
file_id = my_file.id

# アシスタントの生成
my_assistant = client.beta.assistants.create(
    instructions="添付されたファイルは日本語のテキスト形式です。このファイルを開いて質問に答えてください。",
    name="secretary",
    model="gpt-4-turbo-preview",
    tools=[{"type": "code_interpreter"}],
    file_ids=[file_id]
)
assistant_id = my_assistant.id

# スレッドの生成
thread = client.beta.threads.create()
thread_id = thread.id

# スレッドに紐づけたメッセージの生成
message = client.beta.threads.messages.create(
    thread_id= thread_id,
    role="user",
    content="会社名を教えてください。"
)

# スレッドの実行
run = client.beta.threads.runs.create(
  thread_id=thread_id,
  assistant_id=assistant_id,
)
run_id = run.id

while run.status in ["queued", "in_progress", "completed"]:
    run = client.beta.threads.runs.retrieve(
        thread_id=thread_id,
        run_id=run_id
    )
    if run.status == "completed":
        messages = client.beta.threads.messages.list(
            thread_id=thread_id
        )
        print(messages.data[0].content[0].text.value)
        break

# ファイル等の削除
print(client.files.delete(file_id=file_id))  # ファイルの削除
print(client.beta.assistants.delete(assistant_id))  # アシスタントの削除
print(client.beta.threads.delete(thread_id=thread_id))  # スレッドの削除
```

## おまけ 複数指示の実施

[メッセージの生成](#スレッドに紐づけたメッセージの生成)と[スレッドの実行](#スレッドの実行)を書き換えて、配列に格納した3つの指示を順番に投げてみます。

- 会社の休日を教えてください
- 社員数を教えてください
- 私が社員数を聞く前にした質問を教えてください

3つ目は一連の流れが保持されているかを確認できるような質問にしてあります。

```python
my_instructions = [
    "会社の休日を教えてください",
    "社員数を教えてください",
    "私が社員数を聞く前にした質問を教えてください"
]

for instruction in my_instructions:
    message = client.beta.threads.messages.create(
        thread_id=thread.id,
        role="user",
        content=instruction
    )

    run = client.beta.threads.runs.create(
      thread_id=thread_id,
      assistant_id=assistant_id,
    )
    run_id = run.id

    while run.status in ["queued", "in_progress", "completed"]:
        run = client.beta.threads.runs.retrieve(
            thread_id=thread_id,
            run_id=run_id
        )
        if run.status == "completed":
            messages = client.beta.threads.messages.list(
                thread_id=thread_id
            )
            print(messages.data[0].content[0].text.value)
            break
```

出力は以下でした。

> 会社の休日は水曜日です。
> 社員数は65536人です。
> あなたが社員数を聞く前にした質問は「会社の休日を教えてください」でした。

やりとりがきちんと保持されていそうです。

## Playground

[Playground](https://platform.openai.com/playground?mode=assistant)というものが用意されており、ブラウザ上で動作を見ることも可能です。アップロードしたファイルや生成したアシスタントの一覧もブラウザ上で確認可能です。

## 最後に

まだ**Beta**ではあるものの、かなり便利ですね。

## 宣伝

[SupershipのQiita Organization](https://qiita.com/organizations/supership-inc)を合わせてご覧いただけますと嬉しいです。他のメンバーの記事も多数あります。

Supershipではプロダクト開発やサービス開発に関わる方を絶賛募集しております。
興味がある方は[Supership株式会社 採用サイト](https://supership.jp/recruit/)よりご確認ください。