Skip to content
This page has been auto-translated and may contain errors.View in English

RAG

docs.scrimba.com

まず名前の説明から。RAGretrieval-augmented generation(検索拡張生成) の略です。質問があった瞬間に関連するテキストを自分で取得してモデルに渡すことで、凍結された記憶ではなく、あなたが提供した事実をもとに回答させる手法です。この章では、その「取得してから回答する」ループがどのように機能し、どこで失敗するかを解説します。

検索してから生成するパターン

モデルは、あなたの会社のヘルプドキュメントや製品の詳細、あるいは学習後に起きた出来事を知りません。それらについて質問すると、モデルは「答えられない」と認めるか、さらに悪いことに、もっともらしく見えるでたらめを作り出してしまいます。RAGはこの問題を、質問があった瞬間に関連テキストをモデルに渡すことで解決します。

このアイデアはエンベディングを理解すると腑に落ちます。エンベディングとは、テキストを近さで比較できるベクトルに変換するしくみです(コサイン類似度は2つのベクトルがどれだけ同じ方向を向いているかを測ります)。**検索拡張生成**は、通常のモデル呼び出しに2つのステップを追加したものです。

  1. 検索する。 ユーザーの質問を受け取り、ドキュメントから最も関連性の高い箇所を探し、上位の結果を集めます。
  2. 生成する。 それらの箇所をコンテキストとしてプロンプトに組み込み、そのコンテキストのみを使って回答するようモデルに指示します。

モデルの汎用的な言語能力が文章を書き、取得したテキストが事実を提供します。あなたがコンテンツを管理し最新の状態に保てるため、自分のコンテンツに根ざした流暢な回答が得られます。

なぜこれがうまく機能するのかを、予測ループの観点から考えると理解しやすくなります。RAGなしでは、モデルはパラメータに凍結されたパターンから事実を思い出そうとするため、持っていない知識については自信満々に当て推量します。RAGを使うと、事実はコンテキストの中にそのまま存在するので、モデルの役割が「これを思い出す」から「目の前にあるものを読んで答える」に変わります。検索によって、作業が記憶から読み取りへと移行します。 わかりやすい比喩は試験です。閉本試験では当て推量が生まれますが、RAGは関連ページが机の上に開かれた開本試験に変えてくれます。

Juno検索してから生成するパターン RAGは通常のモデル呼び出しに2つのステップを追加します。質問に最も関連するドキュメントを検索し、それをコンテキストとして使って回答を生成します。言語はモデルが担い、事実は取得したテキストが担います。これにより、記憶ではなく自分のコンテンツをもとに回答できるようになり、でたらめを作り出すことを防げます。信頼するまで時間がかかりましたが、開本のモデルは閉本のモデルより本当に当て推量が少ないんです。

モデルは、あなたのヘルプドキュメントや製品の詳細、あるいは学習のカットオフ以降の情報を持っていません。それでも質問すれば、拒否するか、読んでいると問題なさそうに見える作り話を返します。**検索拡張生成**は、リクエスト時に関連テキストを取得してプロンプトに配置することでこのギャップを埋め、モデルが事実を思い出すのではなく読み取るようにします。このしくみはエンベディングを基盤としています。各テキストはベクトルになり、類似度はベクトル間の距離(2つのベクトルのなす角のコサイン、コサイン類似度)で表されます。

ループは2つのステップで構成されます。

  1. 検索する。 質問をドキュメントと同じベクトル空間に埋め込み、類似度が最も近いチャンクを取得します。
  2. 生成する。 それらのチャンクをコンテキストとしてプロンプトに連結し、そこからのみ回答するようモデルに指示します。

検索ステップには top-k というダイヤルがあります。実際に保持する最も近いチャンクの数です。kはバランスの問題です。小さすぎると回答が書かれているチャンクを見逃してモデルに何も渡せなくなります。大きすぎると、うっすら関連するテキストでコンテキストが埋まり、シグナルが薄まり、コストが上がり、「中間で迷子になる」問題が悪化します。3から5程度のkが妥当な出発点で、実際の質問に対して調整していきます。

実践的なコツは、インデックスをチャンクテキストとその事前計算ベクトルを持つレコードのリストとして構造化し、プロンプトは取得したコンテキストが質問の上に明確に区切られたブロックとして配置されるよう構造化することです。言語能力はモデルが書き、何が真実かはあなたの取得したテキストが決めます。

python
def retrieve(question, index, k=4):
    query_vector = embed(question)
    scored = [
        {"text": item["text"], "score": cosine_similarity(query_vector, item["vector"])}
        for item in index
    ]
    return sorted(scored, key=lambda x: x["score"], reverse=True)[:k]
Juno検索してから生成するパターン RAGは検索してから生成するパターンです。質問を埋め込んで最も近いチャンクを取得し、プロンプトに入れてそこから回答します。ここで重要なダイヤルはtop-k、つまり保持するチャンクの数です。少なすぎると回答を見逃し、多すぎるとノイズに埋もれてコストもかかります。3から5あたりから始めて、感覚ではなく実際の質問に対して調整してください。

