LLM アプリ開発 #8 RAG パイプラインの構築
第7回で、質問に関連する文書を見つける方法を身につけました。あとは見つけた文書を Claude に渡し、その文書に基づいて答えさせればよいわけです。この方式を RAG(Retrieval-Augmented Generation、検索拡張生成)と呼びます。うちの文書に答えがあるチャットボットや、社内ナレッジ検索のようなアプリの核心の構造です。
RAG の流れ #
RAG は三つの段階で動きます。
- 検索(Retrieval) — 質問に似た文書をベクトル検索で見つけます。
- 拡張(Augmented) — 見つけた文書を質問と一緒にプロンプトへ入れます。
- 生成(Generation) — Claude がその文書を根拠に答えます。
核心の発想は単純です。Claude は私たちの文書を知りませんが、文書をプロンプトに一緒に入れてあげれば、それを読んで答えられます。第7回のベクトル検索は「プロンプトに入れる価値のある関連文書」を選ぶ第1段階だったわけです。
文書を細かく分ける #
長い文書をまるごと入れると二つの問題があります。トークンが多くかかり、質問と無関係な部分まで混ざって答えがぼやけます。そこで文書を意味の単位で細かく分け、そのかけら(チャンク)を埋め込みます。検索は文書全体ではなく、このチャンク単位で行われます。
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 が仕上がります。見つけたチャンクをプロンプトに入れ、「この資料だけを根拠に答えよ」と指示します。
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 会話メモリとコンテキスト管理」では、会話が長くなるときにたまる履歴をどう扱うか、コンテキストの限界の中で会話を続ける方法を扱います。