【AIリスキリング】50分で完成!LM Studio×LangChainで作る無料RAG対応チャットボット

先日はRAGとMCPの概念を学びました。今回は実際に手を動かして、LM StudioLangChainを使ったRAGチャットボットを作成してみたいと思います。概念がわかっていても実際にプログラミングを行わないとわからないことが多いので🤔今回は環境づくりやコーディングが中心になります。体感50分くらいでできるかなといったところでしょうか。

参考

uepon.hatenadiary.com

LangChainとLM Studioで実践的なRAGチャットボット開発!

今回は以下を目的にしています。

  1. ローカルLLMとの対話システム … プライバシーを守りながら高性能なAIを活用
  2. RAG(検索拡張生成)の実装 … ローカルにあるドキュメント(テキストファイル)を基に回答する仕組み
  3. チャットUIのテンプレート化 … 別の用途でも使えるレベルのテンプレート化

自分はこれを研究の雛形に拡張していく予定えです。皆さんもぜひ自分の業務に合わせてカスタマイズしてみてください。

LangChainとは?

今回はLLMはLM Studioを使い、フレームワークとしてLangChainを使用します。まずはLangChainの概要を簡単に説明します。

LangChainは、LLMを使ったアプリケーションを構築するための便利なフレームワークです。LLMを直接呼び出すだけではなく、処理の組み合わせや複雑なAI処理をシンプルなコードで実現が可能です。

私もこれまではAPIを直接呼び出して、コードを色々と組み合わせていました。ただ、LangChainのドキュメントを見て、かなり省力化できそうだなと感じました。今後はLangChainを使用して行こうと考えています。

LangChainが解決する現実の問題

LLMを使用するコーディングでは、以下のような問題点や悩みがあります。

LLM使用では、やりとり間の会話の記憶がない

LLMをAPI経由で使用していると一回一回のプロンプトでのやり取りでは期待した応答が得られないことがあります。 それは、LLMが会話の履歴を覚えていないからです。そういった場合には、会話の文脈を理解するために、過去のやり取りをプロンプトに含める(文脈を持たせる)必要があります。

例えば、以下のような会話を考えてみましょう。

# 履歴を意識しない場合(毎回文脈を失う)
やりとり1 > "私の名前は太郎です"
# ここで文脈が失われる
#
# 次のやりとりへ
#
やりとり2 > "私の名前は?"
#「分かりません」と答えてしまう

本来であれば、次のやりとりでは「太郎さんですね」と答えてほしいところです。これを実現するには、やりとり1の内容をやりとり2のプロンプトに含める必要があります。

# 履歴を意識(保存)する場合
やりとり1 > "私の名前は太郎です"
# 履歴が保存される
#
# 次のやり取り
#
# やりとり1の履歴が自動的にプロンプトに含まれる
やりとり2 > "私の名前は太郎です"
やりとり2 > "私の名前は?"
#「太郎さんですね」と答える

LangChainを使用することで、会話の履歴を自動的に管理することができます。これは便利です。


大量の文書から必要な情報を探すのが大変

LangChainを使用することで、文書の検索や情報抽出が効率的に行えるようになります。

例えば、参照したい情報が100個のファイルの場合、すべてをプロンプトに入れることはできません。 単純に大量の文書をプロンプトに入れてしまうと、文字数制限に引っかかってしまうのです。

そんな問題を解決するために、LangChainではRAGでよく使用するベクトルデータベースを扱うクラスが用意されており、必要な情報だけをプロンプトに含めることができます。


複数の処理を組み合わせるコードが複雑

LangChainを使用することで、複数の処理を簡潔に連携させることができます。

例えば、よくある形として以下のようなコードが考えられます。ほとんどの場合が、以下のような流れになるでしょう。

  1. 質問を要約
  2. データベース検索
  3. 回答生成
  4. 回答を整形

それを、コードではなくチェインという形式で処理表現できます。チェインはシェルのパイプのように処理を連結できます。以下のように、表現方法はいくつかあります。

パイプでつなぐイメージ(処理の流れが直感的に分かる)

# チェイン定義(擬似コード)
chain = "質問を要約 | データベース検索 | 回答生成 | 回答整形"

# チェインの実行
run(chain)

リストで表すイメージ(コードとの親和性が高い)

# チェイン定義(擬似コード)
chain = [
    "質問を要約",
    "データベース検索",
    "回答生成",
    "回答整形"
]

# チェインの実行
chain.run()

