コンテンツにスキップ

STUDY NOTES

第10回: CRAG(Corrective Retrieval-Augmented Generation)

第09回の Research Agent が「タスクを DAG に分解 して順に実行」する前向きのプランニング型エージェントだったのに対し、第10回 CRAG は「検索した結果を自己評価して、ダメならクエリを書き直して再検索」するフィードバックループ型エージェント。RAG の検索品質に保険をかける発想。

元論文: Corrective Retrieval Augmented Generation (Yan et al., 2024)

全体像

flowchart TD
    START([START]) --> QR[query_refine<br/>LLM がクエリを生成/改善]
    QR --> S[search<br/>Tavily で Web 検索]
    S --> E[evaluate<br/>Cross Encoder で<br/>関連度スコアを算出]
    E -->|score &gt; 0.6<br/>CORRECT| W[write<br/>LLM がレポート執筆]
    E -->|score ≤ 0.6<br/>INCORRECT| QR
    W --> END([END])

    style QR fill:#e3f2fd
    style S fill:#fff3e0
    style E fill:#f3e5f5
    style W fill:#e8f5e9

ポイントは evaluate → query_refineフィードバック辺。これにより、最初の検索クエリがイマイチでも、エージェント自身が「不十分」と判断して言い回しを変えて再挑戦できる。第09回の DAG 型と違って、ループ回数は事前に決まらない(recursion_limit が安全網)。

1ターンの動き(CORRECT で抜けるケース)
sequenceDiagram
    participant U as ユーザー
    participant G as ResearchGraph
    participant L as LLM<br/>(gpt-4o-mini)
    participant T as Tavily API
    participant C as Cross Encoder<br/>(japanese-reranker)

    U->>G: task = "生成AIスタートアップの最新動向"
    G->>L: query_refine(task, refined_query=None, score=None)
    L-->>G: "What are the recent trends in..."
    G->>T: search(refined_query)
    T-->>G: 5件のドキュメント
    G->>C: rank(query, [title+content × 5])
    C-->>G: scores → avg=0.72
    Note over G: 0.72 > 0.6 → CORRECT
    G->>L: write(task, documents)
    L-->>G: 最終レポート
    G-->>U: print(final output)
INCORRECT が出た場合のループ
sequenceDiagram
    participant G as ResearchGraph
    participant L as LLM
    participant T as Tavily
    participant C as Cross Encoder

    G->>L: query_refine(task, prev_query, prev_score=0.42)
    Note over L: prompt が「0.6 未満<br/>=信頼性が低い→改善せよ」<br/>と指示
    L-->>G: "Can you provide reliable<br/>information on..."
    G->>T: search(改善されたクエリ)
    T-->>G: 別の検索結果
    G->>C: rank
    C-->>G: avg=0.68
    Note over G: 0.68 > 0.6 → CORRECT
    Note over G: write に進む

使用ライブラリ・原理

1. LangGraph の add_conditional_edges でループを表現

第09回でも使った API だが、CRAG では本来の用途(分岐&ループ)が活きる。

self._graph.add_conditional_edges(
    "evaluate",
    self._router,         # state を受け取り、次のノード名を返す関数
    {"query_refine": "query_refine", "write": "write"},
)
  • _router(state)"query_refine" を返せば evaluate→query_refine の辺をたどり、ループ継続
  • "write" を返せばループ脱出
  • グラフは循環していい(recursion_limit だけが安全網)
2. Cross Encoder による関連度スコアリング

埋め込みベース検索(Bi-Encoder)と対比すると分かりやすい。

種類 仕組み 速度 精度
Bi-Encoder(埋め込み) クエリと文書を別々にベクトル化→cos類似度 速い(事前計算可)
Cross-Encoder(再ランキング) クエリと文書をペアでTransformerに入力→直接スコア出力 遅い(毎回推論)

このサンプルは hotchpotch/japanese-reranker-cross-encoder-xsmall-v1 という日本語特化の Cross-Encoder(XSmall モデル)を使う。Tavily が返した 5 件の title + content を「クエリと一緒にモデルに食わせて」スコアを得る:

ranks = load_cross_encoder().rank(query, documents)
# [{'corpus_id': 0, 'score': 0.85}, {'corpus_id': 1, 'score': 0.42}, ...]
average_score = sum(r['score'] for r in ranks) / len(ranks)