モデルはプライベートなコーパスもカットオフ以降の情報も持っておらず、どちらであるかを教えてくれません。いずれの場合も同じ自信に満ちたトーンで答えます。**検索拡張生成**は、リクエスト時に関連テキストを取得してその回答を根拠付けるパターンであり、正確さはパラメトリックな想起ではなく、あなたがコントロールできるソースに依存します。エンベディングを基盤としており、テキストはベクトルにマッピングされ、コサイン類似度(2つのベクトルのなす角のコサイン、つまり長さに関わらず意味の近さ)でランク付けされます。

「検索してから生成する」という2ステップの枠組みは正しいですが、作業の本質を隠しています。検索は生成の帽子をかぶった検索問題であり、システム全体の品質はモデル呼び出しではなく、そこで決まります。したがって、最初から「検索→ランク付け→生成」として捉えてください。単純なバージョン、つまりクエリを埋め込んで最近傍k件を取得して貼り付けるだけのものは、下位レベルではまだ遭遇していない失敗パターンで破綻します。完全一致の見逃し、近似重複チャンクがカバレッジを圧迫する問題、古いテキストが自信満々に答える問題などです。

早期に身につけるべき習慣は、これは「感覚」ではなく測定可能なシステムだということです。本来それに答えるべきチャンクがわかっている実際の質問のセットを保持しておき、recall@kを追跡します。本来取得されるべきチャンクのうち、実際にトップkに入った割合です。recall@kはあなたの上限です。正しいチャンクがコンテキストにない場合、どれだけプロンプトを工夫しても回復できませんし、生成ステップは検索が渡さなかったものには答えられません。下流のすべてはこの数値に縛られているため、最初に測定し、最初に改善する対象です。

python
def retrieve(question, index, k=8):
    query_vector = embed(question)
    scored = [
        {"text": it["text"], "score": cosine_similarity(query_vector, it["vector"]), "meta": it["meta"]}
        for it in index
    ]
    # ここでは多めに取得し、その後より小さな最終セットに再ランク付けする(後述)
    return sorted(scored, key=lambda x: x["score"], reverse=True)[:k]
Juno検索してから生成するパターン RAGは生成の帽子をかぶった検索問題です。品質はモデル呼び出しではなく検索で決まるため、単純な「埋め込んでk件取得して貼り付ける」バージョンは完全一致や古いテキストで失敗します。すべてを縛る数値はrecall@k、つまり本来取得されるべきチャンクが実際にトップkに入った割合です。正しいチャンクがコンテキストに入らなければ、どんなプロンプトも救えません。他のものを調整する前に、まずそれを測定してください。

チャンキング

ドキュメント全体を埋め込むわけではありません。まず小さな**チャンク**に分割し、それぞれを1段落か数文程度にしてから埋め込みます。理由は2つあります。第一に、検索の精度が上がります。40ページのマニュアル全体ではなく、関連する1段落だけを取得できます。第二に、コンテキストウィンドウに収まります。巨大なドキュメントは収まらないことがあります。

python
# シンプルなチャンカー:段落で分割する
def chunk(text):
    return [c.strip() for c in text.split("\n\n") if c.strip()]

チャンクサイズはバランスの問題であり、その理由はエンベディングにさかのぼります。各チャンクはその意味全体を要約する単一のベクトルに変換されます。チャンクが大きすぎると複数のトピックをカバーするため、1つのベクトルがそれらすべての曖昧な平均になり、特定の質問にうまく合致しません。小さすぎると、意味を理解するために必要な周囲のコンテキストが失われ、ベクトルが断片を指すようになります。段落サイズのチャンクはそれぞれ1つの一貫したアイデアを保持します。 検索のパフォーマンスに基づいてそこから調整していきます。

Junoチャンキング 埋め込む前に、ドキュメントを小さなチャンク、大体段落サイズ、に分割してそれを埋め込みます。チャンクを小さくすることで検索の精度が上がり、コンテキストウィンドウにも収まりやすくなります。チャンクサイズはバランスの問題です。大きすぎると曖昧で非効率になり、小さすぎると周囲の意味が失われます。回答が曖昧になったとき、ほぼ常にチャンクが大きすぎるのが原因でした。

ドキュメント全体ではなくチャンクを埋め込みます。各チャンクはその意味全体を要約する1つのベクトルになるためです。ドキュメントを段落サイズの断片に分割し、それぞれを埋め込みます。チャンクが大きすぎると、複数のトピックが平均化されて何にも鋭く合致しないベクトルになります。チャンクが小さすぎると、意味を持たせていたコンテキストが失われます。

したがって**チャンクサイズ**は固定ルールではなく調整ダイヤルとして扱ってください。数百トークン程度のチャンクは1つのアイデアをきれいに保持する傾向があります。このダイヤルはtop-kと相互作用します。チャンクが大きいほどコンテキストバジェットに収まる数が減り、1リクエストあたりのカバレッジが低下します。チャンクが小さいと精度は上がりますが、複数にまたがるアイデアを再構成するためにkを大きくする必要があります。

2つ目のダイヤルはオーバーラップです。連続するチャンクが境界でテキストの一部を共有するようにします。たとえば10〜20パーセント程度です。オーバーラップなしでは、質問に答える文が境界をまたいで分割され、一方のチャンクに前半、もう一方に後半が入って、どちらのチャンクも検索でうまくヒットしません。オーバーラップにより重複が増え、ベクトルも少し増えますが、境界で切り捨てられるはずだった回答を取り戻せます。

