実際に機能するRAGパイプラインを構築する:Microsoft Copilotから学ぶ教訓

Dev.to / 2026/4/13

💬 オピニオンDeveloper Stack & InfrastructureIdeas & Deep AnalysisTools & Practical Usage

要点

  • この記事は、多くのRAGチュートリアルが「うまくいく道筋(happy path)」のみを示している一方で、実運用の導入では、スケール、レイテンシ制約、そして回答が誤った場合のユーザーの信頼リスクに対処する必要があると主張しています。
  • チャンク化が重大な失敗ポイントであることを強調しています。固定サイズのチャンク化は意味的な一貫性を損ないがちですが、セマンティック、再帰的、階層的なチャンク化は、自己完結的な意味をよりよく保持できます。
  • RAGを多段階のパイプラインとして捉え直し、フローの各ステップ(クエリ→埋め込み→検索(リトリーバル)→プロンプト拡張→生成)には失敗が起こり得るため、プロダクション志向の設計上の選択が必要だと位置づけています。
  • Microsoft Copilotの検索インフラに関する経験をもとに、研究デモではなく本番環境でも通用するパターンを、複数の領域にまたがって抽出しています。

ほとんどのRAGチュートリアルは、うまくいく最短ルートだけを見せてくれます。少数のPDFをチャンク化して、ベクターストアに放り込み、LLMを組み込み、そして—魔法のように—チャットボットがあなたのドキュメントに関する質問へ答えます。デモ完了。拍手。

でも、そうしたチュートリアルが示してくれないのは、RAGを大規模に展開したときに何が起きるのかということです。コーパスがPDF10本ではなく、1,000万ドキュメントだとしたら。レイテンシ予算が「気が済むまで」ではなく200ミリ秒だとしたら。誤答が軽微な不便ではなく、何百万人ものユーザーに対する信頼を壊す出来事になるとしたら。

私はMicrosoft Copilotの検索基盤チームで働いており、関心領域はセマンティック・インデキシングと、検索拡張生成(RAG)です。また、116以上のオープンソースリポジトリを構築してきました。その多くは、ヘルスケア、開発者向けツール、教育、クリエイティブAIにまたがってRAGパターンを試しています。以下は、私が学んだことを凝縮した内容です。すなわち、本番環境に触れても生き残るパターン、そしてチュートリアルが都合よくスキップする失敗パターンです。

RAGとは実際に何か(簡単な復習)

検索拡張生成(Retrieval-Augmented Generation)はシンプルな発想です。LLMに“記憶だけ”で答えさせるのではなく、まず関連するドキュメントを検索し、その内容をユーザーのクエリと一緒に文脈として投入します。基本の流れは以下の通りです。

ユーザーの質問 → 埋め込み(Embed) → インデックスから検索(Retrieve) → プロンプトを拡張(Augment Prompt) → 応答を生成(Generate Response)

この5ステップのパイプラインには、想像以上の複雑さが隠れています。図中の矢印の1つ1つが、問題の起きるポイントです。各段階を順に見て、実際に何が重要なのかを話していきます。

重要なチャンク化戦略

チャンク化は、RAGパイプラインの中でも最も過小評価されがちな部分です。これを間違えると、後段では何も助けられません—より良い埋め込みモデルでも、より賢いLLMでも、より洗練された検索アルゴリズムでも、です。ダメなチャンクを入れれば、ダメな答えしか返ってきません。

固定サイズのチャンク化

素朴なアプローチは、Nトークンごとに分割することです。これは高速で決定論的で、しかもほとんどの場合で間違っています。512トークンのウィンドウは、段落を途中で切ってしまっていることや、コードの関数をドキュメントストリング(docstring)から切り離してしまっていること、さらにはテーブルを2つのチャンクに分断していることを気にしません。その結果、断片は意味的なまとまりを欠きます。つまり埋め込みはノイジーになり、検索の成績も悪化します。

セマンティックなチャンク化

より良いアプローチは、テキストの自然な境界を尊重します。文、段落、セクション—人間が書くのはそれらの単位であり、そしてそれらが一貫した埋め込みを生み出す単位です。重要な洞察は、チャンクは自己完結した意味の単位であるべきだということです。

再帰的および階層的チャンク化

構造化されたドキュメント(markdown、HTML、コード)では、再帰的なチャンク化がまず構造の境界に沿って分割します。つまり見出し→段落→文の順です。そして、セクションがトークン予算を超える場合に限って、より細かい分割へフォールバックします。これによりドキュメント固有の階層性が保たれ、本当に意味の通るチャンクが生成されます。

重なりのあるウィンドウ