平均スコア が 0.6 を超えたら「全体的に信頼できる」と判断する素朴な閾値判定。本来の CRAG 論文では文書ごとに CORRECT / INCORRECT / AMBIGUOUS の3値判定し、AMBIGUOUS は Web 検索で補強する、というもう少し凝った設計。

3. クエリリファインのプロンプト戦略

prompts/query_refine_user.prompt がポイント:

If refined_query is not empty and the previous score was below 0.6, it means the previous search returned unreliable information. Improve the query accordingly.

つまり LLM に「前回のクエリ+スコア」を渡し、スコアが低かったら言い回しを変えろと指示する。few-shot 例も入っていて:

Task: "Learn about the latest trends in the IT industry"
Refined query: What are the recent trends in the IT industry?
Previous score: 0.4
Search query: Can you provide reliable information on the latest trends in the IT industry?

「reliable information on」「current verified trends in」のような信頼性を強調する語彙に書き換えることで、Tavily が拾うドキュメントの傾向を変える狙い。

4. AgentState の最小構成
class AgentState(TypedDict):
    task: str
    refined_query: str
    artifacts: Annotated[Sequence[Artifact], add]

第09回が tasks / next_task / next_node / completed_task_ids と5フィールド持っていたのに対し、CRAG はループ1本なので state が薄い。refined_query は毎回上書きされ、過去の評価結果は artifacts に追記される(Annotated[..., add] で自動マージ)。

ファイル別の役割

ファイル 役割
crag_agent.py エージェント本体。CrossEncoder ロード、LangGraph 定義、CLI エントリ
prompts/query_refine_user.prompt クエリを生成/改善する LLM へのプロンプト
prompts/write_system.prompt レポート執筆 LLM のシステムプロンプト(出典明記・日本語強制)
prompts/write_user.prompt 執筆 LLM へのタスク+ドキュメント受け渡しテンプレ
requirements.txt langchain==0.1.20, langgraph==0.0.49, sentence-transformers 等(古い)
.env.sample OPENAI_API_KEY, TAVILY_API_KEY のテンプレ

行レベルの工夫

@lru_cache で重い初期化を一度だけ
@lru_cache
def load_cross_encoder(model_name=..., default_activation_function=None):
    _cross_encoder = CrossEncoder(model_name, ...)
    _cross_encoder.max_length = 512
    return _cross_encoder

Cross Encoder のモデルは初回ロードに数秒~数十秒かかる(HuggingFace から DL → メモリ展開)。@lru_cache は引数が同じならキャッシュを返す純粋な仕組みだが、引数なしで呼ぶことで実質シングルトンにしている。Tavily クライアントも同様。

max_length = 512 の意味

Cross Encoder の入力はクエリ+ドキュメント結合後のトークン数で 512 上限。日本語だと体感「論文 abstract 1〜2 段落」程度。title + content を渡すが、content(Tavily スニペット)が長すぎると後ろが切り捨てられる。

retrieve_last_artifact で履歴を遡る
def retrieve_last_artifact(artifacts, action):
    reversed_artifacts = reversed(artifacts)
    selected = (a for a in reversed_artifacts if a.action == action)
    return next(selected, None)

artifacts には search/evaluate/write の成果物が時系列順に追記される。直近の evaluate直近の search を取り出すために逆順走査+ジェネレータ+next(..., None) のイディオム。「あれば返す、なければ None」がワンライナーで書ける Python の作法。

評価結果を Pydantic でラップする利点
class EvaluationContent(BaseModel):
    score: float
    judge: str

    def __str__(self) -> str:
        return self.judge

Artifact.content には str | SearchContent | EvaluationContent のいずれかが入る Union 型。Pydantic でラップしておくと、後段で .score / .judge でアクセスできて型安全。