python
def chunk(text, size=800, overlap=120):
    # sizeとoverlapはここでは文字数。実際のシステムではトークン数で管理する
    out, start = [], 0
    while start < len(text):
        out.append(text[start:start + size])
        start += size - overlap
    return out
Junoチャンキング チャンクサイズとオーバーラップは一度設定すれば終わりではなく、調整ダイヤルです。1つのアイデアを保持するチャンク、数百トークム程度を目標にして、このダイヤルがtop-kとトレードオフになることを忘れないでください。チャンクが大きいほど収まる数が減ります。回答が境界をまたぐ場合でも少なくとも1つのチャンク内に収まるよう、10〜20パーセントのオーバーラップを追加してください。境界こそが検索が静かに回答を失う場所です。

チャンキングは、クエリが1つも実行される前に検索のrecallが決まる場所です。各**チャンク**はその意味全体を代表する1つのベクトルになるため、分割方法が何を検索可能にするかを決めます。ドキュメントに構造がある場合、段落やセクションの境界は固定文字数のウィンドウより優れています。1つの一貫したセクションにわたるベクトルは、2つのセクションの中間にまたがるベクトルより明確なクエリターゲットになるためです。

チャンクサイズとオーバーラップは自由なパラメータではなく、予算全体に結び付いています。サイズはtop-kとコンテキストウィンドウとのトレードオフです。大きなチャンクはそれぞれ多くのコンテキストを持ちますが収まる数が減り、カバレッジが低下します。小さなチャンクは精度を上げますがアイデアを断片化し、再構成するためにkを大きくする必要があり、トークンコストがかかり「中間で迷子になる」問題が再発します。10〜20パーセント程度のオーバーラップは境界をまたぐ回答を救いますが、インデックスに重複が生じます。重複には下流への悪影響があります。ほぼ同一のオーバーラップチャンクがtop-kのスロットを同じ内容で埋め、カバレッジを枯渇させる可能性があります。これが、後で再ランク付けと重複排除が必要になる理由の1つです。

ほとんどの人が痛い目に遭うまで見落とすコスト:チャンキング戦略はインデックスに焼き込まれているため、変更するとコーパス全体の再埋め込みと再インデックスが必要になります。それは実際の計算コスト、実際のお金、そして古いベクトルと新しいベクトルを混在させてはならない移行期間を意味します。実際のドキュメントとクエリに対してチャンクサイズを早期に決定し、保留セットでrecall@kを測定し、再チャンキングは金曜日に出荷する設定変更ではなく、意図的な再インデックスとして扱ってください。

Junoチャンキング 構造があれば構造に沿ってチャンクしてください。各チャンクはその意味全体を代表する1つのベクトルであり、サイズはtop-kとウィンドウと同時にトレードオフになるためです。10〜20パーセントのオーバーラップは境界をまたぐ回答を救いますが、top-kを圧迫する近似重複を生み出します。これが再ランク付けが存在する理由の一部です。そして、チャンキングの選択はインデックスに焼き込まれていることを忘れないでください。変更すると完全な再埋め込みと再インデックス、つまり設定の変更ではなく実際のコストと移行が必要になります。

ベクトルの保存場所

少数のチャンクであれば、ベクトルをメモリに保持してエンベディングの章のコサイン類似度関数で比較できます。後の例もそうしています。数千や数百万のチャンクになると、それは遅くなり、**ベクトルストア**を使うことになります。ベクトルストアは、大規模に最近傍ベクトルを素早く見つけるために設計されたデータベースです。

RAGを学ぶためにベクトルストアは必要ありませんし、今すぐ選ぶ必要もありません。ホステッドサービスやライブラリとして複数が存在します。重要なのはコンセプトです。エンベディングを保存して素早く検索できる場所、ということです。まずはメモリ内で始め、コレクションが大きくなったらストアに移行してください。

Junoベクトルの保存場所 少数のチャンクであれば、ベクトルをメモリに保持して直接比較します。大きなコレクションには、ベクトルストアというデータベースが大規模に最近傍ベクトルを素早く見つけてくれます。RAGを学ぶためにベクトルストアは必要ありません。まずメモリ内で始めて、コレクションが大きくなったときにベクトルストアに移行してください。初日からデータベースを選ぶ必要はありません。

数百チャンク程度であれば、ベクトルをメモリに保持してクエリに対してすべてをスコアリングします。これは線形スキャンです。小さなスケールでは問題ありませんが、コレクションが数千や数百万になると遅くなります。すべてのクエリですべてのベクトルと比較するためです。その時点で**ベクトルストア**に移行します。これは高速な最近傍探索のためにベクトルをインデックス化するデータベースです。

覚えておく価値のある非対称性は、一度の埋め込みとクエリごとの埋め込みの違いです。すべてのドキュメントチャンクを事前に一度だけ埋め込んでインデックス化し、その作業はすべての将来の質問に再利用されます。クエリはリクエストごとに一度だけ埋め込まれ、ホットパスでの埋め込みコストはそれだけです。つまり重い索引化コストは事前に支払われて償却されます。クエリごとのコストは1回の埋め込み呼び出しと1回の検索です。これがRAGを構築した後のサービング費用を安く抑えられる理由です。

このパターンを学ぶためにストアを選ぶ必要はありません。メモリ内のレコードリストから始め、スケールや永続化が必要になったらベクトルストアに切り替えてください。インターフェースは同じに保ちます。クエリを埋め込み、検索し、top-kを返す、という流れです。

