サポート用の RAG に「返金の締め切りは何日ですか」と尋ねたら、Claude は淀みなく「14日以内です」と答えました。実際の規定は 30 日でした。回答は流暢で、根拠まで添えて自信たっぷりだったぶん、むしろ肝が冷えました。
モデルが壊れたわけではありません。壊れていたのは検索側で、答えを含む文書を取得できていなかった。それでも生成は止まらず、Claude は手元にある無関係な断片から、もっともらしい嘘を組み立てていたのです。
この記事は、Claude API で運用していた RAG のサイレントなリコール低下(検索の取りこぼし)を、接地率という数字で炙り出し、段階的に立て直した記録です。RAG の作り方ではなく、作った後に検索が静かに劣化し始めたときの気づき方と直し方に絞ります。既存の構築手順はClaude API のプロンプトキャッシング設計 などに譲り、ここでは計測に集中します。
生成は止まらない、だから劣化が見えない
RAG の厄介さは、検索が失敗しても生成が続くことにあります。関連文書がゼロ件でも、しきい値すれすれの無関係な断片が数件あっても、Claude はそれらしい文章を返します。API はエラーを出しません。ログには 200 が並びます。
私が踏んだ劣化は、ある日突然ではなく、じわじわ進むものでした。ナレッジベースに文書を足し、チャンク分割を少し変え、埋め込みモデルを新しい版に差し替える。どれも良かれと思っての変更です。そのたびに、答えを含む文書が上位 K 件から静かに押し出されていきました。
問題は、この劣化が回答の見た目には一切現れないことです。文章はいつも通り流暢で、むしろ自信は増していく。利用者からの「なんか最近ずれてる」という曖昧な声だけが、遅れて届きます。だから、検索が答えを掴めているかどうかを、生成とは別に計測する必要がありました。
二つの数字に分けて疑う
「RAG がずれる」を一つの塊として扱うと、原因の切り分けができません。私は問題を二段階に分け、それぞれを別の数字にしました。
指標 測るもの 低いときの意味
検索ヒット率 答えを含む文書が取得上位 K 件に入った割合 検索(埋め込み・チャンク・しきい値)が壊れている
接地率 回答が取得文書に実際に支持されている割合 取得はできても生成が文書を無視している
検索ヒット率が低ければ、原因は検索パイプラインにあります。検索ヒット率は高いのに接地率が低ければ、原因は生成側のプロンプトや文脈の詰め方にあります。この切り分けができるだけで、闇雲な試行錯誤がなくなりました。
最初に測ったとき、検索ヒット率は 68%、接地率は 61% でした。三回に一回は、答えの載った文書すら Claude の手元に届いていない。数字を見て、ようやく問題の在り処が腹落ちしました。
Claude を採点者にして接地率を測る
接地率は人手で採点すると続きません。そこで、生成に使うのとは別の呼び出しで Claude を採点者に立て、回答が取得文書に支持されているかを判定させました。採点用のプロンプトは短く、判定を構造化して受け取ります。
// eval/groundedness.ts
import Anthropic from '@anthropic-ai/sdk' ;
const client = new Anthropic ();
type Verdict = { grounded : boolean ; supported_claims : number ; unsupported_claims : number };
export async function scoreGroundedness (
answer : string ,
retrievedDocs : string [],
) : Promise < Verdict > {
const context = retrievedDocs. map (( d , i ) => `[doc ${ i + 1 }] \n ${ d }` ). join ( ' \n\n ' );
const res = await client.messages. create ({
model: 'claude-sonnet-5' ,
max_tokens: 300 ,
// 採点は生成とは別プロセス。文書の外の知識で補わせない
system:
'与えられた文書だけを根拠に、回答の各主張が文書で支持されているか判定してください。' +
'文書に無い主張は unsupported と数えます。JSON のみを返してください。' ,
messages: [
{
role: 'user' ,
content:
`# 取得文書 \n ${ context } \n\n # 回答 \n ${ answer } \n\n ` +
'{"grounded": boolean, "supported_claims": number, "unsupported_claims": number} の形式で返答。' ,
},
],
});
const text = res.content[ 0 ].type === 'text' ? res.content[ 0 ].text : '{}' ;
return JSON . parse (text. slice (text. indexOf ( '{' ), text. lastIndexOf ( '}' ) + 1 ));
}
採点者に「文書の外の知識で補わない」と明示するのが要です。ここを曖昧にすると、Claude が自分の一般知識で回答を擁護してしまい、接地率が甘く出ます。判定を JSON で受け取り、支持された主張と支持されない主張の数まで取ると、後で「どの回答が」「どれだけ」ずれているかを追えます。
検索ヒット率のほうは、評価用に「質問と、その答えを含む正解文書 ID」を 120 件ほど固定したデータセットを用意し、取得結果に正解 ID が含まれるかを機械的に数えました。特別な仕組みは要りません。日次で回すバッチに組み込むだけです。
接地率を毎日の一つの数字にする
計測を一度きりの調査で終わらせると、劣化はまた静かに戻ってきます。私は接地率と検索ヒット率を、毎朝ダッシュボードで眺める二つの数字として固定しました。個人開発で複数サイトの処理を日々モデルに任せている立場では、こうした計器が夜間の自動実行を安心して続ける支えになります。私自身、AdMob 収益のサポート文面までモデルに預けているので、答えの正しさが崩れると影響が実務に直に及びます。
数字を追いながら、主因を上位から潰していきました。
時点 検索ヒット率 接地率 打った手
計測開始 68% 61% —(現状把握)
2週目 82% 74% 肥大したチャンクを 512 トークンへ再分割
4週目 91% 88% 類似度しきい値を再較正・上位 K を 4→8 に
6週目 93% 94% 接地しない主張を出さない指示を生成側に追加
最も効いたのはチャンクの再分割でした。文書追加のたびに、見出し単位の長いチャンクが増えていて、一つのチャンクに複数の話題が同居していたのです。埋め込みが平均化され、どの質問にも中途半端に近い、決め手に欠けるベクトルになっていました。512 トークン前後へ切り直しただけで、検索ヒット率は 14 ポイント上がりました。
しきい値のドリフトも見逃せません。埋め込みモデルを新版へ差し替えたとき、以前のコサイン類似度しきい値をそのまま流用していました。新旧でスコアの分布が違うため、実質的に足切りが厳しくなり、答えの文書を弾いていたのです。
// retrieve.ts — しきい値は分布から較正する(固定値を使い回さない)
export function calibrateThreshold ( scores : number [], keepRatio = 0.3 ) : number {
const sorted = [ ... scores]. sort (( a , b ) => b - a);
const idx = Math. floor (sorted. length * keepRatio);
return sorted[idx] ?? 0 ; // 上位30%が残る位置を動的なしきい値に
}
固定のしきい値を埋め込みモデル更新後も使い回さない。これは、一度痛い目を見て初めて身につく類の教訓でした。
接地しないなら答えない、を生成側に組み込む
検索を直しても、接地率は検索ヒット率に少し遅れて追いつきます。最後の一押しは生成側でした。取得文書が答えを含まないとき、Claude が一般知識で穴埋めするのを止める。プロンプトで、根拠がなければ「わからない」と言い切らせます。
// generate.ts の system プロンプト(抜粋)
const system = `あなたは取得文書のみを根拠に回答します。
- 文書に根拠がある主張だけを述べてください。
- 文書に答えが無い場合は、推測せず「提供された資料には該当の記載がありません」と述べてください。
- 各主張の末尾に、根拠とした [doc n] を必ず付けてください。` ;
根拠に [doc n] を必ず付けさせると、接地率の採点も安定し、利用者にとっても回答の検証がしやすくなります。「わからない」と正直に言える RAG は、流暢に嘘をつく RAG より、はるかに信頼できます。私自身、穴埋めの流暢さより、境界を正しく引く誠実さを取るようになりました。
立て直しの順序を一枚にまとめる
同じ状況に出会ったとき、私が次に辿る手順はこうです。
回答の見た目を信じない。流暢さと正しさは別物として扱う
「検索ヒット率」と「接地率」に分けて疑う。原因が検索側か生成側かを先に切り分ける
Claude を採点者に立て、接地率を毎日の一つの数字にする
チャンク肥大・しきい値ドリフト・埋め込み更新の三つを上位から潰す
しきい値は分布から較正する。固定値を更新後に使い回さない
生成側に「接地しないなら答えない」を組み込み、根拠 [doc n] を必須にする
RAG は、作って終わりではなく、答えを掴み続けているかを計測して初めて信頼できます。派手さはありませんが、この地道な計器が、生成の流暢さに隠れた劣化を静かに照らしてくれます。実装の参考になれば幸いです。お読みいただきありがとうございました。