上記の処理のコードは、本来であればにすると複雑になりがちですが、LangChainを使用することで、その悩みを解消し、可読性が向上します。


LangChainは、このようなLLMを使用したコーディングの問題点や悩みを解決するための便利なフレームワークです。

LangChainの主要コンポーネント

先ほど紹介した問題点を解決するために、LangChainは以下の主要なコンポーネントを提供しています。

  1. Chains(チェーン)- 処理の連結 … 複数のLLM呼び出しや処理を順番に実行したい時
  2. Memory(メモリ)- 会話の記憶 … チャットボットや対話システムを作る時
  3. Document Loaders(文書ローダー)- 様々な形式の読み込み … PDF、Word、Webページなど多様なソースから情報を取得したい時
  4. Vector Stores(ベクトルストア)- 意味的検索 … 大量の文書から関連情報を高速に検索したい時
  5. Agents(エージェント)- 自律的な判断と実行 … AIに複数のツールを使い分けさせたい時

なぜ、LM Studio × LangChain × RAGを使用するのか?

今回は、LM Studio × LangChain × RAG の組み合わせでチャットボットを作成していますが、その理由は以下の通りです。 ※LM StudioでなくてもOllamaでも大差はないと思います。

  • データセキュリティ … 社内情報を外部に送信しない
  • コスト削減 … API利用料金が不要
  • 統一インターフェース … LangChainで様々なLLMを同じコードで扱える
  • 使いやすさ … LM StudioGUIで簡単にモデル管理が可能

今回使用する技術

今回はWindows環境、およびWSL2のUbuntuPythonで開発を行います。そのほか、以下の技術を使用していきます。

  • LM Studio … ローカルでLLMを簡単に動かせる優れたGUIツール
  • uv … 次世代の超高速Python環境管理ツール
  • LangChain … AI開発のベストプラクティスが詰まったフレームワーク
  • Chainlit … Pythonだけで美しいチャットUIを構築
  • ChromaDB … シンプルで高速なベクトルデータベース

今回はLM StudioはすでにWindowsにインストールされていることを前提とします。まだインストールされていない方は以下も参考にしてください。

uepon.hatenadiary.com

作業ステップ

以下のようなステップで進めていきます。2.のステップで基本的なチャットボットを完成させ、3.のステップでRAG機能を追加していきます。2.のステップが終われば、LM Studioと対話できるチャットボットの形になるので、まずはそこまでを目指しましょう。

  1. 環境構築(10分程度)
  2. 基本チャット実装(10分程度)
  3. RAG機能の追加(15分程度)
  4. 動作確認と改善(10分程度)
  5. まとめと発展(5分程度)

1. 開発環境のセットアップ

1.1 LM Studioのセットアップ

LM Studioの動作とAPIサーバーの起動を確認します。

LM Studioの起動

インストール後、LM Studioを起動

モデルのダウンロード

推奨モデルはGPT-oss 20Bとします(PCスペックに応じて選択)

APIサーバーの起動

起動したLM Studioが【Developer】モードになっているか確認を行う。

左にある【Developer】アイコンをクリックして

【Setting】ボタンをクリックし、設定を行う。

設定値は以下の通り

設定後、【Server Running】のトグルボタンをONにする。画面上部にReachable at:と表示され、WSLからアクセス可能なURLが表示されます。

これでLM StudioAPIサーバーが起動します、表示されたURLはメモしておいてください。

操作の詳細は以下を参照してください。

参照リンク

1.2 uvによるプロジェクトの初期化

WSLのUbuntuターミナルを開いて、以下のコマンドを実行します。

# uvのインストール(未インストールの場合)
curl -LsSf https://astral.sh/uv/install.sh | sh

# プロジェクトの作成と移動
$ uv init rag-chat-app
$ cd rag-chat-app
# Pythonバージョンの確認(以下では3.12使用)
$ uv python pin 3.12

あとは、データ格納用のフォルダを作成します。

# ドキュメント格納フォルダ作成
$ mkdir documents

1.3 必要なライブラリのインストール

uvaddコマンドで必要なライブラリを追加します。

$ uv add chainlit langchain langchain-community langchain-openai
$ uv add chromadb sentence-transformers
$ uv add python-dotenv tiktoken
$ uv add types-requests

1.4 環境変数ファイルの設定

プロジェクト内に.envファイルを作成・編集して、LM Studioの設定を記述します。URLはLM StudioAPIサーバーのURL、APIキーはダミー値を使用します。モデルも適宜変更してください。今回は以下で設定した想定とします。

