1. 初めに
1.1 この記事の目的
エージェントライクなRAG(以下、エージェントRAG)を業務で見る機会があり、普通のRAGと具体的に何がどう違うのかを自分の手で測ってみたので、その結果を共有するために作成しました。
1.2 何故実施したのか
以下の理由で実施しました。
- エージェントRAGを実際に見る機会があったので、「検索して、足りなければもう一度検索する」というループが単発の検索とどれくらい違うのかを知りたかったため
- Mastraの本を読んでいたので、少し試してみたかったため
- 自分のQiita記事を題材にすると、「何故この質問を外したのか」を自分で判断できて面白そうだと思ったため
1.3 先に結論
結論を先に書くと、下記のようになりました。
- 検索器の改善(ベクトル検索 → ハイブリッド検索)は明確に効きました。 しかも追加コストはほぼゼロです
- エージェント化は複数記事にまたがる質問でのみ効果がありました。 ただし入力トークン約27倍・レイテンシ約9倍を払っています
- そこにたどり着くまでに、実装バグと設計ミスを何度も踏みました。むしろこちらが本題かもしれないです
コードは下記にあります。
2. 何を実施したのか
実施したことは大きく下記の4つです。
- Qiita記事の収集
- Qiita API v2で自分の記事を取得し、clean → チャンク分割 → 埋め込み → LanceDBに投入
- 3つのRAGシステムの作成
- 通常のベクトル検索(ナイーブRAG)
- ベクトル検索 + BM25(ハイブリッド検索)
- ベクトル検索 + BM25を使用したエージェントRAG
- テスト用のデータの作成
- LLMに質問を自動生成させてgolden setを作成
- 単発検索で引ける
easyと、複数チャンクの統合が必要なmultihopの2種類を用意
- 評価
- 3パターンを同一条件で実行し、検索が正解チャンクを引けたかで採点
2.1 何故2択ではなく3パターンなのか
もともとの動機は「普通のRAG vs エージェントRAG」の2択でした。
しかし2択にすると、改善したときにその原因が分からないという問題があります。エージェント化で良くなったのか、検索器を良くしたから良くなったのかを切り分けられないためです。
そこで、検索器の改善(ベクトル → ハイブリッド)を独立した1パターンとして挟むことにしました。
| # | パターン | 検索 | ループ |
|---|---|---|---|
| 1 | ナイーブRAG | ベクトル top-5 | なし |
| 2 | ハイブリッド | BM25 + ベクトル(RRF融合) top-5 | なし |
| 3 | エージェントRAG | ハイブリッド + スキル選択 + search/fetchループ | あり |
こうすることで、下記のように切り分けられます。
- 1 → 2の差 = 検索精度の改善による効果
- 2 → 3の差 = エージェント化による効果(検索器は同一なので、差はループと判断のみ)
2.2 比較を成立させるための不変条件
比較の妥当性を保つために、下記を不変条件として固定しました。ここが崩れると実験そのものが無意味になります。
- 3パターンは同一のLanceDBインデックスを共有する(同一コーパス・同一チャンク分割・同一埋め込み)
- 生成モデルと生成プロンプトは3パターン共通にする(1ファイルに定義して全パターンがimportする)
- 埋め込みモデルと次元数は、インデックス構築時と検索時で必ず同一にする
- 最終的にプロンプトへ入る根拠は k=5 を上限とする(パターン3もfetch後に件数上限を掛ける)
- 3パターンとも同一形状の結果を返す
特に4が重要かと思います。ここを揃えないと「エージェントは根拠を多く渡しているから強い」という当たり前の結論しか出てこないためです。
2.3 技術スタック
使用した技術スタックは下記です。
| レイヤー | 選定 |
|---|---|
| エージェント / ワークフロー | Mastra (@mastra/core) |
| LLM(生成・エージェント・質問生成) | OpenAI gpt-5.6-luna
|
| 埋め込み | OpenAI text-embedding-3-large(3072次元) |
| ベクトルDB / 全文検索 | LanceDB (@lancedb/lancedb)を直接利用 |
| データ取り込み | Qiita API v2 |
なお、@mastra/lanceは使用していません。理由は下記です。
-
@mastra/lanceはベクトル検索しか呼べず、BM25と融合を自分で制御できない - 今回はBM25側が効いているかどうかが実験の主題そのものなので、ここは譲れなかった
3. Qiita記事の収集
3.1 取り込みの流れ
取り込みの流れは下記の通りです。
HTMLスクレイピングではなくAPIを使用しました。 Qiita API v2はbodyにMarkdown本文をそのまま返してくれるため、ナビゲーション・広告・タグ残骸といったHTML由来のノイズが原理的に発生しないためです。整形処理を書く量がそのまま減ります。
const url = `https://qiita.com/api/v2/users/${username}/items?page=${page}&per_page=100`;
QIITA_TOKENは任意ですが、設定するとレート制限が60req/h → 1000req/hに緩和されます。
3.2 収集できたコーパス
収集できたコーパスの規模は下記です。
| 項目 | 値 |
|---|---|
| 記事数 | 20本 |
| 本文の総文字数 | 約206,000字 |
| チャンク数 | 455 |
| 1記事あたりの平均チャンク数 | 22.8 |
| 1チャンクあたりの平均文字数 | 412字 |
内容はvLLM・ローカルLLM・RAG構築・論文読みが中心で、話題としてはかなり近い記事が固まっています。こちらは後述のマルチホップ質問を作る上では好都合でした。
3.3 チャンク分割は固定長ではなく見出し境界で実施
固定長スライディングウィンドウは使用せず、Markdownの見出し(#〜####)でセクションに切っています。
理由は、見出しをまたいで機械的に切ると「何についての説明か」を失ったチャンクができてしまい、埋め込みもBM25も劣化するためです。
設定は下記の通りです。
- 目標600字 / 最小120字 / オーバーラップ80字
- コードブロック内の
#は見出しとみなさない - 短すぎるセクションは直前のチャンクに併合する
さらに、各チャンクの本文の先頭にheading_path(例: 記事タイトル > セットアップ > Docker)を前置しています。
text: `${headingPath}\n\n${part.trim()}`,
こうすることで、下記のメリットが得られます。
- 埋め込みもBM25も見出しの文脈を利用できる
- 検索結果のスニペットを見たときに「どの記事のどの節か」が人間にも分かる
3.4 実験前に踏んだ地雷: 日本語BM25がデフォルト設定で死んでいる
こちらは取り込みの段階で気づけて本当に良かったという類の罠でした。
LanceDBの全文検索はTantivyベースで、baseTokenizerのデフォルトはsimpleです。こちらは空白と句読点で分割するトークナイザなので、分かち書きしない日本語ではBM25がまともに機能しません。
合成データと実コーパスの両方で測定した結果が下記です。
| baseTokenizer | 合成データ(10クエリ) | 実コーパス(25クエリ) |
|---|---|---|
simple(デフォルト) |
0/10 (0%) | 4/25 (16%) |
icu |
10/10 (100%) | 23/25 (92%) |
ngram (2-3) |
10/10 (100%) | 25/25 (100%) |
合成データでは全クエリがヒット0件でした。実コーパスで16%まで上がるのは、LanceDBやvLLMのようなASCII語だけは空白区切りでもトークン化されるためで、つまり日本語語彙はほぼ引けていないことになります。
対処としては、下記のようにトークナイザを明示するだけです。
await table.createIndex("text", {
config: lancedb.Index.fts({ baseTokenizer: "icu" }), // 明示が必須
});
この問題の厄介な点は、エラーも警告も出ないことです。検索結果が空になるだけなので、最終スコアだけを見ても原因にたどり着けません。
デフォルトのまま進めていた場合、パターン2のBM25側の寄与はゼロになり、ハイブリッド検索はベクトル検索と同じ挙動になります。その結果、「ハイブリッド検索は効果がない」という誤った結論に着地していたと思います。
採用はicuにしました。理由は下記です。
-
ngramは実コーパスで100%を出したが、こちらは「部分文字列検索が部分文字列を見つけた」というほぼ同語反復であり、ランキング品質の証拠にはならないため -
icuは外部モデルのダウンロードが不要で、セットアップの再現性が高いため
なお、検証はnpm run verify-ftsとしてスクリプト化し、合格率80%未満ならexit 1を返すようにして、以降の工程に進むためのゲートにしています。
4. 3つのRAGシステムについて
4.1 通常のベクトル検索(ナイーブRAG)
質問を埋め込んでベクトル近傍をtop-5取り、そのまま生成に渡すだけの構成です。ツール呼び出しなし、ループなし、クエリ整形なしとなります。
const hits = await vectorSearch(question, FINAL_CONTEXT_K);
const docs = await fetchChunks(hits.map((h) => h.chunk_id), "chunk_only");
const gen = await generateAnswer(question, docs);
なお、こちらには絶対に手を入れないと決めていました。 ベースラインに小細工を入れると、「エージェント化で何が改善したか」の基準線が動いて比較が無意味になるためです。
メリット・デメリットは下記です。
- メリット
- 実装が最小で済む
- 決定的(何度実行しても同じ結果になる)
- 速い(平均2.8秒)
- 表記ゆれや言い換えに強い
- デメリット
- 固有名詞・型番・コマンド名の完全一致が苦手
-
Qwen3-8B-FP8のような語が「意味の近い別のモデル名」に引きずられる
4.2 ベクトル検索 + BM25(ハイブリッド検索)
BM25とベクトル検索を両方実行し、RRF(Reciprocal Rank Fusion)で順位を融合する構成です。LanceDBは融合ステップを必ず要求し、既定がRRFになっています。
const q = table.query().fullTextSearch(query).nearestTo(vector).rerank(reranker);
LanceDBのTypeScript SDKにはPythonの.vector()/.text()パターンがなく、埋め込みを自前で計算して.nearestTo()に渡す必要があります。
また、RRFは名前に "Reranker" が付きますが、順位の統計的融合であってCross-Encoderによる意味的な再スコアリングとは別物です。今回Cross-Encoderリランクは意図的に入れていません(パターン2と3の差分が「ループの有無」と「リランクの有無」の2変数になり、切り分けができなくなるためです)。
メリット・デメリットは下記です。
- メリット
- 固有名詞・コマンド名・バージョン番号の完全一致が効く
- ベクトル検索の弱点をピンポイントで埋められる
- 追加コストがほぼゼロ(レイテンシ +0.2秒、トークン +2%程度)
- デメリット
- 日本語ではトークナイザ設定が必須で、失敗しても黙って劣化する
- 単発である以上「1回の検索で取れる範囲」が上限になる
4.3 エージェントRAG
図が大きくなるので、「全体フロー」と「検索スキルの内部」に分けて記載します。
4.3.1 全体フロー
パターン2との差分は「ループの有無」と「読む範囲をエージェントが決めること」です。検索エンジン自体はパターン2とまったく同じものを使用しています。
4.3.2 検索スキルの内部
上図の「検索スキル実行」の中身は下記のようになっています。
設計上のポイントは下記の4つです。
4.3.3 検索要否の判定を「スキル選択」に一本化した
「検索が必要か?」という独立したステップを持たせず、searchスキルとdirect(直接回答)スキルの選択そのものに判定を兼ねさせています。判定ロジックはコードではなく、スキルのdescription(トリガー条件)に集約されます。
こちらは、失敗のコストが非対称であることを前提にしています。
| 失敗 | 帰結 | コスト |
|---|---|---|
| 要検索なのに直接回答 | 誤情報を自信をもって答える | 高 |
| 不要なのに検索 | レイテンシとトークンが余分にかかる | 低 |
そのため、両側から同じ方向にバイアスをかけるようにdescriptionを書いています。
- 検索スキル側: 「判断に迷う場合は、直接回答ではなくこちらを優先する」
- 直接回答スキル側: 「文書に存在しうる情報が少しでも含まれる場合はこのスキルを使わない」
4.3.4 searchとfetchを分離した
searchは本文を返さず、chunk_id / 見出し / 抜粋500字 / スコアだけを返します。本文はfetchでしか取得できません。
この分離によって「どこまで読むか」の判断がエージェント自身の仕事になります。fetchのscopeは下記の3種類です。
-
chunk_only: そのチャンクのみ -
with_neighbors: 前後1チャンクを含む -
whole_section: 同じ見出しのセクション全体
4.3.5 充足チェックは回答生成の「前」に置いた
当初は回答生成の後に「この回答は十分か?」を判定させていました。しかし、一度回答文が生成されると、その流暢さが根拠の不足を覆い隠してしまうという問題がありました。片方の文書しか取れていなくても、文章としては尤もらしく仕上がるので「十分」と判定されてしまいます。
そこでチェックを収集段階に移し、判定器に回答文を渡さず、質問と根拠と試したクエリだけを見せるようにしました。こうして初めてループが回るようになりました。
4.3.6 上限は複数を独立に持たせた
上限は下記の3つを別軸の安全装置として併用しています。
| 上限 | 値 | 役割 |
|---|---|---|
| searchの呼び出し回数(1ラウンド内) | 3回 | 無限ループの防止 |
| 収集ラウンド(充足チェックによる再収集) | 3ラウンド | 集め直しを何度まで許すか |
| 確信度チェックによる再突入 | 1回 | 事前判定の取りこぼしのリカバー |
なお、プロンプトに「最大N回」と書くだけでは守られず、初回実験では1問で10回検索したケースがありました。そのためsearchの回数上限はツール側で機械的に打ち切るようにしています。
4.3.7 メリット・デメリット
- メリット
- 1回の検索で取り切れない構成に対して、二度目・三度目を取りに行ける
- 読む範囲を状況に応じて広げられる
- 雑談や会話メタ発言に対して検索を省ける
- デメリット
- 判断する箇所(決定点)の数だけ、情報を失う機会が増える
- 入力トークン約27倍・レイテンシ約9倍
- 確率的に振る舞うため、結果が実行ごとにブレる
- デバッグが難しい
4.4 まとめ
3パターンをまとめると下記のようになります。
| ナイーブRAG | ハイブリッド | エージェントRAG | |
|---|---|---|---|
| 検索 | ベクトル top-5 | BM25 + ベクトル top-5 | ハイブリッド top-8(+補助クエリ最大4) |
| ループ | なし | なし | あり |
| 決定点の数 | 0 | 0 | 6 |
| LLM呼び出し / 問 | 1.0 | 1.0 | 7.0(easy)〜13.5(multihop) |
| 平均レイテンシ | 2.8秒 | 3.0秒 | 28.3秒 |
| 平均入力トークン | 1,591 | 1,620 | 44,264 |
| 決定性 | 決定的 | 決定的 | 確率的 |
| 得意 | 表記ゆれ・言い換え | + 固有名詞の完全一致 | + 1回の検索で届かない構成 |
| 苦手 | 固有名詞の完全一致 | 単発で届かない範囲 | コスト・再現性・デバッグ性 |
| 向いている用途 | プロトタイプ、基準線 | 大半の実用ケース | マルチホップ、範囲を広げて読む必要がある調査 |
ここでの「決定点」とは、情報を落としうる判断の箇所のことです。
単発ハイブリッドは判断を一切しません(生の質問を投げ、上位5件を機械的に渡すだけ)ので決定点はゼロです。
一方、エージェントRAGは最終的に下記6つの決定点を持つことになりました。
- クエリ書き換え
- 候補の取捨選択
- 読む範囲(scope)
- 十分性判定
- 複合質問の分解
- 検索候補の記事多様化
こちらのすべてが、利得を生みうる箇所であると同時に、損失を生みうる箇所でもあります。
5. テスト用のデータの作成
評価用の質問セット(golden set)はLLMに自動生成させました。
自動生成を選んだ最大の理由は、正解chunk_idが生成時点で自動的に紐づくことです。手書きの場合は「この質問の根拠はどのチャンクか」を人手で対応付ける作業が発生し、そこが一番のボトルネックになります。
最終的に**46問(easy 20問 / multihop 26問)**を用意しました。
5.1 easy
5.1.1 作成方法
各チャンクをLLMに渡し、「このチャンクを読まないと答えられない質問」を1つ作らせています。記事あたり3チャンク × 20記事 = 60問を候補にして、最終的に1記事1問へ間引きました。
作成する上で一番効いたのが自己完結性の強制でした。
質問文に「この記事では」「本文の」「この実験で」といった指示語が入ると、主題を特定する語がなくなり、BM25もベクトル検索も手がかりを得られません。すると3パターンとも一様に失敗して、差がノイズに埋もれてしまいます。
イメージとしては下記です。
悪い例: 「この実験で使用したデータ拡張手法を3つ挙げてください」
→ 何の実験か分からず、検索の手がかりになる語が1つもない
良い例: 「schedule-free optimizer の CIFAR-10 実験で使われたデータ拡張手法は?」
→ 主題が名指しされており、そのまま検索窓に打てる
LLMの自己判定は甘く出るので、決定論的な正規表現でも弾くようにしました。
const DEICTIC_PATTERNS = [
/本文|本記事|この記事|当記事/,
/提示され|上記|下記|前述|後述/,
/この(文書|資料|ページ|セクション|章)/,
/この(実験|実装|検証|取り組み|コンペ|手順|構成|設定|例|ケース|方法|...)/,
/ここでは|今回の記事/,
];
5.1.2 生成例
実際に生成された質問は下記のようなものです。
質問: Slurmのsbatch用train.sh設定例では、使用ノードを明示的に指定する#SBATCHオプションは何で、どのノードを指定していますか?
想定回答:
#SBATCH --nodelist=osk-gpu[01,03]で、osk-gpu01とosk-gpu03を指定しています。正解chunk_id:
09794dc380328ae5cfb7#7(1件)
5.1.3 用途
用途は対照群です。 ここでエージェント化が無駄になることを確認するのが役割になります。
というのも、easyだけで60問作って生成を実行せず検索だけ測定したところ、下記のようになりました。
| recall@5 | |
|---|---|
| ベクトル検索 | 58/60 (97%) |
| ハイブリッド | 60/60 (100%) |
単発検索が満点です。 これではエージェント化に改善の余地が構造的に存在しません。
このまま実験を回すと「ループはコストが増えるだけ」という結論が出ますが、それはエージェントRAGについての発見ではなく、golden setが簡単すぎることの反映になってしまいます。
原因ははっきりしていて、1つのチャンクから質問を作り、かつ主題を名指しさせたため、その1チャンクと語彙・意味の両方がほぼ完全一致していたためです。
5.2 multihop
5.2.1 作成方法
別々の記事にある2つのチャンクを組み合わせないと答えられない質問を作成します。
ペアの選び方が肝で、あるチャンクのベクトル近傍を別記事から引くようにしています。
const neighbors = await table
.query()
.nearestTo(seed.vector)
.where(`article_id != '${seed.article_id}'`) // ← 同一記事を除外
.limit(1)
.toArray();
同一記事内でペアを作ると、近接チャンクが1回の検索でまとめて取れてしまいマルチホップになりません。「話題は近いが別文書にある」という状態を意図的に作っています。
LLM側には下記の型を例示し、片方だけで答えられる質問は失格とさせています。
- 比較: 「AとBのそれぞれの〜は何が違うか」(各文書に片方ずつ書かれている)
- 統合: 「Aの手順で使う〜を、Bではどう設定しているか」
- 因果の接続: 「Aで報告された問題は、Bのどの設定で回避できるか」
また、2つの文書に共通の話題が見出せない場合は、無理に作らずis_natural=falseを返させて破棄しています。
5.2.2 生成例
実際に生成された質問は下記のようなものです。
質問: Slurmで実行する一般的な
train.shのバッチジョブと、Rayを使ったvLLMマルチノード推論のstart_ray_cluster.shでは、メモリ割り当てと標準出力・エラー出力の保存先がそれぞれどのように設定されていますか?想定回答:
train.shではメモリを256GBに指定し、標準出力を./info.out、エラー出力を./error.errに保存します。一方、vLLMのstart_ray_cluster.shではメモリを0(制限なし)にし、標準出力を./ray-minimal-%j.out、エラー出力を./ray-minimal-%j.errに保存します。正解chunk_id:
09794dc380328ae5cfb7#7と807e499107ae27bacbba#3(2件)
5.2.3 用途
用途は実験の主戦場です。 単発検索の取りこぼし率は下記のようになりました。
| バケット | ベクトル | ハイブリッド |
|---|---|---|
| easy(1チャンク) | 95% | 100% |
| multihop(2チャンク必須) | 50% | 65% |
単発top-5では片方しか取れないことが起こりうる一方、ループを持つパターン3は2回目の検索でもう片方を取りに行けます。ここで初めて「エージェント化が何のために必要か」が観測可能になります。
5.3 やってはいけないこと: 検索結果で質問を絞り込む
golden setを作る際に一番気をつけた点なので、独立して記載します。
検索でヒットするかどうかで質問をフィルタしてはいけないです。
「今の検索器で引ける質問だけ残す」ということをすると、評価対象の検索器で評価データを選別することになり、3パターンすべてのスコアが実力以上に嵩上げされてしまいます(循環論法)。
フィルタは質問文と回答文の性質だけを見るようにしました。今回のスクリプトも、除外条件は下記の4つだけで、いずれも検索を実行せずに判定できます。
- 一般知識で答えられる
- 質問文に答えが含まれる
- 一般的すぎる
- 自己完結していない
5.4 採点の定義
正解判定は厳密にしました。
-
完全ヒット:
golden_chunk_idsが全件そろってプロンプトに入ったか(0 or 1) - 部分recall: 正解chunkのうち取得できた割合(途中経過を見るための部分点)
multihopで片方だけ取れても答えは組み立てられないので、ここを緩めるとマルチホップ質問を用意した意味が消えてしまいます。
なお、この主指標はLLMを一切使用していません。実際にプロンプトへ入ったchunk_idと正解chunk_idを突き合わせるだけなので、安定・安価で、実行ごとにブレないというメリットがあります。
6. 評価
今回見たいのは検索性能です。 「回答文の質」ではなく、「回答を書くのに必要な根拠を、検索が実際に引けたか」を主指標にしました。
理由は下記の2つです。
- 3パターンとも生成モデルと生成プロンプトが同一なので、根拠が同じなら回答も概ね同じになる(つまり差が出るとしたら検索側である)
- faithfulnessのようなLLM-as-judgeの指標は実行ごとにブレる上、「検索が正解を引けたか」を直接には表さない
6.1 最終結果
golden set 46問に対する結果です。数字は完全ヒット率となります。
easy(20問・対照群)
| パターン | 完全ヒット | 部分recall | 平均レイテンシ | 平均トークン(in/out) | LLM呼び出し |
|---|---|---|---|---|---|
| ナイーブRAG | 95% (19/20) | 95% | 2,068ms | 1,598 / 92 | 1.0 |
| ハイブリッド | 100% (20/20) | 100% | 2,127ms | 1,666 / 85 | 1.0 |
| エージェントRAG | 95% (19/20) | 95% | 15,952ms | 15,270 / 557 | 7.0 |
multihop(26問・主戦場)
| パターン | 完全ヒット | 部分recall | 平均レイテンシ | 平均トークン(in/out) | LLM呼び出し |
|---|---|---|---|---|---|
| ナイーブRAG | 50% (13/26) | 73% | 3,440ms | 1,586 / 311 | 1.0 |
| ハイブリッド | 65% (17/26) | 83% | 3,664ms | 1,584 / 332 | 1.0 |
| エージェントRAG | 73% (19/26) | 87% | 37,738ms | 66,568 / 2,402 | 13.5 |
全体(46問平均)で見ると下記のようになります。
| easy | multihop | 平均入力トークン | 平均レイテンシ | |
|---|---|---|---|---|
| ナイーブRAG | 19/20 | 13/26 | 1,591 | 2.8秒 |
| ハイブリッド | 20/20 | 17/26 | 1,620 | 3.0秒 |
| エージェントRAG | 19/20 | 19/26 | 44,264 | 28.3秒 |
6.2 切り分け
上記の結果を切り分けると下記のようになります。
-
1 → 2(検索精度の改善): multihop 13 → 17
- ハイブリッド検索は明確に効きました
- しかもコストはほぼゼロです(レイテンシ +0.2秒、トークン +2%)
-
2 → 3(エージェント化): multihop 17 → 19
- 精度は上がりましたが、入力トークンは約27倍・レイテンシは約9倍になりました
-
easyでは一度も上回れていない
- ここまでの全実行を通じて、easyでエージェントRAGが単発ハイブリッドを上回ったことは一度もありませんでした
差がついたのはすべてmultihopで、こちらは設計上の狙い通りとなりました。
6.3 どのような質問で成否が分かれたか
数値だけでは分からないので、質問ごとの成否の内訳も確認しました。
6.3.1 エージェントRAGだけが正解できた5問
単発ハイブリッドが取りこぼし、エージェントRAGだけが根拠をそろえられた質問です。いずれも「AではX、BではYですか」のように、表層で対象が分離できる比較質問でした。
Docker ComposeでvLLMを使ってQwen3-14B-bnb-4bitのチャットモデルをホストする設定と、Qwen3-8B-FP8をホストする設定では、使用するvLLMの起動オプションとGPUメモリ使用量の指定がどのように異なりますか?
質問文を見た時点で「Qwen3-14B-bnb-4bitの話」と「Qwen3-8B-FP8の話」に分けられます。このタイプはクエリ分解が正しく発火して、対象ごとに別々に検索できていました。
6.3.2 エージェントRAGだけが失敗した4問
こちらはその逆で、単発ハイブリッドが正解できたのにエージェントRAGが取りこぼした質問です。うち3問は質問文からは2記事構成だと読み取れないものでした。
GPU環境でvLLMをOpenAI互換APIとしてサービングし、Rayを使ったマルチノード推論を行う場合、ヘッドノードではどのような起動設定にすればよいですか?
一見すると単一の話題に見えますが、正解は別々の記事にまたがっています。
この場合、分解は発火せず、記事多様化(上位が1記事に偏っていたらその記事を除外して再検索する仕組み)は発火するものの、その検索自体が正解チャンクを上位8件に見つけられていませんでした。
こちらは統合方法の問題ではなく埋め込み / BM25の検索力そのものの限界で、今回の工夫の延長では解決しない別の課題として残っています。
なお、残る1問はそもそもsingle-chunk問題で、素の検索順位ミスでした。
6.4 ターン数分布
エージェントRAGが実際に何回検索したか(確信度チェックによる再突入も含めた総検索回数)は下記です。
| ターン数 | 1 | 2 | 3 | 5 | 6 | 8 | 9 | 10 | 12 | 13 |
|---|---|---|---|---|---|---|---|---|---|---|
| 件数 | 5 | 11 | 16 | 2 | 2 | 1 | 3 | 2 | 2 | 2 |
- 中央値: 3ターン
- 最大: 13ターン
- 1ターンで完結(= ループが働かず単発と同じ): 5件
なお、スキル選択(検索するか直接答えるか)の精度は、境界例20件に対してPrecision 100% / Recall 100%でした。「雑談に見えて固有名詞を含む」「専門用語風だが一般知識で答えられる」といったケースを用意していますが、今回のコーパスと質問では判定が破綻する場面は観測できませんでした。
6.5 この結果に一直線でたどり着いたわけではない
初回はmultihop 10/26で、単発ハイブリッド(17/26)を大きく下回っていました。そこから8回の実験を経ています。
| 実行 | multihop | 変更内容 |
|---|---|---|
| (a) | 10/26 | ベースライン |
| (b) | 14/26 | k=5という予算の存在をエージェントに伝えた |
| (c) | 12/26 | 充足チェックを収集段階へ移した(マージ順にバグ混入) |
| (d) | 10/26 | マージ順を修正 |
| (e) | 16/26 | 1手目を元の質問に固定 + 言い換え方針の明示 + 計測バグ修正 |
| (f) | 17/25 | スニペット 200字 → 500字 |
| (g) | 14/26相当 | クエリ分解 + 記事多様化を追加(素朴な統合でregression) |
| (h) | 19/26 | 統合方法を「主クエリ保護」に修正 |
| (参考)hybrid | 17/26 | 全実行で不動・決定的 |
(a)から(f)までの7問ぶんの改善は、新機能を1つも足しておらず、すべて実装バグと計測の交絡の除去によるものです。中身は下記でした。
-
k=5という予算をエージェントに伝えていなかった
- コードでは強制していたがプロンプトに書いていなかったので、46問中33問で予算を使い切っていなかった
- 単発パターンが常に5件取るのに対し、エージェントは1〜2件で切り上げていた
-
追加検索で見つけた根拠を、マージ順の誤りで捨てていた
- 1ラウンド目でk件埋まっていると、2ラウンド目に見つけた根拠が丸ごと切り捨てられていた
-
範囲拡張で読んだ隣接チャンクが採点対象から漏れていた
-
retrieved_chunk_idsにfetchの起点チャンクしか記録していなかった - 範囲拡張はエージェントRAGだけが使う機能なので、エージェント側だけを一方的に減点していた
-
- エージェントが自作した言い換えクエリが、元の質問より弱かった(詳細は7.2に記載)
-
200字のスニペットではfetchの要否を判断できなかった
- 候補リストに正解が見えているのにfetchしないケースが7件あった
そして(g)で新しい工夫を2つ足したところ、一度さらに悪化しました。こちらが今回一番学びが大きかった部分なので、次章で扱います。
7. 考察
7.1 コストの観点
まず、エージェント化のコストは精度差に対して割に合っていないと感じました。
| 入力トークン | レイテンシ | LLM呼び出し | |
|---|---|---|---|
| ハイブリッド | 1,620 | 3.0秒 | 1.0 |
| エージェントRAG | 44,264(27.3倍) | 28.3秒(9.4倍) | 7〜13.5 |
これに対して得られたものは、multihop 26問中2問(65% → 73%)となります。
7.1.1 easy側のコストはほぼ完全に無駄だった
内訳を見ると、easyでヒット率は95%のまま変わらないのに、下記のようにコストだけ増えていました。
- レイテンシ: 10.2秒 → 16.0秒
- 入力トークン: 12,559 → 15,270
原因は記事多様化の誤発火でした。
この仕組みは「種検索の上位5件がほぼ1記事に偏っていたら、その記事を除外してもう一度検索する」というものですが、easy質問は単一記事・単一トピックというgolden setの設計上、上位候補がほぼ必ず1記事に偏ります。つまり毎回発火してしまいます。
const DIVERSITY_CHECK_TOP_N = 5;
// 上位5件が全部同じ記事 → 偏っていると判定 → 追加検索(埋め込み1回 + DB検索1回)
最終的にfetchするかはエージェント判断に委ねているので精度への実害はなかったのですが、効果のない多様化に払うコストがeasy側でそのまま可視化された形になりました。閾値を難易度で分けず全問一律にした設計の代償で、次にコストを詰めるならここが対象になるかと思います。
7.1.2 実運用の観点
レイテンシ28秒はチャットUIでは厳しいと思います。ユーザーが待てるのはせいぜい数秒で、そこを超えるなら下記のどちらかになるかと考えています。
- ストリーミングで途中経過を出す
- 非同期のバッチ処理として設計する
一方で、46問 × 3パターンの評価が丸ごと13.3分で終わっているのは正直かなり楽でした。このサイクルの速さがあったからこそ、8回も実験を回して原因を切り分けられた側面はあります。
7.2 エージェントRAGの性能について
7.2.1 決定点の数が、そのままリスクになる
単発ハイブリッドは判断を一切しません(生の質問を投げ、上位5件を機械的に渡すだけ)ので決定点はゼロです。一方、エージェントRAGは6つの決定点を持ち、そのすべてが情報を失いうる箇所になります。
こちらを実測するために、エージェントが実際に投げたクエリのログから損失を段階分解しました。生成を実行せず埋め込みだけなので、数十秒・ほぼ無料で回せます。
測定した4段階は下記です。いずれもmultihopで正解チャンクをそろえられた質問の割合で、①から④へ進むほどエージェントの判断が1つずつ積み重なっていきます。
| 段階 | 何を測っているか |
|---|---|
| ① | 元の質問をそのまま検索し、上位5件を見た場合(= 単発ハイブリッドと同条件) |
| ② | エージェントが自分で書き換えたクエリで検索し、上位5件を見た場合 |
| ③ | ②と同じクエリで上位8件まで見た場合(= エージェントに候補リストとして見せた範囲) |
| ④ | そこからエージェントがfetchを選び、実際にプロンプトへ入った根拠(= 最終結果) |
したがって、段階間の落ち込みがそのまま「どの決定点で情報を失ったか」になります。
- ① → ②の落ち込み: クエリを書き換えたことで失った分(損失A)
- ③ → ④の落ち込み: 候補としては見えていたのに取らなかった分(損失B)
この分解を、**このあと説明する対策を入れる前((b)時点)と、入れた後((f)時点)**で測った結果が下記です。
| 段階 | 対策前((b)時点) | 対策後((f)時点) |
|---|---|---|
| ① 元の質問そのまま @5(= 単発と同条件) | 80% | 80% |
| ② エージェントの言い換えクエリ @5 | 63% | 76% |
| ③ 同 @8(エージェントが実際に見た候補) | 76% | 82% |
| ④ 実際にfetchした根拠(最終結果) | 67% | 80% |
こちらの測定はクエリ分解・記事多様化を入れる前((g)/(h)の手前)のものです。6.5の推移表でいうと(b)と(f)の比較にあたります。
まず対策前の① → ②で17ポイント落ちていました。 エージェントは元の質問を捨て、同義語・関連語を継ぎ足したクエリを作っていたためです。
元:
vLLMのmax-model-lenを小さくすると速度が改善するのはなぜ?
言い換え:
vLLM max-model-len を小さくすると推論速度が改善する理由
KV cache メモリ 使用量 スケジューリング 長いコンテキスト
語の水増しは、BM25では一致度を薄め、ベクトルでは「質問文の意味」を「語の羅列」に変質させます。「クエリ整形は検索を良くする」という設計時の前提が、実測では成立していませんでした。
損失Aへの対策として、下記2つを実施しました。
- 1ターン目はエージェントにクエリを考えさせない。 元の質問をそのままハイブリッド検索に通す処理をコード側に置き、その検索結果を候補リストとしてエージェントに渡した状態から始める
- 言い換えは「足す」ではなく「置き換える」とプロンプトに明記する
1つ目は、言い換えを禁止したわけではなく使うタイミングを後ろにずらしたという話です。図にすると下記のようになります。
【変更前】
質問 → エージェントが検索クエリを考える → 検索 → 候補を見る → ...
※ 一番成績の良いクエリ(元の質問そのもの)を一度も試さないまま、
①80% ではなく ②63% の地点からスタートしていた
【変更後】
質問 → 元の質問でそのまま検索(コード側で実行) → 候補をエージェントに渡す → ...
※ 必ず ①80% の地点からスタートする。
言い換えは「1回目で足りなかったとき」の手段として2ターン目以降に回す
2つ目のプロンプトには、下記のように記載しています。
- **語を継ぎ足さないこと。** 元の質問に関連語を足して長くすると、検索精度はむしろ**下がる**。
- 正しいやり方は**置き換え**。元の質問と同じ長さかそれ以下の、別の言い方の文にする。
これでクエリ長の水増しは止まり、損失Aは4件 → 0件になりました(表の② 63% → 76%)。
一方の損失Bへの対策は、searchが返すスニペットを200字 → 500字に厚くすることでした。200字では「この文書が本当に質問に答えているか」を判断できず、候補リストの1〜3位に正解が見えているのにfetchしないケースがあったためです。こちらで損失Bは7件 → 4件に減りました(表の④ 67% → 80%)。
対策後の状態をまとめると下記になります。
-
③ 82% > ① 80% となり、エージェントが見ている候補は単発ハイブリッドを上回った
- つまり検索そのものは、もうエージェント側の方が良い
- ただし④ 80%は①とほぼ同水準
- 検索で少し上回り、候補の選択で少し失って、差し引きゼロという状態
- 対策前は①②③④の4つの決定点すべてが正味マイナスだったので、そこからは脱した
なお、この時点ではまだ単発ハイブリッド(17/26)と同点で、ここを超えるために次節の工夫を足すことになります。
7.2.2 一番の学び: 素朴な統合で、一度さらに悪化した
(f)時点で単発ハイブリッドと同点(17 vs 17)だったので、そこから工夫を2つ足しました。
- クエリ分解: 「AではX、BではYですか」のような複合質問を、対象ごとに最大2つへ分解して並行検索する
- 記事多様化: 種検索の上位が1記事に偏っていたら、その記事を除外して再検索し、別記事の候補を混ぜる
両方を実装し、「複数クエリの検索結果をスコア順に統合して上位8件に切る」という素朴な方式で実行したところ、multihopが17/26相当 → 14/26相当まで悪化しました。
1問掘り下げたところ、原因が分かりました。
質問: Amazon Bedrock APIを呼び出す際、AWS CLIの
aws loginによるローカル認証では何を追加インストールする必要があり、Amazon CognitoはNext.jsのAIチャットアプリで認証に関してどのような機能を提供しますか?
| 検索したクエリ | 結果 | |
|---|---|---|
| 分解あり・素朴な統合 | 元の質問 + サブクエリ2本 | 不正解(正解の一方が候補から消えた) |
| 参考: 分解なし | 元の質問1本のみ | 正解(2件とも上位5件に入る) |
元の質問1本で検索すれば正解できていたのに、分解して統合した結果、不正解に転落していました。
原因は統合方法にありました。異なるクエリのRRFスコアを、そのまま比較可能な数値として扱っていたためです。
// バグ: 異なるクエリのスコアをそのまま比較して統合していた
function mergeHits(hitLists: SearchHit[][], limit: number): SearchHit[] {
const byId = new Map<string, SearchHit>();
for (const hits of hitLists) {
for (const hit of hits) {
const existing = byId.get(hit.chunk_id);
if (!existing || hit.score > existing.score) byId.set(hit.chunk_id, hit);
}
}
return [...byId.values()].sort((a, b) => b.score - a.score).slice(0, limit);
}
狭いサブクエリは、その狭い母集団の中で相対的に高いスコアを出しやすく、質問全体を捉えた元の質問の正解候補より上位に来てしまいます。スコアは同一クエリ内の相対順位としてのみ信頼できる、という当たり前のことを見落としていました。
修正としては、主クエリ(元の質問)の候補を無条件で残し、補助クエリの候補はスコアで競合させず、重複しない分だけ追加枠として足す方式に変えました。
// 修正: 主クエリはそのまま確定。補助クエリは重複しない分を追加枠として足すだけ
function mergeProtectingPrimary(primary: SearchHit[], supplementary: SearchHit[]): SearchHit[] {
const seen = new Set(primary.map((h) => h.chunk_id));
const extra = supplementary.filter((h) => !seen.has(h.chunk_id)).slice(0, MAX_SUPPLEMENTARY);
return [...primary, ...extra];
}
これでmultihopは19/26まで上がり、初めてハイブリッドの17/26を上回りました。
ここから引き出せる教訓は下記かと思います。
- 決定点を増やすこと自体が悪いのではない
- 新しい決定点を足すときは、既存の実績のある経路を無条件に保護する必要がある
- 追加の候補を「競合」ではなく「補完」として扱わないと、増やした分がそのまま損失になる
7.3 どのような性能改善が考えられるか
今回試していない、あるいは積み残した改善案を優先度順に記載します。
7.3.1 複数クエリの統合を、スコアではなく順位ベースのRRFにする(最優先)
「主クエリを無条件優先する」という今回の修正は、正直かなり場当たり的です。
regressionの原因は、分解や多様化という発想ではなく、複数の検索結果リストを1つに統合する方法だけでした。順位で統合していれば(= 複数クエリ版のRRF、いわゆるRAG-Fusionに近い手法)、MAX_SUPPLEMENTARY = 4のような仮の閾値も、主クエリを特別扱いするルールも持ち込まずに同じ結果へ届いた可能性があります。
LanceDBのRRFRerankerは、BM25とベクトルという2系統の結果をRRFで融合しています。同じ考え方をクエリ間の統合にも適用するだけなので、実装量も大きくないはずです。
具体的には、下記のように置き換える形になります。
【今回の実装(生スコア)】
各クエリの検索結果を、RRFスコアの数値そのもので比較して並べ替える
→ 狭いサブクエリが高いスコアを出しやすく、主クエリの正解候補を押し出す
【RRFによる統合】
各クエリの検索結果を「そのクエリの中で何位だったか」に変換し、
chunk_id ごとに Σ 1/(k + rank) を足し上げて並べ替える
→ 順位はクエリが違っても比較できる指標なので、スコア比較の前提が壊れない
→ 複数のクエリで上位に来た chunk が自然に優先される
こちらを実施しなかった理由は単純に時間とAPIコストで、「効果がなさそうだから」ではないです。
RAG-Fusionは、以前signateのRAGコンペに参加した際に自分で使っていた手法でした。
https://qiita.com/toypoo/items/0cd09952471aa1e7c924
そのときは「1つの質問をLLMで複数の質問に変換し、それぞれの検索結果をRRFで統合する」という使い方をしていました。
今回やりたかったこと(複合質問を対象ごとに分解し、それぞれの検索結果を統合する)は、クエリの作り方が違うだけで、統合の部分はまったく同じ問題です。にもかかわらず生スコアで統合を書いてしまい、regressionを踏みました。過去に自分で使った手法を思い出せていれば、そもそも踏まずに済んだかもしれないです。
ただし「RRFにしていれば解決した」と言い切れるわけではないです。
RRFは順位というクエリ間で比較可能な指標を使うのでスコア比較の前提が壊れるという原因は取り除けますが、サブクエリで1位の候補が主クエリで5位の候補を押し出す可能性自体は残ります。「主クエリの正解候補が必ず守られる」ことを保証するのは、今回採用した主クエリ保護の方です。
原理的に筋が良いのはRRF、確実なのは主クエリ保護、というトレードオフだと理解しています。こちらは未検証なので、実際に測ってみないと分からないです。
7.3.2 Cross-Encoderによるリランクを4番目のパターンとして追加する
今回は比較の変数を増やさないため意図的に見送りました。
ただし、失敗した4問のうち3問は「多様化で別記事を検索しているのに、その検索自体が正解チャンクを上位8件に見つけられていない」というもので、候補は取れているが順位付けが悪いという状況とは少し違います。
リランクが効くかどうかは、まず「正解チャンクがtop-20には入っているか」を測定してから判断すべきかと思います。入っていない場合は、リランクではなく埋め込みモデルやチャンク分割の側の問題になります。
7.3.3 記事多様化の発火条件を絞る
現状は全問一律の閾値で判定しており、easyでは毎回誤発火してコストだけ払っています。
質問の性質(複合質問かどうか、固有名詞が複数含まれるか)で発火条件を変えるだけでも、精度を落とさずコストを削れるはずです。
7.3.4 分散を測る
こちらが本当は最初にやるべきでした(次節に記載します)。
7.4 正直に書いておく限界
7.4.1 分散を測っていない
各条件1回ずつの実行です。エージェントは確率的に振る舞うので、2〜4問の差は誤差として扱ってきました。
(a) 10 → (f) 17という7問差の方向性は信頼できますが、最終的な「17 → 19」という2問差は、この自分で決めた閾値のぎりぎり内側にあります。最終スコアで「上回った」と言い切るのは、本来この記事の基準に照らせば慎重であるべきでした。
一方で、「素朴な統合がregressionを生み、統合方法を直したら回復した」という因果については、下記3つの独立した確認で繰り返し同じ方向の結果が出ており、単発のブレでは説明しにくいと考えています。
- 狙い撃ちした9問の再検証
- 既存の正解問題を壊していないかのスモークテスト
- 全体評価
つまり、「工夫Xで改善できた」という絶対的な結論より、「統合方法の設計を間違えると簡単に悪化する」という否定的な教訓の方が確信度が高い、というのが正直なところです。
そして、この限界を最初に認識していながら、その後何度も、単発の結果を根拠に設計変更を重ねてしまいました。途中で「マージ順のバグが原因」と因果を断定したものの、修正しても改善しなかったこともあります((c) 12 → (d) 10)。本来は分散を測ってから改善に着手すべきでした。
7.4.2 規模とドメインが限定的
自分のQiita記事20本 / 455チャンクという小規模コーパスで、話題もvLLM・ローカルLLM・RAG構築に偏っています。
数万文書規模、あるいは検索が本質的に難しいドメインでは結論が変わる可能性が高いです。
7.4.3 マルチホップの設計が限定的
「別記事の2チャンク」に限定しており、3ホップ以上や、同一記事内の遠いセクション間などは試していないです。
8. まとめ
今回の実験をまとめると下記のようになります。
| 観点 | 結果 |
|---|---|
| 検索精度の改善(1→2)の効果 | ◎(multihop 13/26 → 17/26、コストはほぼゼロ) |
| エージェント化(2→3)の効果 | △(multihop 17/26 → 19/26、ただしトークン27倍) |
| easyでのエージェント化の効果 | ×(全実行を通じて一度も上回れず、コストだけ増加) |
| 実装の落とし穴の多さ | 多い(初回は10/26まで落ち込み、7問ぶんはすべてバグ由来) |
要点は下記です。
- 自分のQiita記事20本(455チャンク)をコーパスに、ナイーブRAG / ハイブリッド検索 / エージェントRAGの3パターンを、同一インデックス・同一生成モデル・同一プロンプトで比較しました
- 初回はmultihop 10/26と大きく下回っていましたが、そこから17まで詰めた7問ぶんは新機能の追加ではなく、すべて実装バグと計測の交絡の除去によるものでした
- 引き分けで終わらせずクエリ分解と記事多様化を足したところ、異なるクエリのスコアを直接比較するという設計ミスで一度さらに悪化しました(→ 14/26相当)。統合方法を「実績のある候補を無条件で守る」方式に直して19/26に到達しています
- ただし最後の2問差は自分で決めた誤差の閾値とほぼ同じ幅なので、確定した優劣ではなく方向性として読むべきものだと思っています
実務的な結論を1つだけ挙げるなら、まずハイブリッド検索をちゃんと作る方が費用対効果がはるかに良いということかと思います。日本語なら、トークナイザの設定を確認するところからになります。エージェント化が報われるのは、単発top-5では原理的に届かない構成に限られるという印象でした。
そして、比較実験でネガティブな結果が出たときもポジティブな結果が出たときも、それが対象の性質なのか自分の実装なのかを切り分ける手段を持っておくことが重要だと感じました。今回は下記が道具として実際に機能しました。
- 損失の段階分解(生成を実行せず埋め込みだけで回るので、数十秒・ほぼ無料)
- regression発生時のrawログ突き合わせ
9. 感想
- エージェントRAGはトークン使用量が多いです。27倍という数字は、実際に業務で見たエージェント的な構成の印象と相違はなかったので、ここは想定通りでした
- 意外と性能を上げるのが難しいと感じました。ループを回せば性能が上がるだろうと素朴に思っていたのですが、実際は決定点が増えるぶん損失も増えます。同じ工数をかけるなら、どちらかというと検索方法を工夫する方がよいというのが今回の実感です
- 性能評価用のデータは割と簡易的に作ることができます。LLMにチャンクを渡して質問を作らせると、正解chunk_idが自動的に紐づくので手間がほとんどかかりません。ただしそのまま使うと簡単すぎて天井に張り付く(単発ハイブリッドでrecall 100%)ので、マルチホップのような難易度設計は別途必要かと思います
- リランクなどが重要そうなので入れておいてもよかったです。比較の変数を増やしたくなくて見送ったのですが、結果的に「複数クエリの結果をどう統合するか」というほぼ同じ問題に別の形でぶつかりました。最初から4番目のパターンとして持っていれば、もっと素直な比較ができた気がします
-
gpt-5.6-lunaはコスパがよいと感じました。46問 × 3パターンの評価が13.3分で終わり、エージェントが1問で6万トークン以上使う構成でも回し切れました。価格的には4ドル程度で回せてある程度賢いのでいいモデルだなと感じています
10. 最後に
最後まで読んでくださり、ありがとうございました。
余裕があれば、7.3.1に記載した「複数クエリの順位ベース統合(RAG-Fusion)」を実装して、今回の場当たり的な修正と比較してみたいと思います。