S
Shineos Tech Blog
RAG(検索拡張生成)実装ガイド - 基礎から本番運用まで

RAG(検索拡張生成)実装ガイド - 基礎から本番運用まで

| Shineos Dev Team
Share:

はじめに

大規模言語モデル(LLM)の活用が進む中、「古い情報しか返してこない」「社内データを参照できない」といった課題に直面していませんか?

RAG(Retrieval-Augmented Generation:検索拡張生成)は、これらの課題を解決する実用的な技術です。LLMの出力を外部の知識ベースで補強することで、より正確で最新の情報を提供できます。

本記事では、私たちが実プロジェクトで培ったRAGの実装ノウハウを、基礎から本番運用まで包括的に解説します。

RAGとは?

RAGは、LLMに外部の知識ソースを組み合わせる技術です。ユーザーの質問に対して、関連する文書を検索し、その情報をコンテキストとしてLLMに渡すことで、より正確な回答を生成します。

RAGの基本フロー

RAGの基本フロー

RAGは以下の5つのステップで動作します:

  1. 質問を埋め込みベクトルに変換: ユーザーの質問をベクトル形式に変換
  2. ベクトルデータベースから関連文書を検索: 類似度の高い文書を取得
  3. 検索結果をコンテキストとしてLLMに渡す: 関連文書を元にプロンプトを構築
  4. LLMが回答を生成: コンテキストを基に正確な回答を生成
  5. ユーザーに回答を返す: 生成された回答を提供

まとめ

項目説明主要技術
検索(Retrieval)関連する文書を見つけるベクトルデータベース、埋め込みモデル
拡張(Augmented)検索結果でコンテキストを補強プロンプトエンジニアリング
生成(Generation)コンテキストを基に回答生成LLM(GPT-4, Claude等)
メリット最新情報の活用、ハルシネーション低減-
適用例社内Q&A、ドキュメント検索、カスタマーサポート-

RAGの構成要素

1. 文書の前処理とチャンク分割

RAGの最初のステップは、文書を適切なサイズに分割することです。

from langchain.text_splitter import RecursiveCharacterTextSplitter

# テキストを意味のある単位で分割
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,  # チャンクのサイズ(文字数)
    chunk_overlap=200,  # チャンク間のオーバーラップ
    length_function=len,
    separators=["\n\n", "\n", "。", "、", " ", ""]
)

# 文書を分割
chunks = text_splitter.split_text(document_text)

# メタデータを付与
documents = [
    {
        "content": chunk,
        "metadata": {
            "source": "document_name.pdf",
            "page": page_num,
            "chunk_id": idx
        }
    }
    for idx, chunk in enumerate(chunks)
]

チャンク分割のポイント:

  • サイズ選択: 500-1500文字が一般的(用途により調整)
  • オーバーラップ: 文脈の連続性を保つため10-20%のオーバーラップを設定
  • 区切り文字: 段落、文、句読点の順で優先的に分割

2. 埋め込みベクトルの生成

文書を数値ベクトルに変換し、意味的類似性を計算可能にします。

from openai import OpenAI

client = OpenAI(api_key="YOUR_API_KEY")

def create_embeddings(texts):
    """テキストリストを埋め込みベクトルに変換"""
    response = client.embeddings.create(
        model="text-embedding-3-small",  # コスト効率が良い
        input=texts
    )
    
    return [item.embedding for item in response.data]

# チャンクごとに埋め込みを生成
embeddings = create_embeddings([doc["content"] for doc in documents])

# 文書と埋め込みを紐付け
for doc, embedding in zip(documents, embeddings):
    doc["embedding"] = embedding

埋め込みモデルの選択:

  • text-embedding-3-small: コスト効率重視(推奨)
  • text-embedding-3-large: 精度重視
  • 日本語特化モデル: 日本語文書が中心の場合

3. ベクトルデータベースへの格納

埋め込みベクトルを効率的に検索できるデータベースに格納します。

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct

# Qdrantクライアントの初期化
client = QdrantClient(url="http://localhost:6333")

# コレクション作成
collection_name = "documents"
client.create_collection(
    collection_name=collection_name,
    vectors_config=VectorParams(
        size=1536,  # text-embedding-3-smallの次元数
        distance=Distance.COSINE
    )
)

# データを格納
points = [
    PointStruct(
        id=idx,
        vector=doc["embedding"],
        payload={
            "content": doc["content"],
            "metadata": doc["metadata"]
        }
    )
    for idx, doc in enumerate(documents)
]

