LangChainとPythonでRAGアプリを構築する — 初心者向けステップバイステップガイド

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

問題:LLMは賢いのに、あなたのデータを何も知らない

ChatGPTやClaudeは一般的な知識についての回答は得意だ。しかし、社内ドキュメント、製品データベース、進行中のプロジェクトの技術仕様PDFについて聞いてみると——でたらめな回答か、まったく関係ない内容が返ってくる。これはモデルの欠陥ではなく、単純にあなた独自のデータへのアクセス権がないからだ。

ファインチューニングは聞こえはいいが、実際には簡単ではない。1回のトレーニングコストはモデルサイズによって数百ドルから数千ドルかかり、十分なGPUも必要で、ドキュメントが更新されるたびに最初からやり直さなければならない。私もそのアプローチを試みたが、運用コストを計算した後、2週間で諦めた。

RAG(Retrieval-Augmented Generation)はより実用的なアプローチだ。モデルに知識を詰め込む代わりに、回答する前に関連ドキュメントを参照できるようにする——必要な時に参考資料の束を渡すようなイメージだ。

コードを書く前に理解すべき基本概念

RAGは3つのステップで動く

  1. Indexing:ドキュメントをchunkに分割し、vector embeddingに変換してvector databaseに保存する。
  2. Retrieval:ユーザーが質問すると、システムはその質問に最も近いvectorを持つドキュメントのchunkを検索する。
  3. Generation:見つかったドキュメントのchunkと質問をLLMに渡し、根拠のある回答を生成する(ハルシネーションなし)。

LangChainのコンポーネント

LangChainはpipelineを6つのbuilding blockに分けている。それぞれを理解することで、問題が発生したときにずっと早くデバッグできるようになる:

  • Document Loaders:PDF、TXT、URL、databaseなどからドキュメントを読み込む
  • Text Splitters:長いテキストを小さなchunkに分割する
  • Embeddings:テキストを数値vectorに変換する
  • Vector Stores:vectorの保存と検索(Chroma、FAISS、Pineconeなど)
  • Retrievers:vector storeを問い合わせるための標準interface
  • Chains:各コンポーネントを完全なpipelineとして接続する

実践:RAGをゼロから構築する

ステップ1:dependenciesのインストール

virtualenvを作成して必要なpackageをインストールする:

python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate

pip install langchain langchain-community langchain-openai
pip install chromadb
pip install pypdf  # PDFを読み込む必要がある場合
pip install python-dotenv

.envファイルを作成してAPI keyを保存する:

OPENAI_API_KEY=sk-...your-key-here...

ステップ2:ドキュメントの準備とVector Storeの構築

以下のデモはわかりやすいようにプレーンテキストを使用している。動作確認後は、PDF loaderやweb scraperに置き換えることも可能だ——コードの構造は変わらない。

from dotenv import load_dotenv
from langchain_community.document_loaders import TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

load_dotenv()

# ドキュメントを読み込む
loader = TextLoader("docs/manual.txt", encoding="utf-8")
documents = loader.load()

# chunkに分割する
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,       # 各chunkは最大500文字
    chunk_overlap=50,     # contextが途切れないよう50文字のオーバーラップ
    length_function=len,
)
chunks = splitter.split_documents(documents)
print(f"chunks総数: {len(chunks)}")

# embeddingsを作成してChromaに保存する
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db"  # 再利用のためdiskに保存
)
print("Vector storeの作成が完了しました!")

Chunk sizeは品質に大きく影響する:技術文書には500〜800文字が一般的によく機能する。小さすぎるchunk(200文字未満)はcontextが失われ、大きすぎるchunk(1500文字超)はvectorが「ぼやけて」retrieval精度が落ちる。数値を確定する前に、実際のデータで試してみよう。

ステップ3:RAG Chainの構築

このステップではretrieverとLLMを接続する——モデルの回答方法をコントロールできる部分でもある:

from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings

# diskからvector storeを再読み込み(事前に作成済みの場合)
embeddings = OpenAIEmbeddings()
vectorstore = Chroma(
    persist_directory="./chroma_db",
    embedding_function=embeddings
)

# retrieverを作成——最も関連性の高い3つのchunkを取得
retriever = vectorstore.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 3}
)

# modelがcontextに基づいて回答するようpromptをカスタマイズ
prompt_template = """あなたは技術的なアシスタントです。以下の情報をもとに質問に回答してください。
関連情報が見つからない場合は、ドキュメントに記載がないことを明示してください。

参考情報:
{context}

質問:{question}

回答:"""

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

# chainを作成
llm = ChatOpenAI(model_name="gpt-4o-mini", temperature=0)
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",   # "stuff" = 全contextを1つのpromptに詰め込む
    retriever=retriever,
    chain_type_kwargs={"prompt": PROMPT},
    return_source_documents=True  # デバッグ用にソースドキュメントを返す
)

ステップ4:テスト実行と結果の確認

def ask(question: str):
    result = qa_chain.invoke({"query": question})
    print(f"\n質問: {question}")
    print(f"\n回答: {result['result']}")
    print("\n--- 参考ソース ---")
    for i, doc in enumerate(result['source_documents']):
        print(f"[{i+1}] {doc.page_content[:150]}...")
        print(f"    (ファイル: {doc.metadata.get('source', 'unknown')})")
    print()

# テスト実行
ask("アプリをproductionにdeployする手順は?")
ask("システムのtimeoutエラーの処理方法は?")

ステップ5:アップグレード——LCEL(LangChain Expression Language)を使う

LangChainはバージョン0.2以降、旧来のchainの代わりにLCELの使用を推奨している。コードが短くなり、処理ステップの追加が容易で、streamingにも標準対応している:

from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

# LCEL chain——読みやすく、処理ステップの追加も簡単
rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | PROMPT
    | llm
    | StrOutputParser()
)

# Streamレスポンス(webアプリに最適)
for chunk in rag_chain.stream("development環境のセットアップ手順は?"):
    print(chunk, end="", flush=True)
print()

PDFを使う場合の処理

実際のプロジェクトでは、データがPDF形式であることが多い——技術文書、仕様書、社内規則など。loaderを変えるだけでよく、残りはそのまま使える:

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("docs/technical-spec.pdf")
pages = loader.load()  # 各ページが1つのDocument

# その後、通常通りsplitしてindexを作成
chunks = splitter.split_documents(pages)

まとめと発展方向

このアプローチを使って社内向けの技術サポートchatbotを構築した——約200ドキュメント、合計~50MBのPDF。2ヶ月の運用後、質問への正答率は約85%に達し、RAGなしの0%から大幅に改善した。重要なのはコードではなく、入力データの質だ:ドキュメントが明確で構造化されていれば結果は良く、スキャンが不鮮明だったり乱雑だったりすると、どれほど優れたpipelineも救えない。

精度をさらに上げたい?試す価値のあるアプローチをいくつか紹介する:

  • Hybrid Search:vector searchとBM25(keyword search)を組み合わせて精度を向上させる
  • Reranking:cross-encoderモデルを使ってLLMに渡す前に結果をre-rankする
  • Conversation Memory:chat historyを追加して会話のcontextを記憶させる
  • Alternative Vector Stores:production gradeが必要な場合はPinecone、Weaviate、またはpgvector(PostgreSQL)を検討する
  • 代替Embeddings:OpenAIを使いたくない場合は、HuggingFaceEmbeddingsBAAI/bge-m3モデルを試してみよう——多言語対応で日本語もサポートしており、完全無料だ

小さなデータセットから始めよう——20〜50ドキュメントあれば、システムの動作を確認するには十分だ。精度は自分で質問して期待する答えと比較することで測定し、その後でスケールアップしよう。evaluateのステップをスキップするのは最も一般的なミスだ——productionにdeployしてから問題を発見すると、修正に膨大な手間がかかる。

Share: