初めに
CRAG(Corrective RAG)を業務マニュアル向けのRAGに組み込んで運用しています。検索した根拠が十分かをLLMに判定させ、不足なら検索文を書き換えてもう一度探す、という例のループです。
しばらく動かしていて、判定がどうにも厳しいことに気づきました。資料に一般的な手順がちゃんと載っているのに「不足」と判定して、再検索を繰り返した末に「資料に根拠がありません」と答えてしまう。逆に、別のエラーコードの説明を「十分」と判定して、関係ない確認事項を回答に混ぜてしまう。
実際のログを139回分集計したところ、「不足」判定36回のうち、資料に本当に答えがなかったのは8回だけでした。残り28回は誤拒否です。
自分のプロンプトが悪いのは間違いないとして、ではみんなはこの評価器をどう書いているのか。OSSとオリジナル実装のコードを読み比べてみたら、設計思想からして違っていました。その整理です。
読んだ実装
| 実装 | 何を読んだか |
|---|---|
| CRAG(論文オリジナル) |
HuskyInSalt/CRAG の scripts/CRAG_Inference.py、internal_knowledge_preparation.py
|
| LangGraph CRAG |
langchain-ai/langgraph の examples/rag/langgraph_crag.ipynb
|
| LangGraph Self-RAG / Adaptive RAG | 同 langgraph_self_rag.ipynb、langgraph_adaptive_rag.ipynb
|
| Self-RAG(論文オリジナル) |
AkariAsai/self-rag の data_creation/critic/gpt4_reward/
|
| LlamaIndex Corrective RAG |
CorrectiveRAGPack / Corrective RAG Workflow |
| Ragas | Context Precision の定義 |
あわせて、OpenAI の Structured Outputs ガイドと、LLM-as-a-Judge の一般論も見ています。
1. CRAG(論文オリジナル)— 文書ごとにスコア、束ねるときはOR
まず本家です。CRAG の retrieval evaluator はLLMではありません。T5 をファインチューニングした回帰器で、クエリと文書のペアにスコアを返します。
判定の束ね方が process_flag() に書かれていて、ここが一番おもしろいところでした。
def process_flag(scores, n_docs, threshold1, threshold2):
flags = []
for score in scores:
if score >= threshold1:
flags.append('2') # correct
elif score >= threshold2:
flags.append('1') # ambiguous
else:
flags.append('0') # incorrect
tmp_flag = []
identification_flag = []
for i, f in enumerate(flags):
tmp_flag.append(f)
if i % n_docs == n_docs - 1:
if '2' in tmp_flag:
identification_flag.append(2)
elif '1' in tmp_flag:
identification_flag.append(1)
else:
identification_flag.append(0)
tmp_flag = []
return identification_flag
注目すべきは if '2' in tmp_flag の行です。10件の文書のうち1件でも correct があれば、そのクエリ全体を correct と扱う。完全なOR集約です。
そして correct / ambiguous / incorrect の3つとも、行き先は生成です。
- correct → 内部知識(検索結果)を精錬して使う
- incorrect → Web検索に切り替える
- ambiguous → 両方を組み合わせる
評価器が「答えない」を選ぶ経路がない。評価器の仕事は「どの知識源を使うか」を決めることであって、回答可能かどうかの判定ではないわけです。
閾値も --upper_threshold / --lower_threshold としてコマンドライン引数に外出しされています。判定の厳しさはプロンプトの文面ではなく、運用で回せるパラメータになっている。
知識精錬(decompose-then-recompose)も評価器の延長で、文書を strip に切って評価スコアで上位 n 件(top_n = 3 if decompose_mode == "selection" else 6)を選ぶ。抜粋も評価器が決めるという発想です。
2. LangGraph CRAG — 1文書1コール、binary_score だけ
LangChain 公式の実装はもっと割り切っています。
class GradeDocuments(BaseModel):
"""Binary score for relevance check on retrieved documents."""
binary_score: str = Field(
description="Documents are relevant to the question, 'yes' or 'no'"
)
structured_llm_grader = llm.with_structured_output(GradeDocuments)
出力はこれだけ。信頼度も理由も返させません。
システムプロンプトも短いです。
You are a grader assessing relevance of a retrieved document to a user question.
If the document contains keyword(s) or semantic meaning related to the question, grade it as relevant.
Give a binary score 'yes' or 'no' score to indicate whether the document is relevant to the question.
Self-RAG 版と Adaptive RAG 版には、さらに一文入っています。
It does not need to be a stringent test. The goal is to filter out erroneous retrievals.
「厳密な試験である必要はない。目的は誤った検索結果を弾くことだ」。プロンプトで明示的に甘くしている。
呼び出し側はこうです。
def grade_documents(state):
filtered_docs = []
web_search = "No"
for d in documents:
score = retrieval_grader.invoke(
{"question": question, "document": d.page_content}
)
if score.binary_score == "yes":
filtered_docs.append(d)
else:
web_search = "Yes"
continue
return {"documents": filtered_docs, "question": question, "web_search": web_search}
文書ごとに1回ずつLLMを呼び、yes のものだけ残す。no があったら Web検索フラグを立てるけれど、yes の文書はそのまま生成に回る。ここでも評価器は拒否しません。
Self-RAG 版ではさらに、判定器が役割ごとに3つに分かれています。
| 判定器 | 何を見るか | 出力 |
|---|---|---|
GradeDocuments |
検索文書が質問に関連するか | yes / no |
GradeHallucinations |
生成文が根拠に支持されているか | yes / no |
GradeAnswer |
生成文が質問に答えているか | yes / no |
「答えられたか」は生成したあとに別の判定器が見る。検索段階の評価器に背負わせていません。
再検索用のクエリ書き換えも独立したプロンプトで、判定と混ざっていません。
You a question re-writer that converts an input question to a better version that is optimized
for vectorstore retrieval. Look at the input and try to reason about the underlying semantic intent / meaning.
3. LlamaIndex Corrective RAG — 「厳しくするな」がテンプレートに書いてある
DEFAULT_RELEVANCY_PROMPT_TEMPLATE の書き出しはこうです。
As a grader, your task is to evaluate the relevance of a document retrieved in response to a user's question.
そして評価基準として、
the evaluation should not be overly stringent; the primary objective is to identify and filter out clearly irrelevant retrievals
「過度に厳密であるべきではない。主目的は明らかに無関係な検索結果を特定して除外することだ」。
LangGraph と同じ思想が、こちらではテンプレート本文に組み込まれています。ノード単位で yes/no を出し、全部 yes なら関連テキストをそのまま使い、1つでも no があればクエリを書き換えて Web検索を足し、relevant_text + "\n" + search_text として結合してから生成する。**不足時の経路も「答えない」ではなく「足す」**です。
4. Self-RAG(論文オリジナル)— 関連性の定義が具体的、Few-shotは両ラベル
Self-RAG の critic データ作成用プロンプトは、関連性の定義が一段踏み込んでいます。
Your job is to determine if the evidence is relevant to the initial instruction
and the preceding context, and provides useful information to complete the task
described in the instruction.
If the evidence meets this requirement, respond with [Relevant]; otherwise, generate [Irrelevant].
「関連しているか」だけでなく「タスクを完了するのに有用な情報を提供しているか」。ここが効いていて、単なるキーワード一致と、実際に役に立つかを区別できます。
そしてFew-shotが両ラベル揃っているのが特徴です。[Relevant] の例と [Irrelevant] の例が1つずつあり、それぞれに Explanation が付いている。
###
Instruction: age to run for us house of representatives
Evidence: The Constitution sets three qualifications for service in the U.S. Senate:
age (at least thirty years of age); U.S. citizenship (at least nine years); and
residency in the state a senator represents at the time of election.
Rating: [Irrelevant]
Explanation: The evidence only discusses the ages to run for the US Senate,
not for the House of Representatives.
「上院の話しか書いていないので下院の質問には無関係」。近いけれど対象が違うという、一番間違えやすい境界を例で示している。片方のラベルだけ例示すると、そちら側に判定が寄ります。
根拠の支持度(ISSUP)は二値ではなく3段階です。
| ラベル | 定義 |
|---|---|
[Fully supported] |
出力の全情報が根拠に支持されている |
[Partially supported] |
ある程度支持されているが、根拠で議論されていない主要な情報が出力に含まれる |
[No support / Contradictory] |
根拠を完全に無視している、無関係、または矛盾している |
さらにプロンプトに注意書きがあります。
Make sure to not use any external information/knowledge to judge whether the output is true or not.
Only check whether the output is supported by the evidence, and not whether the output follows the instructions or not.
「外部知識で真偽を判断するな」「指示に従っているかではなく、根拠に支持されているかだけを見ろ」。判定軸を1つに絞る指示が明示的に書かれています。
推論時は w_rel のような重みでビームサーチに効かせる設計で、ここでも厳しさがパラメータになっています。
5. Ragas — 順位まで含めて測る
Ragas の Context Precision は、チャンクごとに「回答に役立つか」の二値判定を出し、precision@k の平均を取ります。
Context Precision@k = (Σ precision@k × v_k) / (relevant items in top k)
v_k ∈ {0, 1} が二値の関連フラグ。ポイントは順位を含めて評価することで、無関係なチャンクが1位にいると大きく下がります。
評価指標なので判定器そのものとは役割が違いますが、「チャンク単位の二値」という粒度は他と揃っています。
6. Structured Outputs の使い方も揃っている
OpenAI の Structured Outputs ガイドにはこうあります。
Simpler prompting: No need for strongly worded prompts to achieve consistent formatting
そして「重要なキーには明確なタイトルと説明を付けろ」。実際、上で見た実装はどれも Field(description=...) に判定基準を書いていて、プロンプト本文は3〜4行しかありません。出力契約はスキーマ側、判定規則はプロンプト側という分担です。
Chain-of-thought の例では、explanation フィールドを最終回答より前に置いています。strict schema はプロパティ順に生成するので、結論を先に書かせると後付けの理由が並びます。
LLM-as-a-Judge 一般でも、
- 二値のほうが Likert スケールより一貫する(LLMは任意スケールに較正されていない)
- Few-shot は各ラベル2〜3例で精度が上がる
- 推論を先に、結論を後に
というあたりが繰り返し言われていて、実装群の作りと一致しています。
共通点の整理
6本を並べると、共通しているのは7点でした。
1. 粒度は文書(チャンク)単位
「このセット全体で回答できるか」ではなく「この1件は関連するか」。全体の可否を1回のLLM呼び出しで決めている実装は1つもありませんでした。
2. 束ねるときはOR、ANDではない
CRAG は1件でも correct なら correct。LangGraph は yes のものだけ残して先へ進む。関連文書が1件あれば前進する設計です。
3. 二値、スコアではない
CRAG だけは連続スコアですが、それも閾値で3段階に落としています。LLMに0.0〜1.0を出させている実装はありませんでした。
4. 「厳しくしない」と明文化されている
LangGraph と LlamaIndex は、わざわざプロンプトに書いています。これは装飾ではなく、判定を甘い側に倒すための明示的な設計判断です。
5. 評価器は拒否しない
「答えられない」の判定は、生成後の hallucination grader / answer grader(LangGraph)や ISSUP / ISUSE(Self-RAG)が担当します。検索評価器は知識源とアクションを選ぶだけ。
6. 判定とアクションが1対1で対応している
no → Web検索、no → クエリ書き換え、ambiguous → 内部+外部の併用。判定結果ごとに次の手が決まっていて、「不足だから何かする」という曖昧な分岐がありません。
7. 判定の厳しさがパラメータになっている
CRAG の上下限閾値、Self-RAG の w_rel。プロンプトの文面を書き換えずに調整できる形になっています。
吸収したい点
自分の実装と突き合わせて、取り込む価値があると判断したのは次の順です。
| 出典 | 吸収する点 | 効き方 |
|---|---|---|
| CRAG / LangGraph | 候補ごとの二値判定を先に出させ、全体の結論はコード側でOR集約する | 誤拒否の構造的な原因が消える |
| OpenAI / LLM-as-Judge | 推論フィールド(候補判定・観点判定・理由)をスキーマ上で結論より前に置く | 結論先行の後付け理由がなくなる |
| LangGraph / LlamaIndex | 「厳密な試験ではない、明らかな無関係を弾くだけ」をプロンプトに明記 | 基準が甘い側に寄る |
| Self-RAG | 関連性を「有用な情報を提供しているか」で定義する | キーワード一致との混同が減る |
| Self-RAG | Few-shot は両ラベル + Explanation。特に「近いが対象が違う」例 | 境界事例の判定が安定する |
| Self-RAG | 判定軸を1つに絞る明示(外部知識禁止、指示遵守は見ない) | 観点の混線が減る |
| CRAG / Self-RAG | 厳しさを閾値・重みとして外出しする | プロンプト改訂なしで調整できる |
| Ragas | 順位を含めた評価(precision@k) | 評価器そのものをオフラインで測れる |
| CRAG | 抜粋の選択も評価スコアで行う | 語彙一致ベースの窓選択より素直 |
逆に採らなかったもの。
- 1文書1コール(LangGraph方式): 候補が30件あるので、1問あたり30回のLLM呼び出しは現実的でない。1コールで全候補の二値判定をリストで返させる形に落としました。
- 評価器のファインチューニング(CRAG方式): 学習データがない。まずプロンプトとスキーマで詰める。
自分の実装をどう直したか
元の設計は、4つの観点(対象・求める結果・適用条件・手順)すべてが supported であることを要求するAND型でした。しかも「手順・条件・対象が不足なら false」という抽象的な基準しか書いていなかった。
これだと、
- 資料には一般手順しか載っていない(質問固有の値は載らない)→ 不足
- 「一括登録の機能はありません」という制限の記載がある → 「可否の根拠がない」と判定して不足
- 2つの要求が別々の文書に分かれている → 「両方を同時に満たす記載がない」と判定して不足
- 「〜でよいか」という確認質問 → 「そう書かれていない」と判定して不足
という4パターンで落ちます。実際、誤拒否28回はこの4種類にきれいに分類できました。
直した方向は上の表のとおりで、
- 候補ごとの
relevant: boolを先に出させる(判定基準はField(description)に) - 「厳密な一致試験ではなく、明らかな話題違いを除くための判定」とプロンプトに明記
- 不足にできる条件を3つに限定(話題が別/要求された操作・規則・確認方法がどの候補にもない/エラー文・コードが別)
- 制限の記載・複数要求の分散・確認質問は不足の理由にしない、と個別に否定
- スキーマの項目順を「候補判定 → 観点判定 → 理由 → 結論」に変更
- 出力例のJSONをプロンプトから削除(strict schema で形式は保証されるうえ、全観点 missing の例が不足側にアンカーしていた)
6番は自分でも気づいていなかった事故で、毎回「全部 missing、sufficient=false」というJSON例をプロンプトに載せていました。構造化出力を使っているのに出力例を残していたわけで、形式の保証としては無意味、判定のアンカーとしては有害という最悪の組み合わせでした。
まとめ
検索評価器を「回答できるかどうかの門番」として設計すると、厳しくなりすぎて答えられなくなります。OSS実装が揃って採っているのは、
評価器は文書ごとの関連性を甘めに二値判定するだけ。束ねるのはOR。「答えられない」は生成後に別の判定器が決める。
という役割分担でした。
判定を厳しくしたくなったら、それは評価器ではなく生成後の grounding 判定に足すべき要件かもしれません。評価器を厳しくすると、正しい根拠まで捨てて再検索に行ってしまいます。
あとは当たり前のことですが、構造化出力を使っているならプロンプトに出力例を書かない。契約はスキーマの description に書く。判定規則だけをプロンプトに残す。これだけでプロンプトはかなり短くなります。
参考
- HuskyInSalt/CRAG — Corrective Retrieval Augmented Generation の公式実装
- langchain-ai/langgraph — examples/rag — CRAG / Self-RAG / Adaptive RAG の notebook
- AkariAsai/self-rag — reflection token と critic データ作成プロンプト
- LlamaIndex Corrective RAG Workflow
- Ragas — Context Precision
- OpenAI — Structured Outputs
- F.A.Q on LLM judges(Evidently AI)
- LLM-as-a-Judge: A Practical Guide(Towards Data Science)