iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0

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

數據背後的殘酷現實與洞察

這張客觀對比表呈現出三項關鍵事實:

  1. 精確查詢上,LLM 沒有帶來任何加分:
    在 exact_symbol 與 dependency 分類中,規則式搜尋早已具備 1.00 的 MRR。把已經排在第 1 名的結果再送給本地 LLM 重排,只是浪費將近 800ms 的推論時間,模型甚至可能偶爾發生隨機排序偏誤。
  2. 語意意圖題,LLM 展現強大校正力:
    在 semantic_intent 分類中,MRR@5 從 0.50 大幅躍升至 0.80。模型成功理解了「對話問答進入點在哪裡」背後指涉的是 def chat_loop():,克服了向量搜尋容易被頂層設定常數與檔案說明干擾的缺陷。
  3. 延遲成本膨脹了將近 70 倍:
    純本機 SQLite + Cosine 檢索只需要 12ms,而調用本地 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,純粹增加延遲。

結論:動態路由策略(Adaptive Reranking)

今天的實驗給了我們一個極具實務價值的結論:

「不要為了看起來很 AI,就對所有查詢無腦調用 LLM Reranker。」

最適當的工程架構是動態分流(Adaptive Routing):

  • 若查詢命中明確的函式定義、檔案路徑或常數命名,直接走極速的 Deterministic 規則排序(耗時 < 15ms)。

  • 只有當查詢為純自然語言、且第一階段候選的分數差距過於接近時,才啟動 本地 LLM Reranker 進行精準裁判。

總結與下一步

今天我們完成了檢索系統的量化驗收:

  1. 數據說話:透過 MRR@5 與延遲指標,清楚量化了 LLM 帶來的提升與代價。
  2. 消滅盲從:釐清了本地小模型在不同題型下的長短板,避免過度工程化。

明天在 Day 21(第三週收尾總結)中,我們將回顧第三週建立的評估標準與排序防線,並正式為第四週的重頭戲——跨檔案呼叫圖(AST Call Graph)與變更影響分析拉開序幕!



上一篇
Day 19:Reranker 的提示詞工程與幻覺防線:讓本地小模型只做裁判
下一篇
Day 21:從變更分析開始:定義呼叫邊(Call Edge)與資料模型
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言