LangChainとPythonでRAGアプリを構築する:失敗から本番環境まで

Artificial Intelligence tutorial - IT technology blog
Artificial Intelligence tutorial - IT technology blog

直面した問題:LLMは社内データについて何も知らない

約8ヶ月前、社内ドキュメント検索を支援するチャットボット構築のタスクを任されました。業務フローのガイドライン、会社のポリシー、技術ドキュメントなど400以上のPDFファイルが対象です。最初はシンプルに考えていました:ChatGPT APIを使って質問を投げ込めば答えが返ってくる、と。しかし現実はそう単純ではありませんでした。

最初の問題は初週に早くも現れました:モデルは会社の内部プロセスを何も知らないため、完全に誤った回答を返すのです。「有給休暇の申請手続きは?」と聞くと、もっともらしく聞こえるが完全に間違った手順を作り上げてしまう。典型的なハルシネーションです。

すべてのドキュメント内容をプロンプトに詰め込んでみると — すぐにコンテキストウィンドウの制限に引っかかりました。300ページのPDFを128kトークンに収めることは不可能で、トークン課金のAPIコストが爆発することも問題です。

根本原因:LLMは「閉じた図書館」

LLMは特定の時点(トレーニングカットオフ)までの公開データでトレーニングされます。その後、モデルは「凍結」されます — 自分で学習し続けることはなく、あなたの会社の社内ドキュメント、プライベートデータベース、その他の非公開情報を知る方法はありません。

モデルが知らないことについて質問すると、2つの選択肢があります:「わかりません」と言う(まれ)か、もっともらしく聞こえる回答を作り上げる(ハルシネーション — 非常に多い)。どちらもチャットボットの信頼性を損ないます。業務手順や会社規則のドキュメントでは、誤った情報に従って行動する社員の方が、チャットボットがない場合よりも悪い結果を生みます。

3つのアプローチ — そして最初の2つが使えない理由

アプローチ1:モデルのファインチューニング

ファインチューニングとは、自分のデータでモデルを再トレーニングすることです。理論上は良さそうですが、実際には問題があります:

  • 計算コストが非常に高く、強力なGPUが必要
  • 時間がかかる:データ準備 → トレーニング → 評価 → デプロイのパイプライン
  • ドキュメントが更新されると(ポリシー変更、新バージョン)、最初からファインチューニングし直す必要がある
  • ハルシネーション問題を解決できない — モデルは依然として「誤って記憶」する可能性がある

ファインチューニングはモデルの回答方法を変えたいときに適しています — トーン、フォーマット、言語スタイル。社内ドキュメントの知識を組み込みたい場合は?それは間違ったツールです。

アプローチ2:コンテキスト全体をプロンプトに詰め込む

最もシンプルな方法:すべてのドキュメントをシステムプロンプトに詰め込む。小規模なドキュメント(50ページ未満)なら何とかなりますが:

  • コンテキストウィンドウには制限がある(GPT-4の128k、Claudeの200kでも、大規模なドキュメントには不十分)
  • トークンコストがリクエストごとに線形で増加する
  • コンテキストが長すぎるとモデルのパフォーマンスが低下する(”lost in the middle”問題)

アプローチ3:RAG — 唯一うまく機能するもの

RAG(Retrieval-Augmented Generation)はまったく異なるアプローチを取ります:すべてをプロンプトに詰め込む代わりに、質問に最も関連するドキュメント部分だけを見つけて挿入します。シンプルに聞こえます — そしてその通りです。でもこれこそが、上記3つの問題すべてを解決できるものです。

基本的なフロー:

  1. すべてのドキュメントをベクターエンベディングに変換し、ベクターデータベースに保存する
  2. ユーザーが質問すると、質問をベクターに変換する
  3. ベクターが最も近いドキュメント部分を検索する(セマンティック類似度)
  4. それらの部分を質問と一緒にプロンプトに挿入し、LLMに送る
  5. LLMは提供されたコンテキストに基づいて回答する

LangChainでRAGを構築する — 実際に使っているコード

依存関係のインストール

pip install langchain langchain-community langchain-openai
pip install chromadb  # vector database
pip install pypdf     # PDFを読み込む
pip install python-dotenv

ChromaDBを選んだのは、サーバー不要でローカル実行できプロトタイプに最適だからです。本番環境でスケールが大きくなる場合は、QdrantやPineconeに移行できます。

ステップ1:ドキュメントの読み込みとチャンク分割

from langchain_community.document_loaders import PyPDFDirectoryLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

# ディレクトリ内のすべてのPDFを読み込む
loader = PyPDFDirectoryLoader("./docs/")
documents = loader.load()

# チャンクに分割する — これが最も重要なステップ
splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,      # チャンクごと約1000文字
    chunk_overlap=200,    # チャンク間でコンテキストを失わないためのオーバーラップ
    separators=["\n\n", "\n", ".", " "]
)
chunks = splitter.split_documents(documents)
print(f"チャンクの総数:{len(chunks)}")

