は「実装するより評価するほうが難しい」と言われます。「精度が良くなった気がする」では本番運用できないので、定量・定性・継続の3層で評価設計を組みます。
3層評価フレームワーク
| 層 | 目的 | 頻度 |
|---|---|---|
| 定量評価 | 数値で良し悪しを判定(CIで自動) | PRごと |
| 定性評価 | 数字に出ない品質を人間が確認 | リリース前 |
| 継続評価 | 本番でユーザー満足度を計測 | 運用中ずっと |
層1:定量評価(Eval セット)
100問程度の評価セットを作り、 で毎回スコア計測します。これを最初に作るのが、RAG プロジェクトの肝。
Eval セットの形式
JSON
[ { "id": "q-001", "query": "営業部の経費申請のフローは?", "relevant_docs": ["doc-finance-042", "doc-hr-018"], "expected_keywords": ["事前承認", "freee", "稟議"], "category": "業務手順" }, { "id": "q-002", "query": "退職時の有給消化は何日まで認められる?", "relevant_docs": ["doc-hr-009"], "expected_keywords": ["20日", "計画的付与"], "category": "人事規程" }]層2:定性評価(人間レビュー)
数値では捉えられない「敬語が変」「ビジネス文脈に合わない」「情報は正しいが冷たい」などは、人間が読まないと分かりません。リリース前に5〜10件サンプリングして、ステークホルダーが必ず確認します。
層3:継続評価(本番フィードバック)
本番投入後は、ユーザーの「いいね」「だめね」フィードバックを蓄積します。週次でフィードバックを集計し、悪い回答を再生成・改善ループに回します。
で Eval セット作成を半自動化
プロンプト:Eval セット生成
この社内ドキュメント群から、評価セット(100問)を生成してください。
## 構成
- 業務手順: 30問
- 人事規程: 20問
- 技術仕様: 25問
- 営業マニュアル: 15問
- その他(雑多な質問): 10問
## 各質問の必須要素
- 質問文(自然な業務での聞き方)
- 関連文書ID(複数可)
- 期待キーワード(3-5個)
- カテゴリ
- 難易度(基本/中級/応用)
## 入力
@knowledge_base/
## 出力
@eval/queries.json 想定される実行結果(例示)
## Eval セット生成完了
### 統計
- 総問題数: 100
- カテゴリ別: 業務手順30 / 人事規程20 / 技術仕様25 / 営業マニュアル15 / その他10
- 難易度別: 基本40 / 中級45 / 応用15
### サンプル
```json
{
"id": "q-005",
"query": "中途入社者の研修期間は?",
"relevant_docs": ["hr-onboarding-2024"],
"expected_keywords": ["3ヶ月", "メンター", "1on1"],
"category": "人事規程",
"difficulty": "基本"
}
```
### 注意点
- 「自然な聞き方」を意識(「〜について教えて」より「〜のフローは?」など)
- 関連文書が複数の問題は意図的に20問入れた(Recall@k 評価で重要)
- 難問15問は「複数文書を統合しないと答えられない」ものを含む
→ `eval/queries.json` に保存。CI で `make eval-rag` で実行可能。Python で評価メトリクスを計算する
代表的な検索メトリクス Recall@K、MRR (Mean Reciprocal Rank)、nDCG を実装します。これらを CI に組み込むのが定石。
Recall@K と MRR の実装
Python
import jsonimport numpy as npfrom typing import List
def recall_at_k(retrieved: List[str], relevant: List[str], k: int = 5) -> float: """上位 K 件中、関連文書が含まれていた割合""" if not relevant: return 0.0 top_k = retrieved[:k] hit = sum(1 for doc in top_k if doc in relevant) return hit / len(relevant)
def mrr(retrieved: List[str], relevant: List[str]) -> float: """関連文書が初めて出現する位置の逆数""" for i, doc in enumerate(retrieved, 1): if doc in relevant: return 1.0 / i return 0.0
def ndcg_at_k(retrieved: List[str], relevant: List[str], k: int = 5) -> float: """nDCG: 上位ほど重要視する評価指標""" dcg = sum( 1.0 / np.log2(i + 2) for i, doc in enumerate(retrieved[:k]) if doc in relevant ) idcg = sum(1.0 / np.log2(i + 2) for i in range(min(len(relevant), k))) return dcg / idcg if idcg > 0 else 0.0
# === 評価セット全件で計測 ===def evaluate_rag(eval_set_path: str, retriever) -> dict: queries = json.load(open(eval_set_path)) results = {"recall@5": [], "mrr": [], "ndcg@5": []} for q in queries: retrieved_ids = retriever.search(q["query"], k=10) results["recall@5"].append(recall_at_k(retrieved_ids, q["relevant_docs"], 5)) results["mrr"].append(mrr(retrieved_ids, q["relevant_docs"])) results["ndcg@5"].append(ndcg_at_k(retrieved_ids, q["relevant_docs"], 5)) return {k: np.mean(v) for k, v in results.items()}
scores = evaluate_rag("eval/queries.json", my_retriever)print(f"Recall@5: {scores['recall@5']:.3f}")print(f"MRR: {scores['mrr']:.3f}")print(f"nDCG@5: {scores['ndcg@5']:.3f}")改善前後の比較(CI 用)
Python
# Pull Request ごとに改善前後を比較def diff_eval(before: dict, after: dict, threshold: float = -0.02) -> bool: """戻り値 False で CI 失敗。閾値以上の劣化を検出""" for metric in ["recall@5", "mrr", "ndcg@5"]: diff = after[metric] - before[metric] symbol = "✓" if diff >= 0 else "✗" print(f"{symbol} {metric}: {before[metric]:.3f} → {after[metric]:.3f} ({diff:+.3f})") if diff < threshold: print(f" ❌ {metric} が {abs(diff):.3f} 劣化、PR をブロック") return False return True
before = json.load(open("eval/baseline.json"))after = evaluate_rag("eval/queries.json", new_retriever)ok = diff_eval(before, after)exit(0 if ok else 1) # CI 用次回予告
EP.06 は逆張り回。「結局のところ、RAGより全文検索+の方が良かったケース」をお届けします。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。