client.upsert(
    collection_name=collection_name,
    points=points
)

4. 検索とコンテキスト構築

ユーザーの質問から関連文書を検索し、コンテキストを構築します。

def search_documents(query, top_k=5):
    """質問に関連する文書を検索"""
    # 質問を埋め込みベクトルに変換
    query_embedding = create_embeddings([query])[0]
    
    # ベクトル検索
    search_result = client.search(
        collection_name=collection_name,
        query_vector=query_embedding,
        limit=top_k,
        score_threshold=0.7  # 類似度の閾値
    )
    
    # 検索結果を整形
    contexts = []
    for hit in search_result:
        contexts.append({
            "content": hit.payload["content"],
            "score": hit.score,
            "source": hit.payload["metadata"]["source"]
        })
    
    return contexts

# 実行例
query = "RAGの実装方法について教えてください"
relevant_docs = search_documents(query, top_k=3)

5. LLMへのプロンプト構築と生成

検索結果をコンテキストとしてLLMに渡し、回答を生成します。

def generate_answer(query, contexts):
    """コンテキストを使って回答を生成"""
    # コンテキストを文字列に整形
    context_text = "\n\n".join([
        f"[出典: {ctx['source']}]\n{ctx['content']}"
        for ctx in contexts
    ])
    
    # プロンプト構築
    prompt = f"""以下のコンテキストを参考に、質問に答えてください。
コンテキストに含まれない情報については「情報が見つかりませんでした」と答えてください。

コンテキスト:
{context_text}

質問: {query}

回答:"""
    
    # LLMで生成
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "あなたは親切なアシスタントです。提供されたコンテキストのみを使って正確に回答してください。"},
            {"role": "user", "content": prompt}
        ],
        temperature=0.3  # 低めに設定して幻覚を抑制
    )
    
    return {
        "answer": response.choices[0].message.content,
        "sources": [ctx["source"] for ctx in contexts]
    }

# 実行例
result = generate_answer(query, relevant_docs)
print(f"回答: {result['answer']}")
print(f"出典: {', '.join(result['sources'])}")

実装パターン

パターン1: シンプルRAG

最も基本的な実装パターンです。

class SimpleRAG:
    def __init__(self, vector_store, llm_client):
        self.vector_store = vector_store
        self.llm_client = llm_client
    
    def query(self, question, top_k=5):
        # 1. 関連文書を検索
        docs = self.vector_store.search(question, top_k=top_k)
        
        # 2. コンテキストを構築
        context = "\n".join([doc["content"] for doc in docs])
        
        # 3. LLMで回答生成
        answer = self.llm_client.generate(
            prompt=f"コンテキスト: {context}\n質問: {question}"
        )
        
        return answer

# 使用例
rag = SimpleRAG(vector_store=client, llm_client=openai_client)
answer = rag.query("RAGの利点は何ですか?")

パターン2: ハイブリッド検索

ベクトル検索とキーワード検索を組み合わせた高度なパターンです。

from rank_bm25 import BM25Okapi

class HybridRAG:
    def __init__(self, vector_store, documents):
        self.vector_store = vector_store
        self.documents = documents
        
        # BM25インデックスの構築
        tokenized_docs = [doc["content"].split() for doc in documents]
        self.bm25 = BM25Okapi(tokenized_docs)
    
    def hybrid_search(self, query, top_k=5, alpha=0.5):
        """
        ベクトル検索とBM25を組み合わせた検索
        alpha: ベクトル検索の重み(0.0-1.0)
        """
        # ベクトル検索
        vector_results = self.vector_store.search(query, limit=top_k*2)
        vector_scores = {r.id: r.score for r in vector_results}
        
        # BM25検索
        tokenized_query = query.split()
        bm25_scores = self.bm25.get_scores(tokenized_query)
        
        # スコアを正規化して結合
        combined_scores = {}
        for idx, doc in enumerate(self.documents):
            vector_score = vector_scores.get(idx, 0) * alpha
            bm25_score = bm25_scores[idx] * (1 - alpha)
            combined_scores[idx] = vector_score + bm25_score
        
        # トップKを取得
        top_indices = sorted(
            combined_scores.keys(),
            key=lambda x: combined_scores[x],
            reverse=True
        )[:top_k]
        
        return [self.documents[idx] for idx in top_indices]

パターン3: 再ランキング付きRAG

検索結果を再評価して精度を向上させるパターンです。

from sentence_transformers import CrossEncoder

