ローカルで気軽にRAGを使って会話することが簡単すぎてビビった。

今日は、ローカルにてRAG(Retrieval-Augmented Generation)を使って、あるPFDについて質問するチャットを作ろうと思う。
OpenAIを使うので、無料ではないので注意されたい。

今回のソースは
[LangChain 完全入門](https://amzn.asia/d/cKlBsXd)
という本を参考にしていて、とても勉強になるので購入をお勧めする。

# 行いたいこと

ローカルでPDFを読み込ませて、内容を質問したり、要約させること。

# 大きな流れ

1. 環境準備
1. チャットアプリの土台の作成
1. プロンプトを定義
1. アップロードされたPDFファイルを分割
1. 内容をベクトル化し保存
1. 入力された質問とPDFから抽出した適した文をOpenAIに送信
1. 回答を得る。


# 詳細

## 環境準備

### 環境構築

- Python : 3.11.6
- pip ライブラリ
    - chainlit==1.0.101
        - チャット画面を簡単に作るライブラリ
    - chromadb==0.4.22
        - ベクトルに特化したDBライブラリ。PDFを読み込んだ後に内容を保存する。
    - langchain==0.0.353
        - 大規模言語モデルを簡単に使えるようにするライブラリ。
    - openai==1.6.1
        - OpenAI用のライブラリ。
    - PyMuPDF==1.23.12
        - PDFを読み込むためのライブラリ。
    - tiktoken==0.5.2
        - embedding化するためのライブラリ。
    - python-dotenv==1.0.0
        - .envファイルを読み込み環境変数に登録するためのライブラリ。OpenAIのキーを保存する。

### 手順

1. 上記をインストールした仮想空間のプロジェクトを作成。（割愛）
1. OpenAIを使えるように、設定。
    Open AIのセットアップは[OpenAIを使うまで](https://shishimaro.co.jp/blog/ai/247)を参照すると良い。 

## チャットアプリの土台の作成

チャットアプリを簡単に作るために、「chainlit」を利用する。
https://docs.chainlit.io/get-started/overview

めちゃくちゃ簡単に作れるので正直ビビった・・・・。

```python
import chainlit as cl

# プロンプトを定義する.

@cl.on_chat_start
async def on_chat_start():
    """初回起動時に呼び出される."""

    # PDFを読み込む処理
    # PDFを分割する処理
    # PDFの内容をベクトル化して保存する処理
    await cl.Message(content=f"チャット開始").send()


@cl.on_message
async def on_message(input_message: cl.Message):
    """メッセージが送られるたびに呼び出される."""

    # 入力された文字と似たベクトルをPDFの中身から抽出
    # プロンプトを作成して、OpenAIに送信

    # 下記で結果を表示する(content=をOpenAIの結果にする。)
    await cl.Message(content="メッセージを受け取ったよ。").send()
```

## プロンプトを定義する

今回は、下記でOpenAIに送るものとする。
```txt
文章を前提にして質問に答えてください。
--------------------------------------------
文章 :
「# 入力された文字と似たベクトルをPDFの中身から抽出の中身」
--------------------------------------------
質問 : 「入力された質問」
```

```python
from langchain.prompts import PromptTemplate

prompt = PromptTemplate(
    template="""
    文章を前提にして質問に答えてください。

    文章 :
    {document}

    質問 : {question}
    """,
    input_variables=["document", "question"],
)

```

## PDFに関する処理

### PDFを読み込む

```python
from langchain.document_loaders import PyMuPDFLoader

files = None

# awaitメソッドのために、whileを利用する。アップロードされるまで続く。
while files is None:
    # chainlitの機能に、ファイルをアップロードさせるメソッドがある。
    files = await cl.AskFileMessage(
        # ファイルの最大サイズ
        max_size_mb=20,
        # ファイルをアップロードさせる画面のメッセージ
        content="PDFを選択してください。",
        # PDFファイルを指定する
        accept=["application/pdf"],
        # タイムアウトなし
        raise_on_timeout=False,
    ).send()

file = files[0]
# アップロードされたファイルのパスから中身を読み込む。
documents = PyMuPDFLoader(file.path).load()
```

### PDFを分割する処理

PDFの中身を分割するのは理由がある。
現在、GPTの最大トークン数は限られている。よって、PDFの中身を全てGPTに送るわけにもいかない。
[最大トークン数とモデル](https://qiita.com/akiraokusawa/items/e907c205ab8b0e36b76b)

よって、日本語の意味が壊れない程度にPDFの内容を分割しておく。

```python
from langchain.text_splitter import SpacyTextSplitter


text_splitter = SpacyTextSplitter(chunk_size=400, pipeline="ja_core_news_sm")
splitted_documents = text_splitter.split_documents(documents)
```

pipelineの種類は下記で参考にするといい。

https://spacy.io/usage/models

トレーニングされたモデルは事前にダウンロードする必要があるので注意する。
```bash
python -m spacy download ja_core_news_sm
```

### PDFの内容をベクトル化して保存する処理

Open AIのembedding APIを利用して、取得したテキストをベクトルで保存する。
ベクトルで保存する理由は、質問された質問とベクトルの類似度を測って抽出するためである。

保存先は、簡単にするためにベクトルストアのChromaを利用する。

```python
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma

# テキストをベクトル化するOpenAIのモデル
embeddings = OpenAIEmbeddings(model="text-embedding-ada-002")

# Chromaにembedding APIを指定して、初期化する。
database = Chroma(embedding_function=embeddings)

# PDFから内容を分割されたドキュメントを保存する。
database.add_documents(splitted_documents)

# 今回は、簡易化のためセッションに保存する。
cl.user_session.set("data", database)
```

ここまでで、アップロードされたファイルをRAGとして利用する準備は整った。

## 入力された質問とPDFから抽出した適した文をOpenAIに送信

ここからは、入力された質問を保存したベクトルから類似度の高いものを抽出し、Open AIに送信して、回答を得る流れを明記する。
何度も言うが、抽出する理由は、送信できる文字数には限界があるからである。
「何を抽出するか」と言うところがRAGで一番難しいところである。

```python
from langchain.chat_models import ChatOpenAI
from langchain.schema import HumanMessage


# チャット用のOpenAIのモデル。今回は3.5を選定
open_ai = ChatOpenAI(model="gpt-3.5-turbo")

# セッションからベクトルストアを取得（この中にPDFの内容がベクトル化されたものが格納されている）
database = cl.user_session.get("data")

# 質問された文から似た文字列を、DBより抽出
documents = database.similarity_search(input_message.content)

# 抽出したものを結合
documents_string = ""
for document in documents:
    documents_string += f"""
    ---------------------------------------------
    {document.page_content}
    """

# プロンプトに埋め込みながらOpenAIに送信
result = open_ai(
    [
        HumanMessage(
            content=prompt.format(
                document=documents_string,
                query=input_message.content
            )
        )
    ]
).content

```

# 最後に

LangChainのおかげで、LLMをより簡単に利用できるようになったような感じがする。
今は、LangChain=0.0.353(353の時点ですごい・・・)を使っているが、LangChain=0.1.4がリリースされたので、試してみたい。

# おまけ
私は、ローカルで「今までの人生の振り返り」と言うまとめてきたファイルを読み込ませて、
「今やるべきことは一言で言うと何ですか？」
という問いをした。
解答は、「もっと技術の勉強してください」であった・・・・笑

# 参考
[LangChain 完全入門](https://amzn.asia/d/cKlBsXd)