💨

LangChain アプリケーション開発入門 - Streamlit 活用ガイド

2025/01/30に公開
14

はじめに

最近、Large Language Models (LLM, 大規模言語モデル)を活用する機会が増えてきたことをきっかけに、LangChain の機能を試してみました。
フロントエンドエンジニアとして働く私のような初心者でも LangChain を試すことができるよう、基本的な内容をまとめ、簡単なサンプルコードをご紹介させていただきます。
これから LangChain の学習を始める方の参考になれば幸いです。

対象読者

  • LangChain に興味があるものの、始め方がわからない方
  • LLM を活用したアプリケーション開発に興味がある方
  • Python でのプログラミング経験をお持ちの方
  • Streamlit での開発を検討されている方

事前準備

本記事では Visual Studio Code を開発環境として使用しています。
内容は「基本編」と「実践編」の2部構成になっています。

  • 基本編:Jupyter Notebook を使用してサンプルコードを実行
  • 実践編:Streamlit で簡単なチャットアプリケーションを開発

動作確認環境

  • OS: macOS Monterey 12.7.6
  • Chip: Apple M1 Pro
  • Python: 3.11.6
  • Visual Studio Code: 1.96.4

※ 必要なライブラリは requirements.txt にまとめてありますので、ご参考にしてください。

requirements.txt

langchain==0.0.332
langcodes==3.3.0
langsmith==0.0.52
streamlit==1.27.2
layoutparser==0.3.4
openai==0.28.0
python-dotenv==1.0.0
chromadb==0.4.11
tiktoken==0.5.1
unstructured==0.10.16
faiss-cpu==1.7.4
jupyter_client==8.3.1
jupyter_core==5.3.1

フォルダー構成

langchain
  ├── .env              # OpenAI API キーを保存
  ├── notebooks.ipynb   # Jupyter Notebookファイル
  ├── requirements.txt  # 必要なライブラリ

virtualenv 環境作成

本記事では pyenv (2.5.0) を使用して仮想環境を作成します。
pyenv の詳細については以下のリンクを参照してください。
Simple Python Version Management: pyenv

※ pyenv 以外にも、お好みの仮想環境ツール(venv、conda など)をご利用いただけます。
普段お使いの仮想環境ツールをご利用しても構いません。

# 1.Python 3.11.6 の仮想環境を作成
pyenv virtualenv 3.11.6 env

# 2.仮想環境を有効化
pyenv activate env

# 3. 必要なライブラリをインストール
pip install -r requirements.txt

# 仮想環境を無効化する場合: pyenv deactivate

Jupyter Notebook の実行環境設定

Visual Studio Code (VS Code) で新しい Jupyter Notebook を作成します。

  1. 新規ファイルを作成(拡張子: .ipynb)

    • ファイル名は任意です(例: notebook.ipynb)
  2. VS Code の右上で実行環境を選択

    • 作成した仮想環境を選択

image

その後、.env ファイルに OpenAI API キーを設定します。

OPENAI_API_KEY="XXXXXXXXX"

※ OpenAI API キーは以下のリンクから取得できます。
OpenAI API KEY

jupyter notebook の実行方法:

  1. すべてのセルを実行する場合

    • 上部メニューの「Run All」ボタンをクリックすると、すべてのセルが順番に実行されます
  2. 個別のセルを実行する場合

    • 実行したいセルの左側にある実行ボタン(▶)をクリック

image
image

※ VS Code に以下の拡張機能のインストールをお勧めします。

基本編

LangChain は、Large Language Model (LLM) や Chat Model を簡単に利用できるライブラリです。
OpenAI や Gemini などの各種 API を直接扱う必要がなく、import するだけで簡単に使用することができます。

以下のコードでは、OpenAI の Large Language Model (LLM) と Chat Model の基本的な使い方を説明します。

from langchain.llms import OpenAI
from langchain.chat_models import ChatOpenAI

# LLM のインスタンスを作成
# gpt-3.5-turbo-instruct: テキスト生成に特化したモデル
llm = OpenAI(model="gpt-3.5-turbo-instruct")

モデルの使用方法は以下のようになります。

# invoke() メソッドは、AIと簡単に会話するための機能
# 質問文を入れるだけでAIが回答を返してくる
llm.invoke("Hello, how are you?")

実行結果は以下のようになります。

# output
'\n\nI am an AI, so I do not have emotions. But thank you for asking. How can I assist you?'

このように、invoke() メソッドを使用することで、簡単に AI と対話することができます。
使用するモデルによって応答の特徴が異なりますので、OpenAI 以外のモデル(Gemini など)も試してみることをお勧めします。

LangChain - Chat models

次のサンプルコードでは、Chat Model の基本的な使い方を説明します。

※ サンプルコードで使用した HumanMessage, AIMessage, SystemMessage は、LLM に対してメッセージを送信するための機能です。主な役割は以下の通りです。

  • SystemMessage: AIの役割や動作を設定
  • HumanMessage: ユーザーからの入力
  • AIMessage: AIからの応答

詳細については以下のリンクをご参考ください。
LangChain Python API Reference - messages