チャンクサイズは最も時間をかけてチューニングした部分です。小さすぎると(200〜300文字)コンテキストが失われ、大きすぎると(2000文字以上)プロンプトにノイズが増えます。技術ドキュメントには800〜1200文字がスイートスポットです。

ステップ2:エンベディングの作成とベクターストアへの保存

from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
import os
from dotenv import load_dotenv

load_dotenv()

# エンベディングを作成する
embeddings = OpenAIEmbeddings(
    model="text-embedding-3-small",  # largeより安価で、英語/日本語に十分な品質
    openai_api_key=os.getenv("OPENAI_API_KEY")
)

# ChromaDBに保存する(毎回再エンベディングしないようにpersist_directoryを使用)
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db"
)
print("ベクターストアが作成・保存されました。")

ステップ3:リトリーバルチェーンの構築

from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

# 既存のベクターストアを読み込む(再エンベディング不要)
vectorstore = Chroma(
    persist_directory="./chroma_db",
    embedding_function=embeddings
)

# リトリーバー — 最も関連性の高いチャンクを4つ取得
retriever = vectorstore.as_retriever(
    search_type="mmr",           # Maximum Marginal Relevance — 冗長性を削減
    search_kwargs={"k": 4, "fetch_k": 10}
)

# カスタムプロンプト — モデルのハルシネーションを防ぐために重要
prompt_template = """以下に提供されたドキュメントに基づいて、質問に答えてください。
ドキュメント内に情報がない場合は、「この情報はドキュメントに見つかりませんでした。」と明記してください。

参照ドキュメント:
{context}

質問:{question}
回答:"""

PROMPT = PromptTemplate(
    template=prompt_template,
    input_variables=["context", "question"]
)

# チェーンを構築する
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=retriever,
    chain_type_kwargs={"prompt": PROMPT},
    return_source_documents=True  # デバッグと出典引用のため
)

# テスト
result = qa_chain.invoke({"query": "有給休暇の申請手続きは何ですか?"})
print(result["result"])
print("\n出典:", [doc.metadata for doc in result["source_documents"]])

ステップ4:完全なスクリプト — インデックスとクエリを分離

# index.py — 新しいドキュメントがある時に1回実行
python index.py --docs-dir ./docs/

# query.py — 毎日実行
python query.py --question "新入社員のオンボーディング手続き"

インデックスとクエリを分離することは、チームからのクレームを受けて学んだことです:アプリを再起動するたびに、すべてのドキュメントを再エンベディングするのに5分待たされていました。ベクターストアをディスクに永続化し、ドキュメントが変更された時だけ再インデックスするようにしましょう。

つまずいたポイント — 同じ失敗を繰り返さないために

1. チャンクオーバーラップの見落とし

多くのチュートリアルではシンプルにするためにchunk_overlap=0を設定します。実際には、重要な回答はしばしば2つのチャンクの境界にあります。chunk_sizeの15〜20%のオーバーラップが私が使っている数値です。

2. 取得したドキュメントの検証なし

リトリーバーは関連性のない結果を返しているときでも自分では気づきません — スコアがどれほど低くてもtop-kを返し続けます。事前にフィルタリングするためにthresholdを設定しましょう:

retriever = vectorstore.as_retriever(
    search_type="similarity_score_threshold",
    search_kwargs={"score_threshold": 0.7, "k": 4}
)

3. メタデータの欠如

ユーザーが「Xについてはどのドキュメントに書かれているか」と聞く時、そのチャンクがどのファイルの何ページ目かを知る必要があります。LangChainのPDFローダーは自動的にsourcepageをメタデータに追加します — それらを削除しないでください。

4. 日本語とエンベディング

日本語について:OpenAIのtext-embedding-3-smallはかなり良好に処理します — 約$0.02/1Mトークンで、小規模企業ではほぼ無視できるコストです。予算が本当に厳しい場合はHuggingFaceのparaphrase-multilingual-mpnet-base-v2を試してください — ローカルで動き、無料で、許容できる品質です。

本番環境6ヶ月後の成果

構築したRAGシステムは現在、社員からの1日200〜300件の質問を処理しています。精度(正確な回答で実際のドキュメントに出典がある)は約85〜90%に達しています — コンテキストなしで純粋なLLMを使った場合の0%と比べると大幅な改善です。

APIコストも大幅に削減されました。400ファイルすべてをプロンプトに詰め込んだ場合、1クエリあたり50k〜100kトークン消費する可能性があります。今は平均してわずか1500〜2000トークン — つまり30〜50倍安くなっています。

まずは小さく始めましょう:ChromaDBをローカルで、50〜100ページのサンプルドキュメントで、チームの実際の質問20〜30件でテストしてみてください。1週間あれば、RAGが自分の具体的な課題に適しているかどうかわかります — ファインチューニングパイプラインの完了を待つよりもはるかに速く、安くです。

Share: