🚦

「LLM を更新したら回答が壊れた」を CI で止める — Go 製 OSS raggate で作る RAG 品質ゲート

に公開

リポジトリ: mutton-dev/raggate — 記事の手順はモックサーバー同梱なので、API キーなしで最後まで再現できます。

0. はじめに

RAG パイプラインは「作る」より「良い状態を保つ」方が難しい——運用を始めるとすぐ気づきます。

  • プロバイダがモデルを更新した翌週、特定の質問だけ回答が変わっていた
  • プロンプトを 1 行直したら、直した箇所と関係ない回答が崩れた
  • インデックスを再構築したら、引用元がごっそり入れ替わっていた

どれもコードのテストは全部グリーンのまま起きます。回答品質はユニットテストの外側にあるからです。

一方で、コードの世界にはこの問題への答えがすでにあります。リグレッションテストです。期待値つきのテストケースを用意し、CI で回し、壊れたらマージを止める。この記事では、回答品質を「普通のリグレッション」として扱うための Go 製 CLI raggate を使って、

  1. golden データセット(期待値つき質問集)を YAML で定義する
  2. ベースラインを記録する
  3. 劣化をわざと注入してゲートが発火する(exit 3)ことを確認する
  4. 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