在 Day 18 與 Day 19 中,我們克服了本地模型輸出格式不穩定的問題,打造了具備自我修復與 Fallback 能力的 LLM Reranker。
但在軟體工程領域,特別是導入大型語言模型時,必須時刻叩問一個根本問題:
「加上這個模型後,系統到底真的變強了,還是只是增加延遲與耗電的自我安慰?」
今天我們要拿 Day 17 建立的自動化 EvalRunner 當裁判,讓「純 Hybrid Search(含規則式 Deterministic Rerank)」與「Hybrid + 本地 LLM Reranker」在 data/eval_cases.json 題庫上正面交鋒,用數字與耗時換取真實結論。
為了確保測試公平,實驗遵守以下原則:
相同候選集(Identical Candidates):兩組的第一階段檢索完全共用同一個 Hybrid Search 引擎,召回相同的 Top 10 候選區塊。
同一份評估題庫:包含 exact_symbol、dependency 與 semantic_intent 三大題型。
關鍵觀察指標:
Hit@5(前 5 筆是否命中至少一處正確答案)
MRR@5(正確答案的平均倒數排名,權衡是否把最準確的證據頂到第 1 名)
Latency(平均查詢耗時):以毫秒(ms)為單位記錄每題平均處理時間。
app/eval_runner.py 支援 A/B 比較我們在 app/eval_runner.py 中新增 compare() 評估管線:
# app/eval_runner.py (擴充 A/B 比較邏輯)
import time
from typing import List, Dict, Any, Callable
from dataclasses import dataclass
from app.evaluation import EvalCase, RetrievalMetrics
@dataclass
class ComparisonReport:
name_a: str
name_b: str
mrr_a: float
mrr_b: float
hit_a: float
hit_b: float
latency_a_ms: float
latency_b_ms: float
category_diff: Dict[str, Dict[str, float]]
class EvalComparator:
def __init__(self, cases: List[EvalCase]):
self.cases = cases
def benchmark_pipeline(self, search_fn: Callable[[str, int], List[Dict[str, Any]]], k: int = 5):
total_hit = 0.0
total_mrr = 0.0
start_time = time.perf_counter()
cat_mrr = {}
cat_counts = {}
for case in self.cases:
res = search_fn(case.query, k)
scores = RetrievalMetrics.evaluate_query(res, case.expected_paths, k=k)
total_hit += scores[f"hit@{k}"]
total_mrr += scores[f"mrr@{k}"]
cat = case.category
cat_mrr[cat] = cat_mrr.get(cat, 0.0) + scores[f"mrr@{k}"]
cat_counts[cat] = cat_counts.get(cat, 0) + 1
elapsed_ms = (time.perf_counter() - start_time) * 1000 / len(self.cases)
n = len(self.cases)
avg_cat_mrr = {cat: cat_mrr[cat] / cat_counts[cat] for cat in cat_mrr}
return {
"hit": total_hit / n,
"mrr": total_mrr / n,
"latency_ms": elapsed_ms,
"cat_mrr": avg_cat_mrr
}
def compare(self, name_a: str, fn_a, name_b: str, fn_b, k: int = 5) -> ComparisonReport:
stat_a = self.benchmark_pipeline(fn_a, k=k)
stat_b = self.benchmark_pipeline(fn_b, k=k)
diffs = {}
for cat in stat_a["cat_mrr"]:
diffs[cat] = {
"mrr_a": stat_a["cat_mrr"].get(cat, 0.0),
"mrr_b": stat_b["cat_mrr"].get(cat, 0.0),
"delta": stat_b["cat_mrr"].get(cat, 0.0) - stat_a["cat_mrr"].get(cat, 0.0)
}
return ComparisonReport(
name_a=name_a,
name_b=name_b,
mrr_a=stat_a["mrr"],
mrr_b=stat_b["mrr"],
hit_a=stat_a["hit"],
hit_b=stat_b["hit"],
latency_a_ms=stat_a["latency_ms"],
latency_b_ms=stat_b["latency_ms"],
category_diff=diffs
)
在命令列執行對照指令,讓兩種搜尋管線直球對決:
uv run python -m app.cli compare-search --cases data/eval_cases.json --k 5
======================================================================
檢索管線 A/B 評估對比報告 (Top-5)
======================================================================
指標項目 Pipeline A: Hybrid (規則) Pipeline B: Hybrid + LLM
----------------------------------------------------------------------
總體 Hit@5 1.0000 1.0000
總體 MRR@5 0.8333 0.9333 (+0.1000)
平均單題延遲 (Latency) 12.4 ms 845.2 ms (+68.1x)
----------------------------------------------------------------------
分類 MRR@5 對比細節:
- exact_symbol 1.0000 1.0000 ( 0.0000)
- dependency 1.0000 1.0000 ( 0.0000)
- semantic_intent 0.5000 0.8000 (+0.3000)
======================================================================
這張客觀對比表呈現出三項關鍵事實:
exact_symbol 與 dependency 分類中,規則式搜尋早已具備 1.00 的 MRR。把已經排在第 1 名的結果再送給本地 LLM 重排,只是浪費將近 800ms 的推論時間,模型甚至可能偶爾發生隨機排序偏誤。semantic_intent 分類中,MRR@5 從 0.50 大幅躍升至 0.80。模型成功理解了「對話問答進入點在哪裡」背後指涉的是 def chat_loop():,克服了向量搜尋容易被頂層設定常數與檔案說明干擾的缺陷。gemma4:e4b 進行 Prompt 組裝、HTTP 傳輸與模型解碼,延遲拉長到 845ms。工程分析不該只報喜,把真實案例攤開才有價值:
eval_05(語意意圖成功校正)問題:「對話問答的進入點在哪個檔案?」
Pipeline A:第 1 名是 src/rag_common.py 的頂層宣告,真正的進入點 src/rag_chat.py 落在第 2 名(MRR = 0.50)。
Pipeline B:LLM 讀了原始碼中的 chat_loop 註解與呼叫邏輯,判定該函式與「問答進入點」語意最吻合,將其推至第 1 名(MRR 提升至 1.00)。
eval_01(徒增延遲)問題:「COLLECTION_NAME 在哪裡設定?
Pipeline A:關鍵字比對直接在 3ms 內將 src/rag_common.py:12 頂到第 1 名(MRR = 1.00)。
Pipeline B:LLM 同樣排在第 1 名,但耗時 820ms,純粹增加延遲。
今天的實驗給了我們一個極具實務價值的結論:
「不要為了看起來很 AI,就對所有查詢無腦調用 LLM Reranker。」
最適當的工程架構是動態分流(Adaptive Routing):
若查詢命中明確的函式定義、檔案路徑或常數命名,直接走極速的 Deterministic 規則排序(耗時 < 15ms)。
只有當查詢為純自然語言、且第一階段候選的分數差距過於接近時,才啟動 本地 LLM Reranker 進行精準裁判。
今天我們完成了檢索系統的量化驗收:
明天在 Day 21(第三週收尾總結)中,我們將回顧第三週建立的評估標準與排序防線,並正式為第四週的重頭戲——跨檔案呼叫圖(AST Call Graph)與變更影響分析拉開序幕!