from langchain.chat_models import ChatOpenAI
from langchain.schema import HumanMessage, AIMessage, SystemMessage

# Chat Modelのインスタンスを作成
# gpt-3.5-turbo: 対話に特化したモデ
# temperature パラメータについて:
#   - 設定範囲: 0.0 ~ 1.0
#   - 0.0: より一貫性のある回答
#   - 0.1: 安定した回答を得るための推奨値
#   - 1.0: よりクリエイティブで多様な回答

chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)

# メッセージリスト
messages = [
    # AIの役割を設定
    SystemMessage(
        content='あなたは サッカーの専門家です'
    ),
    # AIの初期応答を設定(任意:会話の文脈を作る)
    AIMessage(
        content='こんにちは'
    ),
     # ユーザーからの質問
    HumanMessage(
        content='Jリーグの試合はいつ始まりますか?'
    ),
]

chat.invoke(messages)

実行結果は以下のようになります。
適切なメッセージ設定により専門的な観点からの回答を得ることができます。

# output
AIMessage(content='Jリーグの試合は通常、土曜日や日曜日に開催されます。試合開始時間は通常午後から夕方にかけてで、具体的な開始時間は試合やスタジアムによって異なります。Jリーグの試合スケジュールは公式ウェブサイトや各クラブのウェブサイトで確認できますので、詳細な情報を知りたい場合はそちらをご覧ください。', additional_kwargs={}, response_metadata={'token_usage': <OpenAIObject at 0x10a4a2030> JSON: {
  "prompt_tokens": 49,
  "completion_tokens": 145,
  "total_tokens": 194,
  "prompt_tokens_details": {
    "cached_tokens": 0,
    "audio_tokens": 0
  },
  "completion_tokens_details": {
    "reasoning_tokens": 0,
    "audio_tokens": 0,
    "accepted_prediction_tokens": 0,
    "rejected_prediction_tokens": 0
  }
}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-88b40ae3-1660-4c6b-9733-7c76542c8640-0')

次は PromptTemplate を使って、AI に対して質問をするサンプルコードです。

from langchain.chat_models import ChatOpenAI 
# 以下を追加
from langchain.prompts import PromptTemplate, ChatPromptTemplate

chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)

# 以下のようにテンプレートを内部に用意し
template = PromptTemplate.from_template(
    "{league}の試合はいつ始まりますか?"
)

# 文字列だけを渡すことで結果を得ることが出来る
user_prompt = template.format(league="Jリーグ")

chat.invoke(user_prompt)

実行結果は以下のようになります。

# output
AIMessage(content='Jリーグの試合は通常、土曜日や日曜日に開催されます。試合の開始時間は、午後1時や午後3時など、各クラブや試合日によって異なります。詳細な試合スケジュールはJリーグ公式ウェブサイトや各クラブのウェブサイトで確認することができます。', additional_kwargs={}, response_metadata={'token_usage': <OpenAIObject at 0x113408710> JSON: {
  "prompt_tokens": 24,
  "completion_tokens": 120,
  "total_tokens": 144,
  "prompt_tokens_details": {
    "cached_tokens": 0,
    "audio_tokens": 0
  },
  "completion_tokens_details": {
    "reasoning_tokens": 0,
    "audio_tokens": 0,
    "accepted_prediction_tokens": 0,
    "rejected_prediction_tokens": 0
  }
}, 'model_name': 'gpt-3.5-turbo', 'system_fingerprint': None, 'finish_reason': 'stop', 'logprobs': None}, id='run-45772b17-574e-4a6b-9dc5-44d7cb42ed46-0')

先ほど作成したチャット用のコードも上記を応用して変更できます。

from langchain.chat_models import ChatOpenAI 
from langchain.prompts import PromptTemplate, ChatPromptTemplate
from langchain.schema import HumanMessage, AIMessage, SystemMessage

chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)

chat_template = ChatPromptTemplate.from_messages([
    ("system", "あなたは {specialist}の専門家です"),
    ("ai", "こんにちは{name}さん"),
    ("user", "{league}の試合はいつ始まりますか?"),
])

chat_prompt = chat_template.format_messages(specialist="サッカー", name="金", league="Jリーグ")

chat.invoke(chat_prompt)

LangChain Expression Language (LCEL)

LCEL は、LangChain の複数の処理(プロンプト作成、モデル実行、結果処理など)をパイプライン演算子「|」で簡単に繋げるための記述方式です。
通常、以下のような処理を一つの Chain として順番に繋げることができます。

  1. プロンプト生成
  2. プロンプトを受け取り、言語モデルを使って結果を生成する
  3. モデルの結果を処理し、有用な形に変換する(例:整理された情報の抽出)
  4. 生成された結果をユーザーに提供するのに適した形に仕上げる

※ Chain は複数の機能要素を組み合わせて作る一連の処理フローです。
※ LCEL の詳細は以下のリンクをご参考ください。
LangChain Expression Language Concepts

以下のコードは、LCEL を使って、チャットモデルを実行するサンプルコードです。
chat_template でプロンプトを作成し、chat でモデルを順次実行します。