以降のホストのIPアドレス192.168.1.100としていますので、こちらも適宜変更してください。

.envファイルを作成

$ nano .env

.envファイルを以下のように編集

# LMStudio API設定
LMSTUDIO_API_BASE=http://192.168.1.100:1234/v1
LMSTUDIO_API_KEY=dummy-key
LMSTUDIO_MODEL_NAME=openai/gpt-oss-20b

# その他の設定(あれば)
TOKENIZERS_PARALLELISM=false

これで環境構築は完了です。


2. 基本的なチャットボットの実装

まずはシンプルなチャットボットから、LangChainを実際のコードで実装していきます。

2.1 LM Studio接続テスト

設定が完了していれば、LM StudioAPIサーバーに接続できるはずです。以下のコマンドでモデル一覧が取得できることを確認します。

$ curl -X GET http://192.168.1.100/v1/models
{
  "data": [
    {
      "id": "openai/gpt-oss-20b",
      "object": "model",
      "owned_by": "organization_owner"
    },
    {
      "id": "text-embedding-nomic-embed-text-v1.5",
      "object": "model",
      "owned_by": "organization_owner"
    },
    {
      "id": "google/gemma-3n-e4b",
      "object": "model",
      "owned_by": "organization_owner"
    }
  ],
  "object": "list"
}

つづいてLM Studioとの接続を確認するテストスクリプトを作成します。

test_lmstudio.py

"""LM Studio接続テストスクリプト"""

from typing import Optional
from langchain_openai import ChatOpenAI
from langchain.schema import BaseMessage, HumanMessage
from dotenv import load_dotenv
import os

# 環境変数の読み込み
load_dotenv()

def get_lmstudio_client() -> ChatOpenAI:
    """
    LM Studioクライアントを作成して返す。
    
    Returns:
        ChatOpenAI: LMStudioに接続するクライアント
    """
    return ChatOpenAI(
        base_url=os.getenv("LMSTUDIO_BASE_URL", "http://localhost:1234/v1"),
        api_key=os.getenv("LMSTUDIO_API_KEY", "lm-studio"),
        model=os.getenv("LMSTUDIO_MODEL", "local-model"),
        temperature=0.7
    )

def test_lmstudio_connection() -> bool:
    """
    LM Studioとの接続をテストする。
    
    Returns:
        bool: 接続成功時はTrue、失敗時はFalse
    """
    try:
        # LMStudioクライアントの作成
        llm: ChatOpenAI = get_lmstudio_client()
        
        # テストメッセージの作成
        test_message: BaseMessage = HumanMessage(
            content="こんにちは!元気ですか?"
        )
        
        # テストメッセージの送信
        response = llm.invoke([test_message])
        
        print("LMStudioとの接続成功!")
        print(f"応答: {response.content}")
        return True
        
    except Exception as e:
        print(f"エラー: {e}")
        print("\n確認事項:")
        print("1. LMStudioが起動しているか")
        print("2. APIサーバーが有効になっているか")
        print("3. .envファイルの設定が正しいか")
        return False

def main() -> None:
    """メイン関数"""
    success: bool = test_lmstudio_connection()
    exit(0 if success else 1)

if __name__ == "__main__":
    main()

テストを実行

以下のコマンドでテストを実行します。uvの環境を使用しているので、uv runコマンドを使用しています

$ uv run python test_lmstudio.py
LMStudioとの接続成功!
応答: こんにちは!はい、元気にしています。今日はどんなことをお手伝いできますか?😊

うまく接続ができたら次に進みます。

2.2 基本的なチャットボット実装

simple_chat.pyに以下のコードを記述します。

simple_chat.py

"""
LM StudioとChainlitを使用したチャットボットアプリケーション。

このモジュールは、LangChainを使用してLM StudioのローカルLLMと対話し、
Chainlitで美しいチャットUIを提供します。
"""

from typing import Optional, Dict, Any
import chainlit as cl
from langchain_openai import ChatOpenAI
from langchain.memory import ConversationBufferMemory
from langchain.chains import ConversationChain
from langchain.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain.schema import BaseMessage
import os
from dotenv import load_dotenv

# 環境変数の読み込み
load_dotenv()