学んだこと(要点)

  • CRAG は RAG の「自己反省」パターン — 検索結果を別のモデル(Cross Encoder)で評価し、品質が低ければクエリを書き直す。1stage の RAG では拾えなかった文書を 2nd / 3rd round で拾える可能性
  • Cross-Encoder と Bi-Encoder の使い分け — 検索の1次(高速・recall重視)に Bi-Encoder、再ランキング(精度重視)に Cross-Encoder という二段構えが業界標準。CRAG では Tavily が1次、Cross-Encoder が2次
  • LangGraph の真価はループ — DAG だけなら Runnable のパイプで書けるが、「条件付き戻り辺」を持つフローはグラフ型でないと書きにくい
  • 「言い換えで検索結果が変わる」現象を逆手に取る — LLM に「reliable」「verified」「current」など強めの語を入れさせると、ニュース系・公式系の文書が上位に来やすい。プロンプトエンジニアリングが検索エンジン側にも効く
  • 平均スコア閾値の素朴さ — 0.6 一発判定なので、極端に低い文書1件が混ざっても平均が下がりすぎないことがある。本来は中央値や下位分位点を見るほうが堅い
  • 無限ループのリスクrecursion_limit=1000 で防いでいるが、低スコアが永続するクエリだとループが回り続けて API コストが嵩む。「3回試して improve しなければ諦めて write」のような上限が現実的

拡張アイデア

  1. 3値判定への拡張: CORRECT / INCORRECT / AMBIGUOUS の3クラスにし、AMBIGUOUS のときだけ別ソース(Wikipedia / arXiv)も併用
  2. 文書ごとのフィルタリング: 平均スコアではなく、score > 0.5 の文書だけを write に渡す(ノイズ除去)
  3. ループ上限の追加: state に loop_count を持たせ、3回ループしたら強制的に write へ(コスト保護)
  4. 再ランキングの結果を使う: Cross-Encoder のスコア順に Tavily 結果を並び替えてから write に渡す(最も関連する文書が prompt の先頭に来る)
  5. 複数クエリの並列実行: query_refine で1つではなく3〜5個のクエリを生成し並列検索、結果をまとめて評価
  6. 評価モデルを LLM-as-judge にする: Cross Encoder の代わりに LLM に「この検索結果は質問に答えているか」を判定させる(高精度だがコスト増)
  7. ストリーミング対応: write 段階を astream にしてレポートを段階的に表示

実際にやった移植差分

crag_agent.py / README.md を 2026 年現在の環境に合わせて移植済み。差分の主なもの:

変更点 Before(連載原典) After(移植後)
Pydantic langchain_core.pydantic_v1 素の pydantic(v2)
LangChain langchain==0.1.20 langchain>=0.3,<1.0
LangGraph langgraph==0.0.49 langgraph>=0.2,<1.0
LLM モデル gpt-4o-2024-05-13 gpt-4o-mini(コスト 1/10、速度数倍)
エントリポイント set_entry_point("query_refine") add_edge(START, "query_refine")
型ヒント Sequence[Artifact] list[Artifact]
シークレット .env + python-dotenv 1Password CLI(_op_read("op://...")
sentence-transformers default_activation_function=None 引数あり 引数削除(3.x で API 整理)
ループ上限 無し(recursion_limit=1000 任せ) MAX_LOOPS=3 で query_refine を打ち切り
context overflow 対策 無し MAX_CONTENT_CHARS=3000 で本文を切り詰め
依存管理 pip install -r requirements.txt uv run --no-project --with ... 1コマンド
修正したバグ
  1. Artifact.__str__self.task.action 参照Artifact には task フィールドが無く action フィールドしかない。self.action に修正
  2. maininitial_staterefined_query が無い_run_query_refinestate["refined_query"] を読むので、初回ノード実行で KeyErrorrefined_query: ""loop_count: 0 を追加
  3. argparse --task と README --query の食い違い — README の起動例と合わせて --query に統一
動作確認
cd 10
uv run --no-project --with ... python crag_agent.py \
  --query "生成AIスタートアップの最新動向について調査してください"

→ 1回の query_refine で score=0.730 / judge=CORRECT、出典 4件付きの日本語レポート生成を確認済み。

記事参照

  • Software Design 連載「実践LLMアプリケーション開発」第10回
  • 元論文: Corrective Retrieval Augmented Generation (Yan et al., 2024)
  • 第08回(LangGraph の応用)・第09回(Research Agent)のメモも併読推奨。LangGraph で表現できるフロー形状の幅が見えてくる

作成: 2026-05-19 / 最終更新: 2026-06-10