from langchain.chat_models import ChatOpenAI 
from langchain.prompts import ChatPromptTemplate

chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)

chat_template = ChatPromptTemplate.from_messages([
    ("system", "あなたは Jリーグの専門家です"),
    ("user", "{team}の試合はいつ始まりますか?"),
])

# チャットテンプレートとモデルを組み合わせて Chain を作成
# 1. chat_template: プロンプトテンプレートからメッセージを生成
# 2. chat: 生成されたメッセージを使ってAIモデルが応答
chat_chain = chat_template | chat

以下のサンプルコードのように, Chain を組み合わせることで、より複雑な処理を実現できます。

from langchain.chat_models import ChatOpenAI 
from langchain.prompts import ChatPromptTemplate

chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)

# chat テンプレートを作成
chat_template = ChatPromptTemplate.from_messages([
    ("system", "あなたは Jリーグの専門家です"),
    ("user", "{team}の試合はいつ始まりますか?"),
])

# chat Chain を作成
chat_chain = chat_template | chat

# team テンプレートを作成
team_template = ChatPromptTemplate.from_messages([
    ("system", "あなたはJリーグ専門家で、Jリーグチームの試合日程について詳しいです。もしわからない場合は、「わからない」と言ってください。"),
    ("user", "{schedule}の試合で最も早い試合を教えてください"),
])

# team Chain を作成
team_chain = team_template | chat

# 複数の Chain を組み合わせる
final_chat_chain = {"schedule":chat_chain} | team_chain

# チーム名を指定して情報を取得
final_chat_chain.invoke({
    "team": "神戸"
})

上記のコードの処理の流れは以下のようになります。

  1. final_chat_chain の実行

    • invoke()に「team: 神戸」が渡される
  2. chat_chain の処理

    • chat_template を使用してプロンプトを生成
    • チャットモデルで実行
    • 結果が「schedule」パラメータとして保存される
  3. team_chain の処理

    • 保存された「schedule」の情報を使用して
    • team_template で新しいプロンプトを生成
    • 最終的な結果を返す

FewShotChatMessagePromptTemplate

Few-Shot Learning を使って、会話型 AI のプロンプトテンプレートを作成する際に使用します。

※ Few-Shot Learning とは、少数の例示から AI モデルが学習できる手法です。
What is few-shot learning

FewShotChatMessagePromptTemplate は、サンプルとなる会話例を使って、AI の応答パターンを設定するためのテンプレートです。
つまり、「このような応答をしてください」という例を示すことで、AI に望ましい会話パターンで応答させる仕組みです。

from langchain.chat_models import ChatOpenAI
from langchain.prompts.few_shot import FewShotChatMessagePromptTemplate
from langchain.prompts import ChatMessagePromptTemplate, ChatPromptTemplate

chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)

# # Few-Shot Learning 用のサンプルデータを定義
# 商品情報の形式を例示として設定
examples = [
   {
       "product": "wine 冷蔵庫",
       "answer": """
       wine 冷蔵庫
       詳細: wineを保存するための冷蔵庫です。
       平均値段: 30000円
       """,
   }
]

# サンプルデータ用のプロンプトテンプレートを作成
# ユーザーの質問と期待する応答形式を定義
example_prompt = ChatPromptTemplate.from_messages(
   [
       ("user", "{product}について知りたい。"),
       ("system", "{answer}"),
   ]
)

# Few-Shot プロンプトの作成
# サンプルデータとプロンプトテンプレートを組み合わせる
example_prompt = FewShotChatMessagePromptTemplate(
   example_prompt=example_prompt,
   examples=examples,
)

# 最終的なプロンプトテンプレートの作成
# システムロール設定、Few-Shot プロンプト、ユーザー入力を組み合わせる
last_prompt = ChatPromptTemplate.from_messages(
   [
       ("system", "あなたは家電商品全問がです。短く答えてください。"), # 役割の設定
       example_prompt, # Few-Shot の例を含める
       ("user", "{product}について知りたい"), # 実際の質問
   ]
)

# chain の作成と実行
chain = last_prompt | chat

chain.invoke({"product": "冷蔵庫"})

上記のコードの実行結果は以下のようになります。
AI は設定された形式(商品名、詳細、平均値段)に従って回答しています。

# output
AIMessage(content='冷蔵庫\n詳細: 食品や飲料を冷やして保存するための家電製品です。\n平均値段: 50000円')

Prompt template を別のファイルで管理する

プロンプトテンプレートを JSON や YAML 形式の外部ファイルで管理できます。
load_prompt 関数を使用して、これらのファイルから簡単にテンプレートを読み込むことができます。


from langchain.prompts.few_shot import FewShotChatMessagePromptTemplate
from langchain.prompts import load_prompt

chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)


# 外部ファイルからプロンプトテンプレートを読み込み
prompt = load_prompt("./prompt.yaml")

# テンプレートに変数を適用
prompt.format(league="Jリーグ")

サンプルファイルの内容は以下のようになります。