def create_llm() -> ChatOpenAI:
    """
    LM StudioのLLMインスタンスを作成する。
    
    Returns:
        ChatOpenAI: 設定済みのLLMインスタンス
    """
    return ChatOpenAI(
        base_url=os.getenv("LMSTUDIO_BASE_URL", "http://localhost:1234/v1"),
        api_key=os.getenv("LMSTUDIO_API_KEY", "lm-studio"),
        model=os.getenv("LMSTUDIO_MODEL", "local-model"),
        temperature=0.7,
        streaming=True  # ストリーミングを有効化
    )

def create_conversation_chain(llm: ChatOpenAI) -> ConversationChain:
    """
    会話用のLangChainチェーンを作成する。
    
    Args:
        llm: 使用するLLMインスタンス
    
    Returns:
        ConversationChain: 設定済みの会話チェーン
    """
    # プロンプトテンプレートの作成
    prompt: ChatPromptTemplate = ChatPromptTemplate.from_messages([
        ("system", """あなたは親切で知識豊富なAIアシスタントです。
        ユーザーの質問に対して、分かりやすく丁寧に回答してください。
        回答は日本語で行ってください。"""),
        MessagesPlaceholder(variable_name="history"),
        ("human", "{input}")
    ])
    
    # メモリの作成
    memory: ConversationBufferMemory = ConversationBufferMemory(
        return_messages=True
    )
    
    # チェーンの作成
    return ConversationChain(
        llm=llm,
        prompt=prompt,
        memory=memory,
        verbose=True  # デバッグ情報を表示
    )

@cl.on_chat_start
async def on_chat_start() -> None:
    """
    チャットセッション開始時の処理。
    
    LLMとチェーンを初期化し、ウェルカムメッセージを送信する。
    """
    try:
        # LLMの作成
        llm: ChatOpenAI = create_llm()
        
        # チェーンの作成
        chain: ConversationChain = create_conversation_chain(llm)
        
        # セッションに保存
        cl.user_session.set("chain", chain)
        
        # ウェルカムメッセージ
        welcome_message: str = (
            "こんにちは!私はあなたのAIアシスタントです。\n"
            "何でもお聞きください。会話の文脈を理解しながらお答えします。"
        )
        await cl.Message(content=welcome_message).send()
        
    except Exception as e:
        error_message: str = (
            f"初期化エラーが発生しました: {str(e)}\n\n"
            "LM Studioが正しく起動しているか確認してください。"
        )
        await cl.Message(content=error_message).send()

@cl.on_message
async def on_message(message: cl.Message) -> None:
    """
    ユーザーメッセージ受信時の処理。
    
    Args:
        message: ユーザーからのメッセージ
    """
    # チェーンの取得
    chain: Optional[ConversationChain] = cl.user_session.get("chain")
    
    if not chain:
        await cl.Message(
            content="セッションが初期化されていません。ページを更新してください。"
        ).send()
        return
    
    # 思考中のメッセージを表示
    msg: cl.Message = cl.Message(content="")
    await msg.send()
    
    try:
        # ストリーミングで回答を生成
        response_text: str = ""
        async for chunk in chain.astream({"input": message.content}):
            if 'response' in chunk:
                token: str = chunk['response']
                response_text += token
                await msg.stream_token(token)
        
        # ストリーミング完了
        await msg.update()
        
    except Exception as e:
        # エラーハンドリング
        error_message: str = (
            f"エラーが発生しました: {str(e)}\n\n"
            "**確認事項:**\n"
            "1. LM Studioが起動しているか\n"
            "2. APIサーバーが有効になっているか\n"
            "3. モデルが選択されているか"
        )
        await msg.update(content=error_message)

def main() -> None:
    """
    メインエントリーポイント。
    
    通常はchainlit runコマンドから実行されるため、
    このメイン関数は直接呼ばれません。
    """
    pass

if __name__ == "__main__":
    main()

2.3 コード解説

LM StudioのローカルLLMとChainlitを使用したシンプルなチャットボットアプリケーションです。 RAG機能はないので、純粋な会話型AIになります。

2.3.1 処理の流れ

初期化フェーズ (on_chat_start)

  • LMStudioのLLMインスタンスを作成
  • 会話用のLangChainチェーンを初期化
  • メモリ機能付きで会話履歴を管理
  • ウェルカムメッセージを表示

会話フェーズ (on_message)

  • ユーザーの質問を受け取る
  • 会話履歴を含めてLLMに送信
  • ストリーミングでリアルタイム回答生成
  • 会話履歴をメモリに保存

2.3.2 処理の詳細

