第12回: ARAG(Adaptive RAG)— 質問の複雑度に応じて検索戦略を切り替える¶
Software Design 2024年12月号 連載第12回。ユーザーからの依頼を LLM が3段階に分類 (A/B/C) し、それぞれに最適な処理経路へ振り分ける Adaptive RAG エージェント。第10回 (CRAG = 自己訂正型) と第11回 (ReAct = 動的多段) に続く「自律エージェントの設計バリエーション」第3弾。
全体像¶
flowchart TD
Start([ユーザー: --task '...']) --> Classifier[method_classifier<br>LLM が A/B/C を判定]
Classifier -->|A: 簡単な質問| NonRet[non_retrieval_qa<br>LLM の知識のみで回答]
Classifier -->|B: 中程度| Single[single_step_approach<br>ReAct: search → report_writer]
Classifier -->|C: 複雑| Multi[multi_step_approach<br>ReAct: search ↔ sufficiency_check loop]
NonRet --> End([END])
Single --> End
Multi --> End
subgraph "Single Step (内部は ReAct)"
Single -.tool_call.-> S_search[search]
Single -.tool_call.-> S_writer[report_writer]
end
subgraph "Multi Step (内部は ReAct + 自己評価ループ)"
Multi -.tool_call.-> M_search[search]
Multi -.tool_call.-> M_check[sufficiency_check]
Multi -.tool_call.-> M_writer[report_writer]
end
着眼点: トップレベルは「静的グラフ」(分類して固定経路に振り分け)、各経路の内部は「動的ループ」(ReAct)。第10回 CRAG が「静的グラフのみ」、第11回 ReAct が「動的ループのみ」だったのに対し、12回は両者のハイブリッドになっている。
時系列で見るとこう (例: C 経路 = Multi-step に振り分けられた場合):
sequenceDiagram
autonumber
participant U as ユーザー
participant G as AdaptiveRagAgent (graph)
participant Cls as method_classifier (LLM)
participant M as multi_step_approach (ReAct sub-agent)
participant T as Tools (search/check/writer)
U->>G: stream(task="生成AI動向...")
G->>Cls: "A/B/C のどれ?"
Cls-->>G: "C"
G->>M: invoke({messages: [(user, task)]})
loop ReAct ループ
M->>T: search(query)
T-->>M: <source>...</source>
M->>T: report_writer(task, sources)
T-->>M: レポートv1
M->>T: sufficiency_check(task, レポートv1)
T-->>M: 判定: True/False
Note over M: False なら再検索ループ
end
M-->>G: 最終レポート
G-->>U: artifact
使用ライブラリ・原理¶
1. Adaptive RAG の発想¶
「すべての質問に同じ検索戦略を適用する」のは非効率。
- 「東京タワーの高さは?」 → LLM の知識で即答可能。検索は無駄
- 「今日の生成AIスタートアップ動向は?」 → 1 回検索すれば足りる
- 「○○技術と××技術の比較分析」 → 複数情報源を統合・検証する必要
論文系では Asai et al. 2023 "Self-RAG" や Jeong et al. 2024 "Adaptive-RAG" がベース。LLM 自身が質問を分類し、最適なパイプラインを選ぶのが核。
2. LangGraph の add_conditional_edges¶
graph.add_conditional_edges(
"method_classifier",
lambda state: state.method, # state を見て分岐先を返す関数
{"A": "non_retrieval_qa", "B": "single_step_approach", "C": "multi_step_approach"},
)
- 第1引数: 元ノード
- 第2引数: state を受け取り、分岐キーを返す関数
- 第3引数: 分岐キー → 次ノード名の辞書
第11回 (ReAct) の暗黙的ループとは対照的に、Adaptive RAG は分岐を明示的にコードで書いている。これは設計判断のトレードオフ: - ReAct (動的): プロンプトに「不十分なら再検索」と書いて LLM に委ねる - ARAG (静的分岐): 「A/B/C」を LLM に出させた後はコードで分岐 → 予測可能
3. Pydantic v1 の State¶
from langchain_core.pydantic_v1 import BaseModel, Field
class AgentState(BaseModel):
task: str = Field(..., description="ユーザーが入力したタスク")
method: Literal["A", "B", "C"] = Field(default="A", ...)
artifacts: Annotated[list[Artifact], add] = Field(default_factory=list, ...)
- LangGraph 0.1.4 当時は
langchain_core.pydantic_v1互換シムが提供されていた - Pydantic BaseModel を State として使うと、TypedDict より型バリデーションが強い
Annotated[list[Artifact], add]のaddはoperator.addを Reducer として指定 → ノードから返された artifact が既存リストに append される
4. add Reducer の仕組み¶
State の各フィールドは Reducer で更新方法を決める。デフォルトは 上書きだが、Annotated[..., add] を付けると 結合 (__add__) になる:
# ノード1が返す: {"artifacts": [Artifact(action="search", content="...")]}
# 既存: state.artifacts = []
# Reducer = add → state.artifacts = [] + [Artifact(...)] = [Artifact(...)]
# ノード2が返す: {"artifacts": [Artifact(action="report", content="...")]}
# Reducer = add → state.artifacts = [...] + [Artifact(...)] = [...×2]
list 同士の + で append される。messages 用の add_messages ヘルパーと同じ思想。
5. create_react_agent の再利用¶
第11回の create_react_agent をここでも使い、ツール構成だけ変えて 2 種類のサブエージェントを作っている:
| サブエージェント | ツール | プロンプト |
|---|---|---|
single_step_approach |
search, report_writer |
single_step_answering_system (3ステップで完結) |
multi_step_approach |
search, sufficiency_check, report_writer |
multi_step_answering_system (自己評価ループ) |
sufficiency_check ツールの ある/なし だけで「1-shot」と「ループ」を切り替えている設計が美しい。sufficiency_check が無ければ LLM は再検索を呼べない (ツールに無い) ので、自然と 1 ターンで終わる。
6. LangSmith トレース連携¶
.env に以下を設定するだけで LangChain 系の処理が全自動でトレースされる:
裏では LangChain SDK が 環境変数を検出して各 Runnable.invoke / .stream の入出力を LangSmith に送信。コード変更ゼロで観測性が手に入るのがウリ。ReAct エージェントは内部状態が複雑なので、トレースが無いとデバッグ困難。
ファイル別の役割¶
| ファイル | 役割 |
|---|---|
arag_agent.py |
トップレベルグラフ。method_classifier + 3 分岐ノード。エントリポイント |
settings.py |
pydantic-settings で .env から設定読み込み |
single_step_approach.py |
search + report_writer で 1-shot リサーチを行う ReAct サブエージェント |
multi_step_approach.py |
search + sufficiency_check + report_writer で自己評価ループ付き ReAct |
tools.py |
3 つのツール (search, sufficiency_check, report_writer) を @tool で定義 |
utility.py |
load_prompt (ファイルからプロンプト読み込み)、run_streaming_agent / run_invoke_agent ヘルパ |
prompts/method_classifier_system.prompt |
A/B/C 分類の system プロンプト |
prompts/non_retrieval_qa_system.prompt |
Class A 用: LLM の知識のみで回答 |
prompts/single_step_answering_system.prompt |
Class B 用: 3 ステップで回答 |
prompts/multi_step_answering_system.prompt |
Class C 用: 自己評価ループ |
prompts/summarize_search_system.prompt |
Tavily 検索結果を要約するプロンプト |
prompts/sufficiency_classifier_system.prompt |
中間回答が十分か判定するプロンプト |
prompts/report_writer_system.prompt |
レポート生成用プロンプト |
学んだこと(要点)¶
1. 「LLM がルーター」になるパターン¶
method_classifier の戻り値 "A"/"B"/"C" がそのままグラフの分岐キーになる。LLM をスイッチとして使う設計は、本来コードで書く if 文を 「自然言語で表現された条件」に置き換えること:
# 普通のコード分岐
if "比較" in query or "違い" in query:
return "multi"
elif need_search(query):
return "single"
else:
return "non_retrieval"
# Adaptive RAG (LLM ルーター)
method = classifier_llm.invoke({"query": query}) # "A"/"B"/"C"
return {"A": "non_retrieval", "B": "single", "C": "multi"}[method]
利点: 自然言語の曖昧さを LLM の解釈力で吸収できる。
欠点: LLM が "Class B" のように prefix を付けてしまうと辞書ルックアップが失敗する → 構造化出力 (with_structured_output) で型を強制すべき (現コードは未対応)。
2. State に「実行履歴」を載せる Pydantic 設計¶
class Artifact(BaseModel):
action: str # "non_retrieval_qa" / "single_step_approach" / "multi_step_approach"
content: str
各ノードが処理結果を Artifact(action=..., content=...) で返し、Annotated[list, add] で蓄積。後から「どの経路を通って、どの結果が得られたか」を追える。監査ログ的な State 設計。
3. サブエージェントの再利用¶
single_step_approach.py と multi_step_approach.py は どちらも main 関数を持つ独立スクリプト として実行可能。これは「Adaptive RAG の前段なしで、特定経路だけテストしたい」というデバッグ用途を見越した設計。LangGraph のサブグラフは独立に動かせる。
4. method_classifier の戻り値はテキスト¶
chain = prompt | self.llm | StrOutputParser()
method = chain.invoke({"query": state.task}) # "A" or "A\n" or "Class A" など
return {"method": method}
ここが脆い。プロンプトに「クラスラベル(A, B, C)のみを1行で出力」と書いているが、LLM が指示を破ると state.method: Literal["A", "B", "C"] のバリデーションで落ちる。本来は with_structured_output(MethodChoice) で型を強制すべき。
5. LangGraph の stream(stream_mode="values") で State 全体を観測¶
for s in self.graph.stream(initial_state, stream_mode="values"):
print(s) # 各ノード完了後の State 全体が出る
if s["artifacts"]:
latest_artifact = s["artifacts"][-1]
final_output = latest_artifact.content
stream_mode="values" は 各ノード完了時に State 全体を yield する。stream_mode="updates" (差分のみ) や stream_mode="messages" (トークンストリーミング) との使い分けは用途次第。デモ向けには values が分かりやすい。
拡張アイデア¶
method_classifierを構造化出力にwith_structured_output(MethodChoice)でLiteral["A", "B", "C"]を Pydantic で強制-
LLM の「Class B」「答え: B」みたいな逸脱を排除
-
第4のクラスを追加
- "D: マルチモーダル質問 (画像/音声を含む)" → 画像理解ツール経由
- "E: コード生成タスク" → REPL ツール経由
-
Literalを拡張するだけでスケール可能 -
ルーター LLM とエージェント LLM を別モデルに
- 分類だけなら
gpt-4o-miniで十分 (現状は両方gpt-4o-2024-05-13) -
コスト削減 + レスポンス高速化
-
Adaptive RAG の根拠を State に保存
- 「なぜ Class C と判定したか」の理由を
MethodDecision(method, reason)で保存 -
後でなぜ multi-step に行ったかを追えるようにする
-
失敗時のフォールバック
- Class A (Non-Retrieval) で「わかりません」と返ったら、自動で Class B に格上げして再試行
-
グラフに
add_conditional_edgesを追加 -
LangSmith Eval セットを作る
- 50問のテストケース (A:B:C = 15:25:10) で分類精度を測定
- LangSmith Dataset + Evaluator API
現代版に移植するなら¶
連載原典は 2024年7月時点の API で、ライブラリも書き方も古い箇所がある。
1. ライブラリの更新¶
| 旧 | 新 |
|---|---|
langchain==0.2.6 |
langchain>=0.3,<1.0 |
langgraph==0.1.4 |
langgraph>=0.2,<1.0 |
langchain_core.pydantic_v1 |
素の pydantic (v2) |
gpt-4o-2024-05-13 |
gpt-4o-mini |
gpt-4o-mini-2024-07-18 |
gpt-4o-mini |
2. messages_modifier → prompt¶
第11回と同じ。create_react_agent の 2 箇所 (single_step_approach.py:10, multi_step_approach.py:10) を prompt=SystemMessage(content=...) に置き換える。
3. API キー取得を op 経由に¶
このリポジトリの方針 (CLAUDE.md 項目7)。settings.py の pydantic-settings を撤去し、第11回と同じ _ensure_key() ヘルパで取得:
OPENAI_API_KEY→op://Personal/openAI_API/credentialTAVILY_API_KEY→op://Personal/Tavily_API_key/credentialLANGCHAIN_API_KEY→op://Personal/LangSmith/credential(任意、トレース時のみ)
4. method_classifier を構造化出力に¶
from pydantic import BaseModel, Field
from typing import Literal
class MethodChoice(BaseModel):
method: Literal["A", "B", "C"]
reason: str = Field(description="その分類を選んだ理由")
llm = ChatOpenAI(model="gpt-4o-mini").with_structured_output(MethodChoice)
result = (prompt | llm).invoke({"query": state.task})
return {"method": result.method}
5. tools.py の Pydantic v1 / settings インスタンス化を修正¶
tools.py 冒頭で settings = Settings() がモジュールロード時に実行される → .env が無いと import 時にコケる。op 経由に切り替えるなら、Settings クラスごと撤去して定数化:
6. sufficiency_check を構造化出力に¶
第11回と同じ。with_structured_output(Verdict) で is_sufficient: bool を Pydantic 化。
既知の不具合・注意点¶
method_classifierがStrOutputParserのままで型保証が無い。LLM が prefix/suffix を付けるとLiteral["A","B","C"]のバリデーションで落ちるtools.py:15settings = Settings()がモジュールロード時に実行 →.env無いと import エラーtools.py:44document["raw_content"]直アクセス → Tavily が None を返すと TypeError (第11回でも同じ問題)langchain_core.pydantic_v1の互換シムは LangChain v1.0 で廃止予定。早めにpydanticv2 直接に移行が無難retryライブラリが requirements にあるがコード上は未使用 (第11回と同じ)- LangSmith トレースは設定すれば自動で有効化されるが、
LANGCHAIN_API_KEY未設定 +LANGCHAIN_TRACING_V2=trueだと無言で失敗する (LangSmith 側が黙って捨てる)
記事参照¶
- Software Design 2024年12月号 連載第12回「ARAG(Adaptive RAG)」
- 関連: 10回 CRAG (自己訂正型)、11回 ReAct (動的多段) — 「自律エージェントの設計バリエーション」3兄弟
- 元論文系:
- Jeong et al. 2024, "Adaptive-RAG: Learning to Adapt Retrieval-Augmented Large Language Models through Question Complexity"
- Asai et al. 2023, "Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection"
作成: 2026-05-22 / 最終更新: 2026-06-10