「LLM を更新したら回答が壊れた」を CI で止める — Go 製 OSS raggate で作る RAG 品質ゲート
リポジトリ: mutton-dev/raggate — 記事の手順はモックサーバー同梱なので、API キーなしで最後まで再現できます。
0. はじめに
RAG パイプラインは「作る」より「良い状態を保つ」方が難しい——運用を始めるとすぐ気づきます。
- プロバイダがモデルを更新した翌週、特定の質問だけ回答が変わっていた
- プロンプトを 1 行直したら、直した箇所と関係ない回答が崩れた
- インデックスを再構築したら、引用元がごっそり入れ替わっていた
どれもコードのテストは全部グリーンのまま起きます。回答品質はユニットテストの外側にあるからです。
一方で、コードの世界にはこの問題への答えがすでにあります。リグレッションテストです。期待値つきのテストケースを用意し、CI で回し、壊れたらマージを止める。この記事では、回答品質を「普通のリグレッション」として扱うための Go 製 CLI raggate を使って、
- golden データセット(期待値つき質問集)を YAML で定義する
- ベースラインを記録する
- 劣化をわざと注入してゲートが発火する(exit 3)ことを確認する
- GitHub Actions で PR をブロックする
までを通します。
やらないこと
| 項目 | 理由 |
|---|---|
| RAG の構築方法そのもの | 本記事は「守り」専門。構築は既存の良記事に譲ります |
| RAGAS 等の評価フレームワーク比較 | 別記事で扱う予定 |
| LLM judge の詳細設計 | 決定的グレーダーを主役にします(理由は §6) |
1. 品質ゲートの考え方
やることは 3 つだけです。
golden データセット (YAML) → 実行して採点 (run) → 前回との差分で判定 (compare)
ポイントは compare の結果を exit code にすること。CI にとって品質ゲートは「終了コードが 0 か 3 か」でしかありません。人間向けのレポート(Markdown)は PR コメントに貼り、機械向けの判定は exit code で返す。この分離が CI 統合を単純にします。
raggate の終了コード契約:
| exit code | 意味 |
|---|---|
| 0 | 合格(閾値内) |
| 1 | 実行エラー(エンドポイント不達など) |
| 3 | 品質ゲート不合格 |
2. 最小構成を動かす
Go 1.22+ があれば API キー不要で試せます。
go install github.com/mutton-dev/raggate/cmd/raggate@latest
# 同梱のモック RAG サーバーを起動(架空プロダクトの FAQ を返す)
go run github.com/mutton-dev/raggate/examples/mockrag@latest --port 8080 &
# スターター設定を生成
raggate init
raggate init が書き出す suite.yaml はこんな形です。
name: starter
endpoints:
- name: local-rag
kind: http_rag # openai | anthropic | http_rag
base_url: http://localhost:8080
graders:
- type: contains # 期待する語句を含むか
- type: recall_at_k # 期待する doc が citations の上位 k 件に入るか
k: 3
thresholds:
min_score: 0.6 # 平均スコアの下限
max_score_drop: 0.1 # ベースライン比で許容するスコア低下
max_p95_ms: 2000 # p95 レイテンシ上限
cases:
- id: auth-token-expiry
input: "When do Atlas auth tokens expire?"
expected:
contains: ["60 minutes"]
doc_ids: ["atlas-auth"]
- id: backup-retention
input: "How long does Atlas retain encrypted backups?"
expected:
contains: ["30 days"]
doc_ids: ["atlas-backup"]
ベースラインを記録します。
raggate run -c suite.yaml -o baseline.json
## Endpoint summary
| Endpoint | Cases | Errors | MeanScore | PassRate | P50ms | P95ms | TotalJPY |
|---|---:|---:|---:|---:|---:|---:|---:|
| local-rag | 2 | 0 | 1.000 | 100.0% | 5.4 | 5.4 | 0.0000 |
## Failures
None.
3. 劣化を注入してゲートを発火させる
ここが本題です。ゲートは「発火するところを見た」ことがない限り信用してはいけません。モックサーバーには劣化モードがあります。
# citations を欠落させ、回答を汎用文に置き換える
go run github.com/mutton-dev/raggate/examples/mockrag@latest --port 8080 --degrade
同じスイートを再実行して比較します。
raggate run -c suite.yaml -o results.json
raggate compare baseline.json results.json
echo $? # → 3
# raggate compare: FAIL
## Endpoints
| Endpoint | Score (base → curr) | Δscore | P95ms (base → curr) | Δp95 | CostJPY (base → curr) | Δcost% |
|---|---|---:|---|---:|---|---:|
| local-rag | 1.000 → 0.000 | -1.000 ↓ | 5.4 → 1.3 | -4.1 ↓ | 0.0000 → 0.0000 | +0.0% — |
## Threshold violations
- ❌ **local-rag** / `min_score`: mean score 0.0000 < min_score 0.6000
- ❌ **local-rag** / `max_score_drop`: score drop 1.0000 > max_score_drop 0.1000
「モデル更新で回答が壊れた」状況をローカルで 30 秒で再現できました。実運用でこのレポートが PR に貼られていれば、壊れた変更はマージされません。
4. 実エンドポイントにつなぐ
自前の RAG API
http_rag は素朴な HTTP 契約です。
POST {base_url}
{"query": "..."}
→ {"answer": "...", "contexts": ["..."], "citations": ["doc-id", ...]}
既存の RAG API がこの形でなければ、薄いアダプタを 1 枚挟むだけです。citations に文書 ID を返すようにしておくと recall_at_k / mrr で検索品質を生成品質と分離して測れるようになります。これが後々効きます(回答が悪いとき、悪いのは検索か生成かをレポートだけで切り分けられる)。
LLM API を直接測る
endpoints:
- name: claude
kind: anthropic
model: claude-sonnet-5
api_key_env: ANTHROPIC_API_KEY # 環境変数「名」。キーは YAML に書かない
max_tokens: 1024
pricing: # 円/100万トークン。費用会計に使う
- model: claude-sonnet-5
input_per_1m: 450
output_per_1m: 2250
pricing を書いておくと、レポートに 1 実行あたりの費用(円) が出ます。モデル更新の差分レビューで「品質は同じだが費用が 1.8 倍」を検知できるのは実務ではかなり便利です(max_cost_increase_pct でゲートもできます)。
5. GitHub Actions で PR をブロックする
リポジトリの gate-example.yml が完全版ですが、骨子はこれだけです。
- name: Run suite
run: raggate run -c suite.yaml -o results.json
- name: Quality gate
run: |
raggate compare testdata/raggate-baseline.json results.json \
--format markdown -o compare.md
# exit 3 ならこの step が fail し、PR がブロックされる
ゲートを固くする 2 つの注意
設計するときに一度は考えるべき抜け道が 2 つあります。
(1) PR が自分のゲートを緩められないか? — 閾値がスイート YAML にある以上、PR で min_score: 0 に書き換えられたら終わりです。raggate の compare はデフォルトでベースライン側の閾値を強制します(--thresholds current で明示的に切り替えない限り)。
(2) ベースラインを PR が置き換えられないか? — baseline.json を PR のブランチから読むと、都合の良いベースラインを同じ PR で作れてしまいます。ベースラインはベースブランチ(または信頼できるストア)から取得してください。
この 2 つは raggate に限らず、どんな品質ゲートを自作する場合でも同じ穴になります。
6. グレーダーの選び方 — 決定的なものから
raggate には 7 種のグレーダーがありますが、推奨順があります。
| 優先 | グレーダー | 判定 |
|---|---|---|
| 1 |
contains / regex / exact / json_schema
|
決定的。flaky にならない |
| 2 |
recall_at_k / mrr
|
決定的。検索品質を分離して測る |
| 3 | llm_judge |
非決定的。ルーブリック必須・最後の手段 |
CI ゲートの敵は flakiness です。LLM judge は便利ですが判定自体が揺れるので、まず決定的グレーダーで測れる形に期待値を書くのが先です。「30 日」という語が入るべき回答なら contains: ["30 days"] で十分で、judge はニュアンス評価が本当に必要なケースだけに絞ります。
設計上のこだわりを 1 つだけ: 期待値が空のケース(contains: [] など)は満点ではなくエラーにしています。設定漏れで「全部グリーン」に見えるのは、品質ゲートにとって最悪の故障モードだからです。
7. 運用の勘所
- golden set は「失敗から育てる」 — 本番で見つけた悪い回答を、そのままテストケースに追加する。評価は一度作って終わりではなく、失敗を還流する継続プロセスです
-
閾値は最初ゆるく —
min_scoreだけから始めて、ベースラインが安定したらmax_score_dropを締める。初日から厳しくすると誤検知でゲートが無視されるようになります -
レポートは PR コメントへ — exit code だけだと「なぜ落ちたか」を見に行くコストが高い。
--format markdownの出力をそのまま貼るのが一番安い運用です
8. おわりに
raggate は v0.1.0 の小さなツールです(Go 製シングルバイナリ、依存は yaml パーサのみ)。Langfuse トレース連携や judge 費用の会計は Roadmap に積んであります。
余談ですが、この CLI 自体も Claude Code・Codex・Grok の 3 エージェントにパッケージ単位で分担させ、相互レビューと統合ゲートを通して 1 日で実装しました。「実装と検証を分離し、ゲートを通らないものはマージしない」という raggate が強制したい規律そのままの開発体制です。この話は別記事で書く予定です。
質問・要望は GitHub Issues へどうぞ。
Discussion