LLM設定 (create_llm)

  • LM Studio連携 … OpenAI互換APIでローカルLLMと通信
  • 環境変数 … .envファイルから設定を読み込み(URL、APIキー、モデル名)
  • ストリーミング有効 … リアルタイムでの応答生成(なくてもいいかも)
  • temperature設定 … temperature=0.7で創造性と一貫性のバランス

会話チェーン (create_conversation_chain)

  • プロンプトテンプレート … システムメッセージで日本語AIアシスタントの役割を定義
  • メッセージ履歴 … MessagesPlaceholderで過去の会話を文脈として活用
  • メモリ管理 … ConversationBufferMemoryで会話履歴を自動保存
  • デバッグモード … verbose=Trueでチェーンの動作を詳細表示

ストリーミング処理 (on_message)

  • 非同期処理 … astreamで非同期ストリーミング生成
  • リアルタイム表示 … stream_tokenトークンごとに画面更新
  • 段階的表示 … ユーザーがLLMの思考過程を追えるUI

2.4 動作確認

アプリケーションを起動します。

$ uv run chainlit run simple_chat.py -w

ブラウザで http://localhost:8000 を開き、以下を試してください。

  1. 「こんにちは」と入力 → AIが挨拶を返すことを確認
  2. 「私の名前は太郎です」と入力 → 名前を覚えることを確認
  3. 「私の名前をしっていますか?」と入力 → 記憶していることを確認

これで基本的なチャットボットが完成しました。


3. RAG機能の追加

続いてはRAGの概念を、実際にLangChainで実装してみます。今回の例では、社内規則や技術ガイドラインを読み込んで、それらに基づいて回答するAIアシスタントを作成します。

3.1 サンプルドキュメントの準備

documentsフォルダに、company_rules.txttech_guidelines.txtというファイルを作成します。 (今回はLLMに作成してもらいました)

documents/company_rules.txt

# 社内規則

## 勤務時間
- 標準勤務時間:9:00-18:00(休憩1時間)
- フレックスタイム制度あり(コアタイム:10:00-15:00)
- リモートワーク:週3日まで可能
- 時間外労働:月45時間まで

## 休暇制度
- 年次有給休暇:初年度10日、最大20日
- 夏季休暇:連続5営業日
- 年末年始休暇:12月29日から1月3日
- 慶弔休暇:規定に基づき付与

## 福利厚生
- 書籍購入補助:月額5,000円まで
- 資格取得支援:合格時に受験料全額補助
- 健康診断:年1回会社負担
- 社内カフェテリア:ランチ補助あり

## 評価制度
- 評価サイクル:年2回(6月、12月)
- 360度評価を導入
- 目標管理制度(MBO)による評価
- 成果主義とチームワークのバランスを重視

documents/tech_guidelines.txt

# 技術ガイドライン

## 開発環境
- Python 3.11以上を使用
- 仮想環境は必須(uv推奨)
- コードフォーマッター:Black
- リンター:Ruff
- 型チェック:mypy

## AIツール活用
- GitHub Copilotの利用を推奨
- ChatGPT/Claude の業務利用可(機密情報は入力禁止)
- 社内LLMサーバー(LMStudio)利用可能
- RAGシステムで社内ナレッジを活用

## コーディング規約
- 型ヒントの使用を必須
- docstringは必須(Google Style推奨)
- テストカバレッジ80%以上
- レビュー必須(2名以上)

## セキュリティ
- APIキーは環境変数で管理
- .envファイルはGitにコミット禁止
- 定期的なセキュリティアップデート必須
- 機密情報の取り扱いに注意

3.2 RAG機能を追加したチャットボット

rag_chat.pyを以下の内容に更新します。

"""
RAG(Retrieval-Augmented Generation)機能を持つチャットボットアプリケーション。

LM StudioのローカルLLMとLangChainのRAG機能を組み合わせて、
独自のドキュメントベースから回答を生成するチャットボットを実装します。
"""

from typing import List, Dict, Any, Optional
import chainlit as cl
from langchain_openai import ChatOpenAI
from langchain.memory import ConversationBufferMemory
from langchain.chains import ConversationalRetrievalChain
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain.prompts import PromptTemplate
from langchain.schema import Document
import os
from dotenv import load_dotenv
import logging

# ログ設定
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# 環境変数の読み込み
load_dotenv()


def create_llm() -> ChatOpenAI:
    """
    LM StudioのLLMインスタンスを作成する。
    
    Returns:
        ChatOpenAI: 設定済みのLLMインスタンス
    """
    return ChatOpenAI(
        base_url=os.getenv("LMSTUDIO_BASE_URL", "http://localhost:1234/v1"),
        api_key=os.getenv("LMSTUDIO_API_KEY", "lm-studio"),
        model=os.getenv("LMSTUDIO_MODEL", "local-model"),
        temperature=0.7,
        streaming=True
    )

