iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
AI Engineering

30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰系列 第 17 篇

Day 17:把評估變成一條可以重跑的指令:打造自動化 Eval Runner

  • 分享至 

  • xImage
  •  

在 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")

整合 CLI 指令: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 偏低,代表候選集中混入了較多不相關的雜訊區塊。

總結與下一步

今天我們成功把檢索評估變成了工程標準化流程:

  1. 一鍵量化:跑一條指令就能得知檢索健康度,杜絕人為猜測。
  2. 看清盲點:數據清楚指出,目前的 Hybrid Search 在「自然語言語意概念」的精確排序上還有進步空間。

上一篇
Day 16:用數據說話:實作檢索品質三大量化指標
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言