# prompt.yaml
_type: "prompt"
template: "{league}のチームを教えてください。"
input_variables: ["league"]
// prompt.json
{
  "_type": "prompt",
  "template": "{league}のチームを教えてください。",
  "input_variables": ["league"]
}

キャッシュ機能の活用

LangChain では、同じ質問に対する応答をキャッシュすることで、API コストを削減できます。
set_llm_cacheInMemoryCache を使用してキャッシュを実装できます。

from langchain.chat_models import ChatOpenAI
from langchain.globals import set_llm_cache
from langchain.cache import InMemoryCache

chat = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)

set_llm_cache(InMemoryCache())

chat.invoke("Jリーグのチームを教えて")

このコードを実行した後、別のセルで同じ質問 chat.invoke("Jリーグのチームを教えて")を実行してみましょう。キャッシュが有効に機能しているため、API リクエストを発生させることなく、即座に結果が返されます。

image

DB にキャッシュを保存する方法もありますので詳細は以下のリンクを参照してください。
LangChain Python API Reference - set_llm_cache

会話履歴の管理(Memory 機能の活用)

ConversationSummaryBufferMemory を使用して会話履歴を管理する方法を説明します。

Memory 機能の主なメリット:

  1. トークン管理の最適化

    • max_token_limit で会話履歴の長さを制御
    • APIコストの効率的な管理が可能
  2. 会話の質の向上

    • 会話の文脈を維持
    • 長い会話を自動的に要約して管理
  3. コストパフォーマンスの向上

    • 必要な会話履歴のみを保持
    • 要約機能による効率的なトークン使用

Memory に関する詳細は以下のリンクを参照してください。
LangChain Python API Reference - Memory

最新 version を使いたい場合は、以下のリンクを参照してください。
Migrate to LangGraph memory

from langchain.memory import ConversationSummaryBufferMemory
from langchain.chat_models import ChatOpenAI
from langchain.schema.runnable import RunnablePassthrough
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder

chat_llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)

# 会話履歴を管理するメモリオブジェクトの作成
memory = ConversationSummaryBufferMemory(
    llm=chat_llm, # ここには ChatOpenAI インスタンスを設定
    max_token_limit=10, # token limit を設定(保持する最大トークン数)
    return_messages=True, # True:メッセージ形式で返す
)

# プロンプトテンプレートの設定
# MessagesPlaceholder について:
# - 会話履歴を配置する位置を指定するための特別なマーカー
# - メモリに保存された過去の会話が自動的にこの位置に展開されます
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", "あなたは user をアシストする AI です。"),
         
        MessagesPlaceholder(variable_name="history"),
        ("user", "{question}"),
    ]
)


# memory.load_memory_variables({}) は保存された会話履歴を辞書形式で返す。
# ["history"]で会話履歴のみを抽出。
def memory_load(_):
    return memory.load_memory_variables({})["history"]

# chain の構成
# 1. RunnablePassthrough.assign(history=memory_load): 
#    memory_load 関数から会話履歴を取得し、historyキーに割り当て
# 2. prompt: 会話履歴とユーザーの質問を含むプロンプトを生成
# 3. chat_llm: 生成されたプロンプトを使用してAIの応答を生成
chain = RunnablePassthrough.assign(history=memory_load) | prompt | chat_llm

# チェーンを実行し、結果をメモリに保存する関数
def process_chain(question):
    result = chain.invoke({"question": question})
    # 現在の会話内容をメモリに保存
    memory.save_context(
        {"input": question},
        {"output": result.content},
    )
    print(result)

1回目の process_chain を実行

process_chain("私わきむたろうです。")
# output
content='はじめまして、わきむたろうさん。何かお手伝いできることはありますか?'

2回目の process_chain を実行

process_chain("私は誰ですか?")

以前の会話を覚えているため、名前を正確に回答しています。
Memory 機能により、AIは前回の会話で登場した名前を記憶し、適切な応答を返すことができています。

# output
content='あなたはきむたろうさんですね。何かお手伝いできることがありますか?'

実践編

実践編では、まず Retrieval-Augmented Generation (RAG) の基本概念を説明し、その後 Streamlit を使用したチャットアプリの開発方法を紹介します。

Retrieval-Augmented Generation (RAG)

Retrieval-Augmented Generation (RAG) とは、 Large Language Models (LLM) に外部の文書やデータを検索・取得(Retrieval)して組み合わせることで、より正確で信頼性の高い回答を生成する手法です。

主な特徴:

  1. 外部知識の活用:データベースや文書から関連情報を検索
  2. 最新情報の反映:モデルの学習データ以降の新しい情報も利用可能
  3. 根拠のある回答:参照元の情報に基づいた信頼性の高い応答を生成

RAG の詳細については以下のリンクを参照してください。
Retrieval augmented generation Concepts
Build a Retrieval Augmented Generation (RAG) App:

処理の流れ:

  1. ドキュメントの分割(Splitting)

    • 長い文書を適切な大きさのチャンクに分割します。
    • これは検索の効率性とトークン制限に対応するためです。
    • LangChainのTextSplitterを使用します
  2. Vector化(Embedding)

    • 分割したテキストを Vector に変換します。
      • Vector は、単語や文章を数値で表現したものです。
    • これは意味的な類似性検索を可能にするために必要です。
    • OpenAI の Embedding API を使用します
  3. Vector store の作成

  • 生成されたベクトルをデータベースに保存します
  • これは効率的な検索のために必要です
  • Chroma や FISSなどの Vecotor store を使用します。
  1. 検索と回答生成
    • 質問に関連する文書を検索します
    • 検索結果を基に LLM が回答を生成します

上記の処理を実装のサンプルコードは以下のようになります。

from langchain.memory import ConversationSummaryBufferMemory
from langchain.chat_models import ChatOpenAI
from langchain.schema.runnable import RunnablePassthrough
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.storage import LocalFileStore
from langchain.document_loaders import UnstructuredFileLoader
from langchain.text_splitter import CharacterTextSplitter
from langchain.embeddings import OpenAIEmbeddings, CacheBackedEmbeddings
from langchain.vectorstores import FAISS

chat_llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)

# 文書の分割(Splitting)設定
# 長い文書を小さな部分(チャンク)に分割します
#
# - chunk_size: 各チャンクのサイズ(200文字)
#   - 文書を200文字ごとに区切ります
#   - 例:400文字の文書は2つのチャンクに分割されます
#
# - chunk_overlap: チャンク間の重複部分(100文字)
#   - 前のチャンクの最後の100文字を次のチャンクの先頭に含めます
#   - これにより文脈が途切れることを防ぎます
#
# 分割例:
# 元の文書:「あいうえお かきくけこ さしすせそ たちつてと...」(400文字)
# chunk 1:「あいうえお かきくけこ...」(200文字)
# chunk 2:「けこ さしすせそ...」(前のチャンクと100文字重複)
text_chunker = CharacterTextSplitter.from_tiktoken_encoder(
    separator="\n",
    chunk_size=200,
    chunk_overlap=100,
)

# embeding の キャッシュを保存するローカルディレクトリの設定
#
# キャッシュとは?
# - エンベディング(テキストの数値変換)結果を保存する仕組み
# - 同じテキストの変換を何度も行わないようにする
#
# 動作の流れ:
# 1. 初回実行時:
#    テキスト → OpenAI API 呼び出し → embeding 取得 → .cache フォルダに保存
#
# 2. 2回目以降:
#    テキスト → .cache フォルダをチェック → 既にある場合はそれを使用 (ない場合はAPIを呼び出し
#
# メリット:
# - OpenAI API の呼び出し回数を減らせる(コスト削減)
# - 処理速度が向上する
cache_dir = LocalFileStore("./.cache/")

# ドキュメント(テキストファイル)の読み込み
loader = UnstructuredFileLoader("./files/test_document.txt")

# 読み込んだドキュメントを分割
splited_docs = loader.load_and_split(text_splitter=text_chunker)

# Vector 化 (Embedding)
# OpenAI の Embedding モデルを初期化
embeddings = OpenAIEmbeddings()

# Embedding 処理結果をキャッシュして API 呼び出しを最小限にsする
cached_embeddings = CacheBackedEmbeddings.from_bytes_store(embeddings, cache_dir)

## FAISS を使用して文書の vector を保存し検索可能にする
vectorstore = FAISS.from_documents(splited_docs, cached_embeddings)

# 検索用の retriver を作成
# ご参考: https://python.langchain.com/docs/concepts/retrievers/
retriver = vectorstore.as_retriever()

# プロンプトテンプレートの設定
# - system: AIの役割と応答方法を定義
# - user: ユーザーの質問を受け取る部分
prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "あなたは日本に詳しいアシスタントです。以下のコンテキストのみを使用して質問に答えてください。答えがわからない場合は、「わかりません」と答えてください。決して適当に答えないでください。\n\n{context}",
        ),
        ("user", "{question}"),
    ]
)

# chain の構成と実行フロー
# 1. 入力の準備:
#    - context: retriver が関連文書を検索
#    - question: RunnablePassthrough() でユーザーの質問をそのまま転送
#
# 2. プロンプトの生成:
#    - 検索された文書(context)と質問を組み合わせる
#    - 例:「context:○○○○\n質問:利根川はどこに流れていますか?」
#
# 3. 最終応答の生成:
#    - chat_llmが組み合わされたプロンプトを基に回答を生成
chain = (
    {
        "context": retriver,
        "question": RunnablePassthrough(),
    }
    | prompt
    | chat_llm
)

# チェーンの実行
# 1. chain.invoke から質問を受け取る
# 2. 関連文書を検索
# 3. プロンプトを生成
# 4. 回答を生成
chain.invoke("利根川はどこに流れていますか?")

ChatBot を Streamlit で作成する

Streamlit を使って、簡単なチャットアプリを作成します。
Streamlit は、Python でデータを簡単に可視化できるオープンソース Python フレームワークです。
本記事の requirements.txt をインストール出来ていればすぐに実行できます。

基本的には以下のように Streamlit をインポートし、必要な widget を使って画面を作成します。
以下はメイン画面のタイトルを表示する例です。


import streamlit as st

st.title("Home")

