LLM アプリ開発 #8 RAG パイプラインの構築

読了 5分

第7回で、質問に関連する文書を見つける方法を身につけました。あとは見つけた文書を Claude に渡し、その文書に基づいて答えさせればよいわけです。この方式を RAG(Retrieval-Augmented Generation、検索拡張生成)と呼びます。うちの文書に答えがあるチャットボットや、社内ナレッジ検索のようなアプリの核心の構造です。

RAG の流れ #

RAG は三つの段階で動きます。

  1. 検索(Retrieval) — 質問に似た文書をベクトル検索で見つけます。
  2. 拡張(Augmented) — 見つけた文書を質問と一緒にプロンプトへ入れます。
  3. 生成(Generation) — Claude がその文書を根拠に答えます。

核心の発想は単純です。Claude は私たちの文書を知りませんが、文書をプロンプトに一緒に入れてあげれば、それを読んで答えられます。第7回のベクトル検索は「プロンプトに入れる価値のある関連文書」を選ぶ第1段階だったわけです。

文書を細かく分ける #

長い文書をまるごと入れると二つの問題があります。トークンが多くかかり、質問と無関係な部分まで混ざって答えがぼやけます。そこで文書を意味の単位で細かく分け、そのかけら(チャンク)を埋め込みます。検索は文書全体ではなく、このチャンク単位で行われます。

chunking.py
def chunk_text(text: str, size: int = 300, overlap: int = 50):
    chunks = []
    start = 0
    while start < len(text):
        end = start + size
        chunks.append(text[start:end])
        start = end - overlap  # チャンク間を少し重ねて文脈が途切れないようにする
    return chunks

チャンク間を少し重ねる理由は、ちょうど境目で切れた文が、どちらのチャンクでも完全でなくなることを減らすためです。チャンクのサイズと重なりは、文書の性格に合わせて調整します。

検索と生成をつなぐ #

第7回のベクトル検索に Claude の生成をつなげば、RAG が仕上がります。見つけたチャンクをプロンプトに入れ、「この資料だけを根拠に答えよ」と指示します。

rag.py
import numpy as np
import anthropic
from sentence_transformers import SentenceTransformer

client = anthropic.Anthropic()
embedder = SentenceTransformer("all-MiniLM-L6-v2")

# あらかじめチャンクを埋め込んでおく(chunks は chunk_text で作ったチャンクの一覧)
chunk_vectors = embedder.encode(chunks)

def retrieve(query: str, top_k: int = 3):
    q = embedder.encode([query])[0]
    scores = chunk_vectors @ q
    ranked = np.argsort(scores)[::-1][:top_k]
    return [chunks[i] for i in ranked]

def answer(query: str) -> str:
    found = retrieve(query)
    context = "\n\n".join(f"<doc>{c}</doc>" for c in found)

    prompt = f"""下の <資料> 内の内容だけを根拠に質問に答えてください。
資料に答えがなければ「資料からは見つかりません」と答えてください。

<資料>
{context}
</資料>

質問: {query}"""

    response = client.messages.create(
        model="claude-sonnet-4-6",
        max_tokens=1024,
        messages=[{"role": "user", "content": prompt}],
    )
    return next(b.text for b in response.content if b.type == "text")

print(answer("返金は何日以内に申請すればよいですか?"))

質問が来ると、関連するチャンクを3つ見つけ、<資料> タグ(第4回で見た方式)で包んでプロンプトに入れます。Claude はその資料だけを見て答えます。資料にない質問には作り話をせず「見つかりません」と答えよ、という指示が重要です。この一行が幻覚を大きく減らします。

なぜ RAG を使うのか #

全文書を毎回プロンプトに全部入れてはだめなのか、と思うかもしれません。文書が少なければそれでも構いません。しかし文書が数百件あればトークンのコストが手に負えなくなり、無関係な内容まで入って答えの品質も落ちます。RAG は質問ごとに関連するチャンクだけを選んで入れるので、文書がいくら多くてもプロンプトは小さく保たれます。

もう一つ、文書が変わったら該当するチャンクだけ埋め込み直せば済みます。モデルを学習し直す必要はありません。最新の情報を反映しやすいのも RAG の大きな利点です。

検索の品質を上げる #

RAG の答えが良くないとき、原因は生成より検索にある場合が多いです。見当違いのチャンクを取ってくれば、Claude がいくらうまく答えても無意味です。検索の品質を上げる方法をいくつか挙げておきます。

  • チャンクサイズの調整 — まず手を入れる部分です。答えが一段落に収まる文書なら、チャンクをその段落の単位に合わせるのがよいです。
  • 取ってくる数(top_k — 少なすぎると正解のチャンクを逃し、多すぎると無関係な内容が混ざります。ふつう3〜5個から始めて調整します。
  • キーワード検索と混ぜる — 意味検索は「返金」と「決済キャンセル」をつなぎますが、製品コードや固有名詞のように正確に一致すべきものには弱いです。ベクトル検索とキーワード検索を一緒に使うハイブリッド検索が、互いの弱点を補います。

また、答えに根拠を一緒に見せると信頼度が上がります。どのチャンクを根拠に答えたかを出典として表示すれば、ユーザーが答えを検証でき、幻覚も見つけやすくなります。

よくつまずくところ #

  • チャンクが大きすぎる・小さすぎる — 大きすぎると無関係な内容が混ざり、小さすぎると文脈が切れます。文書に合わせてサイズを調整し、検索結果がおかしいときはまずチャンクサイズを疑います。
  • 根拠の指示を抜かす — 「資料だけを根拠に答えよ」という指示がないと、Claude が資料を無視して学習知識で答え、幻覚が混じることがあります。
  • 検索の品質を点検しない — 答えがおかしいときは、生成ではなく検索の段階、つまりそもそも見当違いのチャンクを取ってきた場合が多いです。retrieve が何を取ってきたかをまず確認します。

まとめ #

今回は、検索と生成をつないで RAG パイプラインを作りました。

  • RAG は検索、拡張、生成の三段階で、私たちの文書に基づく答えを作ります。
  • 長い文書はチャンクに分けて埋め込み、質問ごとに関連するチャンクだけをプロンプトに入れます。
  • 「資料だけを根拠に答えよ」という指示で幻覚を減らし、文書が多くてもプロンプトを小さく保ちます。

ここまでは一度の質問と答えが中心でした。次回の「LLM アプリ開発 #9 会話メモリとコンテキスト管理」では、会話が長くなるときにたまる履歴をどう扱うか、コンテキストの限界の中で会話を続ける方法を扱います。

X