7月23日、Towards Data Scienceが「Loop Engineering for RAG Generation: iterate top-k one at a time」と題した記事を公開した。この記事では、RAG(検索拡張生成)パイプラインにおいてtop-K候補を一括ではなく1件ずつ順次処理する手法と、その切り替えロジックについて詳しく紹介されている。
「全部まとめて渡す」が損をしている場面
ほとんどのRAGチュートリアルで採用されているデフォルトの実装は次のようなものだ。
top_k = retrieval(question, k=5)
answer = generation(question, top_k)
検索結果のtop-5をそのままLLMに渡し、一発で回答を得る。シンプルだが、事実型の質問では過剰なトークンを消費しているケースが多い。
記事ではこんな例が挙げられている。ユーザーが「このポリシーの発効日はいつか?」と質問する。検索は"effective"というキーワードが含まれる5つのチャンクを返す。top-1には答えがある(「2026年1月1日から有効」)。残りの2〜5番目は署名欄、脚注、過去のポリシーの発効日を説明した段落だ。LLMはそれでも5つ全部を読み、top-1が既に持っていた同じ日付を抽出する。50,000件のポリシーを抱える企業コーパスでは、これが毎月の実費になる。
コスト試算:入力トークンを最大65%削減
記事では企業保険Q&Aの具体的なケースを試算している。
- 前提:1日100件の質問、K=5、平均チャンクサイズ=600トークン
- バッチ(全件):100 × (質問 + 5×600トークン) ≒ 33万トークン/日
- シーケンシャル(80%がtop-1で完結と仮定):80 × (質問 + 600) + 20 × (質問 + 5×600) ≒ 11.5万トークン/日
削減率は**入力トークンで約65%**。事実型の質問が多い企業トラフィックほど効果が大きい。
※ この試算は「質問の80%がtop-1の候補1件だけで回答が完結する」という仮定に基づいている。実際の削減率はコーパスの性質や質問の種類によって大きく変わるため、あくまで目安として捉えるべきだ。
コアアイデア:top-1を先に送り、「十分か」を判定してから次へ進む
記事が提案するのがシーケンシャル処理だ。候補を順番に1件ずつ処理し、生成ブリックが「この1件で答えが出た」と自己申告したらループを止める。
for i, candidate in enumerate(top_k):
answer = generation(question, [candidate])
if answer.answer_found and answer.complete_answer_found:
break
このループを成立させるのが、typed contract(型付きコントラクト)と呼ばれるスキーマだ。LLMの出力を決まった型のオブジェクトとして受け取ることで、ループ制御に使う値が常に安定した形式で得られる。
class AnswerWithEvidence(BaseModel):
value: Any
evidence: list[Span]
answer_found: bool
complete_answer_found: bool
confidence: float = Field(ge=0, le=1)
caveats: list[str] = []
ここでSpanは、LLMが回答の根拠として参照したテキストの範囲(チャンク内の特定箇所)を表すオブジェクトだ。どの部分を根拠に回答したかを追跡できるため、監査や検証にも使いやすい。
ループが参照するのはanswer_found(この候補に情報があったか)とcomplete_answer_found(答えが断片でなく完全に揃ったか)の2つのブール値だけだ。信頼度スコアのような浮動小数点値をしきい値で判定する方式は、モデルが変わるたびにしきい値がずれる。ブール値はずれない、というのが記事の主張だ。
発効日の例に戻ると、top-1でanswer_found = True、complete_answer_found = Trueが返ればループ終了。消費トークンはバッチ方式の1/5になる。
どちらを選ぶか:質問の「形」でディスパッチする
シーケンシャルが有利なのは事実型の質問だけで、以下のケースではバッチが正解だ。
- 列挙型(「この契約の除外事項を全部列挙して」):答えが複数の候補にまたがるため、top-1で止まると回答が欠落する
- 比較型(「今年のプレミアムは前年より高いか?」):2つの候補を同一コール内で比較させる必要がある
- スコア差が小さいとき:top-Kの関連スコアが5%以内に収まっている場合、top-1を信頼できない
記事が強調するのは、バッチかシーケンシャルかをパイプライン全体で決めるのではなく、質問ごとに決めるという設計だ。ディスパッチャーはパーサーが事前に質問を解析して生成した構造体を参照し、ルーティングを決定する。具体的にはquestion_df.answer_shape(期待される回答の形式。単一値か列挙かなど)とquestion_df.decomposition(質問を複数のサブクエリに分解した情報)という2つのフィールドを読み取る。ナイーブなRAGはパースされた質問情報を持たないため、この判断ができない。
ループには必ず終端を設ける
記事はシーケンシャルループの終了条件を3つに整理している。
- 充足:
answer_found and complete_answer_foundが真 → 即座に停止して回答を返す - 枯渇:K件全部を見ても不十分 →
answer_found = Falseを返す(これ自体が正当な回答) - 予算上限:トークンまたは時間の上限に達したら強制終了
ナイーブな実装が3番目を省略してエッジケースでトークンを際限なく消費する問題を記事は指摘している。
「エージェント的ディスパッチ」には踏み込まない
「LLM自身にバッチかシーケンシャルかを判断させる」という設計は、一見スマートに見える。しかし記事はこのアプローチを意図的に避けている。理由は監査可能性だ。同じ質問が同じ日に入力されたとき、同じルーティングを保証する必要がある。LLMがコールごとに再計画するディスパッチャーはその保証を与えられない。ルーティングの決定はパーサーが行い、LLMは行わないという原則が通底している。
実装コードはdoc-intel/notebooks-vol1で公開されており、バッチとシーケンシャルの両方で同じ質問を処理してトークンコストと2つの充足ブール値を出力するノートブックが含まれている。
詳細はLoop Engineering for RAG Generation: iterate top-k one at a timeを参照していただきたい。