Python ファイルを作成し、Streamlit をインポートして、st.title("タイトルを入力") で画面にすぐに表示されます。

(env) [~/langChain-gpt]$ streamlit run Home.py 

image

Sidebar も以下のように簡単に作成できます。


import streamlit as st

# main タイトルを設定
st.title("Home")

# Sidebar を作成 
# 以下のように書くと、Sidebar がすぐ表示される
# st.sidebar.title("Navigation")

# with を使うと、with block 中にある widget のみ sidebar を表示できる
with st.sidebar:
    st.title("Side Navigation")
    page = st.radio("Choice", ["Home", "A-GPT", "B-Gemini"])

image

Streamlit に関する上記のようなウィジェットは多数ございますので、詳しくは以下のドキュメントをご参照ください。
Streamlit documentation

本格的にチャットアプリを作成する前に、app.py ファイルと pages フォルダを作成します。
※「基本編」で作成した .env ファイルは Streamlit では認識されないため、.streamlit フォルダを作成し、その中に secrets.toml ファイルを作成してください。
そして、.env に記載されている OpenAI の API キーをコピーして保存してください。

/Root
  |-- .streamlit
  |     |-- secrets.toml
  ├── app.py
  ├── pages
      ├── test_gpt.py

※ pages フォルダに test_gpt.py を作成すると自動的に Sidebar に表示されます。

image

コードは以下のようになります。

import streamlit as st
import time

# メインタイトルを設定
st.title("Test GPT Chatbot")

# session_stat について:
# Streamlit の特徴:
# - ユーザーの操作(入力など)があるたびに、スクリプト全体が再実行される
# - そのため、通常の変数では値を保持できない
# - session_state を使うことで、再実行しても値を保持できる
if "messages" not in st.session_state:
    # messages キーを初期化
    # - チャット履歴を保存するための空リストを作成
    # - この履歴は、ブラウザのセッションが続く限り保持される
    st.session_state["messages"] = []

# メッセージを表示と保存をする関数
def post_message(message, role, save=True):
    # role に応じてメッセージを表示
    # - role: "user" または "ai"
    with st.chat_message(role):
        st.write(message)
        
     # save=True の場合、メッセージを session_state に保存
    if save:
        st.session_state["messages"].append({"message": message, "role": role})

# 保存された全メッセージを表示
# - session_state から履歴を取得して表示
# - save=False で表示のみ行い、重複保存を防ぐ
for message in st.session_state["messages"]:
    post_message(
        message["message"],
        message["role"],
        save=False,
    )


# chat_input: 
# - メッセージを送信する widget
# - ユーザーがメッセージを入力できるUIを表示
message = st.chat_input("メッセージを送信 ")

# メッセージが入力された場合の処理
if message:
    # ユーザーメッセージを表示・保存
    post_message(message, "user")
    # 応答待ち時間を演出
    time.sleep(3)
    # AIの応答を表示・保存
    post_message(f"あなたのメッセージ: {message}", "ai")

    # サイドバーに session_state の内容を表示
    # - デバッグ用:現在の状態を確認できる
    with st.sidebar:
        st.write(st.session_state)

次は「実践編」の最初に作成した jupyter notebook コードを上記に移植します。
まずは、Chat 用のモデルをインポートし、ファイルを読み込む関数を作成します。
Streamlit でファイルをアップロードする際に、関数が何度も実行されるのを防ぐためにキャッシュを利用しています。
詳しくは、以下のコード内のコメントをご参照ください。


from langchain.chat_models import ChatOpenAI

# main タイトルを設定
st.title("Test GPT Chatbot")

chat_llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)


"""
reate_document_retriever():
ファイルからテキストを抽出し、検索可能な形式に変換するための関数
    
    処理内容:
    1. ファイル読み込み
    2. テキスト分割
    3. ベクトル化
    4. 検索システム作成
    
    Args:
        file: アップロードされたファイル
    Returns:
        retriever: 文書検索用の retriever
"""
# @st.cache_data: 同じファイルの場合、処理結果をキャッシュして再利用
# show_spinner: 処理中に表示されるローディングメッセージ
@st.cache_data(show_spinner="embeding..")
def create_document_retriever(file):
    # ファイルの読み込みと保存
    # - アップロードされたファイルの内容を読み込む
    file_read_content = file.read()

    # キャッシュ用のファイルパスを設定
    # .cache/files/ディレクトリにファイルを保存
    cache_file_path =  f"./.cache/files/{file.name}"

    # ファイルをキャッシュディレクトリに保存
    with open(cache_file_path, "wb") as f:
        f.write(file_read_content)
    
    # embeding のキャッシュディレクトリを設定
    cache_dir = LocalFileStore(f"./.cache/embeddings/{file.name}")
    
    # ファイルのテキスト抽出
    # - UnstructuredFileLoader を使用してファイルからテキストを抽出
    loader = UnstructuredFileLoader(cache_file_path)

    # テキストの分割設定
    # - separator: 改行で分割
    # - chunk_size: 各チャンクの最大サイズ(200トークン)
    # - chunk_overlap: チャンク間の重複サイズ(100トークン)
    text_chunker = CharacterTextSplitter.from_tiktoken_encoder(
    separator="\n",
    chunk_size=200,
    chunk_overlap=100,
    )

    # テキストを分割
    docs = loader.load_and_split(text_splitter=text_chunker)
    
    # embeding の設定
    # - OpenAIのエンベディングモデルを初期化
    openai_embeddings = OpenAIEmbeddings()

    # キャッシュを使用する embedding 設定
    # - 同じテキストの場合、既存の embedding を再利用
    cached_embeddings = CacheBackedEmbeddings.from_bytes_store(openai_embeddings, cache_dir)

    # vectorstore の作成
    # FAISSを使用してベクトルデータベースを作成
    vectorstore = FAISS.from_documents(docs, cached_embeddings)

    # 検索用リトリーバーの作成
    # vctorstore から類似文書を検索するための retriever を作成
    retriever = vectorstore.as_retriever()
    return retriever

