iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0

昨天我們把考題規格化,建立了機器可讀的評估資料集 data/eval_cases.json。

有了題目和標準答案後,下一個問題是:怎麼用程式量化「搜尋得好不好」?

如果搜尋回傳前 5 筆結果:

  • 案例 A:正確答案出現在第 1 筆。
  • 案例 B:正確答案出現在第 5 筆。
  • 案例 C:前 5 筆都沒有正確答案。

如果只算「有沒有找到」,案例 A 和 B 都算成功,但對於後面要閱讀 Evidence 的 LLM 或工程師來說,體驗與 Token 消耗有著天壤之別。今天我們要實作資訊檢索領域最經典的三個指標:Hit@k、MRR 與 Precision@k。

三大指標的物理意義

  1. Hit@k(命中率)
  • 定義:在回傳的前 $k$ 筆結果中,是否至少包含一筆預期的正確檔案或 Symbol。
  • 公式:有命中為 $1.0$,完全沒中為 $0.0$。
  • 意義:門檻指標。先看系統在有限視窗內能不能「找得到」。
  1. MRR(Mean Reciprocal Rank,平均倒數排名)
  • 定義:看第一個正確答案出現在第幾名(Rank $r$),取其倒數 $\frac{1}{r}$。若前 $k$ 筆都沒有,分數為 $0.0$。
  • 意義:排名指標。第 1 名命中得 $1.0$、第 2 名得 $0.5$、第 5 名只得 $0.2$。這項指標能敏銳反映出「好的排序演算法是否把最佳答案頂在最前排」。
  1. Precision@k(前 k 筆精確率)
  • 定義:前 $k$ 筆結果中,有多少比例是與問題相關的正確答案。
  • 公式:$\text{Precision@k} = \frac{\text{前 } k \text{ 筆中的相關結果數}}{k}$。
  • 意義:雜訊指標。檢驗檢索回傳的內容是不是純淨的乾貨,還是夾帶了太多無關片段。

核心實作:擴充 app/evaluation.py

我們在 app/evaluation.py 中實作不依賴任何外部套件的指標計算器:

# app/evaluation.py (擴充指標計算)
from typing import List, Dict, Any, Set

class RetrievalMetrics:
    @staticmethod
    def calculate_hit_at_k(actual_paths: List[str], expected_paths: Set[str], k: int = 5) -> float:
        """前 k 筆是否至少命中一個預期檔案"""
        top_k = actual_paths[:k]
        for path in top_k:
            if path in expected_paths:
                return 1.0
        return 0.0

    @staticmethod
    def calculate_mrr(actual_paths: List[str], expected_paths: Set[str], k: int = 5) -> float:
        """計算前 k 筆第一個命中的倒數名次 (Reciprocal Rank)"""
        top_k = actual_paths[:k]
        for rank, path in enumerate(top_k, start=1):
            if path in expected_paths:
                return 1.0 / rank
        return 0.0

    @staticmethod
    def calculate_precision_at_k(actual_paths: List[str], expected_paths: Set[str], k: int = 5) -> float:
        """計算前 k 筆中正確檔案所佔的比例"""
        top_k = actual_paths[:k]
        if not top_k:
            return 0.0
        relevant_count = sum(1 for p in top_k if p in expected_paths)
        return relevant_count / min(len(top_k), k)

    @classmethod
    def evaluate_query(cls, actual_results: List[Dict[str, Any]], expected_paths: List[str], k: int = 5) -> Dict[str, float]:
        """對單一題目的檢索結果計算完整指標"""
        actual_paths = [item["path"] for item in actual_results]
        expected_set = set(expected_paths)

        return {
            f"hit@{k}": cls.calculate_hit_at_k(actual_paths, expected_set, k),
            f"mrr@{k}": cls.calculate_mrr(actual_paths, expected_set, k),
            f"p@{k}": cls.calculate_precision_at_k(actual_paths, expected_set, k)
        }

實作單元測試:tests/unit/test_metrics.py

在 tests/unit/test_metrics.py 驗證不同名次下的分數計算正確性:

# tests/unit/test_metrics.py
from app.evaluation import RetrievalMetrics

def test_metrics_calculation_first_rank():
    # 正確答案出現在第 1 筆
    actual = [{"path": "src/build_index.py"}, {"path": "src/other.py"}]
    expected = ["src/build_index.py"]

    res = RetrievalMetrics.evaluate_query(actual, expected, k=5)
    assert res["hit@5"] == 1.0
    assert res["mrr@5"] == 1.0
    assert res["p@5"] == 0.5  # 2 筆中有 1 筆命中

def test_metrics_calculation_lower_rank():
    # 正確答案出現在第 2 筆
    actual = [{"path": "src/noise.py"}, {"path": "src/build_index.py"}]
    expected = ["src/build_index.py"]

    res = RetrievalMetrics.evaluate_query(actual, expected, k=5)
    assert res["hit@5"] == 1.0
    assert res["mrr@5"] == 0.5  # 1 / 2
    assert res["p@5"] == 0.5

def test_metrics_calculation_miss():
    # 前 5 筆完全沒命中
    actual = [{"path": "src/wrong1.py"}, {"path": "src/wrong2.py"}]
    expected = ["src/target.py"]

    res = RetrievalMetrics.evaluate_query(actual, expected, k=5)
    assert res["hit@5"] == 0.0
    assert res["mrr@5"] == 0.0
    assert res["p@5"] == 0.0

執行測試確認通過:

uv run pytest tests/unit/test_metrics.py -v

tests/unit/test_metrics.py::test_metrics_calculation_first_rank PASSED   [ 33%]
tests/unit/test_metrics.py::test_metrics_calculation_lower_rank PASSED  [ 66%]
tests/unit/test_metrics.py::test_metrics_calculation_miss PASSED        [100%]
============================== 3 passed in 0.04s ==============================

模擬評估驗收

利用昨天載入的題目,模擬跑一次測試並印出客觀分數:

# scripts/eval_demo.py
from app.evaluation import RetrievalMetrics

mock_results = [
    {"path": "src/build_index.py"},
    {"path": "src/rag_common.py"},
    {"path": "src/rag_chat.py"}
]
expected = ["src/rag_common.py"]

scores = RetrievalMetrics.evaluate_query(mock_results, expected, k=3)
print(f"評估得分: Hit@3={scores['hit@3']}, MRR@3={scores['mrr@3']:.4f}, P@3={scores['p@3']:.4f}")

終端機輸出:

評估得分: Hit@3=1.0, MRR@3=0.5000, P@3=0.3333

總結與下一步

今天我們完成了檢索系統的量尺:

  1. 擺脫主觀感覺:好或不好不再由人決定,而是由明確的數學公式說話。
  2. 多維度衡量:Hit@k 衡量召回底線,MRR 衡量排序品質,Precision@k 衡量資訊雜訊。

明天,我們將把這兩者串起來,實作一條可以自動重跑的 Eval Runner 指令,一次跑完所有題庫並印出完整的檢索品質成績單!



上一篇
Day 15:先做一份真的能重跑的評估資料集
下一篇
Day 17:把評估變成一條可以重跑的指令:打造自動化 Eval Runner
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言