Skip to content
Published on

RAGチャットボット構築実践 — LangChain + ChromaDB + OpenAIで自分だけのドキュメントQAボットを作る

Authors
  • Name
    Twitter
RAGチャットボット — LangChain + ChromaDB + OpenAI

概要

LLMは汎用的な知識を持っていますが、自社の内部ドキュメントや最新情報には答えられません。**RAG(Retrieval-Augmented Generation)**はこの限界を克服するパターンで、質問に関連するドキュメントをまず検索し、そのコンテキストをLLMに渡して正確な回答を生成します。

この記事では、LangChain + ChromaDB + OpenAIを使用してPDFドキュメントベースのQAチャットボットを最初から最後まで構築します。最終的にStreamlitでWeb UIも作成し、実際に使用可能なチャットボットを完成させます。

RAGアーキテクチャ

RAGの全体フローは2つのステージに分かれます:

ステージ1:インデキシング(オフライン)

ドキュメント → チャンキング → エンベディング → ベクトルDB保存

ステージ2:クエリ(オンライン)

質問 → エンベディング → 類似ドキュメント検索 → プロンプト構成 → LLM回答

環境設定

パッケージインストール

pip install langchain langchain-openai langchain-community \
            chromadb pypdf tiktoken streamlit python-dotenv

環境変数

# .env
OPENAI_API_KEY=sk-proj-your-api-key-here
# config.py
import os
from dotenv import load_dotenv

load_dotenv()

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
CHROMA_PERSIST_DIR = "./chroma_db"
COLLECTION_NAME = "my_documents"
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
EMBEDDING_MODEL = "text-embedding-3-small"
LLM_MODEL = "gpt-4o-mini"

Step 1:ドキュメントの読み込み

# document_loader.py
from langchain_community.document_loaders import (
    PyPDFLoader,
    DirectoryLoader,
    TextLoader,
)

def load_pdf(file_path: str):
    """単一のPDFファイルを読み込む"""
    loader = PyPDFLoader(file_path)
    documents = loader.load()
    print(f"Loaded {len(documents)} pages from {file_path}")
    return documents

def load_directory(dir_path: str, glob: str = "**/*.pdf"):
    """ディレクトリ内のすべてのPDFを読み込む"""
    loader = DirectoryLoader(
        dir_path,
        glob=glob,
        loader_cls=PyPDFLoader,
        show_progress=True,
    )
    documents = loader.load()
    print(f"Loaded {len(documents)} pages from {dir_path}")
    return documents

# 使用例
documents = load_directory("./docs")

Step 2:テキストチャンキング

チャンキングはRAGの性能に大きな影響を与えます。小さすぎるとコンテキストが不足し、大きすぎると検索精度が低下します。

# chunker.py
from langchain.text_splitter import RecursiveCharacterTextSplitter

def chunk_documents(documents, chunk_size=1000, chunk_overlap=200):
    """ドキュメントをチャンクに分割"""
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=len,
        separators=["\n\n", "\n", ".", " ", ""],
    )

    chunks = text_splitter.split_documents(documents)
    print(f"Split {len(documents)} documents into {len(chunks)} chunks")

    # メタデータにチャンクインデックスを追加
    for i, chunk in enumerate(chunks):
        chunk.metadata["chunk_index"] = i
        chunk.metadata["chunk_size"] = len(chunk.page_content)

    return chunks

chunks = chunk_documents(documents)

チャンキング戦略の比較

戦略長所短所推奨ケース
RecursiveCharacter文脈保持に優れる汎用的一般ドキュメント
TokenTextSplitterトークン数を正確に制御文脈が途切れる可能性トークン制限が厳しい場合
MarkdownHeader構造を保持Markdown専用技術ドキュメント
SemanticChunker意味ベースの分割遅い、コスト高高品質が求められる場合

Step 3:ベクトルストア(ChromaDB)

# vectorstore.py
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from config import CHROMA_PERSIST_DIR, COLLECTION_NAME, EMBEDDING_MODEL