# ...省略

次はメッセージを送信する関数と会話の履歴処理する関数を作成します。
※ post_message 関数を既存のコードをそのまま使ってます。

# メッセージを表示と保存をする関数
def post_message(message, role, save=True):
    # role に合わせてメッセージを表示
    with st.chat_message(role):
        st.write(message)
        
    # save=True でメッセージを session_state 保存する
    if save:
        st.session_state["messages"].append({"message": message, "role": role})

# 保存された全メッセージを表示
def show_message_history():
    # session_state から履歴を取得して表示
    # save=False で表示のみ行い、重複保存を防ぐ
    for message in st.session_state["messages"]:
        post_message(
            message["message"],
            message["role"],
            save=False,
        )

# リトリーバーから取得した文書(docs)の内容(page_content)を改行(\n)で区切って連結する関数
def join_docs(docs):
    return "\n".join(document.page_content for document in docs)

プロンプトは既存のもをそのまま使います。


prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "あなたは日本に詳しいアシスタントです。以下のコンテキストのみを使用して質問に答えてください。答えがわからない場合は、「わかりません」と答えてください。決して適当に答えないでください。\n\n{context}",
        ),
        ("user", "{question}"),
    ]
)

ファイルアップロードはサイドバーで行います。

with st.sidebar:
    file = st.file_uploader(
        "次の形式のファイルをアップロードしてください。.txt .pdf or .docx ",
        type=["pdf", "txt", "docx"],
    )

ファイルがアップロードされた場合の処理
は以下のようになります。


if file:
     # create_document_retriever でファイルを処理し、検索可能な形式に変換
    retriever = create_document_retriever(file)

    # 準備完了メッセージを表示(履歴には保存しない)
    post_message("準備できました!", "ai", save=False)

    # これまでのメッセージ履歴を表示
    show_message_history()

    # ユーザーがドキュメントに関する質問を入力できる widget を表示
    message = st.chat_input("アップロードしたドキュメントに関する質問を入力してください。")
    
    # ユーザーが質問を入力した場合の処理
    if message:
        # ユーザーの質問をチャット履歴に追加
        post_message(message, "user")

        # 質問応答 chain
        chain = (
            {
                "context": retriever | RunnableLambda(join_docs),
                "question": RunnablePassthrough(),
            }
            | prompt
            | chat_llm
        )
        # チェーンを実行して回答を生成
        response = chain.invoke(message)

        post_message(response.content, "ai")

else:
    st.session_state["messages"] = []

全体のコードは以下のようになります。

import streamlit as st
from langchain.chat_models import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain.document_loaders import UnstructuredFileLoader
from langchain.embeddings import CacheBackedEmbeddings, OpenAIEmbeddings
from langchain.schema.runnable import RunnableLambda, RunnablePassthrough
from langchain.storage import LocalFileStore
from langchain.text_splitter import CharacterTextSplitter
from langchain.vectorstores.faiss import FAISS


# main タイトルを設定
st.title("Test GPT Chatbot")

chat_llm = ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0.1)

# ファイルからテキストを抽出し、検索可能な形式に変換するための関数
@st.cache_data(show_spinner="embeding..")
def create_document_retriever(file):
    # ファイルの読み込みと保存
    # - アップロードされたファイルの内容を読み込む
    file_read_content = file.read()

    # キャッシュ用のファイルパスを設定
    # .cache/files/ディレクトリにファイルを保存
    cache_file_path =  f"./.cache/files/{file.name}"

    # ファイルをキャッシュディレクトリに保存
    with open(cache_file_path, "wb") as f:
        f.write(file_read_content)
    
    # embeding のキャッシュディレクトリを設定
    cache_dir = LocalFileStore(f"./.cache/embeddings/{file.name}")
    
    # ファイルのテキスト抽出
    # - UnstructuredFileLoader を使用してファイルからテキストを抽出
    loader = UnstructuredFileLoader(cache_file_path)

    # テキストの分割設定
    text_chunker = CharacterTextSplitter.from_tiktoken_encoder(
    separator="\n",
    chunk_size=200,
    chunk_overlap=100,
    )

    # テキストを分割
    docs = loader.load_and_split(text_splitter=text_chunker)
    
    # embeding の設定
    # - OpenAIのエンベディングモデルを初期化
    openai_embeddings = OpenAIEmbeddings()

    # キャッシュを使用する embedding 設定
    # - 同じテキストの場合、既存の embedding を再利用
    cached_embeddings = CacheBackedEmbeddings.from_bytes_store(openai_embeddings, cache_dir)

    # vectorstore の作成
    # FAISSを使用してベクトルデータベースを作成
    vectorstore = FAISS.from_documents(docs, cached_embeddings)

     # 検索用リトリーバーの作成
    # vctorstore から類似文書を検索するための retriever を作成
    retriever = vectorstore.as_retriever()
    return retriever
  