Junoベクトルの保存場所 メモリ内ではクエリごとにすべてのベクトルをスキャンします。コレクションが大きくなるまではそれで十分ですが、その後はベクトルストアがインデックス化された最近傍探索を提供します。非対称性を頭に入れておいてください。ドキュメントは一度埋め込まれてインデックス化され永遠に再利用されますが、クエリはリクエストごとに新たに埋め込まれます。初期コストは一度支払われて償却されるため、サービングは安く保てます。まずメモリ内で始め、スケールや永続化が必要になったらストアに切り替えてください。

メモリ内ではクエリをすべてのチャンクベクトルに対してスコアリングする線形スキャンを行います。これは正確ですが、クエリごとにO(n)であり、数万のどこかでスケールが止まります。**ベクトルストア**はスキャンを近似最近傍インデックス(ANN)に置き換えます。すべてのベクトルをチェックしないことで少しrecallを犠牲にしながら大幅に高速化します。このトレードオフはダイヤルであり、本番規模ではインデックスが真の最近傍を見逃すことがあると受け入れることになります。これはrecall@kに直接影響します。

埋め込みの非対称性がコスト構造を決めます。インデックス化はコーパス全体を一度埋め込む大きな初期バッチコストです。サービングはリクエストごとに1つのクエリを埋め込んで1回検索するだけです。したがって、定常状態のレイテンシ予算はおおよそ次のようになります。クエリを埋め込み、インデックスを検索し、生成呼び出しを行う。検索は通常最も小さなスライスです。生成呼び出しがレイテンシを支配し、クエリの埋め込みは検索前に支払う固定税です。各レグを測定する価値があります。実際のレイテンシが生成にあったのに、より速いベクトルストアを求めてしまうことがあるからです。

さらに、インメモリのおもちゃでは決して見えない失敗パターンがあります。陳腐化です。インデックスはスナップショットであり、ソースドキュメントは変化します。ポリシーが変わり、ドキュメントが編集され、古いチャンクが依然としてインデックスに残って過去の情報を指し、検索が完全な自信でそれを提供します。したがって、インデックスは一度書けば終わりではありません。変更されたドキュメントを再埋め込みする再インデックスパスが必要であり、できれば各チャンクにメタデータ(ソースID、バージョン、タイムスタンプ)を付けて古いエントリを無効化またはフィルタリングできるようにします。真実がどれだけ速く変わるかに対して更新頻度を決め、「インデックスは最新だ」を前提ではなく確認するものとして扱ってください。

Junoベクトルの保存場所 ベクトルストアは正確な線形スキャンを近似最近傍インデックスに置き換えます。より高速ですが、真の最近傍を見逃すことがあり、それはrecall@kに戻ってきます。埋め込みはコーパスに対して一度、クエリに対してリクエストごとに一度なので、定常状態のレイテンシはクエリ埋め込み+検索+生成であり、生成呼び出しが通常支配します。ストアを非難する前に測定してください。おもちゃでは見えない落とし穴は陳腐化です。インデックスはスナップショットであり、ソースは変化するため、再インデックスパスを構築してチャンクにバージョンとタイムスタンプを付け、インデックスが最新だと信頼するのではなく確認してください。

根拠付けと引用

生成ステップはプロンプトであり、その書き方がRAGが実際に作り話を減らすかどうかを左右します。2つの指示が核心的な役割を果たします。提供されたコンテキスト**のみ**から回答するようモデルに指示することと、コンテキストに回答が含まれていないときはそう言うよう指示することです。

python
context_text = "\n\n".join(retrieved_chunks)

system_prompt = f'''以下のコンテキストのみを使って質問に答えてください。
コンテキストに答えが含まれていない場合は、「その情報は持っていません。」と言ってください。
回答には、コンテキストの関連部分を引用してください。

コンテキスト:
"""
{context_text}
"""'''

これは以前の2つの教えをまとめたものです。区切り文字と「これのみから答える」という明示的な指示はプロンプトから来ています。「その情報は持っていません。」と言う許可はハルシネーション対策です。逃げ道を持つ根拠付けされたモデルは、当て推量に任されたモデルよりはるかに作り話をしません。根拠付けと逃げ道の組み合わせが、作り話を減らします。 ソースを引用させることで、ユーザーが確認できるものも生まれます。

Juno根拠付けと引用 生成ステップはプロンプトであり、RAGが機能するかどうかを決めます。提供されたコンテキストのみから回答するよう、そして回答がない場合はそう言うようモデルに指示してください。その根拠付けと、知らないと認める許可の組み合わせが作り話を減らします。ソースを引用させることでユーザーに確認できるものが生まれ、検索が外れたときのヒントにもなります。

検索が完璧なコンテキストをモデルに渡しても、モデルはそれを無視できます。生成プロンプトが回答をコンテキストに結び付けるものです。意図的に書いてください。提供されたコンテキスト**のみ**から回答するよう、コンテキストに回答がない場合は認めるよう、そして使ったスパンを引用するよう指示してください。

python
context_text = "\n\n".join(f"[{i}] {c}" for i, c in enumerate(retrieved_chunks))

