コンテンツにスキップ

第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]addoperator.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_TRACING_V2=true
LANGCHAIN_API_KEY=lsv2_pt_...
LANGCHAIN_PROJECT=sd-12

裏では 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.pymulti_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 が分かりやすい。

拡張アイデア

  1. method_classifier を構造化出力に
  2. with_structured_output(MethodChoice)Literal["A", "B", "C"] を Pydantic で強制
  3. LLM の「Class B」「答え: B」みたいな逸脱を排除

  4. 第4のクラスを追加

  5. "D: マルチモーダル質問 (画像/音声を含む)" → 画像理解ツール経由
  6. "E: コード生成タスク" → REPL ツール経由
  7. Literal を拡張するだけでスケール可能

  8. ルーター LLM とエージェント LLM を別モデルに

  9. 分類だけなら gpt-4o-mini で十分 (現状は両方 gpt-4o-2024-05-13)
  10. コスト削減 + レスポンス高速化

  11. Adaptive RAG の根拠を State に保存

  12. 「なぜ Class C と判定したか」の理由を MethodDecision(method, reason) で保存
  13. 後でなぜ multi-step に行ったかを追えるようにする

  14. 失敗時のフォールバック

  15. Class A (Non-Retrieval) で「わかりません」と返ったら、自動で Class B に格上げして再試行
  16. グラフに add_conditional_edges を追加

  17. LangSmith Eval セットを作る

  18. 50問のテストケース (A:B:C = 15:25:10) で分類精度を測定
  19. 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_modifierprompt

第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.pypydantic-settings を撤去し、第11回と同じ _ensure_key() ヘルパで取得:

  • OPENAI_API_KEYop://Personal/openAI_API/credential
  • TAVILY_API_KEYop://Personal/Tavily_API_key/credential
  • LANGCHAIN_API_KEYop://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 クラスごと撤去して定数化:

LLM_MODEL_NAME = "gpt-4o-mini"
TAVILY_MAX_RESULTS = 5

6. sufficiency_check を構造化出力に

第11回と同じ。with_structured_output(Verdict)is_sufficient: bool を Pydantic 化。

既知の不具合・注意点

  • method_classifierStrOutputParser のままで型保証が無い。LLM が prefix/suffix を付けると Literal["A","B","C"] のバリデーションで落ちる
  • tools.py:15 settings = Settings() がモジュールロード時に実行 → .env 無いと import エラー
  • tools.py:44 document["raw_content"] 直アクセス → Tavily が None を返すと TypeError (第11回でも同じ問題)
  • langchain_core.pydantic_v1 の互換シムは LangChain v1.0 で廃止予定。早めに pydantic v2 直接に移行が無難
  • 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