LangChain×Streamlit×RAGによるチャットボットアプリを開発してみた

# はじめに
最近流行りのChatGPTについて学習する中で、何やらLangChainという便利なライブラリがあることを知り、ネット記事や書籍を参考に勉強がてらチャットボットアプリを開発してみました。
※OpenAI, GitHub, Streamlitの登録が必要です。登録方法は各ネット記事等をご参照下さい。

:::note warn
2025/5/12 更新
GitHubのリポジトリのリンクを載せました。コードの更新はありません。
:::

https://github.com/so-engineer/lc_sl_chatbot?tab=readme-ov-file

# 具体的な内容
まず成果物と全体のコードを記載します。

#### 成果物
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3088985/c87ad6f1-410f-f583-442a-b4dd9491117e.png)

#### 全体のコード

```python:app.py
import os
import tempfile # PDFアップロードの際に必要

from langchain.chat_models import ChatOpenAI
from langchain.document_loaders import PyMuPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import Chroma
from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferMemory
# from langchain.callbacks.base import BaseCallbackHandler
import streamlit as st


folder_name = "./.data"
if not os.path.exists(folder_name):
    os.makedirs(folder_name)

# ストリーム表示
# class StreamCallbackHandler(BaseCallbackHandler):
#     def __init__(self):
#         self.tokens_area = st.empty()
#         self.tokens_stream = ""

#     def on_llm_new_token(self, token, **kwargs):
#         self.tokens_stream += token
#         self.tokens_area.markdown(self.tokens_stream)

# UI周り
st.title("QA")
uploaded_file = st.file_uploader("Upload a file after paste OpenAI API key", type="pdf")
    
with st.sidebar:
    user_api_key = st.text_input(
        label="OpenAI API key",
        placeholder="Paste your openAI API key",
        type="password"
    )
    os.environ['OPENAI_API_KEY'] = user_api_key
    select_model = st.selectbox("Model", ["gpt-3.5-turbo-1106", "gpt-4-1106-preview",])
    select_temperature = st.slider("Temperature", min_value=0.0, max_value=2.0, value=0.0, step=0.1,)
    select_chunk_size = st.slider("Chunk", min_value=0.0, max_value=1000.0, value=300.0, step=10.0,)

if uploaded_file:
    # 一時ファイルにPDFを書き込みバスを取得
    with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
        tmp_file.write(uploaded_file.getvalue())
        tmp_file_path = tmp_file.name

    loader = PyMuPDFLoader(file_path=tmp_file_path) 
    documents = loader.load() 

    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size = select_chunk_size,
        chunk_overlap  = 100,
        length_function = len,
    )

    data = text_splitter.split_documents(documents)

    embeddings = OpenAIEmbeddings(
        model="text-embedding-ada-002",
    )

    database = Chroma(
        persist_directory="./.data",
        embedding_function=embeddings,
    )

    database.add_documents(data)

    chat = ChatOpenAI(
        model=select_model,
        temperature=select_temperature,
        # streaming=True,
    )

    # retrieverに変換（検索、プロンプトの構築）
    retriever = database.as_retriever()

    # 会話履歴を初期化
    if "memory" not in st.session_state:
        st.session_state.memory = ConversationBufferMemory(
            memory_key="chat_history",
            return_messages=True,
        )

    memory = st.session_state.memory

    chain = ConversationalRetrievalChain.from_llm(
        llm=chat,
        retriever=retriever,
        memory=memory,
    )

    # UI用の会話履歴を初期化
    if "messages" not in st.session_state:
        st.session_state.messages = []

    # UI用の会話履歴を表示
    for message in st.session_state.messages:
        with st.chat_message(message["role"]):
            st.markdown(message["content"])

    # UI周り
    prompt = st.chat_input("Ask something about the file.")

    if prompt:
        # UI用の会話履歴に追加
        st.session_state.messages.append({"role": "user", "content": prompt})
        with st.chat_message("user"):
            st.markdown(prompt)

        with st.chat_message("assistant"):
            with st.spinner("Thinking..."):
                response = chain(
                    {"question": prompt},
                    # callbacks=[StreamCallbackHandler()], # ストリーム表示
                )
                st.markdown(response["answer"])
        
        # UI用の会話履歴に追加
        st.session_state.messages.append({"role": "assistant", "content": response["answer"]})

    # メモリの内容をターミナルで確認
    print(memory)
```
</div></details>

次にコードの中身です。気になった点や苦労した点を中心に記載します。

#### 必要なライブラリのインストール
- openai==1.5.0, langchain==0.0.350, PyMuPDF==1.23.7, tiktoken==0.5.2, chromadb==0.4.20, streamlit==1.29.0で動作確認しました。
- pymupdf, tiktoken, chromadbを使用しました。他のLoaderやベクトルDBに入れ替えも可能かと思います。
```
pip install openai langchain pymupdf tiktoken chromadb streamlit
```

#### UI周り
- UIの大半の実装はこれだけです。凄いです。サイドバーでOpenAI APIキー, Model, Temperature, Chunkの設定を可能にします。
- 「st.file_uploader」を「with st.sidebar:」内に記載するとPDFアップロードボタンをサイドバーに配置することも出来ます。

```python:app.py
# UI周り
st.title("QA")
uploaded_file = st.file_uploader("Upload a file after paste OpenAI API key", type="pdf")
    
with st.sidebar:
    user_api_key = st.text_input(
        label="OpenAI API key",
        placeholder="Paste your openAI API key",
        type="password"
    )
    os.environ['OPENAI_API_KEY'] = user_api_key
    select_model = st.selectbox("Model", ["gpt-3.5-turbo-1106", "gpt-4-1106-preview",])
    select_temperature = st.slider("Temperature", min_value=0.0, max_value=2.0, value=0.0, step=0.1,)
    select_chunk_size = st.slider("Chunk", min_value=0.0, max_value=1000.0, value=300.0, step=10.0,)
```

#### PDFの読み取り
- PDFを読み取る前にtempfileに入れてパスを取得します。この辺りは色々なやり方がありそうです。
```python:app.py
    # 一時ファイルにPDFを書き込みバスを取得
    with tempfile.NamedTemporaryFile(delete=False) as tmp_file:
        tmp_file.write(uploaded_file.getvalue())
        tmp_file_path = tmp_file.name

    loader = PyMuPDFLoader(file_path=tmp_file_path) 
    documents = loader.load() 
```

#### 会話履歴の初期化
- 会話履歴を保持するために「ConversationBufferMemory」と「ConversationalRetrievalChain」を使います。似たような機能で「RetrievalQA」がありますが、こちらでは会話履歴を保持することが出来ません。
```python:app.py
    # 会話履歴を初期化
    if "memory" not in st.session_state:
        st.session_state.memory = ConversationBufferMemory(
            memory_key="chat_history",
            return_messages=True,
        )

    memory = st.session_state.memory

    chain = ConversationalRetrievalChain.from_llm(
        llm=chat,
        retriever=retriever,
        memory=memory,
    )
```
- 会話履歴はHumanMessageとAIMessageのタイトルでリストの中に辞書のような形式で保存されます（コード最下部のprint文でメモリの内容をターミナルに表示しています）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3088985/70a9bb22-b9cf-1464-bfd5-6389ca0d0aab.png)

#### UI用の会話履歴の初期化
- これらはUI用に会話履歴を保持するための実装となり、「ConversationBufferMemory」や「ConversationalRetrievalChain」とは関係がないので注意です。
```python:app.py
    # UI用の会話履歴を初期化
    if "messages" not in st.session_state:
        st.session_state.messages = []

    # UI用の会話履歴を表示
    for message in st.session_state.messages:
        with st.chat_message(message["role"]):
            st.markdown(message["content"])

# --------------------------------------------------------------------------
    # UI用の会話履歴に追加
    st.session_state.messages.append({"role": "user", "content": prompt})
    st.session_state.messages.append({"role": "assistant", "content": response["answer"]})
```

#### ストリーム表示（今後のTODO）
- 回答をストリーム表示するためにCallbacksを使いました。しかし、回答に質問が含まれる現象が起きるため、やむなくコメントアウトをしています。全ての実装でここが一番苦労しており、本質的ではないのですが、解決に至っておりません（CallbacksのCallbackManagerやConversationalRetrievalChainのcondence_question_llm等試しましたが上手くいかず、、）。どなたか解決方法がわかる方がいらっしゃいましたらアドバイスをいただけますと幸いです・・・！！
```python:app.py
# from langchain.callbacks.base import BaseCallbackHandler

# --------------------------------------------------------------------------
# ストリーム表示
# class StreamCallbackHandler(BaseCallbackHandler):
#     def __init__(self):
#         self.tokens_area = st.empty()
#         self.tokens_stream = ""

#     def on_llm_new_token(self, token, **kwargs):
#         self.tokens_stream += token
#         self.tokens_area.markdown(self.tokens_stream)

# --------------------------------------------------------------------------
chat = ChatOpenAI(
        model=select_model,
        temperature=select_temperature,
        # streaming=True,
    )

# --------------------------------------------------------------------------
response = chain(
    {"question": prompt},
    # callbacks=[StreamCallbackHandler()], # ストリーム表示
)
```
- 以下のように回答に質問が含まれてしまいます。なお、次の質問を入力すると解消されます（謎）。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3088985/5a490d99-f648-4dc6-c01b-24b96a268c41.png)

#### ローカルでの実行
```
streamlit run app.py
```

#### Streamlitへデプロイ
- こちらの記事を参考にさせていただきました。[【Python】Streamlit Sharingで簡単・爆速でWebページをデプロイする！](https://qiita.com/kagami_t/items/c54702e271d729948e24)
- また、Streamlitはテキストファイルを読み込んで必要なパッケージを自動でインストールするため、事前にインストールしたパッケージを「requirements.txt」に出力しておく必要があります。
```requirements.txt
pip freeze > requirements.txt
```

# まとめ
#### 実装してみてどうだったか
- PDFの読み取りに苦労した
- 会話履歴の保持に苦労した
- ストリーム表示に苦労している（現在進行形）
- Streamlit最高！！フロントはほぼ実装要らずすごい

#### 今後やってみたいこと
- **回答のストリーム表示**
- PDFの読み取り方法のリファクタリング
- 会話履歴の削除や要約により長い会話履歴に対応する
- Agentsにより外部検索を取り入れる等、より柔軟な実装が可能か確認する

# あとがき
最後までご覧いただきありがとうございました！!
少しでも良いと感じていただけましたらLGTMポチッとしていただけますと幸いです。
いつもQiitaを参考にしております、皆様良質な記事をありがとうございます！!

### 参考記事
#### ネット記事
- PDFアップロード等全体的な実装方法
    - [LangChain×Streamlitを使ったチャットボットアプリを開発してみた](https://liginc.co.jp/631680)
    - [PDFと対話しよう。　LangChainとStreamlitを使ったChatGPTアプリの作成](https://qiita.com/nigo1973/items/c6f2045cf0c00a523fc6)
- 会話履歴（メモリ機能）の実装方法
    - [LangChain応用 ConversationalRetrievalChain編](https://blog.elcamy.com/posts/cca2905b/)
    - [LangChain Memoryとは？【Chat Message History・Conversation Buffer Memory】](https://zenn.dev/umi_mori/books/prompt-engineer/viewer/langchain_memory)
    - [【Streamlit】Session Stateで変数の値を保持する方法](https://qiita.com/kuriyan1204/items/d8342f3a40c6aeb57e5e)
- ストリーム表示の実装方法
    - [LangchainをStreamlit上で文字ストリーミングする方法](https://qiita.com/yazoo/items/b318adbab0d5f3f58dc8)
- Streamlitのデプロイ方法
    - [【Python】Streamlit Sharingで簡単・爆速でWebページをデプロイする！](https://qiita.com/kagami_t/items/c54702e271d729948e24)
- 公式Docs
    - [LangChain](https://python.langchain.com/docs/get_started/introduction)
    - [Streamlit](https://docs.streamlit.io/)
- その他（Qiita記事の書き方）
    -  [【パクれ】エンジニア転職で企業から評価されるQiita記事の書き方を解説](https://qiita.com/mokio/items/a87a4e8d3ef58c5b865f)

#### 書籍
- [LangChain完全入門　生成AIアプリケーション開発がはかどる大規模言語モデルの操り方](https://www.amazon.co.jp/LangChain%E5%AE%8C%E5%85%A8%E5%85%A5%E9%96%80-%E7%94%9F%E6%88%90AI%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E9%96%8B%E7%99%BA%E3%81%8C%E3%81%AF%E3%81%8B%E3%81%A9%E3%82%8B%E5%A4%A7%E8%A6%8F%E6%A8%A1%E8%A8%80%E8%AA%9E%E3%83%A2%E3%83%87%E3%83%AB%E3%81%AE%E6%93%8D%E3%82%8A%E6%96%B9-%E7%94%B0%E6%9D%91%E6%82%A0/dp/4295017965/ref=pd_bxgy_img_d_sccl_1/358-5789838-4241361?pd_rd_w=mQBQo&content-id=amzn1.sym.5773d2b1-1110-481e-bc73-38bad5475a70&pf_rd_p=5773d2b1-1110-481e-bc73-38bad5475a70&pf_rd_r=7ZXJ3JYB0HYKRAW3F8TQ&pd_rd_wg=nUodR&pd_rd_r=88e19594-89ed-4a8d-ac9c-057dcf999c69&pd_rd_i=4295017965&psc=1)
- [ChatGPT/LangChainによるチャットシステム構築［実践］入門](https://www.amazon.co.jp/ChatGPT-LangChain%E3%81%AB%E3%82%88%E3%82%8B%E3%83%81%E3%83%A3%E3%83%83%E3%83%88%E3%82%B7%E3%82%B9%E3%83%86%E3%83%A0%E6%A7%8B%E7%AF%89%EF%BC%BB%E5%AE%9F%E8%B7%B5%EF%BC%BD%E5%85%A5%E9%96%80-%E5%90%89%E7%94%B0-%E7%9C%9F%E5%90%BE/dp/4297138395/ref=sr_1_6?__mk_ja_JP=%E3%82%AB%E3%82%BF%E3%82%AB%E3%83%8A&crid=26YNOLLJHHBIG&keywords=lang+chain&qid=1702772688&sprefix=langchai%2Caps%2C244&sr=8-6)