class ReRankingRAG:
    def __init__(self, vector_store, llm_client):
        self.vector_store = vector_store
        self.llm_client = llm_client
        # 再ランキングモデル
        self.reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
    
    def query(self, question, initial_k=20, final_k=5):
        # 1. 初期検索(多めに取得)
        candidates = self.vector_store.search(question, top_k=initial_k)
        
        # 2. 再ランキング
        pairs = [[question, doc["content"]] for doc in candidates]
        scores = self.reranker.predict(pairs)
        
        # 3. スコアでソートして上位を選択
        ranked_docs = sorted(
            zip(candidates, scores),
            key=lambda x: x[1],
            reverse=True
        )[:final_k]
        
        # 4. LLMで回答生成
        context = "\n".join([doc["content"] for doc, _ in ranked_docs])
        answer = self.llm_client.generate(
            prompt=f"コンテキスト: {context}\n質問: {question}"
        )
        
        return answer

パフォーマンス最適化

1. チャンクサイズの最適化

import matplotlib.pyplot as plt

def evaluate_chunk_sizes(queries, ground_truth, sizes=[500, 1000, 1500, 2000]):
    """異なるチャンクサイズでの精度を評価"""
    results = {}
    
    for size in sizes:
        # チャンクを再分割
        splitter = RecursiveCharacterTextSplitter(chunk_size=size)
        chunks = splitter.split_documents(documents)
        
        # ベクトルストアを再構築
        vector_store = build_vector_store(chunks)
        
        # 精度を評価
        accuracy = evaluate_accuracy(vector_store, queries, ground_truth)
        results[size] = accuracy
    
    return results

# 結果の可視化
results = evaluate_chunk_sizes(test_queries, test_answers)
plt.plot(results.keys(), results.values())
plt.xlabel('Chunk Size')
plt.ylabel('Accuracy')
plt.title('Chunk Size vs Accuracy')
plt.show()

2. キャッシング戦略

from functools import lru_cache
import hashlib

class CachedRAG:
    def __init__(self, vector_store, llm_client):
        self.vector_store = vector_store
        self.llm_client = llm_client
        self.embedding_cache = {}
    
    def get_cached_embedding(self, text):
        """埋め込みをキャッシュ"""
        text_hash = hashlib.md5(text.encode()).hexdigest()
        
        if text_hash not in self.embedding_cache:
            embedding = create_embeddings([text])[0]
            self.embedding_cache[text_hash] = embedding
        
        return self.embedding_cache[text_hash]
    
    @lru_cache(maxsize=1000)
    def cached_query(self, question):
        """頻繁な質問をキャッシュ"""
        return self.query(question)

3. バッチ処理

def batch_index_documents(documents, batch_size=100):
    """大量の文書を効率的にインデックス化"""
    for i in range(0, len(documents), batch_size):
        batch = documents[i:i + batch_size]
        
        # バッチで埋め込み生成
        texts = [doc["content"] for doc in batch]
        embeddings = create_embeddings(texts)
        
        # バッチで格納
        points = [
            PointStruct(
                id=i + idx,
                vector=embedding,
                payload={"content": doc["content"]}
            )
            for idx, (doc, embedding) in enumerate(zip(batch, embeddings))
        ]
        
        client.upsert(
            collection_name=collection_name,
            points=points
        )
        
        print(f"Processed {min(i + batch_size, len(documents))}/{len(documents)} documents")

本番運用のポイント

1. モニタリング

import time
from datetime import datetime

class MonitoredRAG:
    def __init__(self, vector_store, llm_client):
        self.vector_store = vector_store
        self.llm_client = llm_client
        self.metrics = []
    
    def query(self, question):
        start_time = time.time()
        
        try:
            # 検索
            search_start = time.time()
            docs = self.vector_store.search(question, top_k=5)
            search_time = time.time() - search_start
            
            # 生成
            gen_start = time.time()
            answer = self.llm_client.generate(question, docs)
            gen_time = time.time() - gen_start
            
            # メトリクスを記録
            self.metrics.append({
                "timestamp": datetime.now().isoformat(),
                "question": question,
                "search_time": search_time,
                "generation_time": gen_time,
                "total_time": time.time() - start_time,
                "num_docs_retrieved": len(docs),
                "status": "success"
            })
            
            return answer
            
        except Exception as e:
            self.metrics.append({
                "timestamp": datetime.now().isoformat(),
                "question": question,
                "status": "error",
                "error": str(e)
            })
            raise