すぐに回収できる(元が取れる)パターンがこれです。隣接するチャンク同士の間に重なり(overlap)を持たせます。重なりがないと、チャンク境界をまたいで存在する情報は、検索から実質的に見えなくなります。概念Xについてのクエリは、チャンク4の末尾とチャンク5の先頭にまたがって一致するかもしれませんが、しかしどちらのチャンク単体も、検索されるほど十分に高いスコアを得られない可能性があります。

def semantic_chunk(text, max_tokens=512, overlap=50):
    sentences = split_into_sentences(text)
    chunks, current_chunk = [], []
    current_tokens = 0
    for sentence in sentences:
        tokens = count_tokens(sentence)
        if current_tokens + tokens > max_tokens and current_chunk:
            chunks.append(" ".join(current_chunk))
            # Keep overlap sentences
            overlap_sentences = current_chunk[-2:]
            current_chunk = overlap_sentences
            current_tokens = sum(count_tokens(s) for s in current_chunk)
        current_chunk.append(sentence)
        current_tokens += tokens
    if current_chunk:
        chunks.append(" ".join(current_chunk))
    return chunks

私は重なりとして末尾の2文を保持します。これは意図的な選択です。境界をまたぐ意味を保つのに十分な文脈を残しつつ、冗長な内容でインデックスを膨らませすぎないようにするためです。重なり幅は領域に合わせて調整してください。技術ドキュメントは、会話文よりも重なりを多めに必要とする傾向があります。

埋め込みモデル—賢く選ぶ

埋め込みモデルは、あなたのコーパス全体がどのように見えるかを決めるレンズです。選び方を間違えると、検索が運ゲーになります。

概観

OpenAIのtext-embedding-ada-002は、多くのチームでデフォルトの選択肢であり、堅実なベースラインです—1536次元で、領域をまたいだパフォーマンスも妥当で、API統合も簡単です。ですが、常に正解とは限りません。BGE-largeE5-large-v2、およびsentence-transformersファミリーのようなオープンソースモデルは、競争力のある品質を提供しつつ、重要な利点があります。規模に応じたAPIコストが不要、レイテンシが低い(ローカル実行または自前のGPUファーム上で動かせる)、そして自分のドメインで微調整できることです。

ドメイン特化型 vs 一般用途

あなたのコーパスが専門的である場合——法務文書、医学文献、コードベース——汎用の埋め込みモデルでは、重要なニュアンスを捉えられないかもしれません。生物医学テキストで微調整されたモデルなら、「MI」が「ミシガン」ではなく「心筋梗塞(myocardial infarction)」を意味することを理解します。ここで頼りになるのがMTEBリーダーボードです。一般的なベンチマークではなく、実際のクエリ分布に対してモデルをベンチマークしてください。

次元数に関するトレードオフ

次元数が高いほど、より多くのニュアンスを捉えられますが、ストレージと検索のレイテンシのコストが増えます。大規模になると、384次元と1536次元の差は学術的な話ではなくなります——インデックスをメモリに収められるか、それとも分散インフラが必要になるか、その差です。微調整後に、768次元のモデルが1536次元のモデルを、ドメイン固有のタスクで上回ったのを見たことがあります。測定してください。仮定してはいけません。

非対称エンベディング

これは、プロダクションのRAGとチュートリアルのRAGを分ける洞察です:クエリとドキュメントは同じ方法で埋め込むべきではありません。 「パスワードをリセットするにはどうすればいいですか?」というクエリは、答えを含むドキュメント本文とは意味的に異なります。E5のようなモデルは、query:passage: のプレフィックスでこれを明示的に扱います。埋め込みモデルが非対称なエンコードに対応しているなら、それを使ってください。検索品質の改善は大きく、しかも実質的に無料です。

取得ランキング——コサイン類似度を超えて

密ベクトルのインデックスに対するコサイン類似度は出発点であって、ゴールではありません。プロダクションでは、単一の類似度スコアではなく、ランキング用のパイプラインが必要です。

ハイブリッド検索:密(Dense)+疎(Sparse)

密検索(ベクトル検索)は、意味のマッチングに優れています——「automobile」と「car」が関連していることを理解します。疎検索(BM25、キーワード一致)は、完全一致に強みがあります——「error code 0x8007045D」が意味概念ではなく正確な文字列であることを知っています。どちらも単独では不十分です。勝つ組み合わせは両方です。

def hybrid_search(query, index, bm25_index, k=10, alpha=0.7):
    dense_results = index.search(embed(query), k=k*2)
    sparse_results = bm25_index.search(query, k=k*2)
    # Reciprocal Rank Fusion
    scores = {}
    for rank, doc_id in enumerate(dense_results):
        scores[doc_id] = scores.get(doc_id, 0) + alpha / (rank + 60)
    for rank, doc_id in enumerate(sparse_results):
        scores[doc_id] = scores.get(doc_id, 0) + (1 - alpha) / (rank + 60)
    return sorted(scores, key=scores.get, reverse=True)[:k]

