HANDS-ON / 55 MIN
RAGの構成要素 ── 5つのパーツを順に体験する
ファイル: 04_rag_components.py
EXERCISE
スクリプトを実行する
python 04_rag_components.py を実行
Document Loader
→
Text Splitter
→
Embedding
→
Vector Store
→
Retriever
RAGの「検索」側パイプラインの全体像
これから体験する5つのステップは、「紙の文書をAIが読める形に変換し、質問に関係する部分だけを取り出す」一連の処理。図書館に例えると、(1)本を仕入れる → (2)ページごとに切り分ける → (3)各ページの内容を索引カードに変換する → (4)索引カードを棚に整理する → (5)利用者の質問に合う索引カードを探す、という流れに対応する。
Step 1 ── Document Loader でPDFを読み込む
# Step 1: Document Loader
from langchain_community.document_loaders import PyPDFLoader
loader = PyPDFLoader("data/sample.pdf")
pages = loader.load()
print(f"読み込んだページ数: {len(pages)}")
print(pages[0].page_content[:200])
PDFの内容がテキストとして取得される。RAGの第一歩は、対象文書をシステムに取り込むこと。
- LangChainが提供する「ファイル読み込み部品」の総称。PDF以外にも、Word、Excel、Webページ、CSVなど多数のフォーマットに対応している
- PyPDFLoader は PDF専用のLoader。PDFファイルのパスを渡すと、ページごとにテキストを抽出してくれる
- loader.load() を実行すると、Documentオブジェクトのリストが返る。各Documentには page_content(テキスト本文)と metadata(ページ番号など)が含まれている
- 実務では社内のFAQ文書、マニュアル、仕様書などをLoaderで読み込むことになる
LangChain Document Loaders
Step 2 ── Text Splitter で文書を分割する
# Step 2: Text Splitter
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
)
chunks = splitter.split_documents(pages)
chunk_size=500
500文字ごとに区切る。LLMに渡せるテキスト長には上限があるため、文書全体ではなく適切なサイズに分割する。
chunk_overlap=50
前後50文字を重複させる。文脈が途切れるのを防ぐ。
- LLMには一度に処理できるテキスト量の上限がある(「コンテキストウィンドウ」と呼ぶ。GPT-4o-miniは約128,000トークン)
- 上限内でも、文書が長すぎると回答精度が下がる。関係のない部分まで読ませると「ノイズ」になる
- そのため、文書を小さな塊(チャンク)に分割し、質問に関連するチャンクだけをLLMに渡す戦略を取る
- chunk_size=500 は「500文字ごとに区切る」という意味。短すぎると文脈が失われ、長すぎると検索精度が落ちるため、300〜1000文字がよく使われる範囲
- chunk_overlap=50 は「前のチャンクの最後50文字を次のチャンクの先頭にも含める」という意味。文の途中で切れて意味が通じなくなるのを防ぐ
- RecursiveCharacterTextSplitter は段落 → 改行 → 文 → 文字 の順で分割点を探す賢いSplitter。単純に500文字で切るのではなく、なるべく文の区切りで分割しようとする
LangChain Text Splitters
Step 3 ── Embedding でテキストをベクトルに変換する
# Step 3: Embedding
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
text = "RAGとは検索拡張生成のことです"
vector = embeddings.embed_query(text)
print(f"ベクトルの次元数: {len(vector)}")
「意味が近いテキスト同士は数値的にも近くなる」という性質がある。これが類似検索の基盤。
- Embeddingとは、テキストを数百〜数千個の数値の列(ベクトル)に変換する技術
- たとえば「犬」→ [0.2, 0.8, 0.1, ...] のように変換される。この数値の列が、その単語の「意味」を表現している
- 地図の座標に例えるとわかりやすい。「東京」と「大阪」は地図上で近い位置にあるように、「犬」と「猫」はベクトル空間上で近い位置に配置される。「犬」と「経済学」は遠い位置になる
- text-embedding-3-small は OpenAI が提供するEmbeddingモデル。テキストを1536次元のベクトルに変換する(1536個の数値の列)
- embed_query() は1つのテキストをベクトルに変換するメソッド。検索クエリ(質問文)のベクトル化に使う
- この変換により、テキスト同士の「意味の近さ」を数値で計算できるようになる。これがRAG検索の核心技術
OpenAI Embeddings ガイド
OpenAI Embedding モデル一覧
# 2つの文の類似度を比較
text_a = "RAGとは検索拡張生成です"
text_b = "Retrieval-Augmented Generationの略です"
text_c = "今日の天気は晴れです"
# コサイン類似度: 1に近いほど意味が近い
# RAG同士 → 0.4〜0.5前後(意味が近いので高い)
# RAGと天気 → 0.1〜0.2前後(関係ない話題なので低い)
- 2つのベクトルがどれくらい同じ方向を向いているかを表す指標。値は -1 から 1 の範囲
- 1 = 完全に同じ意味、0 = まったく無関係、-1 = 正反対の意味
- 計算の詳細は知らなくてよい。「数値が1に近いほど意味が似ている」とだけ覚えておけば十分
- 実際にはLangChainとFAISSが内部で自動計算してくれるため、自分でコサイン類似度を計算するコードを書く必要はない
Step 4 ── Vector Store に文書を格納する
# Step 4: Vector Store
from langchain_community.vectorstores import FAISS
vectorstore = FAISS.from_documents(chunks, embeddings)
print(f"Vector Storeへの格納完了。格納チャンク数: {len(chunks)}")
FAISSはMeta社が開発した、ローカルで動作するVector Store。本番環境ではPinecone、Weaviateなどのクラウドサービスを使うこともある。
- ベクトルに変換されたテキストチャンクを保存し、検索できるようにするデータベース
- 通常のデータベース(SQLなど)は「キーワードの完全一致・部分一致」で検索する。Vector Storeは「意味の近さ」で検索できる点が根本的に異なる
- FAISS.from_documents(chunks, embeddings) は、チャンクを1つずつEmbeddingモデルでベクトル化し、FAISSに格納する処理。チャンク数が多いほど時間がかかる(API呼び出しが発生するため)
- FAISSはローカルPC上で動作する。データはメモリ上に保持され、プログラム終了時に消える(永続化も可能だが本研修では扱わない)
- 本番環境ではPinecone、Weaviate、Chromaなどのクラウド型Vector Storeを使うことが多い
LangChain Vector Stores
FAISS GitHub
Step 5 ── Retriever で検索する
# Step 5: Retriever
retriever = vectorstore.as_retriever(
search_kwargs={"k": 3} # 上位3件を返す
)
question = "PCが起動しない場合はどうすればいいですか"
results = retriever.invoke(question)
for i, doc in enumerate(results):
print(f"--- 結果{i+1} ---")
print(doc.page_content[:200])
質問の「意味」に近い文書が、大量のチャンクの中から見つかる。これがRAGの核心。
- Vector Storeから関連文書を取り出す「検索係」の役割を担うオブジェクト
- vectorstore.as_retriever() でVector StoreをRetrieverに変換する。変換後は retriever.invoke(質問文) で検索結果が返る
- search_kwargs={"k": 3} は「上位3件を返す」という設定。kを大きくすれば多くの文書が返るが、関連性の低い文書も混ざりやすくなる
- Retrieverが返すのはDocumentオブジェクトのリスト。各Documentの page_content に文書テキストが入っている
- この検索結果が、次のSession 04でLLMに渡される「参考資料」になる
LangChain Retrievers
EXERCISE
自分で試してみよう
- chunk_size を 200 に変えてチャンク数の変化を観察する
- Step 3の文を自由に変えて類似度の変化を確認する
- Step 5の質問を変えて検索結果の違いを見る
この操作がどう活きるか
最終演習では、このStep 1〜5が retrieve_node という1つの関数に集約される。
# 最終演習でのretrieve_node
def retrieve_node(state):
question = state["question"]
documents = retriever.invoke(question)
return {"documents": documents}