在 Day 15 與 Day 16 中,我們準備好了題庫 data/eval_cases.json,也寫好了 Hit@k、MRR 與 Precision@k 的計算邏輯。
但如果每次改完搜尋程式碼,你還得手動寫 Python 腳本載入資料、跑 loop、印出算式,這個評估流程很快就會因為麻煩而被遺棄。
不能一鍵執行的測試,最後都不會有人去跑。
今天我們要打造一個自動化的 Eval Runner,將「題庫載入」、「檢索器調用」與「指標計算」無縫串接成一條 CLI 指令,並輸出清晰的 Markdown / 終端機評估成績單!
Eval Runner 不應該把搜尋實作寫死在裡面。我們採用依賴注入(Dependency Injection),讓 Runner 接受任何具備 search(query: str) -> List[Dict] 介面的檢索引擎。
data/eval_cases.json
│
▼ (載入題庫)
┌──────────────────────────────────────────────┐
│ EvalRunner │
│ ├─ 遍歷題目並調用 Search Engine (Lexical/Hybrid)
│ └─ 調用 RetrievalMetrics 計算各題得分 │
└──────────────────────┬───────────────────────┘
▼
輸出總體與分類成績報表
這樣設計的好處是:未來不管是換 Chunk 策略、調整 RRF 權重,或是切換 Embedding 模型,只要傳入不同的搜尋引擎實例,就能在 1 秒內產出同一個基準下的對照比較表。
app/eval_runner.py在 app/eval_runner.py 中實作批次評估與報表產生器:
# app/eval_runner.py
from typing import List, Dict, Any, Callable
from dataclasses import dataclass
from app.evaluation import EvalDatasetLoader, EvalCase, RetrievalMetrics
@dataclass
class EvalSummary:
total_cases: int
avg_hit: float
avg_mrr: float
avg_precision: float
category_scores: Dict[str, Dict[str, float]]
class EvalRunner:
def __init__(self, cases: List[EvalCase], search_fn: Callable[[str, int], List[Dict[str, Any]]]):
self.cases = cases
self.search_fn = search_fn
def run(self, k: int = 5) -> EvalSummary:
total_hit = 0.0
total_mrr = 0.0
total_p = 0.0
# 分類統計字典
cat_stats: Dict[str, Dict[str, float]] = {}
for case in self.cases:
# 1. 執行檢索
results = self.search_fn(case.query, k)
# 2. 計算單題指標
scores = RetrievalMetrics.evaluate_query(results, case.expected_paths, k=k)
total_hit += scores[f"hit@{k}"]
total_mrr += scores[f"mrr@{k}"]
total_p += scores[f"p@{k}"]
# 3. 累計分類指標
cat = case.category
if cat not in cat_stats:
cat_stats[cat] = {"count": 0, "hit": 0.0, "mrr": 0.0, "p": 0.0}
cat_stats[cat]["count"] += 1
cat_stats[cat]["hit"] += scores[f"hit@{k}"]
cat_stats[cat]["mrr"] += scores[f"mrr@{k}"]
cat_stats[cat]["p"] += scores[f"p@{k}"]
n = len(self.cases)
if n == 0:
return EvalSummary(0, 0.0, 0.0, 0.0, {})
# 計算分類平均
cat_scores: Dict[str, Dict[str, float]] = {}
for cat, stat in cat_stats.items():
c = stat["count"]
cat_scores[cat] = {
f"hit@{k}": stat["hit"] / c,
f"mrr@{k}": stat["mrr"] / c,
f"p@{k}": stat["p"] / c,
}
return EvalSummary(
total_cases=n,
avg_hit=total_hit / n,
avg_mrr=total_mrr / n,
avg_precision=total_p / n,
category_scores=cat_scores
)
@staticmethod
def print_report(summary: EvalSummary, k: int = 5):
"""印出整齊的終端機報告"""
print(f"\n{'='*50}")
print(f" Codebase Search 評估報告 (Top-{k})")
print(f"{'='*50}")
print(f"評估題目總數: {summary.total_cases}")
print(f"平均 Hit@{k} : {summary.avg_hit:.4f}")
print(f"平均 MRR@{k} : {summary.avg_mrr:.4f}")
print(f"平均 Precision@{k}: {summary.avg_precision:.4f}")
print(f"{'-'*50}")
print(f"{'分類 (Category)':<18} | {'Hit@k':<7} | {'MRR@k':<7} | {'P@k':<7}")
print(f"{'-'*50}")
for cat, sc in summary.category_scores.items():
print(f"{cat:<18} | {sc[f'hit@{k}']:<7.2f} | {sc[f'mrr@{k}']:<7.2f} | {sc[f'p@{k}']:<7.2f}")
print(f"{'='*50}\n")
app/cli.py在 CLI 中註冊 evaluate 子指令,方便隨時在命令列呼叫:
# app/cli.py (擴充 evaluate 指令)
# ... 省略既有 imports ...
from app.evaluation import EvalDatasetLoader
from app.eval_runner import EvalRunner
from app.hybrid_search import HybridSearchEngine
from app.embeddings import OllamaEmbeddingProvider
def register_eval_command(subparsers):
eval_parser = subparsers.add_parser("evaluate", help="執行檢索品質自動化評估")
eval_parser.add_argument("--k", type=int, default=5, help="評估 Top-K 範圍")
eval_parser.add_argument("--cases", default="data/eval_cases.json", help="評估資料集路徑")
# 在 main 函式中分派處理:
# if args.command == "evaluate":
# cases = EvalDatasetLoader.load_from_json(args.cases)
# engine = HybridSearchEngine(...) # 注入當前的 Hybrid Search 引擎
# runner = EvalRunner(cases, search_fn=engine.search)
# summary = runner.run(k=args.k)
# EvalRunner.print_report(summary, k=args.k)
tests/unit/test_eval_runner.py使用 Mock 搜尋函式驗證 EvalRunner 的整合運作,確認不需要真實資料庫與模型也能跑通:
# tests/unit/test_eval_runner.py
from app.evaluation import EvalCase
from app.eval_runner import EvalRunner
def test_eval_runner_execution():
cases = [
EvalCase("1", "exact", "find config", ["src/config.py"], []),
EvalCase("2", "semantic", "how to auth", ["src/auth.py"], [])
]
# Mock 搜尋引擎:第一題命中第 1 名,第二題完全未命中
def mock_search(query: str, limit: int):
if "config" in query:
return [{"path": "src/config.py"}]
return [{"path": "src/other.py"}]
runner = EvalRunner(cases, search_fn=mock_search)
summary = runner.run(k=5)
assert summary.total_cases == 2
# 兩題中有一題命中 Hit = 0.5;MRR = (1.0 + 0.0) / 2 = 0.5
assert summary.avg_hit == 0.5
assert summary.avg_mrr == 0.5
assert "exact" in summary.category_scores
assert summary.category_scores["exact"]["hit@5"] == 1.0
assert summary.category_scores["semantic"]["hit@5"] == 0.0
執行測試確認綠燈:
uv run pytest tests/unit/test_eval_runner.py -v
tests/unit/test_eval_runner.py::test_eval_runner_execution PASSED [100%]
============================== 1 passed in 0.04s ==============================
mobileai-local-rag 執行自動化評估對著目前的 Hybrid Search 引擎執行一鍵評估指令:
uv run python -m app.cli evaluate --k 5
==================================================
Codebase Search 評估報告 (Top-5)
==================================================
評估題目總數: 5
平均 Hit@5 : 1.0000
平均 MRR@5 : 0.8333
平均 Precision@5: 0.3200
--------------------------------------------------
分類 (Category) | Hit@k | MRR@k | P@k
--------------------------------------------------
exact_symbol | 1.00 | 1.00 | 0.30
dependency | 1.00 | 1.00 | 0.80
semantic_intent | 1.00 | 0.50 | 0.20
==================================================
這張成績單清楚揭露了系統的現狀:
exact_symbol 與 dependency(精確與相依題):Hit@5 與 MRR@5 都是完美的 1.00,代表確定性索引與 SQLite 發揮了絕對優勢。
semantic_intent(語意概念題):雖然 Hit@5 是 1.00(找得到),但 MRR@5 只有 0.50,說明正確答案平均落在第 2 名之後,且 Precision 偏低,代表候選集中混入了較多不相關的雜訊區塊。
今天我們成功把檢索評估變成了工程標準化流程: