RAG_Music_Tutor / experiments /evaluation.py
github-actions
Deploy snapshot from 3637771
3340567
Raw
History Blame Contribute Delete
19.7 kB
"""retrieval / 生成 評価モジュール(hit-rate・MRR・RAGAS)。
責務:
- eval set(data/eval/questions.json)を読む
- 各質問を embedder → retriever に通して hits を得る
- hit-rate@k / MRR を計算する(retrieval層、LLM不使用)
- RAGAS で Faithfulness / AnswerRelevancy / AnswerCorrectness /
ContextPrecision / ContextRecall を計算する(生成層、LLM使用・節目のみ)
- 複数の chunking 戦略(config.COLLECTIONS)を比較する
- スコアを data/eval/scores_YYYYMMDD.json / ragas_*.json / ragas_*.csv に保存する
設計上の立場:
- evaluation.py は main.py と同じ「接着剤」。embedder / retriever / llm を直接呼ぶ。
- custom_types は import しない(素の dict / primitive で扱う)。
- hit-rate / MRR は retrieval のみ(LLM不使用、普段から回せる)。
- RAGAS は LLM を大量消費するため、普段は回さない。節目でのみ実行する。
生成層(llm.explain)と評価層(judge LLM)でモデルを分けて、
片方のレート制限に評価全体が引きずられないようにする。
1問ごとにチェックポイントへ保存し、途中で落ちても再実行で続きから
再開できるようにする(同じ collection の再実行時に既に終わった質問はスキップ)。
"""
from __future__ import annotations
import asyncio
import csv
import json
import os
from datetime import date
from pathlib import Path
from music_rag import config
from music_rag import embedder
from music_rag import retriever
from music_rag import llm as llm_module # 生成層(本番と同じ NVIDIA 呼び出し)
EVAL_PATH = config.EVAL_DIR / "eval_set_merged.json"
SCORES_DIR = config.EVAL_DIR
# RAGASの評価者(judge)モデル。生成層(NVIDIA・meta/llama-3.3-70b-instruct)とは
# 意図的にプロバイダごと分離する(self-preference bias回避 + レート制限の独立)。
# Gemini を使う(config.GEMINI_MODEL、既定 gemini-3.1-flash-lite)。モデルは
# env(RAGAS_JUDGE_MODEL)で上書き可能。
RAGAS_JUDGE_MODEL = os.getenv("RAGAS_JUDGE_MODEL", config.GEMINI_MODEL)
# judge のプロバイダ。gemini(既定)/ openrouter。
# 生成層が gemini のときは openrouter にして別プロバイダへ逃がすと bias 分離を保てる。
JUDGE_PROVIDER = os.getenv("JUDGE_PROVIDER", "gemini")
# 質問間ウェイト(秒)。judge・embeddings とも Gemini 無料枠に集中するため、
# RPD枯渇(20問中11問しか回らなかった問題)を踏まえてNVIDIA分離時より余裕を持たせる。
RAGAS_SLEEP_SEC = int(os.getenv("RAGAS_SLEEP_SEC", "15"))
# ── eval set 読み込み ───────────────────────────
def load_eval_set() -> list[dict]:
"""統合 eval set(eval_set_merged.json)を読む。
各問は question / expected_source(list) / match_type を持つ。
experiments/build_eval_set.py で生成する。"""
data = json.loads(EVAL_PATH.read_text(encoding="utf-8"))
for i, row in enumerate(data):
if "question" not in row or "expected_source" not in row:
raise ValueError(f"row {i}: question / expected_source が必要です")
if not isinstance(row["expected_source"], list):
raise ValueError(f"row {i}: expected_source は list である必要があります")
return data
# ── 1問を retrieve ──────────────────────────────
def retrieve_sources(question: str, top_k: int, collection: str) -> list[str]:
"""1問を embed → search し、ヒットした source のリストを順位順で返す。"""
vec = embedder.embed_query(question)
hits = retriever.search(vec, top_k=top_k, collection=collection)
return [h["source"] for h in hits]
def retrieve_with_text(question: str, top_k: int, collection: str) -> list[dict]:
"""1問を embed → search し、text/source/score を保持したまま返す(RAGAS用)。"""
vec = embedder.embed_query(question)
return retriever.search(vec, top_k=top_k, collection=collection)
# ── 指標(recall@k 主 / strict hit-rate / MRR) ──
# 多ソース対応。expected_source は常に list。
# recall@k : top-k に入った正解記事の割合(主指標。連続値、部分点が見える)
# strict_hit@k : match_type を尊重した二値。and=全記事必須 / or・single=1つでOK
# MRR : いずれかの正解記事が最初に現れた順位の逆数
def _recall_at_k(retrieved: list[str], expected: list[str], k: int) -> float:
"""top-k に入った正解記事の割合(主指標)。多ソースでも連続値で測れる。"""
if not expected:
return 0.0
top = set(retrieved[:k])
return sum(1 for e in expected if e in top) / len(expected)
def _strict_hit_at_k(
retrieved: list[str], expected: list[str], k: int, match_type: str
) -> bool:
"""match_type を尊重した二値hit。
and → 全正解記事が top-k に揃って初めて hit。
or / single → どれか1つ取れれば hit。"""
if not expected:
return False
top = set(retrieved[:k])
hits = [e in top for e in expected]
return all(hits) if match_type == "and" else any(hits)
def _reciprocal_rank(retrieved: list[str], expected: list[str]) -> float:
"""いずれかの正解記事が最初に現れた順位の逆数。出なければ 0。"""
exp = set(expected)
for rank, source in enumerate(retrieved, start=1):
if source in exp:
return 1.0 / rank
return 0.0
def _mean(xs: list[float]) -> float:
return round(sum(xs) / len(xs), 4) if xs else 0.0
# ── 1 collection を評価(hit-rate / MRR) ───────
def evaluate_retrieval(
eval_set: list[dict],
collection: str,
k: int = config.TOP_K,
) -> dict:
"""1つの collection について recall@k(主指標)・strict hit-rate@k・MRR を計算する。
source(silver_manual/forum)・match_type(single/and/or)・difficulty で層別集計も返す。"""
per_question = []
for row in eval_set:
retrieved = retrieve_sources(row["question"], top_k=k, collection=collection)
expected = row["expected_source"]
match_type = row.get("match_type", "or")
recall = _recall_at_k(retrieved, expected, k)
is_hit = _strict_hit_at_k(retrieved, expected, k, match_type)
rr = _reciprocal_rank(retrieved, expected)
per_question.append({
"id": row.get("id"),
"question": row["question"],
"expected": expected,
"match_type": match_type,
"source": row.get("source"),
"difficulty": row.get("difficulty"),
"reviewed": row.get("reviewed"),
"retrieved": retrieved,
"recall": round(recall, 4),
"strict_hit": is_hit,
"reciprocal_rank": round(rr, 4),
})
def summarize(rows: list[dict]) -> dict:
return {
"n": len(rows),
"recall_at_k": _mean([r["recall"] for r in rows]),
"strict_hit_rate": _mean([float(r["strict_hit"]) for r in rows]),
"mrr": _mean([r["reciprocal_rank"] for r in rows]),
}
def stratify(key: str) -> dict:
groups: dict[str, list[dict]] = {}
for r in per_question:
groups.setdefault(str(r.get(key)), []).append(r)
return {g: summarize(rs) for g, rs in sorted(groups.items())}
overall = summarize(per_question)
return {
"collection": collection,
"k": k,
**overall,
"by_source": stratify("source"),
"by_match_type": stratify("match_type"),
"by_difficulty": stratify("difficulty"),
"per_question": per_question,
}
# ── 全戦略を比較(hit-rate / MRR) ──────────────
def main() -> None:
eval_set = load_eval_set()
print(f"loaded {len(eval_set)} questions\n")
all_scores = {}
for strategy, collection in config.COLLECTIONS.items():
# collection が存在しない戦略はスキップ(未 ingestion / 削除済み)
try:
result = evaluate_retrieval(eval_set, collection=collection)
except Exception as e:
print(f"[{strategy}] collection={collection} をスキップ: {e}\n")
continue
all_scores[strategy] = result
print(f"[{strategy}] collection={collection} (n={result['n']}, k={result['k']})")
print(f" recall@{result['k']} = {result['recall_at_k']}")
print(f" strict_hit_rate = {result['strict_hit_rate']}")
print(f" mrr = {result['mrr']}")
print(" ── by match_type ──")
for mt, sc in result["by_match_type"].items():
print(f" {mt:8s} n={sc['n']:2d} recall={sc['recall_at_k']:.3f} "
f"strict_hit={sc['strict_hit_rate']:.3f} mrr={sc['mrr']:.3f}")
print(" ── by source ──")
for src, sc in result["by_source"].items():
print(f" {src:14s} n={sc['n']:2d} recall={sc['recall_at_k']:.3f} "
f"strict_hit={sc['strict_hit_rate']:.3f} mrr={sc['mrr']:.3f}")
print()
# 日付付きで保存(前後比較のため)
out = SCORES_DIR / f"scores_{date.today():%Y%m%d}.json"
out.write_text(json.dumps(all_scores, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"saved → {out}")
# ════════════════════════════════════════════════
# RAGAS による生成層評価(hit-rate で方向性を掴んだ後の節目で使う)
# LLM を大量消費するため、普段は回さない。
# ════════════════════════════════════════════════
def _ragas_setup():
"""RAGAS評価用のLLM/embeddingsをセットアップする。
judge LLM は OpenAI互換エンドポイント経由(AsyncOpenAI)。
google-genai ネイティブクライアントはRAGASのアダプタと相性が悪いため
(instructor/litellmアダプタが非同期判定に失敗する)、この経路を使う。
judge のプロバイダは env JUDGE_PROVIDER で切替(gemini / openrouter)。
生成層が gemini のとき judge も gemini だと self-preference bias が乗るため、
openrouter 経由で別プロバイダ(MiniMax / Kimi / Qwen / DeepSeek)に逃がせるようにしてある。
embeddings(AnswerRelevancy / AnswerCorrectness のみ使用)は judge に関わらず Gemini
(埋め込みは bias の論点ではなく、OpenRouter は embeddings を提供しないため)。
max_tokens はデフォルトだと日本語+複数statement照合で出力が途中で切れる
(IncompleteOutputException)ため、明示的に大きめに設定する。
"""
from openai import AsyncOpenAI
from ragas.llms import llm_factory
from google import genai
from ragas.embeddings import GoogleEmbeddings
if JUDGE_PROVIDER == "openrouter":
judge_key, judge_base = config.OPENROUTER_API_KEY, config.OPENROUTER_BASE_URL
judge_model = os.getenv("RAGAS_JUDGE_MODEL", config.OPENROUTER_MODEL)
else:
judge_key, judge_base = config.GEMINI_API_KEY, config.GEMINI_BASE_URL
judge_model = RAGAS_JUDGE_MODEL
print(f" judge: provider={JUDGE_PROVIDER} model={judge_model}")
async_client = AsyncOpenAI(api_key=judge_key, base_url=judge_base)
ragas_llm = llm_factory(
judge_model,
provider="openai",
client=async_client,
max_tokens=8192, # ← デフォルトだと途中で切れるので増やす
)
genai_client = genai.Client(api_key=config.GEMINI_API_KEY)
embeddings = GoogleEmbeddings(client=genai_client, model="gemini-embedding-001")
return ragas_llm, embeddings
def _checkpoint_path(collection: str) -> Path:
"""RAGAS途中経過の保存先。collectionごとに分ける。"""
return SCORES_DIR / f"_ragas_checkpoint_{collection}.json"
def _load_checkpoint(collection: str) -> list[dict]:
"""既存のチェックポイントがあれば読み込む。無ければ空リスト。"""
path = _checkpoint_path(collection)
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
return []
def _save_checkpoint(collection: str, per_question: list[dict]) -> None:
"""1問終わるたびにチェックポイントを丸ごと書き直す(追記ではなく上書き、壊れ防止)。"""
path = _checkpoint_path(collection)
path.write_text(json.dumps(per_question, ensure_ascii=False, indent=2), encoding="utf-8")
def _clear_checkpoint(collection: str) -> None:
"""全問完了後、チェックポイントを削除する。"""
path = _checkpoint_path(collection)
if path.exists():
path.unlink()
async def evaluate_generation(eval_set: list[dict], collection: str) -> dict:
"""RAGAS で ContextRecall / ContextPrecision / Faithfulness /
AnswerRelevancy / AnswerCorrectness を計算する。
- retrieved_contexts: retriever.search の各 hit の text
- response: llm.explain で生成(本番と同じ生成層、NVIDIA・config.NVIDIA_LLM_MODEL)
- reference: eval set の ground_truth
- judge: RAGAS_JUDGE_MODEL(生成層とは別モデルでレート制限を分離)
途中で落ちても、同じ collection で再実行すれば、チェックポイントに
保存済みの質問はスキップして続きから再開する。
"""
from ragas.metrics.collections import (
Faithfulness,
AnswerRelevancy,
AnswerCorrectness,
ContextPrecision,
ContextRecall,
)
ragas_llm, embeddings = _ragas_setup()
faithfulness = Faithfulness(llm=ragas_llm)
answer_relevancy = AnswerRelevancy(llm=ragas_llm, embeddings=embeddings)
answer_correctness = AnswerCorrectness(llm=ragas_llm, embeddings=embeddings)
context_precision = ContextPrecision(llm=ragas_llm)
context_recall = ContextRecall(llm=ragas_llm)
# 既存チェックポイントを読み込み、済みの質問をスキップ対象にする
per_question = _load_checkpoint(collection)
done_questions = {row["question"] for row in per_question}
if done_questions:
print(f" チェックポイントから再開: {len(done_questions)} 問は完了済み\n")
remaining = [row for row in eval_set if row["question"] not in done_questions]
total = len(eval_set)
for row in remaining:
question = row["question"]
reference = row.get("ground_truth", "")
i = len(per_question) + 1
hits = retrieve_with_text(question, top_k=config.TOP_K, collection=collection)
contexts = [h["text"] for h in hits]
# 本番と同じ生成層(llm.explain)で回答を作る(503等は内部でリトライ済み)
chunks_for_llm = [{"text": h["text"], "meta": {"source": h["source"]}} for h in hits]
response = llm_module.explain(question, chunks_for_llm, None)
faith_result = await faithfulness.ascore(
user_input=question, response=response, retrieved_contexts=contexts
)
relevancy_result = await answer_relevancy.ascore(
user_input=question, response=response
)
correctness_result = await answer_correctness.ascore(
user_input=question, response=response, reference=reference
)
precision_result = await context_precision.ascore(
user_input=question, retrieved_contexts=contexts, reference=reference
)
recall_result = await context_recall.ascore(
user_input=question, retrieved_contexts=contexts, reference=reference
)
per_question.append({
"question": question,
"response": response,
"faithfulness": faith_result.value,
"answer_relevancy": relevancy_result.value,
"answer_correctness": correctness_result.value,
"context_precision": precision_result.value,
"context_recall": recall_result.value,
})
_save_checkpoint(collection, per_question) # 1問ごとに保存
print(f" [{i}/{total}] done: {question[:30]}...")
# レート制限(judge 40RPM / embedding RPM 100)回避。最後の問では待たない
if i < total:
await asyncio.sleep(RAGAS_SLEEP_SEC)
n = len(per_question)
def avg(key: str) -> float:
return round(sum(q[key] for q in per_question) / n, 4) if n else 0.0
result = {
"collection": collection,
"n": n,
"faithfulness": avg("faithfulness"),
"answer_relevancy": avg("answer_relevancy"),
"answer_correctness": avg("answer_correctness"),
"context_precision": avg("context_precision"),
"context_recall": avg("context_recall"),
"per_question": per_question,
}
_clear_checkpoint(collection) # 全問完了したのでチェックポイントは不要
return result
# ── RAGAS結果を CSV に変換(Excel / Googleスプレッドシート用) ──
def export_ragas_csv(result: dict, out_path: Path | None = None) -> Path:
"""RAGAS結果(dict)を CSV に変換する。1行=1問、列=5指標+question+response。"""
if out_path is None:
out_path = SCORES_DIR / f"ragas_{result['collection']}_{date.today():%Y%m%d}.csv"
fieldnames = [
"question", "response",
"faithfulness", "answer_relevancy", "answer_correctness",
"context_precision", "context_recall",
]
with open(out_path, "w", newline="", encoding="utf-8-sig") as f:
# utf-8-sig: Excelで開いたときに日本語が文字化けしないようBOM付きにする
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for row in result["per_question"]:
writer.writerow({k: row.get(k, "") for k in fieldnames})
print(f"CSV saved → {out_path}")
return out_path
def run_ragas(collection: str = "music_theory", limit: int | None = None) -> dict:
"""RAGAS評価を同期的に実行するエントリーポイント。
limit: 動作確認用に先頭N問だけ回す場合に指定(本番は None で全問)。
途中で落ちても、同じ collection で再実行すれば続きから再開する。
"""
eval_set = load_eval_set()
if limit:
eval_set = eval_set[:limit]
print(f"limit={limit}: 先頭 {len(eval_set)} 問のみ実行\n")
result = asyncio.run(evaluate_generation(eval_set, collection))
print(f"\n[RAGAS] collection={collection}")
print(f" faithfulness = {result['faithfulness']}")
print(f" answer_relevancy = {result['answer_relevancy']}")
print(f" answer_correctness = {result['answer_correctness']}")
print(f" context_precision = {result['context_precision']}")
print(f" context_recall = {result['context_recall']}")
out = SCORES_DIR / f"ragas_{collection}_{date.today():%Y%m%d}.json"
out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"saved → {out}")
export_ragas_csv(result)
return result
if __name__ == "__main__":
main()