def create_embeddings() -> HuggingFaceEmbeddings:
    """
    埋め込みモデルのインスタンスを作成する。
    
    Returns:
        HuggingFaceEmbeddings: 日本語対応の埋め込みモデル
    """
    return HuggingFaceEmbeddings(
        model_name="intfloat/multilingual-e5-small",
        model_kwargs={'device': 'cpu'},
        encode_kwargs={'normalize_embeddings': True}
    )

def load_documents() -> List[Document]:
    """
    documentsフォルダからドキュメントを読み込む。
    
    Returns:
        List[Document]: 分割されたドキュメントのリスト
    
    Raises:
        FileNotFoundError: documentsフォルダが存在しない場合
    """
    if not os.path.exists('documents'):
        raise FileNotFoundError("documentsフォルダが存在しません")
    
    # DirectoryLoaderで複数ファイルを一括読み込み
    loader: DirectoryLoader = DirectoryLoader(
        'documents',
        glob="**/*.txt",
        loader_cls=TextLoader,
        loader_kwargs={'encoding': 'utf-8'}
    )
    
    # ドキュメントの読み込み
    documents: List[Document] = loader.load()
    logger.info(f"{len(documents)}個のドキュメントを読み込みました")
    
    # RecursiveCharacterTextSplitterで適切なサイズに分割
    text_splitter: RecursiveCharacterTextSplitter = RecursiveCharacterTextSplitter(
        chunk_size=500,        # 各チャンクの最大文字数
        chunk_overlap=100,     # チャンク間の重複文字数
        separators=["\n\n", "\n", "。", "、", " ", ""],
        length_function=len
    )
    
    # 分割実行
    split_docs: List[Document] = text_splitter.split_documents(documents)
    logger.info(f"{len(split_docs)}個のチャンクに分割しました")
    
    return split_docs

def create_qa_prompt() -> PromptTemplate:
    """
    質問応答用のプロンプトテンプレートを作成する。
    
    Returns:
        PromptTemplate: カスタマイズされたプロンプトテンプレート
    """
    template: str = """以下の文脈を使用して、質問に答えてください。
文脈に答えが含まれていない場合は、「提供された情報では回答できません」と答えてください。
回答は具体的で分かりやすく、必要に応じて箇条書きを使用してください。

文脈:
{context}

質問: {question}

回答:"""
    
    return PromptTemplate(
        template=template,
        input_variables=["context", "question"]
    )


def create_condense_prompt() -> PromptTemplate:
    """
    会話履歴を考慮した質問再構成用のプロンプトテンプレートを作成する。
    
    Returns:
        PromptTemplate: 質問再構成用のプロンプトテンプレート
    """
    template: str = """以下の会話履歴と新しい質問を基に、
文脈を含む独立した質問を作成してください。

会話履歴:
{chat_history}

新しい質問: {question}

独立した質問:"""
    
    return PromptTemplate(
        template=template,
        input_variables=["chat_history", "question"]
    )


def create_vectorstore(documents: List[Document]) -> Chroma:
    """
    ドキュメントからベクトルストアを作成する。
    
    Args:
        documents: ベクトル化するドキュメントのリスト
    
    Returns:
        Chroma: 作成されたベクトルストア
    """
    embeddings: HuggingFaceEmbeddings = create_embeddings()
    
    vectorstore: Chroma = Chroma.from_documents(
        documents=documents,
        embedding=embeddings,
        persist_directory="./chroma_db"
    )
    
    logger.info("ベクトルストアを作成しました")
    return vectorstore


def create_rag_chain(
    llm: ChatOpenAI,
    vectorstore: Chroma
) -> ConversationalRetrievalChain:
    """
    RAG対応の会話チェーンを作成する。
    
    Args:
        llm: 使用するLLMインスタンス
        vectorstore: 検索に使用するベクトルストア
    
    Returns:
        ConversationalRetrievalChain: RAG対応の会話チェーン
    """
    # メモリの作成
    memory: ConversationBufferMemory = ConversationBufferMemory(
        memory_key="chat_history",
        return_messages=True,
        output_key="answer"
    )
    
    # プロンプトの取得
    qa_prompt: PromptTemplate = create_qa_prompt()
    condense_prompt: PromptTemplate = create_condense_prompt()
    
    # チェーンの作成
    chain: ConversationalRetrievalChain = ConversationalRetrievalChain.from_llm(
        llm=llm,
        retriever=vectorstore.as_retriever(
            search_kwargs={"k": 5}  # 検索する類似チャンクの数
        ),
        memory=memory,
        return_source_documents=True,
        combine_docs_chain_kwargs={"prompt": qa_prompt},
        condense_question_prompt=condense_prompt,
        verbose=True
    )
    
    return chain