def create_vectorstore(chunks):
    """チャンクをエンベディングしてChromaDBに保存"""
    embeddings = OpenAIEmbeddings(model=EMBEDDING_MODEL)

    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=CHROMA_PERSIST_DIR,
        collection_name=COLLECTION_NAME,
    )

    print(f"Stored {len(chunks)} chunks in ChromaDB")
    return vectorstore

def load_vectorstore():
    """既存のChromaDBを読み込む"""
    embeddings = OpenAIEmbeddings(model=EMBEDDING_MODEL)

    vectorstore = Chroma(
        persist_directory=CHROMA_PERSIST_DIR,
        collection_name=COLLECTION_NAME,
        embedding_function=embeddings,
    )

    count = vectorstore._collection.count()
    print(f"Loaded ChromaDB with {count} documents")
    return vectorstore

エンベディングモデル選択ガイド

モデル次元コスト性能
text-embedding-3-small1536$0.02/1M tokens良好
text-embedding-3-large3072$0.13/1M tokens非常に良好
text-embedding-ada-0021536$0.10/1M tokens普通(レガシー)

コスト対性能の観点でtext-embedding-3-smallを推奨します。

Step 4:リトリーバーの設定

# retriever.py

def get_retriever(vectorstore, search_type="mmr", k=4):
    """ベクトルストアからリトリーバーを作成"""

    if search_type == "mmr":
        # MMR:関連性と多様性のバランス
        retriever = vectorstore.as_retriever(
            search_type="mmr",
            search_kwargs={
                "k": k,
                "fetch_k": 20,      # 候補ドキュメント数
                "lambda_mult": 0.7,  # 1.0=関連性, 0.0=多様性
            },
        )
    elif search_type == "similarity_score":
        # 類似度閾値ベース
        retriever = vectorstore.as_retriever(
            search_type="similarity_score_threshold",
            search_kwargs={
                "score_threshold": 0.7,
                "k": k,
            },
        )
    else:
        # デフォルトの類似度検索
        retriever = vectorstore.as_retriever(
            search_kwargs={"k": k},
        )

    return retriever

MMR(Maximal Marginal Relevance)

MMRは検索結果から関連性が高いが互いに異なるドキュメントを選択します。類似した内容のチャンクが重複して返されるのを防ぎ、LLMにより豊かなコンテキストを提供します。

Step 5:RAGチェーンの構成

# rag_chain.py
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain.schema.runnable import RunnablePassthrough
from langchain.schema.output_parser import StrOutputParser
from config import LLM_MODEL

SYSTEM_TEMPLATE = """あなたはドキュメントベースのQAアシスタントです。
提供されたコンテキストのみを使用して質問に回答してください。

ルール:
1. コンテキストにない情報は「該当する情報をドキュメントから見つけられませんでした」と回答してください。
2. 回答は韓国語で作成してください。
3. 可能であれば具体的な数値や引用を含めてください。
4. 回答の根拠となるドキュメントに言及してください。

コンテキスト:
{context}
"""

def format_docs(docs):
    """検索されたドキュメントをフォーマット"""
    formatted = []
    for i, doc in enumerate(docs, 1):
        source = doc.metadata.get("source", "unknown")
        page = doc.metadata.get("page", "?")
        formatted.append(
            f"[ドキュメント {i}] (出典: {source}, ページ: {page})\n{doc.page_content}"
        )
    return "\n\n---\n\n".join(formatted)

def create_rag_chain(retriever):
    """RAGチェーンを作成"""
    llm = ChatOpenAI(
        model=LLM_MODEL,
        temperature=0,
        max_tokens=2000,
    )

    prompt = ChatPromptTemplate.from_messages([
        ("system", SYSTEM_TEMPLATE),
        ("human", "{question}"),
    ])

    chain = (
        {
            "context": retriever | format_docs,
            "question": RunnablePassthrough(),
        }
        | prompt
        | llm
        | StrOutputParser()
    )

    return chain

