RAGシステムの構築入門 — LLMに自社データを参照させる検索拡張生成
ChatGPT は社内マニュアルを知りません。RAG(検索拡張生成)を使えば LLM に自社ドキュメントを参照させられます。仕組み・実装手順・よくある失敗まで Python コードで解説します。
「社内マニュアルに基づいて回答する AI チャットボットを作りたい」「最新の製品情報を LLM に参照させたい」——こうした要件に答えるのが RAG(Retrieval-Augmented Generation / 検索拡張生成) です。
LLM は学習データのカットオフ以降の情報を知らず、社内ドキュメントにもアクセスできません。RAG はこの問題を、検索(Retrieval)と生成(Generation)を組み合わせることで解決します。
この記事では RAG の仕組みを図解し、Python + LangChain + pgvector を使った実装手順をステップバイステップで解説します。
なぜ RAG が必要か
| 問題 | 原因 | RAG による解決 |
|---|---|---|
| 社内ドキュメントを知らない | 学習データに含まれていない | クエリ時に関連文書を注入 |
| 最新情報に対応できない | 知識カットオフが存在する | リアルタイムで外部データを参照 |
| もっともらしい嘘をつく(幻覚) | 知識がないときも推測で答える | 根拠ある文書を参照させる |
Fine-tuning(再学習)でも同じことを実現できますが、コスト・更新頻度・柔軟性の観点で RAG の方が実用的なケースが多いです。
RAG の全体アーキテクチャ
RAG には 2 つのフェーズがあります。
インデックスフェーズ(事前処理)
flowchart LR
A["📄 ドキュメント\nPDF / Word / Web"] --> B["✂️ チャンキング\n分割・重複付与"]
B --> C["🔢 Embedding\nベクトル変換"]
C --> D["🗄️ ベクトルDB\n保存・インデックス"]
style D fill:#1e3358,stroke:#4F8EF7クエリフェーズ(回答生成)
flowchart LR
A["👤 ユーザーの質問\n「有給の申請方法は?」"] --> B["🔢 Embedding\nクエリをベクトル化"]
B --> C["🔍 類似検索\nTop-k チャンク取得"]
C --> D["📝 プロンプト構築\n質問 + コンテキスト"]
D --> E["🤖 LLM\n回答生成"]
E --> F["✅ 回答\n根拠付き"]
style E fill:#1e3358,stroke:#4F8EF7① ドキュメントの読み込み
LangChain はさまざまな形式のドキュメントローダーを提供しています。
from langchain_community.document_loaders import (
PyPDFLoader, # PDF
TextLoader, # テキスト
UnstructuredWordDocumentLoader, # Word
WebBaseLoader, # Webページ
)
# PDF の場合
loader = PyPDFLoader("company_manual.pdf")
documents = loader.load()
print(f"ページ数: {len(documents)}")
print(f"先頭: {documents[0].page_content[:200]}")複数ファイルをまとめて処理する場合は DirectoryLoader が便利です。
from langchain_community.document_loaders import DirectoryLoader
loader = DirectoryLoader("./docs/", glob="**/*.pdf", loader_cls=PyPDFLoader)
documents = loader.load()② チャンキング(分割)
ドキュメントをそのまま 1 つのベクトルにすると検索精度が下がります。適切なサイズに分割(チャンキング)することが重要です。
チャンキング戦略の比較
flowchart TD
A["チャンキング戦略"] --> B["固定サイズ\n(文字数・トークン数)"]
A --> C["再帰的分割\n(LangChain デフォルト)"]
A --> D["セマンティック分割\n(意味的な区切り)"]
B --> B1["✅ シンプル\n❌ 文脈が途切れやすい"]
C --> C1["✅ バランスが良い\n✅ 段落・文を優先"]
D --> D1["✅ 精度が高い\n❌ 処理コストが高い"]実装では RecursiveCharacterTextSplitter が最もよく使われます。
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 1チャンクの最大文字数
chunk_overlap=64, # チャンク間の重複(文脈の連続性を保つ)
separators=["\n\n", "\n", "。", "、", " ", ""], # 日本語向け区切り文字
)
chunks = splitter.split_documents(documents)
print(f"チャンク数: {len(chunks)}")チャンクサイズの目安:
| 用途 | chunk_size | chunk_overlap |
|---|---|---|
| Q&A(短い回答) | 256〜512 | 32〜64 |
| 長文要約 | 1024〜2048 | 128〜256 |
| コード解析 | 512〜1024 | 100〜200 |
③ Embedding(ベクトル変換)
テキストを高次元のベクトルに変換します。意味的に近いテキストはベクトル空間上でも近い位置に配置されます。
主要な Embedding モデル
| モデル | 次元数 | 特徴 | 料金 |
|---|---|---|---|
text-embedding-3-small |
1,536 | コスパ最良・多言語対応 | $0.02 / 1M tokens |
text-embedding-3-large |
3,072 | 高精度 | $0.13 / 1M tokens |
multilingual-e5-large |
1,024 | OSS・日本語強い | 無料 |
intfloat/multilingual-e5-base |
768 | OSS・軽量 | 無料 |
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# テスト:テキスト → ベクトル変換
vector = embeddings.embed_query("有給休暇の申請方法")
print(f"次元数: {len(vector)}") # 1536④ ベクトルDB への保存
ベクトルDB の選択肢
| DB | 特徴 | 向いているケース |
|---|---|---|
| pgvector | PostgreSQL 拡張・既存 DB に追加可能 | 本番環境・既に PostgreSQL を使用 |
| Chroma | ローカル動作・ファイルベース | プロトタイプ・小規模 |
| Pinecone | フルマネージド・スケーラブル | 大規模・運用負荷ゼロ優先 |
| Qdrant | OSS・高性能・Docker で動作 | 中〜大規模 |
pgvector を使った実装
PostgreSQL に pgvector 拡張をインストールします。
-- PostgreSQL に pgvector を追加
CREATE EXTENSION IF NOT EXISTS vector;Python からベクトルを保存・検索します。
from langchain_postgres import PGVector
CONNECTION_STRING = "postgresql://user:password@localhost:5432/ragdb"
# ベクトルDBを作成してチャンクを保存
vectorstore = PGVector.from_documents(
documents=chunks,
embedding=embeddings,
connection=CONNECTION_STRING,
collection_name="company_docs",
)
print("インデックス完了")Chroma(ローカル開発・プロトタイプ向け)
from langchain_chroma import Chroma
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db", # ローカルに保存
)⑤ 類似検索 + 回答生成
保存したベクトルDBから関連チャンクを検索し、LLM に渡します。
シンプルな実装
from langchain_openai import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
# カスタムプロンプト(日本語回答・根拠明示)
prompt_template = """
以下のコンテキストを参照して、質問に日本語で回答してください。
コンテキストに情報がない場合は「資料に記載がありません」と答えてください。
コンテキスト:
{context}
質問: {question}
回答:
"""
PROMPT = PromptTemplate(
template=prompt_template,
input_variables=["context", "question"],
)
# Retriever(Top-4 チャンクを取得)
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 4},
)
# LLM
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
# QA チェーン
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("【回答】")
print(result["result"])
print("\n【参照元】")
for doc in result["source_documents"]:
print(f"- {doc.metadata.get('source', '不明')} p.{doc.metadata.get('page', '?')}")出力例
【回答】
有給休暇の申請は、申請日の3営業日前までに勤怠管理システム「KING OF TIME」
から申請してください。承認者は直属の上長です。
【参照元】
- company_manual.pdf p.12
- company_manual.pdf p.13⑥ メタデータフィルタリング
複数のドキュメントカテゴリが混在する場合、メタデータで検索範囲を絞ることで精度が上がります。
from langchain.schema import Document
# ドキュメントにメタデータを付与
docs_with_meta = [
Document(
page_content="有給休暇は年間20日付与されます...",
metadata={"source": "hr_manual.pdf", "category": "人事", "year": 2026},
),
Document(
page_content="サーバーの構成は...",
metadata={"source": "infra_doc.pdf", "category": "技術", "year": 2025},
),
]
# カテゴリ「人事」のドキュメントのみを検索対象にする
retriever = vectorstore.as_retriever(
search_kwargs={
"k": 4,
"filter": {"category": "人事"},
}
)RAG の評価指標
実装後は回答品質を定量評価することが重要です。RAGAS というライブラリを使うと自動評価できます。
from ragas import evaluate
from ragas.metrics import (
faithfulness, # 回答がコンテキストに忠実か(幻覚のなさ)
answer_relevancy, # 回答が質問に関連しているか
context_precision, # 検索されたコンテキストの精度
context_recall, # 必要な情報を取りこぼしていないか
)
from datasets import Dataset
# 評価データセット(質問・回答・コンテキスト・正解を準備)
eval_data = {
"question": ["有給休暇の申請期限は?"],
"answer": ["3営業日前までに申請が必要です"],
"contexts": [["有給休暇は申請日の3営業日前までに..."]],
"ground_truth": ["申請日の3営業日前"],
}
dataset = Dataset.from_dict(eval_data)
result = evaluate(dataset, metrics=[faithfulness, answer_relevancy])
print(result)| 指標 | 説明 | 目標値 |
|---|---|---|
| Faithfulness | 回答がコンテキストに根拠を持つか | > 0.9 |
| Answer Relevancy | 回答が質問に関連しているか | > 0.85 |
| Context Precision | 検索されたチャンクのうち有用なものの割合 | > 0.8 |
| Context Recall | 正解を導くのに必要なチャンクが取得できているか | > 0.8 |
よくある失敗パターンと対策
① チャンクサイズが不適切
# ❌ 大きすぎる(検索ノイズが増える・コスト増)
splitter = RecursiveCharacterTextSplitter(chunk_size=4096)
# ❌ 小さすぎる(文脈が途切れて意味不明になる)
splitter = RecursiveCharacterTextSplitter(chunk_size=64)
# ✅ Q&A 用途なら 256〜512 が基本
splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=64)② Top-k が少なすぎる
# ❌ 1件だけでは情報が足りないことが多い
retriever = vectorstore.as_retriever(search_kwargs={"k": 1})
# ✅ 3〜6件を目安に調整する
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})③ 日本語のクエリと英語の Embedding が一致しない
# ❌ 英語専用モデルで日本語を処理すると精度が落ちる
embeddings = OpenAIEmbeddings(model="text-embedding-ada-002") # 旧モデル(多言語弱め)
# ✅ 多言語対応モデルを使う
embeddings = OpenAIEmbeddings(model="text-embedding-3-small") # 多言語対応
# または
from langchain_huggingface import HuggingFaceEmbeddings
embeddings = HuggingFaceEmbeddings(model_name="intfloat/multilingual-e5-large")④ プロンプトに「情報がない場合の指示」がない
# ❌ コンテキスト外の質問でも推測で答えてしまう
prompt = "コンテキスト: {context}\n質問: {question}\n回答:"
# ✅ 「資料外の場合は答えない」を明示する
prompt = """
コンテキストに基づいて回答してください。
情報が見つからない場合は「資料に記載がありません」とだけ答えてください。
コンテキスト: {context}
質問: {question}
回答:
"""⑤ ドキュメントの更新がベクトルDBに反映されない
インデックス作成を定期実行するか、更新検知の仕組みを作ります。
import hashlib
def get_doc_hash(filepath: str) -> str:
"""ファイルの MD5 ハッシュを計算"""
with open(filepath, "rb") as f:
return hashlib.md5(f.read()).hexdigest()
# ハッシュが変わったファイルだけ再インデックス
def sync_documents(doc_dir: str, vectorstore):
for filepath in Path(doc_dir).glob("**/*.pdf"):
current_hash = get_doc_hash(filepath)
stored_hash = load_stored_hash(filepath) # 前回のハッシュ
if current_hash != stored_hash:
reindex_document(filepath, vectorstore)
save_hash(filepath, current_hash)構成パターン別の選択指針
flowchart TD
A["RAG を構築したい"] --> B{"ドキュメント規模"}
B -->|"〜数百件"| C["Chroma + ローカル\nプロトタイプ向け"]
B -->|"数百〜数千件"| D["pgvector\n既存 PostgreSQL に追加"]
B -->|"数万件以上"| E["Pinecone / Qdrant\nマネージド or 専用DB"]
C --> F{"Embedding モデル"}
D --> F
E --> F
F -->|"日本語中心"| G["text-embedding-3-small\nor multilingual-e5-large"]
F -->|"英語中心"| H["text-embedding-3-small\nor text-embedding-3-large"]まとめ
| ステップ | 内容 | キーポイント |
|---|---|---|
| ① ドキュメント読み込み | PDF・Word・Web を LangChain で取得 | 形式に合ったローダーを選ぶ |
| ② チャンキング | 適切なサイズに分割(重複あり) | 512 文字・64 重複が基本 |
| ③ Embedding | テキスト → 高次元ベクトル変換 | 多言語対応モデルを使う |
| ④ ベクトルDB 保存 | 類似検索用インデックスを構築 | 規模に応じて pgvector / Chroma / Pinecone |
| ⑤ 類似検索 | クエリに近いチャンクを Top-k 取得 | k=4 前後が出発点 |
| ⑥ 回答生成 | コンテキスト付きプロンプトで LLM に問い合わせ | 「情報なし時の指示」を必ず入れる |
| ⑦ 評価 | RAGAS で Faithfulness・Relevancy を測定 | 定量評価なしの改善はギャンブル |
RAG は「LLM + 検索」のシンプルな組み合わせですが、チャンクサイズ・Embedding モデル・検索件数・プロンプト設計のチューニングで精度が大きく変わります。まず Chroma でプロトタイプを作り、RAGAS で評価しながら本番構成に移行するのが最も効率的なアプローチです。