LLM初心者ですが、専門知識から回答するチャットシステムを作りたい

## はじめに

一年ほど前から、仕事で社会課題をICTで解決する企画作りに取り組んでおり、その一環として生成AIを活用して合理的配慮の対応を支援するサービス『RaNavi』を企画しました。合理的配慮とは、障がいのある人がない人と同じように社会で活躍できるよう、環境の調整を行うことです。この企画は、合理的配慮の対応が必要となった事業者を、専門知識を持ったAIがサポートするチャットサービスです。(企画の詳細は[こちら](https://sakinokoto.esm.co.jp/plan-detail/ranavi)）。

この記事では、LLM(大規模言語モデル)初心者の私が、「特定の知識から回答するチャットシステムのプロトタイプを作成した過程」を記録します。

## やりたいこと

プロトタイプ作成の目的は、下記の通りです。
- サービスのコンセプトである「専門的な知識を持ったAIモデルが、利用者と直感的に対話する」という部分を実現できるかを確認する
- 関係各所へヒアリングする際、実際に動くものを見てもらい、フィードバックや改善点などのご意見をいただく

## プロトタイプの構築の道のり

### 初めの一歩

「AI 独自データ チャット」などで検索すると、たくさんの記事や広告が表示されます。使用するLLMの種類や独自データの取得方法も様々で、どれがよいか判断できなかったため、まずは以下の書籍で基本を学びました。 

https://gihyo.jp/book/2023/978-4-297-13839-4

LLMはもちろんPythonでの開発も初めてだったため、開発環境の構築やコードの書き方が非常に参考になりました。また、使用するクラウドサービスの導入手順も丁寧に解説されていて、わかりやすかったです。

ただ、技術書あるあるですが、OpenAI APIやライブラリを最新バージョンにすると、非推奨になっていたり動かないことも...(涙)。そんな時は、ChatGPTに聞いたり、技術ブログやStack Overflowで解決策を探したりしましたが、最終的には公式ドキュメントをじっくり読むのが一番早く解決につながりました。

### プロトタイプ開発

LLMから独自データの知識を活用して回答を得る方法はいくつかありますが、今回は前述の[書籍](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#customerReviews) でも紹介されていた「RAG」というワークフローを採用しました。

RAGは、実際にLLMに独自データを学習させるわけではなく、外部データベースとLLMを組み合わせて、独自データから回答を生成する方法です。[LangChain](https://www.langchain.com/langchain)というLLMを拡張するためのフレームワークを使用すると、比較的簡単に構築できます。

今回は、LangChain提供のアーキテクチャから、会話の連続性も保ちつつ、外部データベースからの知識をもとに質問へ回答する仕組みを使いました。この仕組みについてはこちらの記事がとても参考になりました。

https://qiita.com/mashmoeiar11/items/5aa2c1f3381e5184c919

ただ、上記の記事で解説されている ConversationalRetrievalChain は既に非推奨になっていたので、[LnagChainのチュートリアル](https://python.langchain.com/docs/tutorials/qa_chat_history/)を見ながら最新化しました。

プロトタイプの構成は以下です。

![AWS Architecture Diagram (1).jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3919085/289e0d82-a70e-bbde-1109-90e337f785aa.jpeg)

目的を考えるとリッチなチャットUIは不要なため、プロトタイプはSlackのアプリとして作成しました。RAGワークフローで質問に対する回答を生成する処理は、AWS Lambda上で稼働させます。
LLM、会話履歴の保持、VectorStoreはそれぞれ以下のサービスを使用しました。Vector Storeには、専門知識（合理的配慮に関する法律やガイドライン、合理的配慮の事例集など）を登録しました。

| 機能 | 使用サービス |
|:-:|:-:|
| LLM | OpenAI API (GPT-4o) |
| 会話履歴の保持 | Momento Cache  |
| 独自データの Vector Store | Pinecone  |

RAGワークフローを実現する処理のコードと、LangChainの各パッケージのバージョンは以下です。

<details><summary>コードはこちら</summary>

```python:rag.py
import os
from datetime import timedelta
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.chains import create_history_aware_retriever, create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.chat_history import BaseChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_community.chat_message_histories import MomentoChatMessageHistory
from langchain_pinecone import PineconeVectorStore


def answer_from_context(message, callbacks, session_id):
    """
    ユーザの質問に対して、Vector Storeに格納された知識から回答します。

    Parameters
    ----------
    message: string
        ユーザーの質問        
    callback : Callbacks
        LLMから得られた回答を入力にして呼び出すコールバック群、
        プロトタイプではSlackへ出力するストリームを指定します。
    session_id : string
        会話のセッションID、プロトタイプではスレッドのIDを指定します。
    """

    # (1) ユーザの質問が会話履歴を参照している場合、履歴がなくても意味が通じる
    # 新しい質問を再構成するためのプロンプト。
    # 実際のコードでは英文を使用しました。
    contextualize_q_system_prompt = (
        "チャット履歴と最新のユーザー質問が与えられており、"
        "最新の質問がチャット履歴の内容を参照している場合があります。"
        "この質問がチャット履歴なしでも理解できるように、独立した形で質問を再構成してください。"
        "質問に回答せず、必要に応じて再構成した質問を返すだけにしてください。"
    )    
    contextualize_q_prompt = ChatPromptTemplate.from_messages(
        [
            ("system", contextualize_q_system_prompt),
            MessagesPlaceholder("chat_history"),
            ("human", "{input}"),
        ]
    )
    # (1)のプロンプトを使って質問を再構成する際に使用するLLM。
    contextualize_q_llm = ChatOpenAI(
        model_name=os.environ["OPEN_API_MODEL"],
        temperature=os.environ["OPENAI_API_TEMPERATURE"],
    )

    # 質問に関連するコンテキスト情報をVector Storeから検索するためのretrieverを取得。
    retriever = get_retriever()

    # 会話履歴を考慮して、コンテキスト情報を検索するためのretrieverを生成。
    # 会話履歴がない場合は、ユーザの質問をそのままretrieverに渡します。
    # 会話履歴がある場合は、(1)のプロンプトからLLMを使用して質問を生成し、
    # その質問をretrieverに渡します。
    history_aware_retriever = create_history_aware_retriever(
        contextualize_q_llm, 
        retriever, 
        contextualize_q_prompt 
    )

    # (2) Vector Storeに格納した独自データ内を検索した結果から、
    # ユーザへの回答を得るためのプロンプト。
    # 実際のコードでは英文を使用しました。
    qa_system_prompt = (
        "あなたは質問応答タスクのアシスタントです。"
        "以下の取得したコンテキストを使用して質問に回答してください。"
        "もし回答がコンテキストに見つからない場合は、「わかりません」と答えてください。"
        "回答は最大3文にまとめ、簡潔にしてください。 "
        "\n\n"
        "{context}"
    )    
    qa_prompt = ChatPromptTemplate.from_messages(
        [
            ("system", qa_system_prompt),
            MessagesPlaceholder("chat_history"),
            ("human", "{input}"),
        ]
    )

    # (2)のプロンプトを使ってユーザへの回答を生成する際に使用するLLM。
    # 基本的には (1)の質問を再構成する際のLLMと同じでよいのですが、
    # 今回ユーザへの回答はコールバックに流したいので、別としています
    llm = ChatOpenAI(
        model_name=os.environ["OPEN_API_MODEL"],
        temperature=os.environ["OPENAI_API_TEMPERATURE"],
        streaming=True,
        callbacks=callbacks,
    )

    # (2)のプロンプトと指定したLLMを用いて、ユーザへの回答を生成するチェーンを作成します。
    question_answer_chain = create_stuff_documents_chain(llm, qa_prompt)

    # 会話履歴を考慮したretrieverと、ユーザへの回答生成のプロセスを組み合わせた
    # RAGチェーンを生成します。
    # このチェーンは、retrieverで検索したコンテキスト情報をもとに
    # ユーザへの回答を生成する一連の処理を統合しています。
    rag_chain = create_retrieval_chain(history_aware_retriever, question_answer_chain)
    
    # 会話履歴を考慮した RAG チェーンを構築します。
    conversational_rag_chain = RunnableWithMessageHistory(
        rag_chain,
        get_session_history,
        input_messages_key="input",
        history_messages_key="chat_history",
        output_messages_key="answer"
    )

    # ユーザの質問とセッションIDを指定して、会話履歴を考慮したRAGチェーンを実行します。
    conversational_rag_chain.invoke(
        {"input": message},
        config={ "configurable": {"session_id": session_id} }
    )

def get_retriever():
    """
    ユーザー質問に関連するコンテキスト情報をVector Storeから取得するretrieverを返します。
    """
    index_name = os.environ["PINECONE_INDEX"]
    embeddings = OpenAIEmbeddings()
    vectorstore = PineconeVectorStore(index_name=index_name, embedding=embeddings)

    return vectorstore.as_retriever()


def get_session_history(session_id: str) -> BaseChatMessageHistory:
    """
    指定のセッションIDの会話履歴を返します。

    Parameters
    ----------
    session_id : string
        会話のセッションID
    """    
    history = MomentoChatMessageHistory.from_client_params(
      session_id,
      os.environ["MOMENTO_CACHE"],
      timedelta(hours=int(os.environ["MOMENTO_TTL"])),
    )
    return history
```
</details>

| パッケージ | バージョン |
|:-:|:-:|
| langchain-core | 0.3.13 |
| langchain | 0.3.4  |
| langchain-community | 0.3.3  |
| langchain-openai | 0.2.4 |

## できたもの

### ChatGPTとの比較

できあがったプロトタイプとChatGPTの両方に、「民間の事業者でも合理的配慮の対応が義務になったのはいつからですか？」という質問をしてみました。

- プロトタイプ
![2024-10-30_08h52_28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3919085/ca6a93fc-4605-de9c-89c7-13e86667ece8.png)

- Chat GPT (モデルは ChatGPT 4o)
![2024-10-30_09h00_38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3919085/c8d05f58-da71-862a-c2e0-2c1825482488.png)

民間事業者の義務化は2024年の4月1日からなので、プロトタイプの回答が正です。
プロトタイプが Vector Storeに登録した情報をもとに、正確に回答できていることが確認できました！

### 対話のようす

次に、事業者が使うケースを想定してRaNaviと対話してみました。

![RaNavi_movei2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3919085/8d51ae5c-31e6-a37f-d73d-47e2be33377d.gif)
事業者の質問に簡潔に回答することができています。また、事業者がこれまでの会話と連続性がある質問をしても、回答に違和感を感じることはなく、スムーズに会話できていました。
サービスとしてはVector Storeに登録する事例をまだまだ増やす必要がありますが、プロトタイプは一旦これで完成としました。

## おわりに

プロトタイプを作ると決めたものの、ネットやChatGPTを頼っても何から始めればいいのかわからず、最初は途方に暮れました。ですが、入門書で基礎を学んだことでネットの記事も理解できるようになり、スムーズに開発を進められるようになりました。私のような初心者には、このように基礎から学ぶ方法をおすすめします。

ただ、技術の更新が早く、バージョンアップでコードが動かなくなることも。今後もこの変化に追いつきながら、少しずつ理解を深めていこうと思います！