逆順ランキング融合(Reciprocal Rank Fusion:RRF)は美しい手法です。スコア正規化を要求しないためで、順位位置だけで動作します。分母の60という定数は、上位に来た結果が支配的になりすぎるのを防ぐための標準的な減衰係数です。alphaパラメータは、密(dense)と疎(sparse)のバランスを制御します。0.7は妥当な出発点ですが、評価セットに対して調整すべきです。

クロスエンコーダによる再ランキング

バイエンコーダ(埋め込みモデル)は、クエリとドキュメントを独立にエンコードするため高速です。クロスエンコーダは、クエリとドキュメントのペアを一緒に処理するため正確です。細かな相互作用を捉えられます。典型的な流れは、バイエンコーダで広く取得し、その上位候補をクロスエンコーダで再ランキングすることです。cross-encoder/ms-marco-MiniLM-L-12-v2のようなモデルは、100件の候補をミリ秒単位で再ランキングできます。

メタデータによるフィルタリング

取得は必ずしも純粋に意味ベースであるべきではありません。ユーザーが「Python 3.11の機能」について尋ねたなら、ベクトル検索を実行する前に、言語とバージョンでフィルタリングすべきです。実行した後ではありません。事前フィルタリングにより探索空間が縮小し、文脈ウィンドウの予算を浪費してしまうであろう誤検出を排除できます。

「真ん中に埋もれる(Lost in the Middle)」問題

スタンフォードの研究によると、LLMはコンテキストウィンドウの先頭と末尾に不釣り合いな注意を払い、中間の情報を無視しがちです。これは、取得したパッセージをどのように並べるべきかに直接影響します。最も関連性の高いチャンクを、取得ランキングの順ではなく、コンテキストの先頭に配置してください。あるいはさらに良い方法として、高関連度と低関連度を交互に配置し、モデルに均等に注意を払わせます。

コンテキストウィンドウ管理

チャンクを取得できました。次に、それら——システムプロンプト、ユーザーのクエリ、そして応答のための余地も含めて——固定トークン予算の中に収める必要があります。これはパッキング問題であり、与えられる以上に注意が払われるべきです。

トークン予算の割り当て

予算配分を明確にしてください:

TOTAL_CONTEXT = 8192  # または 128k、使用しているモデルによります
SYSTEM_PROMPT_TOKENS = 500
RESPONSE_RESERVE = 1024
USER_QUERY_TOKENS = 200  # 推定するか測定する

CONTEXT_BUDGET = TOTAL_CONTEXT - SYSTEM_PROMPT_TOKENS - RESPONSE_RESERVE - USER_QUERY_TOKENS
# = 取得したパッセージのための 6468 tokens

関連性の低いチャンクに費やしたトークンは、高い関連性のチャンクに使えなかったトークンです。取得スコアでチャンクをランク付けし、予算が満たされるまで貪欲に詰めてください。

圧縮

上位のチャンクが予算を超える場合、選択肢は 2 つです。チャンクを削除するか、圧縮するか。圧縮手法は、単純な抽出要約(各チャンク内で最も関連性の高い文だけを保持する)から、LLM による要約までさまざまです。トレードオフは、遅延(レイテンシ)と情報密度です。遅延が重要なパイプラインでは、抽出ベースのアプローチが勝ちます。

戦略の選択:Stuff vs. Map-Reduce vs. Refine

  • Stuff: 取得したすべてのチャンクを 1 つのプロンプトに連結します。シンプルで高速で、すべてがコンテキストウィンドウに収まるときにうまく機能します。
  • Map-Reduce: 各チャンクを独立に処理し、その結果を集約します。取得したコンテンツの合計がコンテキストウィンドウを超えるときに必要です。LLM 呼び出し回数が増え、遅延も高くなりますが、スケールに対応できます。
  • Refine: チャンクを順番に処理し、新しいチャンクごとに回答を洗練(改良)していきます。高品質な回答が得られますが、最も遅延が大きくなります。オフラインやバッチ処理のワークロードで使用してください。

本番では、私はフィルタリングを強めたうえで Stuff をデフォルトにします。取得とランキングがうまくできていれば、ほとんどの質問に答えるのに、非常に関連性の高いチャンクを 5〜8 個以上必要とすることはないはずです。

よくある失敗パターン(そしてデバッグ方法)

取得の取りこぼし