@cl.on_chat_start
async def on_chat_start() -> None:
    """
    チャットセッション開始時の処理。
    
    ドキュメントを読み込み、ベクトルストアを作成し、
    RAG対応のチェーンを初期化する。
    """
    # 起動メッセージ
    msg: cl.Message = cl.Message(content="ドキュメントを読み込んでいます...")
    await msg.send()
    
    try:
        # ドキュメントの読み込み
        documents: List[Document] = load_documents()
        
        # ベクトルストアの作成
        vectorstore: Chroma = create_vectorstore(documents)
        
        # LLMの作成
        llm: ChatOpenAI = create_llm()
        
        # RAGチェーンの作成
        chain: ConversationalRetrievalChain = create_rag_chain(llm, vectorstore)
        
        # セッションに保存
        cl.user_session.set("chain", chain)
        cl.user_session.set("documents_count", len(documents))
        
        # 準備完了メッセージ
        success_message: str = (
            f"準備が完了しました! {len(documents)}個のドキュメントを読み込みました。\n\n"
            "社内規則や技術ガイドラインについて何でも聞いてください。\n\n"
            "**質問例:**\n"
            "- リモートワークは週何日まで可能ですか?\n"
            "- 福利厚生について教えてください\n"
            "- AIツールの利用方針は?\n"
            "- 開発環境の推奨設定を教えてください"
        )
        msg.content = success_message
        await msg.update()

    except Exception as e:
        error_message: str = (
            f"エラーが発生しました: {str(e)}\n\n"
            "**確認事項:**\n"
            "1. documentsフォルダが存在するか\n"
            "2. テキストファイルが配置されているか\n"
            "3. LM Studioが起動しているか"
        )
        msg.content = error_message
        await msg.update()

@cl.on_message
async def on_message(message: cl.Message) -> None:
    """
    ユーザーメッセージ受信時の処理。
    
    RAGを使用して関連ドキュメントを検索し、
    それを基に回答を生成する。
    
    Args:
        message: ユーザーからのメッセージ
    """
    # チェーンの取得
    chain: Optional[ConversationalRetrievalChain] = cl.user_session.get("chain")
    
    if not chain:
        await cl.Message(
            content="セッションが初期化されていません。ページを更新してください。"
        ).send()
        return
    
    # 思考中メッセージ
    msg: cl.Message = cl.Message(content="")
    await msg.send()
    
    try:
        # RAGを使用した回答生成
        response: Dict[str, Any] = await chain.ainvoke(
            {"question": message.content}
        )
        
        # 回答の作成
        answer: str = response["answer"]
        
        # 参照したドキュメントがある場合は表示
        if response.get("source_documents"):
            answer += "\n\n---\n📚 **参照した情報:**\n"
            
            # 重複を除去して表示
            seen_contents: set = set()
            for doc in response["source_documents"]:
                content_preview: str = doc.page_content[:150]
                if content_preview not in seen_contents:
                    seen_contents.add(content_preview)
                    # ソースファイル名を取得
                    source_file: str = doc.metadata.get(
                        'source', 'unknown'
                    ).split('/')[-1]
                    answer += f"\n- [{source_file}] {content_preview}...\n"
        
        # 回答を更新
        msg.content = answer
        await msg.update()

    except Exception as e:
        error_message: str = (
            f"エラーが発生しました: {str(e)}\n\n"
            "LM Studioの接続を確認してください。"
        )
        msg.content = error_message
        await msg.update()

def main() -> None:
    """
    メインエントリーポイント。
    
    通常はchainlit runコマンドから実行されるため、
    このメイン関数は直接呼ばれません。
    """
    pass

if __name__ == "__main__":
    main()

3.3 RAG機能付きチャットボットコード解説

このコードは、ローカルLLM(LM Studio)とRAGを組み合わせたチャットボットアプリケーションです。 documentsディレクトリに格納したドキュメントを基に質問応答を行うことができます。

3.3.1 主要な処理の流れ

