RAG実装パターン は何をしますか?
Retrieval-Augmented Generation (RAG) システムの実装に関する包括的なガイドです。ベクトルデータベースの選定、チャンク分割戦略、埋め込みモデル、検索最適化、本番環境へのデプロイパターンを網羅しています。
クイックリファレンス
このスキルを使用するタイミング:
- RAG/セマンティック検索システムの構築
- ドキュメント検索パイプラインの実装
- ベクトルデータベースのパフォーマンス最適化
- 検索品質の問題デバッグ
- ベクトルデータベースオプションの選択
- チャンク分割戦略の設計
- ハイブリッド検索の実装
カバーするテクノロジー:
- ベクトルDB: Qdrant、Pinecone、Chroma、Weaviate、Milvus
- 埋め込み: OpenAI、Sentence Transformers、Cohere
- フレームワーク: LangChain、LlamaIndex、Haystack
パート1: ベクトルデータベースの選定
データベース比較マトリックス
| データベース | 最適な用途 | デプロイ | パフォーマンス | コスト |
|---|---|---|---|---|
| Qdrant | セルフホスト、本番環境 | Docker/K8s | 優れている (Rust) | 無料 (セルフホスト) |
| Pinecone | マネージド、迅速なプロトタイピング | クラウド | 優れている | 従量課金制 |
| Chroma | ローカル開発、組み込み | インプロセス | 良好 (Python) | 無料 |
| Weaviate | 複雑なスキーマ、GraphQL | Docker/クラウド | 優れている (Go) | 無料 + クラウド |
| Milvus | 大規模、分散環境 | K8s | 優れている (C++) | 無料 (セルフホスト) |
Qdrant セットアップ(本番環境推奨)
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
# クライアントの初期化(ローカルまたはクラウド)
client = QdrantClient(url="http://localhost:6333") # またはクラウドURL
# コレクションの作成
client.create_collection(
collection_name="documents",
vectors_config=VectorParams(
size=1536, # OpenAI text-embedding-3-small の次元数
distance=Distance.COSINE # または DOT, EUCLID
)
)
# ペイロード付きでベクトルを挿入
client.upsert(
collection_name="documents",
points=[
PointStruct(
id=1,
vector=[0.1, 0.2, ...], # 1536次元
payload={
"text": "ドキュメントの内容",
"source": "doc.pdf",
"page": 1,
"metadata": {...}
}
)
]
)
# 検索
results = client.search(
collection_name="documents",
query_vector=[0.1, 0.2, ...],
limit=5,
score_threshold=0.7 # 最小類似度
)
Pinecone セットアップ(マネージドサービス)
from pinecone import Pinecone, ServerlessSpec
# 初期化
pc = Pinecone(api_key="your-key")
# インデックスの作成
pc.create_index(
name="documents",
dimension=1536,
metric="cosine",
spec=ServerlessSpec(cloud="aws", region="us-east-1")
)
# インデックスの取得
index = pc.Index("documents")
# ベクトルのアップサート
index.upsert(vectors=[
("doc1", [0.1, 0.2, ...], {"text": "...", "source": "..."})
])
# クエリ
results = index.query(
vector=[0.1, 0.2, ...],
top_k=5,
include_metadata=True
)
パート2: チャンク分割戦略
戦略1: 固定サイズチャンク分割(シンプル、高速)
def fixed_size_chunking(text: str, chunk_size: int = 512, overlap: int = 50) -> list[str]:
"""
テキストをオーバーラップ付きの固定サイズチャンクに分割します。
長所: シンプル、チャンクサイズが予測可能
短所: 文の途中で分割される可能性がある、意味的な境界が不十分
"""
words = text.split()
chunks = []
for i in range(0, len(words), chunk_size - overlap):
chunk = ' '.join(words[i:i + chunk_size])
chunks.append(chunk)
return chunks
# 使用法
chunks = fixed_size_chunking(document, chunk_size=512, overlap=50)
使用するタイミング:
- シンプルなドキュメント(ログ、トランスクリプト)
- プロトタイピング/MVP
- 一貫したトークン予算が必要な場合
戦略2: セマンティックチャンク分割(高品質)
from langchain.text_splitter import RecursiveCharacterTextSplitter
def semantic_chunking(text: str, chunk_size: int = 1000, overlap: int = 200) -> list[str]:
"""
意味的な境界(段落、文)で分割します。
長所: 意味を保持、検索品質が向上
短所: チャンクサイズが可変、処理が遅い
"""
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=["\n\n", "\n", ". ", " ", ""], # 優先順位
length_function=len
)
return splitter.split_text(text)
# 使用法
chunks = semantic_chunking(document, chunk_size=1000, overlap=200)
使用するタイミング:
- 長文ドキュメント(記事、書籍、レポート)
- 品質 > 速度
- 自然言語コンテンツ
戦略3: 階層的チャンク分割(構造化ドキュメントに最適)
def hierarchical_chunking(document: dict) -> list[dict]:
"""
ドキュメント構造(セクション、サブセクション)に基づいてチャンク分割します。
長所: 階層を保持、親子検索が可能
短所: 構造化された入力が必要、より複雑
"""
chunks = []
for section in document['sections']:
# 親チャンク(セクション要約)
chunks.append({
'text': section['title'] + '\n' + section['summary'],
'type': 'parent',
'section_id': section['id']
})
# 子チャンク(段落)
for para in section['paragraphs']:
chunks.append({
'text': para,
'type': 'child',
'parent_id': section['id']
})
return chunks
使用するタイミング:
- 技術文書
- 目次付きの書籍
- 法的文書
- コンテキスト階層を保持する必要がある場合
戦略4: スライディングウィンドウ(最大のコンテキスト保存)
def sliding_window_chunking(text: str, window_size: int = 512, stride: int = 256) -> list[str]:
"""
最大のコンテキストを得るためのオーバーラップウィンドウ。
長所: 境界での情報損失がない
短所: ストレージオーバーヘッド(コンテンツの重複)
"""
words = text.split()
chunks = []
for i in range(0, len(words) - window_size + 1, stride):
chunk = ' '.join(words[i:i + window_size])
chunks.append(chunk)
return chunks
使用するタイミング:
- 重要な検索精度が必要な場合
- 短いクエリに広いコンテキストが必要な場合
- ストレージコストが問題にならない場合
パート3: 埋め込みモデル
モデル選択ガイド
| モデル | 次元数 | 速度 | 品質 | コスト | ユースケース |
|---|---|---|---|---|---|
| OpenAI text-embedding-3-small | 1536 | 高速 | 優れている | $0.02/100万トークン | 本番環境、汎用 |
| OpenAI text-embedding-3-large | 3072 | 中程度 | 最高 | $0.13/100万トークン | 高品質な検索 |
| all-MiniLM-L6-v2 | 384 | 非常に高速 | 良好 | 無料 | セルフホスト、プロトタイピング |
| all-mpnet-base-v2 | 768 | 高速 | 非常に良好 | 無料 | セルフホスト、品質重視 |
| Cohere embed-english-v3.0 | 1024 | 高速 | 優れている | $0.10/100万トークン | セマンティック検索特化 |
OpenAI 埋め込み(推奨)
from openai import OpenAI
client = OpenAI(api_key="your-key")
def get_embeddings(texts: list[str], model: str = "text-embedding-3-small") -> list[list[float]]:
"""
OpenAI を使用して埋め込みを生成します。
バッチサイズ: リクエストあたり最大2048入力
レート制限: 階層制限を確認
"""
response = client.embeddings.create(
model=model,
input=texts
)
return [item.embedding for item in response.data]
# 使用法
chunks = ["チャンク1", "チャンク2", ...]
embeddings = get_embeddings(chunks)
Sentence Transformers(セルフホスト)
from sentence_transformers import SentenceTransformer
# モデルのロード(初回ダウンロード後はキャッシュ)
model = SentenceTransformer('all-MiniLM-L6-v2')
def get_embeddings_local(texts: list[str]) -> list[list[float]]:
"""
ローカルで埋め込みを生成します(APIコストなし)。
バッチサイズ > 100 の場合はGPU推奨
小規模バッチではCPUでも許容
"""
return model.encode(texts, show_progress_bar=True).tolist()
# 使用法
embeddings = get_embeddings_local(chunks)
パート4: 検索最適化
テクニック1: ハイブリッド検索(密 + 疎)
from qdrant_client.models import Filter, FieldCondition, MatchValue
def hybrid_search(query: str, query_vector: list[float], top_k: int = 10):
"""
密(ベクトル)検索と疎(キーワード)検索を組み合わせます。
密: セマンティック類似度
疎: 完全一致キーワード
"""
# 密検索
dense_results = client.search(
collection_name="documents",
query_vector=query_vector,
limit=top_k * 2 # より多くの候補を取得
)
# 疎検索(メタデータによるBM25)
sparse_results = client.search(
collection_name="documents",
query_filter=Filter(
must=[
FieldCondition(
key="text",
match=MatchValue(value=query)
)
]
),
limit=top_k * 2
)
# マージと再ランク付け
combined = merge_results(dense_results, sparse_results, weights=(0.7, 0.3))
return combined[:top_k]
テクニック2: クエリ拡張
def expand_query(query: str) -> list[str]:
"""
より良い再現率を得るためにクエリのバリエーションを生成します。
テクニック:
- 同義語展開
- 質問の言い換え
- エンティティ抽出
"""
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4",
messages=[{
"role": "system",
"content": "ユーザーのクエリの言い換えを3つ生成してください。"
}, {
"role": "user",
"content": query
}]
)
expanded = [query] + response.choices[0].message.content.split('\n')
return expanded
# 使用法
queries = expand_query("ニューラルネットワークの訓練方法は?")
# → ["ニューラルネットワークの訓練方法は?",
# "ニューラルネットワークの訓練手法とは?",
# "ニューラルネットワーク最適化手法",
# "深層学習モデルの訓練"]
テクニック3: 再ランク付け
from sentence_transformers import CrossEncoder
# クロスエンコーダーのロード(再ランク付けにはバイエンコーダーより優れている)
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
def rerank_results(query: str, results: list[dict], top_k: int = 5) -> list[dict]:
"""
クロスエンコーダーを使用して初期結果を再ランク付けします。
最初の検索よりも正確だが遅い
上位20~50件の候補にのみ使用
"""
# 各クエリ-ドキュメントペアをスコアリング
pairs = [(query, result['text']) for result in results]
scores = reranker.predict(pairs)
# スコアと結果を結合
for result, score in zip(results, scores):
result['rerank_score'] = float(score)
# ソートして上位top_kを返す
reranked = sorted(results, key=lambda x: x['rerank_score'], reverse=True)
return reranked[:top_k]
テクニック4: メタデータフィルタリング
def filtered_search(
query_vector: list[float],
filters: dict,
top_k: int = 5
):
"""
メタデータ(日付、カテゴリ、作成者など)で検索をフィルタリングします。
プリフィルター: 高速だが結果を見逃す可能性がある
ポストフィルター: より多くの結果が得られるが低速
"""
from qdrant_client.models import Filter, FieldCondition, Range
# フィルター条件を構築
conditions = []
if 'date_range' in filters:
conditions.append(
FieldCondition(
key="date",
range=Range(
gte=filters['date_range']['start'],
lte=filters['date_range']['end']
)
)
)
if 'category' in filters:
conditions.append(
FieldCondition(
key="category",
match=MatchValue(value=filters['category'])
)
)
# フィルター付きで検索
results = client.search(
collection_name="documents",
query_vector=query_vector,
query_filter=Filter(must=conditions) if conditions else None,
limit=top_k
)
return results
パート5: コンテキスト管理
パターン1: 取得コンテキストの最適化
def optimize_context(query: str, retrieved_docs: list[str], max_tokens: int = 4000) -> str:
"""
取得したコンテキストをLLMのコンテキストウィンドウに収まるように最適化します。
戦略:
1. 関連性に基づく切り詰め
2. 抽出型要約
3. 重複除去
"""
# 関連性でソート
sorted_docs = sorted(retrieved_docs, key=lambda d: d['score'], reverse=True)
# トークン予算内でコンテキストを構築
context_parts = []
total_tokens = 0
for doc in sorted_docs:
doc_tokens = estimate_tokens(doc['text'])
if total_tokens + doc_tokens <= max_tokens:
context_parts.append(f"[ソース: {doc['source']}]\n{doc['text']}")
total_tokens += doc_tokens
else:
# 収まるように最後のドキュメントを切り詰める
remaining = max_tokens - total_tokens
truncated = truncate_to_tokens(doc['text'], remaining)
context_parts.append(f"[ソース: {doc['source']}]\n{truncated}")
break
return "\n\n---\n\n".join(context_parts)
パターン2: 引用追跡
def generate_with_citations(query: str, context: str, sources: list[dict]) -> dict:
"""
引用追跡付きで回答を生成します。
戻り値:
- answer: 生成されたテキスト
- citations: 使用されたソースドキュメントのリスト
"""
from openai import OpenAI
client = OpenAI()
# ソースマップの作成
source_map = {i+1: source for i, source in enumerate(sources)}
numbered_context = "\n\n".join([
f"[{i+1}] {source['text']}"
for i, source in enumerate(sources)
])
response = client.chat.completions.create(
model="gpt-4",
messages=[{
"role": "system",
"content": "提供されたソースを使用して回答してください。ソースは[1]、[2]などと引用してください。"
}, {
"role": "user",
"content": f"コンテキスト:\n{numbered_context}\n\n質問: {query}"
}]
)
answer = response.choices[0].message.content
# 実際に使用されたソースを抽出
used_sources = extract_cited_sources(answer)
return {
'answer': answer,
'citations': [source_map[i] for i in used_sources if i in source_map]
}
パターン3: 会話履歴管理
class ConversationMemory:
"""
RAG会話の履歴を管理します。
制限:
- トークン予算に基づいて履歴をトリミング
- 検索コンテキストとして関連する過去のやり取りを保持
"""
def __init__(self, max_tokens: int = 2000):
self.history = []
self.max_tokens = max_tokens
def add_interaction(self, query: str, answer: str, sources: list[dict]):
self.history.append({
'query': query,
'answer': answer,
'sources': sources
})
self._trim_history()
def _trim_history(self):
"""
トークン予算内に収まるように履歴をトリミングします。
"""
while self._estimate_tokens() > self.max_tokens and len(self.history) > 1:
self.history.pop(0)
def _estimate_tokens(self) -> int:
return sum(
estimate_tokens(item['query'] + item['answer'])
for item in self.history
)
def get_context(self, max_past_interactions: int = 5) -> str:
"""
会話履歴をフォーマットされたコンテキストとして取得します。
"""
recent = self.history[-max_past_interactions:]
parts = []
for i, item in enumerate(recent):
parts.append(f"[過去のやり取り {i+1}]")
parts.append(f"ユーザー: {item['query']}")
parts.append(f"アシスタント: {item['answer']}")
return "\n".join(parts)
パート6: 本番環境へのデプロイメント
推奨アーキテクチャ
[ユーザークエリ]
↓
[クエリ拡張]
↓
[エンベディング生成]
↓
[ハイブリッド検索] ←→ [ベクトルDB]
↓
[結果のマージ]
↓
[再ランク付け]
↓
[コンテキスト最適化]
↓
[LLM生成]
↓
[回答 + 引用]
パフォーマンスのベストプラクティス
- バッチ処理: 頻繁なラウンドトリップを避けるためにAPIコールをバッチ処理する
- キャッシング: 頻繁にアクセスされる埋め込みと検索結果
- 非同期処理: 複数の検索を並行して実行
- 監視: 検索品質メトリクス(MRR、NDCG@K)を追跡
- A/Bテスト: 本番環境で異なる戦略を比較
監視設定
def log_search_quality(query: str, results: list, relevant_docs: list[str]):
"""
検索品質メトリクスを追跡します。
メトリクス:
- MRR (Mean Reciprocal Rank)
- Recall@K
- Precision@K
"""
mrr = calculate_mrr(results, relevant_docs)
recall = calculate_recall_at_k(results, relevant_docs, k=5)
log_metric("search_mrr", mrr)
log_metric("search_recall@5", recall)
トラブルシューティングガイド
問題1: 低い検索品質
考えられる原因:
- 不適切なチャンクサイズ(大きすぎるか小さすぎる)
- 間違った埋め込みモデル
- 不十分なメタデータフィルタリング
解決策:
- ドキュメントの種類に基づいてチャンク戦略を調整する
- 異なる埋め込みモデルを比較する
- メタデータフィルターを実装する
- より良い結果を得るためにハイブリッド検索を使用する
問題2: 応答時間が遅い
考えられる原因:
- 最適化されていないベクトルデータベース
- 非効率なチャンク分割
- LLMの長いコンテキスト処理
解決策:
- ベクトルDBのインデックス設定を最適化する
- APIコールをバッチ処理する
- 応答をキャッシュする
- コンテキストウィンドウサイズを制限する
問題3: 高いレイテンシー
考えられる原因:
- ネットワークオーバーヘッド
- レート制限
- ボトルネックとなっているサービス
解決策:
- 可能な限りローカル処理を使用する
- 指数バックオフで再試行ロジックを実装する
- コンポーネントをプロファイリングしてボトルネックを特定する
付録: ユーティリティ関数
トークン見積もり
def estimate_tokens(text: str) -> int:
"""
テキストのおおよそのトークン数を推定します。
英語の場合は1単語あたり約1.3トークン
正確な見積もりにはtiktokenを使用
"""
return len(text.split()) * 4 // 3
切り詰め関数
def truncate_to_tokens(text: str, max_tokens: int) -> str:
"""
テキストを指定されたトークン数に切り詰めます。
"""
words = text.split()
target_words = max_tokens * 3 // 4
return ' '.join(words[:target_words])
結果のマージ
def merge_results(
dense_results: list,
sparse_results: list,
weights: tuple = (0.7, 0.3)
) -> list:
"""
密検索と疎検索の結果を重み付け付きでマージします。
"""
from collections import defaultdict
scores = defaultdict(float)
for i, result in enumerate(dense_results):
scores[result.id] += weights[0] * (1.0 / (i + 1))
for i, result in enumerate(sparse_results):
scores[result.id] += weights[1] * (1.0 / (i + 1))
# スコアでソート
sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
return [result for result_id, score in sorted_results]