system_prompt = f'''以下のコンテキストのみを使って回答してください。
コンテキストに回答がない場合は、正確に「その情報は持っていません。」と返してください。
使用したチャンク番号を [2] のように引用してください。

コンテキスト:
"""
{context_text}
"""'''

チャンクに番号を付けることで「ソースを引用する」がプログラムで確認できる引用になります。モデルがチャンク[2]を指し示し、チャンク[2]が実際にその主張を裏付けているか確認できます。これがRAGで最も見逃されやすい失敗パターンへのハンドルになります。つまり、正しい回答だが取得されたチャンクが間違っている場合、つまり提供されたコンテキストが支持していないのにもっともらしい回答を生成してしまう場合です。引用なしでは、根拠付けられた回答と運良く当たった推測を区別できません。

逃げ道は指示と同じくらい重要です。コンテキストのみから答えるよう指示されながらも失敗の許可がないモデルは、質の悪い検索結果から何かを継ぎ合わせてしまいます。正確な「その情報は持っていません。」という文字列という明示的な出口を与えれば、検索の失敗が自信満々な創作ではなく、きれいな空白として現れます。ここのメッセージ形式はOpenAIスタイルです。システムとコンテキストの配置はプロバイダーによって異なりますが、原則は変わりません。

Juno根拠付けと引用 生成プロンプトが回答をコンテキストに結び付けます。コンテキストのみから回答させ、ない場合は認めさせ、チャンクを引用させてください。番号を付けることで引用が確認可能になり、回答は正しいが取得されたチャンクがそれを裏付けていないという巧妙な失敗を捕捉できます。正確な逃げ道の文字列を与えてください。そうしないと、質の悪い検索結果が自信満々な誤回答に継ぎ合わされます。形式はプロバイダーによって異なりますが、システム→コンテキストの構造は通用します。

根拠付けは検索とモデルの間の契約であり、最も安価な信頼性レバーです。プロンプトをそこに費やしてください。提供されたコンテキスト**のみ**から回答するよう、コンテキストが回答を支持しない場合は固定の棄権文字列を返すよう、そして使用したチャンクIDを引用するようモデルに指示してください。

python
context_text = "\n\n".join(f"[{c['id']}] {c['text']}" for c in retrieved)

system_prompt = f'''以下のコンテキストのみを使って回答してください。
コンテキストが回答を支持しない場合は、正確に「その情報は持っていません。」と返してください。
各主張について、[doc12] のようにチャンクIDを括弧内に引用してください。

コンテキスト:
"""
{context_text}
"""'''

引用は装飾ではありません。根拠付けを測定可能にする手段です。出力にチャンクIDがあれば、引用されたチャンクが実際にその主張を含んでいるかどうかを自動的に確認でき、根拠付けられた回答と流暢な推測を区別できます。これはまた、単純なRAGを打ち負かす失敗を検出する方法でもあります。回答はもっともらしいが、取得されたコンテキストがそれを支持していない場合、つまり検索が外れてモデルがパラメトリックなメモリでギャップを埋めた場合です。IDなしではスポットチェックになりますが、IDがあれば評価(既知の正解を持つ保留済み質問セットを自動スコアリング)で根拠付けをスコアリングして忠実度を数値として追跡できます。

本番環境での2つの注記。棄権パスは安価で許可されたものでなければなりません。失敗の許可がないモデルは常に何かを生成し、質の悪い検索が自信満々な誤回答になります。きれいな空白ではなく。そして、ここのメッセージ構造はOpenAI-SDK形式です。システムとユーザーの分離やコンテキストの添付方法はプロバイダーによって異なります。配置は移植可能ですが、正確なフィールド名はそうではありません。根底にある変わらないルールは、モデルをソースにするのではなく、あなたが引用して確認できるソースのフレーザーにとどめておくことです。

Juno根拠付けと引用 根拠付けは契約です。コンテキストのみから回答させ、固定の文字列で棄権させ、チャンクIDを引用させることで、忠実度を目視確認ではなく評価でスコアリングできるものにします。引用は回答が正しいが裏付けがない場合、つまり検索が外れてモデルがメモリでギャップを埋めた場合を捕捉する手段です。棄権パスを安価で許可されたものに保ってください。そうしないと、質の悪い検索が自信満々な誤回答になります。形式はプロバイダーによって異なります。構造は移植できますが、フィールド名はできません。

最近傍を超えて:ハイブリッド検索と再ランク付け

純粋なベクトル検索は意味で合致します。これはほとんどの場合に求めているものです。しかし盲点があります。重要な完全一致の単語を見逃すことがあります。E_4021 のようなエラーコードや特定の製品名を検索すると、意味ベースの検索は同じトピックについてのチャンクを返しながら、その正確な文字列を含むチャンクをスキップしてしまうことがあります。

この解決策には名前があります。**ハイブリッド検索**です。意味ベースの検索と昔ながらのキーワード検索を組み合わせて、完全一致の用語と一般的な意味の両方に投票権を与えます。RAGを始める段階でこれを構築する必要はありません。完全一致の用語が重要で、純粋なベクトルが見逃し続けるときに取り入れてください。

Juno最近傍を超えて:ハイブリッド検索と再ランク付け ベクトル検索は意味で合致します。これは素晴らしいのですが、誰かが正確なコードや製品名を検索して正しいチャンクがスキップされるまでです。ハイブリッド検索はプレーンなキーワード検索を混ぜることで、完全一致の用語も考慮されるようにします。RAGはこれなしで始めて、完全一致の見逃しが積み重なってきたら追加できます。