2. エラーハンドリング

class RobustRAG:
    def query(self, question, max_retries=3):
        for attempt in range(max_retries):
            try:
                # 検索
                docs = self.vector_store.search(question, top_k=5)
                
                # 検索結果が空の場合
                if not docs:
                    return {
                        "answer": "関連する情報が見つかりませんでした。",
                        "confidence": "low"
                    }
                
                # 生成
                answer = self.llm_client.generate(question, docs)
                
                return {
                    "answer": answer,
                    "confidence": "high",
                    "sources": [doc["source"] for doc in docs]
                }
                
            except Exception as e:
                if attempt == max_retries - 1:
                    return {
                        "answer": "申し訳ございません。エラーが発生しました。",
                        "error": str(e)
                    }
                time.sleep(2 ** attempt)  # Exponential backoff

3. コスト管理

class CostAwareRAG:
    def __init__(self, vector_store, llm_client):
        self.vector_store = vector_store
        self.llm_client = llm_client
        self.cost_tracker = {
            "embedding_tokens": 0,
            "llm_input_tokens": 0,
            "llm_output_tokens": 0
        }
    
    def estimate_cost(self):
        """コストを見積もり"""
        # OpenAI pricing (2024年時点)
        embedding_cost = self.cost_tracker["embedding_tokens"] * 0.00002 / 1000
        input_cost = self.cost_tracker["llm_input_tokens"] * 0.01 / 1000
        output_cost = self.cost_tracker["llm_output_tokens"] * 0.03 / 1000
        
        return {
            "embedding_cost": embedding_cost,
            "llm_cost": input_cost + output_cost,
            "total_cost": embedding_cost + input_cost + output_cost
        }

よくある課題と対策

課題1: ハルシネーション(幻覚)

対策:

def verify_answer_with_context(answer, contexts):
    """回答がコンテキストに基づいているか検証"""
    verification_prompt = f"""
以下の回答が、提供されたコンテキストのみに基づいているか評価してください。

コンテキスト:
{contexts}

回答:
{answer}

コンテキスト外の情報が含まれている場合は「False」、
すべてコンテキストに基づいている場合は「True」と答えてください。
"""
    
    result = llm_client.generate(verification_prompt)
    return "True" in result

課題2: 検索精度の低下

対策:

  • メタデータフィルタリングの活用
  • ハイブリッド検索の導入
  • 定期的な埋め込みモデルの更新
def search_with_filters(query, filters=None):
    """メタデータフィルタを使った検索"""
    search_params = {
        "query_vector": create_embeddings([query])[0],
        "limit": 5
    }
    
    if filters:
        search_params["filter"] = filters
    
    # 例: 特定の日付以降の文書のみ検索
    # filters = {
    #     "must": [
    #         {"key": "date", "range": {"gte": "2024-01-01"}}
    #     ]
    # }
    
    return client.search(collection_name, **search_params)

課題3: レスポンス時間の長さ

対策:

  • 並列処理の活用
  • 結果のキャッシング
  • 軽量なモデルの使用
import asyncio

async def parallel_rag_query(questions):
    """複数の質問を並列処理"""
    tasks = [
        asyncio.create_task(rag.async_query(q))
        for q in questions
    ]
    
    results = await asyncio.gather(*tasks)
    return results

実運用での成果

私たちの実装では、以下の成果を達成しました:

  • 回答精度: 従来のFAQシステムと比較して 85%→93%に向上
  • レスポンス時間: 平均 2.5秒以内で回答
  • コスト削減: カスタマーサポートの対応工数を 40%削減
  • ユーザー満足度: NPS(Net Promoter Score)が +15ポイント向上

おわりに

RAGは、LLMの能力を最大限に引き出すための実用的な技術です。本記事で紹介した実装パターンと最適化手法を組み合わせることで、高精度で実用的なシステムを構築できます。

RAG導入の際は、以下のポイントを押さえることをお勧めします:

  1. 適切なチャンクサイズ: 用途に応じて500-1500文字で調整
  2. ハイブリッド検索: ベクトル検索とキーワード検索の組み合わせ
  3. 再ランキング: 初期検索結果の精度向上
  4. モニタリング: パフォーマンスとコストの継続的な監視
  5. エラーハンドリング: ユーザー体験を損なわない適切な対応

弊社では、これらの知見を活かして、お客様のビジネスに最適なRAGシステムの構築を支援しています。RAGの導入や最適化についてお困りの際は、お気軽にご相談ください。

参考リンク