初期化フェーズ (on_chat_start)

  • documentsフォルダからテキストファイルを読み込み
  • ドキュメントを適切なサイズのチャンクに分割
  • 埋め込みベクトルを生成してベクトルストア(Chroma)に保存
  • LM StudioのLLMとRAGチェーンを初期化

質問応答フェーズ (on_message)

  • ユーザーの質問を受け取る
  • ベクトルストアから関連する文書チャンクを検索
  • 検索結果をコンテキストとしてLLMに送信
  • 生成された回答と参照元を表示

3.3.2 主要な処理の詳細解説

ドキュメント処理 (load_documents)

  • ファイル読み込み … DirectoryLoaderでdocumentsフォルダ内のテキストファイルを一括読み込み
  • テキスト分割 … RecursiveCharacterTextSplitterで500文字のチャンクに分割
  • オーバーラップ … 100文字のオーバーラップで文脈の連続性を保持
  • 日本語対応 … 句読点、改行などを使った適切な区切り処理

埋め込み生成 (create_embeddings)

  • 言語モデル … intfloat/multilingual-e5-smallで日本語を含む多言語対応
  • 正規化 … 埋め込みベクトルを正規化してコサイン類似度計算を最適化
  • CPU処理 … リソース効率を考慮したCPU処理設定

ベクトルストア (create_vectorstore) - Chroma使用 … 軽量で高速なベクトルデータベース - データベースの永続化 … ./chroma_dbディレクトリに保存して再利用可能 - インデックス作成 … 全ドキュメントチャンクの埋め込みベクトルを生成・保存

RAGチェーン (create_rag_chain)

  • メモリ管理 … ConversationBufferMemoryで会話履歴を保持
  • 質問再構成 … 会話文脈を考慮した独立した質問への変換
  • 検索設定 … 類似度上位5つの文書チャンクを取得
  • プロンプト設定 … カスタマイズされた質問応答形式

LM Studio連携 (create_llm)

  • OpenAI互換API … 標準的なAPIインターフェースでローカルLLMと通信
  • ストリーミング … リアルタイムでの回答生成
  • 環境変数設定 … 柔軟な設定管理

3.3.3 ベクトルデータベース(Chroma)について

ベクトルデータベースは、テキストを数値ベクトルに変換して保存し、意味の類似性を使った検索を可能にする技術です。

仕組み

  • 埋め込み生成 … 各文書チャンクを高次元ベクトル空間上の点として表現
  • 類似検索 … ユーザーの質問をベクトル化し、コサイン類似度で最も関連する文書を特定
  • インデックス … 効率的な検索のための最適化されたデータ構造

ベクトルデータベースの利点

  • 意味理解 … キーワード一致では見つけられない意味的に関連する情報も検索可能
  • 高速処理 … ベクトル演算による高速な類似度計算
  • スケーラビリティ … 大量のドキュメントにも対応可能

3.4 動作確認と改善

3.4.1 アプリケーションを起動

# アプリケーションを起動
$ uv run chainlit run rag_chat.py -w

ブラウザで http://localhost:8000 を開きます。

3.4.2: RAG機能の動作確認

以下の質問を順番に試してみましょう。

基本的な質問

年次有給休暇は何日ありますか?」

技術関連の質問

「AIツールの利用方針は?」

複合的な質問

「エンジニアとして入社したら、どんな福利厚生と開発環境が使えますか?」

ドキュメントにない情報

Pythonの勉強方法を教えてください」→「提供された情報では回答できません」と返すことを確認


4. おわりに

今回の内容では、以下のことができるようになりました。

  • LangChainを使用して、メモリ管理での文脈保持やRAGの機能を実装しました。
  • Chainlitを使用して、LM Studioに配置されたLLMと対話するチャットUIを作りました。

これでLLMを使用したプログラミングができるようになったのではないでしょうか。

おわりに

今回はチャットボットを作成することを通して、その際に必要になるメモリ管理での文脈保持、RAG、UIの作成を行いました。 巷に溢れているチャットボットが持つべき機能についても理解が深まったのではないかと思います。これまでの学びが形になったなという実感があります。

あとは応用なので、修正や改善を繰り返して、より良いチャットボットやターゲットの実装を作成してみてはどうでしょうか。

私は、今回ベクトルデータベースとしてChromaDBから、グラフデータベースのNeo4jを変更したRAGに挑戦してみようと思います🫡

参考リンク

uepon.hatenadiary.com

uepon.hatenadiary.com

uepon.hatenadiary.com