最近傍ベクトル検索は意味で合致します。そしてそれがその弱点でもあります。エラーコード、SKU、関数名、まれな固有名詞など、必要な完全一致トークンを含む唯一のチャンクより、トピック的に近いチャンクを上位にランク付けすることがあります。エンベディングはそれらを「大体この意味の領域」に広げてしまい、完全一致の文字列がその広がりの中で失われます。

**ハイブリッド検索**は2つの検索器を実行してマージします。意味のためのベクトル検索と、キーワード検索(クラシックな用語マッチング、多くの場合BM25。クエリの正確な単語をどれだけ含み、その単語がどれだけ希少かでドキュメントをスコアリングします)です。両方から候補を取得してランキングを組み合わせることで、完全一致の用語または意味的な近さのどちらかで勝つチャンクが浮上できます。実際的な効果は、純粋なベクトルが苦手とするクエリでのrecallの向上です。

2つ目のテクニックは**再ランク付け**です。最初のパスの候補をより正確でより高コストなモデルで再スコアリングする第二パスです。パターンは多めに取得してから絞り込むことです。安く20件程度の多めのセットを取得し、各候補をクエリに対して読んで再ランク付けするリランカーを実行して上位4件だけを送信します。第一パスの検索は高速で大まかです。リランカーは遅くて鋭いため、短いリストにのみ実行します。結果として、リランカーのコストをインデックス全体に支払わずに、より良い最終kが得られます。

Juno最近傍を超えて:ハイブリッド検索と再ランク付け 純粋なベクトル検索はエンベディングがそれらを意味の領域にぼかしてしまうため、エラーコードやSKUのような完全一致の文字列を見逃します。ハイブリッド検索はキーワードマッチング(BM25)を追加することで、完全一致の用語も意味と並んで投票権を持ちます。再ランク付けは安く取得してから鋭くするパターンです。高速な検索で広いセットを多めに取得し、短いリストをクエリとチャンクを一緒に読む遅くてより正確なモデルで再スコアリングして最良の数件を保持します。高価なスコアラーをすべてに対して実行せずに、より良い最終結果が得られます。

純粋な近似最近傍は強力なデフォルトであり、既知の失敗面でもあります。意味的な近さを最適化するため、クエリが完全一致のトークンに依存する場面で低パフォーマンスになります。エラーコード、識別子、SKU、まれな固有名詞などです。エンベディングがそのトークンを意味の近傍に圧縮し、完全一致の文字列がそこでぼかされるためです。これはチューニングの問題ではありません。密なベクトルがすることです。

**ハイブリッド検索**は、密な検索(ベクトル、意味)とスパースな検索(キーワード用語マッチング、通常BM25。用語の希少性で重み付けされた完全一致の用語頻度でスコアリング)を並行して実行し、結果リストを融合することで対処します。一般的には相互ランク融合を使います。これは比較できない生のスコアではなく、各アイテムの位置によって2つのランキングを組み合わせます。効果は完全一致クエリでのrecall@kとして測定可能です。これは純粋な密な検索が静かに落とすスライスです。コストは2つ目のインデックスと融合ステップです。したがって、トラフィックに識別子や希少な用語が含まれる場合に利益があり、すべてが自然言語の言い換えであれば少ない効果しかありません。

**再ランク付け**は別の見逃しに対処します。第一パスの検索(密、スパース、またはハイブリッド)は大きなインデックスでの速度のために作られているため、大まかにランク付けします。リランカーはクロスエンコーダー(事前計算された2つのベクトルを比較するのではなく、クエリと候補チャンクを一緒に読んで関連性をジョイントにスコアリングするモデル)であり、より正確ですがコーパス全体に対して実行するには遅すぎます。そのため、安い検索器でトップ20〜50を多めに取得し、そのショートリストを再ランク付けして最終的な数件を保持します。

これはほとんどの本番RAGで最大の効果をもたらすrecall改善策です。本当に回答を支持するチャンクを単にトピック的なものの上に昇格させることで、正しい回答だが間違ったチャンクという失敗に直接対処します。再ランク前後のrecall@kとして改善を測定できます。予算を立ててください。リランカーはホットパスにレイテンシと1クエリあたりのコストを追加します。抽象的にスコアリングできる候補数ではなく、レイテンシ目標に対して多めの取得量を設定してください。

Juno最近傍を超えて:ハイブリッド検索と再ランク付け 密なベクトルは完全一致トークンをぼかすため、ハイブリッド検索はスパースなキーワード検索(BM25)を密な検索と並行して実行し、相互ランク融合でリストを統合することで、識別子や希少な用語でのrecall@kを取り戻します。再ランク付けはより大きな改善です。安く多めに取得し、クエリとチャンクを一緒に読むクロスエンコーダーで短いリストを再スコアリングして、回答を支持するチャンクを昇格させることで正しい回答だが間違ったチャンクという問題に直接対処します。再ランク前後のrecall@kを測定し、ホットパスのターゲットに対してリランカーのレイテンシと1クエリあたりのコストを予算に入れてください。

実践

エンベディングの章の searchembed ヘルパーを再利用した最小限のRAGの回答:

python
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パイプライン**全体です。エンベディング検索が根拠付けられたプロンプトに支えられたモデル呼び出しに繋がります。品質のほぼすべては正しいチャンクを取得することにあります。

Juno実践 最小限のRAGの回答はエンベディング検索がモデル呼び出しに繋がるものです。最も近いチャンクを取得し、プロンプトに入れて、それのみから回答するようモデルに求めます。検索が事実を提供し、根拠付けられたプロンプトが回答をそこに固定します。品質のほぼすべては正しいチャンクを取得することにあるため、時間をかけるべきはそこです。

動作するRAG呼び出しは検索、根拠付け、生成の流れで、先ほどの引用ハンドルを組み込んだものです。

python
def answer_from_docs(question, index, k=4):
    top = retrieve(question, index, k)  # パターンのセクションから
    context = "\n\n".join(f"[{i}] {t['text']}" for i, t in enumerate(top))
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content":
                f'このコンテキストのみを使って回答してください。ない場合は '
                f'"その情報は持っていません。" と返してください。[1] のようにチャンクを引用してください。\n\n"""{context}"""'},
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content

全体としてエンベディング検索が根拠付けられたプロンプトに支えられたモデル呼び出しに繋がっています。chat.completions 形式はOpenAIスタイルです。メッセージのロールと呼び出し署名はプロバイダーによって異なりますが、検索→根拠付け→生成の流れはどこでも同じです。

回答が間違っている場合は、**ステージの順番でデバッグ**してください。まず取得されたチャンクを確認します。関連するテキストが top に入りましたか?そうでなければ、修正は検索側にあります。kを増やす、チャンキングを修正する、ハイブリッドや再ランク付けを追加する、といった対応であって、プロンプトではありません。正しいチャンクが取得されているのに回答がまだ間違っているなら、それはプロンプトの問題です。この順番を守ることで、検索のバグに対してプロンプトの文言を調整してしまう、決して収束しない状況を避けられます。

Juno実践 動作するRAG呼び出しは検索、引用付きの根拠付け、生成の流れで、OpenAIスタイルのメッセージ形式は構造の面で他のプロバイダーにも移植できますが、フィールド名はそうではありません。固定の順番でデバッグしてください。まず取得されたチャンクを確認します。正しいテキストが入っていなければ検索を修正してください(k、チャンキング、ハイブリッド、再ランク)。正しいチャンクがコンテキストに入って初めて、誤回答はプロンプトの問題です。

実践では、検索→ランク付け→生成パイプラインは1つの関数であり、計測すべきはステージ間の接合部です。

python
def answer_from_docs(question, index, k_fetch=20, k_final=4):
    candidates = retrieve(question, index, k=k_fetch)   # 密(またはハイブリッド)
    top = rerank(question, candidates)[:k_final]        # クロスエンコーダーパス
    context = "\n\n".join(f"[{c['id']}] {c['text']}" for c in top)
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content":
                f'このコンテキストのみを使って回答してください。サポートされていない場合は '
                f'"その情報は持っていません。" と返してください。[doc12] のようにIDを引用してください。\n\n"""{context}"""'},
            {"role": "user", "content": question},
        ],
    )
    return response.choices[0].message.content, [c["id"] for c in top]

回答と一緒にチャンクIDを返すことがシステムを**観測可能**にする手段です。取得されたもの、引用されたもの、引用されたIDが実際に回答を支持しているかどうかをログに記録でき、これが評価での忠実度シグナルになります。chat.completions 形式はOpenAI-SDKです。流れは移植可能として扱い、フィールド名はプロバイダー固有として扱ってください。

このパイプラインのレイテンシとコストのマップを頭に入れておく価値があります。最適化は時間がかかる場所に向かうためです。

  • クエリの埋め込みは固定の小さな税です。
  • 第一パスの検索は安価です。
  • 再ランク付けは多めの取得サイズに比例したクエリごとの実際のコストが発生します。
  • 生成呼び出しがレイテンシとドルコストの両方を支配します。

したがって、より速いベクトルストアを追い求める前に、検索が実際にボトルネックだったか確認してください。たいていは生成でした。

そして失敗はステージの順番でデバッグしてください。取得されたセット、次に再ランク付けされたセット、次に引用、次にプロンプトの順です。正しいチャンクが candidates に入らなかった誤回答は検索のバグであり、そこになかったチャンクをプロンプトの変更で修正することはできません。

Juno実践 パイプラインは検索、再ランク付け、根拠付け、生成であり、回答の隣にチャンクIDを返すことが観測可能にする手段です。取得されたものと引用されたものをログに記録すれば、忠実度を推測ではなく評価でスコアリングできます。コストマップを確認してください。生成がレイテンシとドルを支配しているため、ベクトルストアを交換する前に検索がボトルネックだったことを確認してください。ステージの順番でデバッグしてください。取得、次に再ランク、次に引用、次にプロンプトの順です。候補セットに入らなかったチャンクはどんなプロンプトも修正できません。形式はOpenAI-SDKです。流れは移植できますが、フィールド名はできません。

RAGが不要なケース

RAGはタダではなく、常に答えとも限りません。次の場合はスキップしてください。

  • データが小さくプロンプトに収まる場合。 ナレッジベース全体が数段落であれば、直接貼り付けてください。RAGは一度に送るには多すぎるときのためのものです。
  • 長コンテキストモデルがすべてを保持できる場合。 一度にドキュメント全体を受け取れるほど大きな入力を受け付けるモデルもあり、検索を構築するより手間が少ない場合があります。