# session_state : 
# streamlit はコードが実行されるたびに、全コードーが再実装されるため、
# session_state を使ってメッセージを保持する
if "messages" not in st.session_state:
    # messages キーでメッセージを保存する
    # 初期は空のリスト
    st.session_state["messages"] = []

# メッセージを表示と保存をする関数
def post_message(message, role, save=True):
    # role に合わせてメッセージを表示
    with st.chat_message(role):
        st.write(message)
        
    # save=True でメッセージを session_state 保存する
    if save:
        st.session_state["messages"].append({"message": message, "role": role})

# 保存された全メッセージを表示
def show_message_history():
    for message in st.session_state["messages"]:
        post_message(
            message["message"],
            message["role"],
            save=False,
        )

# リトリーバーから取得した文書(docs)の内容(page_content)を改行(\n)で区切って連結する関数
def join_docs(docs):
    return "\n".join(document.page_content for document in docs)

prompt = ChatPromptTemplate.from_messages(
    [
        (
            "system",
            "あなたは日本に詳しいアシスタントです。以下のコンテキストのみを使用して質問に答えてください。答えがわからない場合は、「わかりません」と答えてください。決して適当に答えないでください。\n\n{context}",
        ),
        ("user", "{question}"),
    ]
)

with st.sidebar:
    file = st.file_uploader(
        "次の形式のファイルをアップロードしてください。.txt .pdf or .docx ",
        type=["pdf", "txt", "docx"],
    )

if file:
    # create_document_retrieverでファイルを処理し、検索可能な形式に変換
    retriever = create_document_retriever(file)
    
    # 準備完了メッセージを表示(履歴には保存しない)
    post_message("準備できました!", "ai", save=False)

    # これまでのメッセージ履歴を表示
    show_message_history()

    # ユーザーがドキュメントに関する質問を入力
    message = st.chat_input("アップロードしたドキュメントに関する質問を入力してください。")
    
     # ユーザーが質問を入力した場合の処理
    if message:
        post_message(message, "user")
        chain = (
            {
                "context": retriever | RunnableLambda(join_docs),
                "question": RunnablePassthrough(),
            }
            | prompt
            | chat_llm
        )
        response = chain.invoke(message)
        post_message(response.content, "ai")

else:
    st.session_state["messages"] = []

上記のコードを実行すると、以下のような結果が得られます.

  1. 任意のドキュメントファイルをアップロードする
    image

  2. アップロードしたドキュメントに関する質問を入力すると、AI が回答
    image

※ 上記に使用した text_document.txt ファイルの中身は以下のようになります。

利根川は大水上山を水源として関東地方を北から東へ流れ、太平洋に注ぐ河川。河川法に基づく国土交通省政令により1965年(昭和40年)に指定された一級河川・利根川水系の本流である。
「坂東太郎」の異名を持つ。
河川の規模としては日本最大級の規模を持ち、東京都を始めとした首都圏の水源として日本の経済活動上重要な役割を有する、日本を代表する河川の一つである。
群馬県利根郡みなかみ町にある三国山脈の一つ、大水上山(標高1,840メートル)にその源を発し、高崎市付近までは概ね南へ流れる

まとめ

以上で、LangChain と Streamlit 使って簡単なチャットアプリを作成する方法をご紹介いたしました。

今回初めて使ってみた感想は以下の通りです:

  1. LangChain について

    • 一般的なプロンプトよりも RAG を活用することで、より正確な回答が得られます。
    • キャッシュ機能により、API の利用を効率化できます。
    • 様々なチャットモデルを手軽に切り替えることができます。
    • 頻繁にアップデートされるため、バージョンによって使用できない機能があります。
    • LangSmith と連携することで、LLM の処理フローが把握しやすくなります。
  2. Streamlit について

    • 少ないコードで対話型UIが実現できます。
    • 豊富なウィジェットにより、簡単に画面を作成できます。
    • デザインのカスタマイズには制限があります。
    • Python で手軽にプロトタイプが作成できて便利です。

ご紹介した LangChain の機能以外にも、音声、画像、動画を処理する機能が用意されています。
LLM アプリケーションを開発する際には、これらの機能も役立つかと思います。

初心者向けの内容のため、物足りなさを感じる方もいらっしゃるかもしれませんが、LangChain を初めて体験される方の参考になれば幸いです。
最後までお読みいただき、ありがとうございました。

14

Discussion