在前兩週,我們依序完成了文字搜尋、AST 語法索引、直接 import 追蹤、向量嵌入以及基於 RRF 的 Hybrid Search。
但如果你問一個工程問題:「加上向量與 RRF 之後,檢索結果真的有變好嗎?好多少?」
很多人優化 RAG 的方式是:在終端機隨便敲兩句「Qdrant 設定在哪」,看到螢幕跳出滿意的結果,就宣布「檢索效果大幅提升」。
這不叫評估,這叫倖存者偏差。
在真實世界裡,往往你為了解決問題 A 調大了向量權重,原本答得很好的問題 B 卻被擠出了 Top 3。
今天進入第三週的第一天,我們要建立一份真正能用程式重跑、完全客觀的評估資料集(Evaluation Dataset),把 Day 3 列出的願景正式轉化為系統隨時可跑的基準測試。
在 Day 3 我們雖然列了 15 題驗收題目,但如果題目只保存在文章或 Markdown 條列裡,每次改了程式碼,你就得「人眼逐行檢查」。
評估資料集必須滿足三個工程鐵律:
機器可讀(Machine-Readable):使用結構化的 JSON 格式,評估程式能自動批次執行。
黃金標準明確(Ground Truth / Expected Target):精準標註每個查詢預期必須命中的「檔案相對路徑」與「相關 Symbol 名稱」。
分群分類(Categorized):涵蓋不同難度的問題類型,才能清楚看出不同檢索管道在各題型上的長處與短板。
data/eval_cases.json我們在專案自己的 data/ 目錄建立測試集,先涵蓋最常見的三種實戰情境:
exact_symbol:已知精準名稱的定位題(測試 Lexical / Symbol Index 的準確率)。
dependency:模組引用與依賴題(測試 Import 關係檢索)。
semantic_intent:不知道精確名稱、偏向自然語言描述的概念題(測試 Vector / Hybrid 檢索)。
[
{
"id": "eval_01",
"category": "exact_symbol",
"query": "COLLECTION_NAME 在哪裡設定?",
"expected_paths": [
"src/rag_common.py"
],
"expected_symbols": [
"COLLECTION_NAME"
]
},
{
"id": "eval_02",
"category": "dependency",
"query": "哪些檔案直接 import rag_common?",
"expected_paths": [
"src/build_index.py",
"src/chat_reranker.py",
"src/chat_reranker_guarded.py",
"src/rag_chat.py"
],
"expected_symbols": []
},
{
"id": "eval_03",
"category": "semantic_intent",
"query": "Qdrant 本機路徑在哪裡設定?",
"expected_paths": [
"src/rag_common.py",
"src/build_index.py"
],
"expected_symbols": [
"QDRANT_LOCAL_DIR"
]
},
{
"id": "eval_04",
"category": "exact_symbol",
"query": "建立索引的實作在哪裡?",
"expected_paths": [
"src/build_index.py"
],
"expected_symbols": [
"build_index"
]
},
{
"id": "eval_05",
"category": "semantic_intent",
"query": "對話問答的進入點在哪個檔案?",
"expected_paths": [
"src/rag_chat.py"
],
"expected_symbols": [
"chat_loop"
]
}
]
app/evaluation.py在撰寫指標計算法之前,先建立一個載入與驗證資料集的資料類別(Data Class),確保資料格式完備:
# app/evaluation.py
import json
from dataclasses import dataclass
from pathlib import Path
from typing import List
@dataclass
class EvalCase:
id: str
category: str
query: str
expected_paths: List[str]
expected_symbols: List[str]
class EvalDatasetLoader:
@classmethod
def load_from_json(cls, file_path: str = "data/eval_cases.json") -> List[EvalCase]:
path = Path(file_path)
if not path.exists():
raise FileNotFoundError(f"找不到評估資料集: {file_path}")
raw_data = json.loads(path.read_text(encoding="utf-8"))
cases = []
for item in raw_data:
cases.append(EvalCase(
id=item["id"],
category=item["category"],
query=item["query"],
expected_paths=item.get("expected_paths", []),
expected_symbols=item.get("expected_symbols", [])
))
return cases
tests/unit/test_eval_loader.py驗證評估資料能否被正確解析,並確保至少包含上述三種核心題型:
# tests/unit/test_eval_loader.py
import json
from app.evaluation import EvalDatasetLoader
def test_eval_dataset_loading(tmp_path):
dataset_file = tmp_path / "eval_test.json"
dummy_cases = [
{
"id": "test_1",
"category": "exact_symbol",
"query": "Where is COLLECTION_NAME?",
"expected_paths": ["src/rag_common.py"],
"expected_symbols": ["COLLECTION_NAME"]
}
]
dataset_file.write_text(json.dumps(dummy_cases), encoding="utf-8")
loaded = EvalDatasetLoader.load_from_json(str(dataset_file))
assert len(loaded) == 1
assert loaded[0].id == "test_1"
assert loaded[0].category == "exact_symbol"
assert "src/rag_common.py" in loaded[0].expected_paths
執行測試確認綠燈:
uv run pytest tests/unit/test_eval_loader.py -v
tests/unit/test_eval_loader.py::test_eval_dataset_loading PASSED [100%]
============================== 1 passed in 0.04s ==============================
在終端機檢查正式的評估資料集規模:
uv run python -c "from app.evaluation import EvalDatasetLoader; cases = EvalDatasetLoader.load_from_json('data/eval_cases.json'); print(f'已載入 {len(cases)} 題測試案例')"
輸出結果:
已載入 5 題測試案例
今天我們完成了「評估工程化」的第一步:
測試標準代碼化:不再把考題當成文章邊角料,而是做成可重複運行的 eval_cases.json。
定義黃金標準(Ground Truth):為每一個查詢鎖定預期命中的相對路徑與符號,消滅打分數時的人為模糊空間。
現在考卷已經出好了,但我們該如何為檢索結果打分數?如果命中結果排在第 1 名跟排在第 5 名,分數該怎麼算?
明天,我們將實作檢索評估的三大核心量化指標:Hit@k、MRR(Mean Reciprocal Rank) 與 Precision@k,為搜尋品質打造真正的量尺!