本当の限界を忘れないでください。RAGの品質はほぼ検索の品質です。 検索ステップが間違ったチャンクを返せば、完璧なプロンプトでも回答を救えません。実際のRAGシステムのほとんどの努力は、最終的なモデル呼び出しではなく、正しいテキストを取得することに費やされます。

エンベディングとRAGは、モデル自体を変えることなく、質問の瞬間にモデルに知識を与えます。次の章ファインチューニングでは、もう一方の選択肢を扱います。自分の例でモデルを変えること、そしてそれがプロンプトやRAGより価値があるのはいつかを解説します。

JunoRAGが不要なケース データが小さくてプロンプトに貼り付けられる場合、または長コンテキストモデルが一度にすべてを保持できる場合はRAGをスキップしてください。本当の限界も念頭に置いてください。RAGの品質はほぼ検索の品質です。検索が間違ったチャンクを返せば、どんなプロンプトも回答を救えません。本当の作業のほとんどは最終呼び出しではなく、正しいテキストを取得することにあります。

RAGは**インフラ**です。インデックス、埋め込みステップ、検索ステップ、再インデックスパス、そしてそのオーバーヘッドが正当化されるのは、データが大きすぎて送れず、再学習が選択肢にならないほど頻繁に変化する場合だけです。より安価な形式で十分な場合はスキップしてください。

  • コーパスがプロンプトに収まる場合。 数段落や小さなポリシードキュメントはそのままコンテキストに入れてください。RAGはその利点を発揮するのは、一度に送れる以上のものがある場合だけです。
  • 長コンテキストモデルがソース全体を保持できる場合。 ドキュメントが大きなウィンドウに収まるなら、全体を送る方が構築してチューニングしなければならない検索パイプラインより優れていることがあります。ただし、すべての呼び出しでそれらのトークンを支払い、依然として「中間で迷子になる」問題に直面します。

支配的な事実を保持してください。RAGの品質は検索の品質に縛られています。間違ったチャンクに対する完璧なプロンプトは依然として間違って答えます。したがって、インデックスと検索ステップが作業と改善の場所であり、最終的なモデル呼び出しではありません。動作を変えるのではなく事実を供給する必要がある場合は、ファインチューニングが次の章であり、RAGとは異なる問題を解決します。

JunoRAGが不要なケース RAGは実際のオーバーヘッドを持つインフラです。コーパスがプロンプトに収まる場合や、長コンテキストモデルがソース全体を飲み込める場合はスキップしてください。ただし、長コンテキストのルートはすべての呼び出しでトークンを支払い、中間では依然として信頼性が落ちることを忘れないでください。支配的な事実は変わりません。品質は検索に縛られているため、間違ったチャンクに対する完璧なプロンプトは依然として間違って答えます。最終呼び出しではなく、インデックスと検索に費やしてください。

RAGは、あなたが構築、チューニング、監視、再インデックスする検索システムの価格で、通貨性と根拠付けを提供します。したがって、この決断はデフォルトではなくコストとベネフィットの問題です。より軽い形式でバーをクリアできる場合はスキップしてください。

  • 小さくて安定したコーパス。 プロンプトに収まって滅多に変わらない場合は、コンテキストに入れてプロンプトキャッシングを活用することで、固定ブロックが繰り返し呼び出しで安く提供されるようにしてください。ここで検索パイプラインを持つことは、メリットのないオーバーヘッドです。
  • 長コンテキストで控えめな再利用。 ソースが大きなウィンドウに収まるなら、全体を送ることで構築を省けますが、すべてのリクエストでその入力トークンを支払い、中間でrecallが低下するため、ボリュームやドキュメントサイズが増えるとRAGに負けます。

支配的な制約はこの章全体を貫いているものと同じです。エンドツーエンドの品質はrecall@kに縛られています。どんなプロンプトも、どんなモデルアップグレードも、どんな再ランク付けも、検索がコンテキストに入れなかった事実を回復できません。したがって、エンジニアリングの投資は検索、チャンキング、ハイブリッド、再ランク付け、評価、再インデックスに向かい、モデル呼び出しは正確にするのが安い部分です。そして、RAGとファインチューニングを**それぞれの担当領域**に保ってください。検索は何が真実かを供給してソースが変わるにつれて最新の状態を保ちます。ファインチューニングはモデルの応答の仕方を形作り、事実を注入する陳腐で裏付けのない方法です。知識には検索を、形式にはファインチューニングを使い、どちらにも相手の仕事を求めないでください。

JunoRAGが不要なケース RAGは、あなたが構築して再インデックスするシステムの価格で通貨性と根拠付けを提供します。コーパスが小さくて安定している場合(貼り付けてプロンプトキャッシングを活用)や、控えめな再利用で長いウィンドウに収まる場合はスキップしてください。すべてを貫く制約はrecall@kです。検索が取り上げなかった事実を下流で何も回復できないため、検索に投資してモデル呼び出しには投資しないでください。担当領域をきれいに保ってください。検索は何が真実かのために、ファインチューニングはどう聞こえるかのために使い、どちらにも相手の仕事を求めないでください。