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 > 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」のような上限が現実的
拡張アイデア¶
- 3値判定への拡張: CORRECT / INCORRECT / AMBIGUOUS の3クラスにし、AMBIGUOUS のときだけ別ソース(Wikipedia / arXiv)も併用
- 文書ごとのフィルタリング: 平均スコアではなく、
score > 0.5の文書だけをwriteに渡す(ノイズ除去) - ループ上限の追加: state に
loop_countを持たせ、3回ループしたら強制的に write へ(コスト保護) - 再ランキングの結果を使う: Cross-Encoder のスコア順に Tavily 結果を並び替えてから write に渡す(最も関連する文書が prompt の先頭に来る)
- 複数クエリの並列実行: query_refine で1つではなく3〜5個のクエリを生成し並列検索、結果をまとめて評価
- 評価モデルを LLM-as-judge にする: Cross Encoder の代わりに LLM に「この検索結果は質問に答えているか」を判定させる(高精度だがコスト増)
- ストリーミング対応: 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コマンド |
修正したバグ¶
Artifact.__str__のself.task.action参照 —Artifactにはtaskフィールドが無くactionフィールドしかない。self.actionに修正mainのinitial_stateにrefined_queryが無い —_run_query_refineでstate["refined_query"]を読むので、初回ノード実行でKeyError。refined_query: ""とloop_count: 0を追加- argparse
--taskと README--queryの食い違い — README の起動例と合わせて--queryに統一
動作確認¶
→ 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