ChatGPT APIとFaissを使って長い文章から質問応答する仕組みを作ってみる

こんにちは！逆瀬川( https://twitter.com/gyakuse )です！
今日はさいきんよく質問されるGPT-3の事実ベースのQAについて書いていこうと思います。

## したいこと

長めの文章ファイルを対象に質問を投げかけ、適切に回答してくれる仕組みを作る

## うれしみ

今回の手法を用いると、ファクトに基づいた回答ができるので、以下のような分野に応用が可能です。

- QAデータをもっている企業における質問応答チャットボットの構築
- 企業/事業ごとの専門知識をもったチャットボットの構築
- 教科書等を読み込ませた家庭教師的なチャットボットの構築
- 論文等の各種文献の読解補佐チャットボットの構築
- AITuberやAIキャラクターに長期記憶を持たせる
- BingGPTなどのようなシステムの構築

## どのように実現するか考える

### 大量の文章ファイルや長文を対象にQAする難しさについて

単純な質問-応答は以下のようなプロンプトで達成可能です。

```text
{参照する文章}

上記の文章を参考に{質問}に回答してください。

回答:
```

一方で、大量の文章ファイルや長文を対象とした場合、GPT-3.5のトークン制限によって問題が難しくなります(詳細はAppendix::GPT-3.5等におけるトークン制限についてをお読みください)。上記のようなQAを行う場合、参照する文章には日本語の場合、2500文字程度に収めなければなりません。

### どうすれバインダー

基本的なアプローチとしては以下が考えられます。

- 要約QA
    - 手法について
        - 事前に要約し、要約した文章を渡す手法
        - 文章要約は非常に重要なタスクです。たとえば、5000文字->500文字の要約を行えば、25,000文字の文章までQAを行うことができます。
    - うれしみ
        - 要約とはいえ、すべて渡しているため、以下で述べるような近傍探索アプローチと比べ、探索ミスによる完全に間違った回答を防げます
    - つらみ
        - この手法ではより大規模な文章群を対象にすることはできません。
        - また、要約に対する回答になるため回答の精度が落ちてしまいます。
- チャンクQA
    - 手法について
        - 文章を2500文字ごとに分割し、質問との類似度をGPT-3.5で測り、類似度が高いものを対象にQAを行う手法
        - 質問との類似度計算もGPT-3.5に任せる手法です。
    - うれしみ
        - それぞれの分割された文章(chunk)に対して類似度を測るため、回答の正確性が期待できます。
        - また、QAの際には要約を行わないため、要約QAより回答精度が高くなる場合が考えられます
    - つらみ
        - この手法では大規模文章を対象にすることはできません。
        - また、質問毎に各chunkの類似度を測るためコストがかかり、回答までの時間も伸びます。
- 埋め込みQA
    - 手法について
        - 文章を埋め込み表現にして、QAを行う手法
        - 文章埋め込みをvector storeに格納して、質問との類似度を測り、コサイン類似度が近い文章を取り出し、QAを行う手法です。
    - うれしみ
        - 大量の文章に対して実行可能で、現代の検索やQAはこれで実装されているものが多いです(Notion検索などはこれです)
    - つらみ
        - 埋め込みの近傍探索の精度に依存します。
        - また、埋め込みを行うModelは言語への依存性が高いので多言語対応が難しいです(Notionがおそらく採用しているOpenAI Embeddingsは日本語向けではないためNotionの検索はつらいです)→ここの記述間違っている可能性があります。以前は非英語向けではないと説明されていましたが、最近のOpenAIの発言を見ると解決されているようです
            - 参考: https://twitter.com/OfficialLoganK/status/1621942366914461699

## 要約QAの実装

この手法は、1. 対象となる文章のchunk化, 2. chunk化された文章の要約, 3. 要約に対する質問, という3段階に作業に分かれます。

- chunk化
    - chunk化においては2,500文字毎に分割します。
- chunk化された文章の要約
    - 各chunkが2,500文字から500文字程度に要約されます。
- 要約に対する質問
    - 要約を統合し、これを対象に質問を投げ、回答を受け取ります。

なお、今回は段落や文章構造を見ませんが、文章構造ごとに要約したほうが精度が出るため改善点として覚えておくと良いでしょう。
今回はCC-BYライセンスで公開されている大阪市大正区の令和4年度第2回大正区区政会議議事録を対象にQAをしてみます。

https://www.city.osaka.lg.jp/taisho/page/0000582388.html

ちなみに、他のオープンライセンスな文書もあったのですが、たとえば青空文庫をもとにQAを行うとChatGPTが事前知識を持っているのでそれらの知識のない文書を対象にしました。

### 対象となる文章のchunk化

PDFをダウンロードし、2500文字毎に分割します。

```py
from PyPDF2 import PdfReader

def pdf_to_chunks(pdf_path: str) -> list:
    """
    PDFファイルを読み込んで、3000文字を超えないように
    テキストを分割し、リストに格納して返す

    Args:
        pdf_path (str): 分割するPDFファイルのパス

    Returns:
        list[str]: 2500文字を超えないように分割されたテキストが格納されたリスト
    """
    reader = PdfReader(pdf_path)
    chunks = []
    for i in range(len(reader.pages)):
        page = reader.pages[i]
        text = page.extract_text()
        if len(chunks) == 0:
            chunks.append(text)
        if len(chunks[-1] + text) > 2500:
            chunks.append(text)
        else:
            chunks[-1] += text
    return chunks

chunked_text = pdf_to_chunks("giji.pdf")
```

### chunk化された文章の要約

それぞれ要約します。ChatGPT APIを使います。

```py
import openai
openai.api_key = openai_key
def completion(new_message_text:str, settings_text:str = '', past_messages:list = []):
    """
    この関数は、OpenAIのChatGPT API(gpt-3.5-turbo)を使用して、新しいメッセージテキスト、オプションの設定テキスト、
    過去のメッセージのリストを入力として受け取り、レスポンスメッセージを生成するために使用されます。

    Args:
    new_message_text (str): モデルがレスポンスメッセージを生成するために使用する新しいメッセージテキスト。
    settings_text (str, optional): 過去のメッセージリストにシステムメッセージとして追加されるオプションの設定テキスト。デフォルトは''です。
    past_messages (list, optional): モデルがレスポンスメッセージを生成するために使用するオプションの過去のメッセージのリスト。デフォルトは[]です。

    Returns:
    tuple: レスポンスメッセージテキストと、新しいメッセージとレスポンスメッセージを追加した過去のメッセージリストを含むタプル。
    """
    if len(past_messages) == 0 and len(settings_text) != 0:
        system = {"role": "system", "content": settings_text}
        past_messages.append(system)
    new_message = {"role": "user", "content": new_message_text}
    past_messages.append(new_message)

    result = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=past_messages,
        max_tokens=512
    )
    response_message = {"role": "assistant", "content": result.choices[0].message.content}
    past_messages.append(response_message)
    response_message_text = result.choices[0].message.content
    return response_message_text, past_messages

def summarize(target_text):
    system_text = "あなたは要約システムです。与えられた文章に対して要約を行ってください。"
    summary, _ = completion(target_text, system_text, [])
    return summary

# 分割された文章をそれぞれ要約する
summarized_texts = []
for part_text in chunked_text:
    summary = summarize(part_text)
    summarized_texts.append(summary)
```


### 要約に対する質問

要約QA用の関数を実装します。こちらでもChatGPT APIを使用します。
「段階的に考えて」だけでなく、「論理的に」と入れると回答の論理性が向上しました。

```py
# 要約QA
def summary_qa(summarized_text, question):
    system_text = "あなたは参考文章をもとに質問に回答するシステムです。参考文章をもとに、段階的に考えて論理的に回答してください。"
    question_prompt = f"""## 参考文章

{summarized_text}

## 質問

{question}"""
    answer, _ = completion(question_prompt, system_text, [])
    return answer

# 要約された文章でQAする
question = "大正区役所が行っている見守りについてまとめてください"
answer = summary_qa(summarized_text_str[:2500], question)
```

この結果、「大正区役所が行っている見守りについてまとめてください」という質問に対して以下のような回答が得られました。

> 大正区役所は、要援護者の援護や介護が必要な方の見守り活動を進めている。これにより、少子高齢化や生活課題の複雑化、社会的孤立、災害不安などの課題に対応している。具体的には、要援護者の名簿を地域ごとに作成し、地域の方々が見守りを行う体制を整えている。また、福祉専用職のワーカーを配置した見守り相談室を通じて、地域の見守り体制強化事業が実施されており、要援護者の同意を得た上で、区社協に提供されている個人情報をもとに、見守り活動が行われている。さらに、災害時には、地域の人たちが自主的に活動できるように、災害時の対応方法を周知することも行っている。地域の要援護者や高齢者を見守ることが必要であるとして、区民の連帯を強めることが求められている。

要約QAは非常に有効な場面が多く、想定される質問すら生成させ、事前に回答を作っておくことで、質疑応答の短縮等も狙えます。また、本文が2500文字以内であれば要約せずこのままqa関数を適用できます。

### Colab

https://colab.research.google.com/drive/1NSuK3GIUzq2IzO9vyOC_Kp3YJWYJhHWs?usp=sharing

## chunkQAの実装

この手法では、chunk化までは要約QAと同様です。
質問ごとに動的に全chunkに対して類似度を測り、質問を行います。
この手法はコスト面で高く、5円*チャンク数かかるのでつらみも多いですがより細かい回答を行ってくれることが期待されます。

### 類似度の計算

以下のようなプロンプトで各チャンクに対する類似度を計算します。result以降に出力される数字を用います。
text-davinci-003を使い、temperature: 0.7, max length: 512でうまく動きました。
型について注釈を入れたり、数値を出力させる場合範囲指定を明確にしてあげると出力が安定します。

```
## textchunk1

{chunk}

## タスクの説明

「{question}」という質問に対し、回答するために必要な情報が textchunk1 に、
どの程度の有用性で含まれているかを段階的に考え、0-100の間で答えよ

## 回答についての説明

summary: textchunk1について何が書かれているかまとめる
step by step thinking: textchunk1と「{question}」という質問との関連性を段階的に考えてまとめる。
result: textchunk1と「{question}」という質問との関連性に関するスコアを0-100の間で表す

## 回答についての型

summary: text
step by step thinking: text
result: int (0 < x < 100)

## 回答

summary: {summary}
step by step thinking:
```

### 最も近い類似度のテキストからのQA

類似度スコアがもっとも高かったテキストを使いQAします。

```py
question = "三軒家西地域において見守り等で個人情報の提供に同意している要援護者のかたの人数を教えて下さい"
answer = summary_qa(recommend_chunk, question)
```

## embeddingsQAの実装: BertJapaneseTokenizerを使う

K-NNにあやかって、夏目漱石のこころを対象にします。

https://www.aozora.gr.jp/cards/000148/files/773_14560.html

### 埋め込みにする

以下の記事を参考に、BertJapaneseTokenizerで埋め込み表現にします。
126token以降切り捨てしていることに注意してください。

https://note.com/hajime_pol/n/n5d3192362111

```py
# tokenize
# ref: https://note.com/hajime_pol/n/n5d3192362111
import torch
from transformers import BertJapaneseTokenizer, BertForMaskedLM
from transformers import BertJapaneseTokenizer, BertModel

tokenizer = BertJapaneseTokenizer.from_pretrained('cl-tohoku/bert-base-japanese-whole-word-masking')

model_bert = BertModel.from_pretrained('cl-tohoku/bert-base-japanese-whole-word-masking', output_hidden_states=True)
model_bert.eval()

def calc_embedding(text):
    bert_tokens = tokenizer.tokenize(text)
    ids = tokenizer.convert_tokens_to_ids(["[CLS]"] + bert_tokens[:126] + ["[SEP]"])
    tokens_tensor = torch.tensor(ids).reshape(1, -1)
    with torch.no_grad():
        output = model_bert(tokens_tensor)
    return output[1].numpy()

textlist = to_textlist(pdf_file_path)
tmp_embeds = list(map(calc_embedding, textlist))
embeddings = []
for embed in tmp_embeds:
    embeddings.append(embed[0])
```

### Vector Storeに格納する

Faissを用いて、格納します。コサイン類似度を用いたindexを作成して埋め込みを追加します。
大規模な文章の場合、Vertex AI Matching Engineなどを利用すると良さそうです。

```py
import faiss
import numpy as np
index = faiss.IndexFlatIP(embeddings[0].shape[0])
index.add(np.array(embeddings))
```

### QAを行う

質問を埋め込み表現にし、類似度の高い文章を抽出します。

```py
question = "Kはなぜ襖を開けて死んだのか？"
question_embed = calc_embedding(question)
D, I = index.search(question_embed, 5)
fact = np.array(textlist)[I[0][0]]
```

これに対してさきほどと同じようにqaを行えば完了です。

```py
def fact_qa(fact, question):
    system_text = "あなたは参考文章をもとに質問に回答するシステムです。参考文章をもとに、段階的に考えて論理的に回答してください。"
    question_prompt = f"""## 参考文章

{fact}

## 質問

{question}"""
    answer, _ = completion(question_prompt, system_text, [])
    return answer

answer = fact_qa(fact, question)
print(answer)
```

### Colab

https://colab.research.google.com/drive/11z9YfZT8OQrrz_5-LerKoj4euIGQtJdg?usp=sharing

## Appendix

### GPT-3.5等におけるトークン制限について

GPT-3.5やChatGPT APIにおいては、入力と出力にかかわる文章について、最大で4096トークンの制約があります(TransformerXLなどの研究もあり、今後のアップデートによって32k程度まで増えるとの報道があります)。約750文字で1000トークンなので、3000文字を目安として、入出力を管理します。回答や回答に関する定義等において500文字程度消費すると考えると、参照してほしい文章はだいたい2500文字くらいに収める必要があります。

なお、トークン数は以下のサイトで事前に計算することができます。

https://platform.openai.com/tokenizer

### 埋め込み表現について

今回は質問に対する類似度の高い文章を検索するというタスクに関して埋め込み表現を用いました。
文章検索、あるいは情報検索は非常に面白い問題であり、与えられた文章に対して任意の文章集合からK個取り出し、順位付けして返却するという操作を行います。
もっとも容易なものとしてはキーワード一致探索があり、これは文章集合から与えられたキーワードに一致するものを返却するものとなります。
また、文字列距離を考慮したjaro-winkler距離やlevenshtein距離などもあります (以前書いた[契約書の差分比較](https://qiita.com/sakasegawa/items/836a30730a2421774354#%E3%83%91%E3%83%A9%E3%82%B0%E3%83%A9%E3%83%95%E3%82%92%E7%AA%81%E5%90%88%E3%81%95%E3%81%9B%E3%82%8B)ではlevenshtein距離を用いて文章の更新を検出しているように、これらのアルゴリズムは今なお有効です)

最近では文章検索に埋め込み表現を使う機会が増えてきました。
これは、文章をベクトル表現にし、コサイン類似度などでベクトル間の距離を求めることで実現されます。
埋め込み表現は検索だけでなく、分類やクラスタリングなどに活用されます。

日本語文章の埋め込みを作るmodelとしては、 `universal-sentence-encoder-multilingual` がかなり便利な気がします。

https://www.ai-shift.co.jp/techblog/1949

https://qiita.com/kenta1984/items/9613da23766a2578a27a

また、日本語固有表現を使わない場合は、英語に翻訳してOpenAI Embeddings APIを利用するのも良いかもしれません。

### AIキャラクターの長期記憶の実装

長期記憶は陳述記憶と非陳述記憶に区別でき、そのうち陳述記憶をうまく扱うことで長期記憶は実装できます。

https://learn-tern.com/episodic-and-semantic/

また、陳述記憶はエピソード記憶と意味記憶に分けられます。
○○に行った、○○の話をした、というようなエピソード記憶は1日単位のサマリ用のembeddingsを保管するvector storeに格納すると良さそうです。

また、意味記憶は知識や概念についての記憶であり、人物や物事について上記とはことなるvector storeに格納するべきでしょう。

## References

- ChatGPTに自社データを組み込んで新しい検索体験を模索してみました
    - https://note.com/masa_kazama/n/n246df4af19f6
- 青空文庫の小説の表紙を自動生成してみた
    - https://qiita.com/e10persona/items/e79dba270df9d8028d93