Step 6:会話履歴のサポート

# conversation.py
from langchain.memory import ConversationBufferWindowMemory
from langchain.chains import ConversationalRetrievalChain
from langchain_openai import ChatOpenAI
from config import LLM_MODEL

def create_conversational_chain(retriever):
    """会話履歴をサポートするRAGチェーン"""
    llm = ChatOpenAI(model=LLM_MODEL, temperature=0)

    memory = ConversationBufferWindowMemory(
        memory_key="chat_history",
        return_messages=True,
        output_key="answer",
        k=5,  # 直近5ターンのみ保持
    )

    chain = ConversationalRetrievalChain.from_llm(
        llm=llm,
        retriever=retriever,
        memory=memory,
        return_source_documents=True,
        verbose=False,
    )

    return chain

# 使用例
chain = create_conversational_chain(retriever)

result = chain.invoke({"question": "このドキュメントの核心的な内容は何ですか?"})
print(result["answer"])
print(f"\n参照ドキュメント {len(result['source_documents'])}件")

Step 7:Streamlit UI

# app.py
import streamlit as st
from document_loader import load_pdf
from chunker import chunk_documents
from vectorstore import create_vectorstore, load_vectorstore
from retriever import get_retriever
from rag_chain import create_rag_chain

st.set_page_config(page_title="📚 ドキュメントQAチャットボット", layout="wide")
st.title("📚 RAGドキュメントQAチャットボット")

# サイドバー:ドキュメントアップロード
with st.sidebar:
    st.header("📁 ドキュメントアップロード")
    uploaded_files = st.file_uploader(
        "PDFファイルをアップロードしてください",
        type=["pdf"],
        accept_multiple_files=True,
    )

    if uploaded_files and st.button("🔄 ドキュメント処理"):
        with st.spinner("ドキュメント処理中..."):
            all_chunks = []
            for file in uploaded_files:
                # 一時ファイル保存
                temp_path = f"/tmp/{file.name}"
                with open(temp_path, "wb") as f:
                    f.write(file.getbuffer())

                docs = load_pdf(temp_path)
                chunks = chunk_documents(docs)
                all_chunks.extend(chunks)

            vectorstore = create_vectorstore(all_chunks)
            st.session_state["vectorstore"] = vectorstore
            st.success(f"✅ {len(all_chunks)}個のチャンク処理完了!")

# メイン:チャットインターフェース
if "messages" not in st.session_state:
    st.session_state.messages = []

# 以前のメッセージを表示
for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["content"])

# ユーザー入力
if prompt := st.chat_input("ドキュメントについて質問してください..."):
    st.session_state.messages.append({"role": "user", "content": prompt})

    with st.chat_message("user"):
        st.markdown(prompt)

    with st.chat_message("assistant"):
        if "vectorstore" not in st.session_state:
            try:
                st.session_state["vectorstore"] = load_vectorstore()
            except Exception:
                st.error("まずドキュメントをアップロードしてください。")
                st.stop()

        vectorstore = st.session_state["vectorstore"]
        retriever = get_retriever(vectorstore)
        chain = create_rag_chain(retriever)

        with st.spinner("回答生成中..."):
            response = chain.invoke(prompt)

        st.markdown(response)
        st.session_state.messages.append(
            {"role": "assistant", "content": response}
        )
# 実行
streamlit run app.py --server.port 8501

パフォーマンス最適化のヒント

1. ハイブリッド検索(キーワード + ベクトル)

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

def create_hybrid_retriever(chunks, vectorstore, k=4):
    """BM25 + ベクトル検索のアンサンブル"""
    bm25_retriever = BM25Retriever.from_documents(chunks)
    bm25_retriever.k = k

    vector_retriever = vectorstore.as_retriever(search_kwargs={"k": k})

    ensemble = EnsembleRetriever(
        retrievers=[bm25_retriever, vector_retriever],
        weights=[0.4, 0.6],  # ベクトル検索に重み付け
    )

    return ensemble

