RAG

まず名前の説明から。RAG は retrieval-augmented generation(検索拡張生成) の略です。質問があった瞬間に関連するテキストを自分で取得してモデルに渡すことで、凍結された記憶ではなく、あなたが提供した事実をもとに回答させる手法です。この章では、その「取得してから回答する」ループがどのように機能し、どこで失敗するかを解説します。
検索してから生成するパターン
モデルは、あなたの会社のヘルプドキュメントや製品の詳細、あるいは学習後に起きた出来事を知りません。それらについて質問すると、モデルは「答えられない」と認めるか、さらに悪いことに、もっともらしく見えるでたらめを作り出してしまいます。RAGはこの問題を、質問があった瞬間に関連テキストをモデルに渡すことで解決します。
このアイデアはエンベディングを理解すると腑に落ちます。エンベディングとは、テキストを近さで比較できるベクトルに変換するしくみです(コサイン類似度は2つのベクトルがどれだけ同じ方向を向いているかを測ります)。**検索拡張生成**は、通常のモデル呼び出しに2つのステップを追加したものです。
- 検索する。 ユーザーの質問を受け取り、ドキュメントから最も関連性の高い箇所を探し、上位の結果を集めます。
- 生成する。 それらの箇所をコンテキストとしてプロンプトに組み込み、そのコンテキストのみを使って回答するようモデルに指示します。
モデルの汎用的な言語能力が文章を書き、取得したテキストが事実を提供します。あなたがコンテンツを管理し最新の状態に保てるため、自分のコンテンツに根ざした流暢な回答が得られます。
なぜこれがうまく機能するのかを、予測ループの観点から考えると理解しやすくなります。RAGなしでは、モデルはパラメータに凍結されたパターンから事実を思い出そうとするため、持っていない知識については自信満々に当て推量します。RAGを使うと、事実はコンテキストの中にそのまま存在するので、モデルの役割が「これを思い出す」から「目の前にあるものを読んで答える」に変わります。検索によって、作業が記憶から読み取りへと移行します。 わかりやすい比喩は試験です。閉本試験では当て推量が生まれますが、RAGは関連ページが机の上に開かれた開本試験に変えてくれます。
チャンキング
ドキュメント全体を埋め込むわけではありません。まず小さな**チャンク**に分割し、それぞれを1段落か数文程度にしてから埋め込みます。理由は2つあります。第一に、検索の精度が上がります。40ページのマニュアル全体ではなく、関連する1段落だけを取得できます。第二に、コンテキストウィンドウに収まります。巨大なドキュメントは収まらないことがあります。
# シンプルなチャンカー:段落で分割する
def chunk(text):
return [c.strip() for c in text.split("\n\n") if c.strip()]チャンクサイズはバランスの問題であり、その理由はエンベディングにさかのぼります。各チャンクはその意味全体を要約する単一のベクトルに変換されます。チャンクが大きすぎると複数のトピックをカバーするため、1つのベクトルがそれらすべての曖昧な平均になり、特定の質問にうまく合致しません。小さすぎると、意味を理解するために必要な周囲のコンテキストが失われ、ベクトルが断片を指すようになります。段落サイズのチャンクはそれぞれ1つの一貫したアイデアを保持します。 検索のパフォーマンスに基づいてそこから調整していきます。
ベクトルの保存場所
少数のチャンクであれば、ベクトルをメモリに保持してエンベディングの章のコサイン類似度関数で比較できます。後の例もそうしています。数千や数百万のチャンクになると、それは遅くなり、**ベクトルストア**を使うことになります。ベクトルストアは、大規模に最近傍ベクトルを素早く見つけるために設計されたデータベースです。
RAGを学ぶためにベクトルストアは必要ありませんし、今すぐ選ぶ必要もありません。ホステッドサービスやライブラリとして複数が存在します。重要なのはコンセプトです。エンベディングを保存して素早く検索できる場所、ということです。まずはメモリ内で始め、コレクションが大きくなったらストアに移行してください。
根拠付けと引用
生成ステップはプロンプトであり、その書き方がRAGが実際に作り話を減らすかどうかを左右します。2つの指示が核心的な役割を果たします。提供されたコンテキスト**のみ**から回答するようモデルに指示することと、コンテキストに回答が含まれていないときはそう言うよう指示することです。
context_text = "\n\n".join(retrieved_chunks)
system_prompt = f'''以下のコンテキストのみを使って質問に答えてください。
コンテキストに答えが含まれていない場合は、「その情報は持っていません。」と言ってください。
回答には、コンテキストの関連部分を引用してください。
コンテキスト:
"""
{context_text}
"""'''これは以前の2つの教えをまとめたものです。区切り文字と「これのみから答える」という明示的な指示はプロンプトから来ています。「その情報は持っていません。」と言う許可はハルシネーション対策です。逃げ道を持つ根拠付けされたモデルは、当て推量に任されたモデルよりはるかに作り話をしません。根拠付けと逃げ道の組み合わせが、作り話を減らします。 ソースを引用させることで、ユーザーが確認できるものも生まれます。
最近傍を超えて:ハイブリッド検索と再ランク付け
純粋なベクトル検索は意味で合致します。これはほとんどの場合に求めているものです。しかし盲点があります。重要な完全一致の単語を見逃すことがあります。E_4021 のようなエラーコードや特定の製品名を検索すると、意味ベースの検索は同じトピックについてのチャンクを返しながら、その正確な文字列を含むチャンクをスキップしてしまうことがあります。
この解決策には名前があります。**ハイブリッド検索**です。意味ベースの検索と昔ながらのキーワード検索を組み合わせて、完全一致の用語と一般的な意味の両方に投票権を与えます。RAGを始める段階でこれを構築する必要はありません。完全一致の用語が重要で、純粋なベクトルが見逃し続けるときに取り入れてください。
実践
エンベディングの章の search と embed ヘルパーを再利用した最小限のRAGの回答:
def answer_from_docs(question, index):
# 1. 検索:質問に最もマッチするチャンクのトップを取得する
query_vector = embed(question)
scored = [{"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])} for item in index]
top = sorted(scored, key=lambda x: x["score"], reverse=True)[:3]
# 2. 生成:それらのチャンクのみを使って回答する
context = "\n\n".join(t["text"] for t in top)
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": f'このコンテキストのみを使って回答してください。ここにない場合は、わからないと言ってください。\n\n"""{context}"""'},
{"role": "user", "content": question},
],
)
return response.choices[0].message.content最も近い3つのチャンクを取得し、プロンプトに入れて、モデルにそこから回答させます。これが**RAGパイプライン**全体です。エンベディング検索が根拠付けられたプロンプトに支えられたモデル呼び出しに繋がります。品質のほぼすべては正しいチャンクを取得することにあります。
RAGが不要なケース
RAGはタダではなく、常に答えとも限りません。次の場合はスキップしてください。
- データが小さくプロンプトに収まる場合。 ナレッジベース全体が数段落であれば、直接貼り付けてください。RAGは一度に送るには多すぎるときのためのものです。
- 長コンテキストモデルがすべてを保持できる場合。 一度にドキュメント全体を受け取れるほど大きな入力を受け付けるモデルもあり、検索を構築するより手間が少ない場合があります。
本当の限界を忘れないでください。RAGの品質はほぼ検索の品質です。 検索ステップが間違ったチャンクを返せば、完璧なプロンプトでも回答を救えません。実際のRAGシステムのほとんどの努力は、最終的なモデル呼び出しではなく、正しいテキストを取得することに費やされます。
エンベディングとRAGは、モデル自体を変えることなく、質問の瞬間にモデルに知識を与えます。次の章ファインチューニングでは、もう一方の選択肢を扱います。自分の例でモデルを変えること、そしてそれがプロンプトやRAGより価値があるのはいつかを解説します。