ドキュメントはコーパス内に存在するのに取得されませんでした。クエリ埋め込みを、対象ドキュメントの埋め込みに対して直接実行してデバッグしてください。類似度が低いなら、問題はチャンク分割または埋め込みモデルにあります。類似度は高いのにトップ-K にドキュメントが入っていないなら、インデックスの量子化の問題か、再ランキングの前に候補を十分に取得できていない可能性があります。

コンテキストの汚染

10 個のチャンクを取得したが、そのうち 7 個は無関係です。LLM は、信号とノイズを区別しなければなりませんが、常に成功するとは限りません。修正は上流で行うのがポイントです。より良いチャンク分割、より良いランキング、そして厳格な関連性の閾値です。固定の K を常に返すのではなく、最低の類似度スコアを下回るチャンクはすべて落としてください。

正しいコンテキストなのに幻覚(ハルシネーション)

正しいチャンクが取得され、プロンプトに含まれたのに、LLM はそれでも幻覚を起こしました。これはしばしばプロンプトエンジニアリングの問題です。「与えられたコンテキストのみに基づいて答える。コンテキストに答えが含まれていなければ、その旨を言う」といった明示的な指示は、任意ではなく必須です。加えて考えてください。関連情報は長いパッセージの中に埋もれていませんか?「ミドルで失われる(lost in the middle)」効果は、個々のチャンク内でも同様に起こります。

古い埋め込み

ドキュメントは更新されたのに、埋め込みが再計算されていません。これは RAG におけるキャッシュ無効化バグ相当です。インデックス構築パイプラインは、初日から増分更新で作ってください。ドキュメントのハッシュを追跡し、変更された部分だけを再埋め込みします。大規模になると、完全な再インデックスは数時間〜複数 GPU を要する作業です。不要なときに実施したくありません。

スケールから得られる教訓

デモから、本番環境で数百万ユーザにサービスするシステムへ移行すると、何が変わるのでしょうか?

インデックス管理が最重要の関心事になります。 インデックスのバージョニング、インデックス更新のためのブルーグリーンデプロイ、そしてダウンタイムなしで不良インデックスへロールバックできる能力が必要です。あなたのインデックスはデータベースと同じくらいクリティカルです。そのように扱ってください。

レイテンシの予算が、厳しいトレードオフを強制します。 Microsoft のような規模では、1 ミリ秒ごとに意味があります。重要度の低いクエリでは再ランキングをスキップするかもしれません。初期の取得にはより小さな埋め込みモデルを使い、コストの高いクロスエンコーダは最終的な上位 10 件のために温存する、という設計もあり得ます。段階的(ティア)な取得アーキテクチャは、本番ではよく見られます。

監視は妥協できません。 ラベル付きのクエリセットに対して、取得の適合率と再現率を追跡します。時間経過による埋め込みのドリフトを監視します。回答品質が急に低下したらアラートします。パイプライン全体をログに残します:クエリ → 取得したチャンク → 生成された回答。そうすれば、事後に失敗をデバッグできます。観測できない RAG パイプラインは、静かに劣化する RAG パイプラインです。

評価は継続的に行います。 実際のクエリ分布を反映した評価セットを作りましょう。自動指標(忠実性、関連性、回答の正しさ)は、パイプラインのあらゆる変更のたびに実行します。人手による評価は、自動指標が見逃すものを拾います。これは任意ではありません。品質を時間をかけて維持するための方法です。

結論

RAG パイプラインは、試作(プロトタイプ)するときには見かけ以上にシンプルに思えますが、スケールして運用するのは本当に大変です。アーキテクチャ図はナプキンに収まるでしょう:埋め込み、取得、生成。ですが、デモと本番システムの違いは細部にあります。ドキュメントをどうチャンク分割するか、どの埋め込みモデルを選ぶか、結果をどうランキング・フィルタリングするか、コンテキストウィンドウをどう管理するか、そして全体をどう監視するかです。

私の助言は、段階的に作ることです。うまく動く最も単純なバージョンから始め、すべてに計測(計装)を入れ、評価データに次に投資すべき場所を示させてください。チャンク分割が妥当であることを検証する前に、取得を過剰に設計(オーバーエンジニア)しないでください。ベースの取得が妥当であることを確認する前に、再ランキングを追加しないでください。

そして、つまらない部分を省略しないでください。チャンク分割とランキングは華やかではありませんが、そこで本番の RAG システムの勝ち負けが決まります。LLM の部分は簡単です。

Nrk Raju Guthikonda は Microsoft の Copilot Search Infrastructure チームのシニアソフトウェアエンジニアです。Semantic Indexing と Retrieval-Augmented Generation(RAG)の開発に取り組んでいます。AI/ML、ヘルスケア、開発者ツール、クリエイティブ AI にまたがる 116+ のオープンソースリポジトリを構築してきました。GitHub での彼の活動は github.com/kennedyraju55 で見つけられます。