2. Rerankerで検索精度を向上

from langchain.retrievers import ContextualCompressionRetriever
from langchain_community.document_compressors import CohereRerank

def create_reranked_retriever(vectorstore, k=4, top_n=3):
    """Cohere Rerankerで検索結果を再ランク付け"""
    base_retriever = vectorstore.as_retriever(search_kwargs={"k": k * 3})

    compressor = CohereRerank(
        model="rerank-v3.5",
        top_n=top_n,
    )

    return ContextualCompressionRetriever(
        base_compressor=compressor,
        base_retriever=base_retriever,
    )

3. チャンクメタデータの強化

# チャンクに要約メタデータを追加
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

for chunk in chunks:
    summary = llm.invoke(
        f"以下のテキストを一文で要約してください:\n{chunk.page_content}"
    ).content
    chunk.metadata["summary"] = summary

まとめ

RAGチャットボットの核心は検索品質です。LLMがいくら優秀でも、関連のないドキュメントが渡されれば良い回答は生成できません。パフォーマンス改善の優先順位をまとめると:

  1. チャンキング戦略の最適化 — ドキュメント特性に合ったチャンクサイズと分割方法
  2. エンベディングモデルの選択 — ドメインに適したエンベディングモデル
  3. ハイブリッド検索 — BM25 + ベクトル検索のアンサンブル
  4. Rerankerの適用 — 検索結果の再ランク付けで精度向上
  5. プロンプトエンジニアリング — 出力形式とルールの明示

これらすべてをLangChainが抽象化してくれるため、各コンポーネントを簡単に入れ替えながら実験できるのが大きな利点です。

クイズ

Q1:RAGにおけるRetrievalとGenerationの役割は? Retrievalは質問に関連するドキュメントをベクトル類似度検索で見つける段階で、Generationは検索された ドキュメントをコンテキストとしてLLMに渡して回答を生成する段階です。

Q2:チャンキングでchunk_overlapを設定する理由は? チャンク境界で文脈が途切れるのを防ぐためです。隣接するチャンク間に重複部分を設けることで、 文章が途中で切れても次のチャンクで完全な文脈を維持できます。

Q3:ChromaDBのpersist_directory設定の意味は? ベクトルデータをディスクに永続保存するパスを指定します。これを設定すると、プロセス再起動時にも エンベディングを再計算することなく既存のベクトルDBを読み込めます。

Q4:MMR(Maximal Marginal Relevance)検索の利点は? 関連性が高く、かつ互いに異なるドキュメントを選択します。類似した内容のチャンクが重複して返される のを防ぎ、LLMにより広い範囲のコンテキストを提供できます。

Q5:text-embedding-3-smalltext-embedding-3-largeの選択基準は? smallはコストが6.5倍安く、ほとんどの用途で十分な性能を提供します。医療や法律などドメイン特化の 高精度が必要な場合にのみlargeを検討します。

Q6:ハイブリッド検索(BM25 + ベクトル)が純粋なベクトル検索より優れている理由は?

ベクトル検索は意味的な類似性に強いですが、正確なキーワードマッチングには弱いです。BM25はキーワード マッチングに強いため、両方をアンサンブルすることで意味的類似性とキーワード精度の両方を確保できます。

Q7:ConversationBufferWindowMemoryのk=5の意味は? 直近5ターンの会話のみをメモリに保持するという意味です。全会話を保持するとトークンが超過する 可能性があるため、最近の会話のみを保持してコンテキストウィンドウを効率的に使用します。

Q8:Reranker使用時にベースリトリーバーのkを大きく設定する理由は? Rerankerがより広い候補群から最も関連性の高いドキュメントを再ランク付けできるようにするためです。 例えばk=12で候補を取得した後、top_n=3で最終選択すれば、初期検索で見逃した関連ドキュメントを 再ランク付けプロセスで救い出すことができます。