ふくふくHukuhuku Inc.
EP.05RAG 11分公開: 2026-05-10

RAG の評価設計:定性・定量・継続的評価の3層

「動いてるけど良くなってる?」を定量化する評価設計。Eval セット作成からヒューマンインザループまで。

#Eval#RAG#評価
CO📔 Google Colab で開く(上から順にセルを実行)
シェア

は「実装するより評価するほうが難しい」と言われます。「精度が良くなった気がする」では本番運用できないので、定量・定性・継続の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:継続評価(本番フィードバック)

本番投入後は、ユーザーの「いいね」「だめね」フィードバックを蓄積します。週次でフィードバックを集計し、悪い回答を再生成・改善ループに回します。

継続評価本番運用ユーザーが質問いいね/だめねフィードバック収集週次レビュー悪い回答を分析改善PRプロンプト/Eval追加
継続評価ループ

で 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@KMRR (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 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。

シリーズの外も探す:

まずは、現状を聞かせてください。

要件が固まっていなくて大丈夫です。現状診断と方針提案までを無料でお手伝いします。

無料相談フォームへ hello [at] hukuhuku [dot] co [dot] jp