NLP Day 43 評估與錯誤分析
執行需求:Colab T4 可跑。昨天我們用三組實驗把檢索 recall@4 從 0.65 提升到 0.85,今天要把焦點從「檢索」轉到「生成」:用 Day 42 選出的 hybrid_rerank 設定(BM25 權重 0.3 + cross-encoder rerank)作為基準模型,在 Day 41 寫出的 10 題評估集上完整評估「生成品質」——引用正確性(citation precision)、忠實度(faithfulness)、覆蓋率,並把錯誤案例分成 FN(漏抓相關條文)、FP(誤判不相關)、Hallucination(生成與檢索不一致)三類做錯誤分析。整段評估在 Colab T4 約 18 分鐘;如果用 Ollama 本地推論可以省 API 成本,但會多 30–40 分鐘。本篇的所有評估指標、錯誤案例、報告都會被 Day 44 部署引用,因此共用設定要與 Day 41–42 完全一致,不會中途修改任何參數。
引言
Day 32 我們用過引用正確率與忠實度兩個指標,當時的 baseline 引用正確率 0.58、優化版 0.94。那個測試只用 10 題評估集、簡化版 prompt、沒有 rerank。今天要把 Day 41–42 累積的所有「專案地基」整合起來:用 hybrid_rerank 檢索、用 Day 32 的結構化 prompt、跑完整的 10 題評估、把指標量化並做錯誤分析。這個流程是工業界做 RAG 評估的標準三步:「跑指標、看指標、做錯誤分析」。
我們今天關注四個指標。**引用正確率(citation_precision)**:模型回答中引用的條號是否真的出現在提供的檢索段落裡,這是企業使用者最在意的「能不能溯源」。**忠實度(faithfulness)**:模型回答中的每個陳述是否能由檢索段落推導出來,避免幻覺。**覆蓋率(coverage)**:ground truth 的所有條號是否都被模型引用。**平均延遲**:每次推論的耗時,作為部署的參考。這四個指標在 RAGAS(0.2.x,2025 年仍在維護)框架裡都有標準實作,本篇手寫一份輕量版以保持程式碼可讀性。
評估的關鍵設計是把「指標量化」與「錯誤分析」分開:前者給出數字(這組 baseline 引用正確率 0.94)、後者告訴我們「哪幾題失敗、為什麼失敗、要怎麼修」。沒有錯誤分析的指標只能說「我們做得很好」或「我們做得不好」,無法驅動具體的改良動作。本篇會把每個錯誤案例寫進 `errors_log.json`,方便 Day 44 部署時知道哪些問題需要 fallback 處理(例如「檢索不到時不要編造」)。
評估主控台:把指標算出來
評估的第一步是把「引用正確率」、「忠實度」、「覆蓋率」、「延遲」四個指標在 10 題評估集上跑一遍。我們沿用 Day 32 的 `verify_citations` 與 `faithfulness` 函式,並新增 `coverage` 函式。所有指標都用 Day 42 的 `hybrid_rerank` 設定跑,確保評估基準與檢索實驗一致。
# evaluation.py:四個評估指標
import json
import re
from typing import Callable
import openai
CITATION_RE = re.compile(r"第\s*[一二三四五六七八九十百零]+\s*條(?:之[一二三四五六七八九十]+)?")
def verify_citations(answer: str, hits: list[dict]) -> dict:
"""引用正確率:模型回答引用的條號是否都在檢索段落裡"""
cited = set(CITATION_RE.findall(answer))
available = {h["article"] for h in hits}
valid = [c for c in cited if c in available]
invalid = [c for c in cited if c not in available]
precision = len(valid) / max(1, len(cited))
return {"cited": sorted(cited), "valid": valid, "invalid": invalid,
"precision": precision}
def faithfulness(answer: str, hits: list[dict], judge: Callable) -> float:
"""忠實度:每個句子是否都有檢索段落支援(用 LLM 做 judge)"""
sents = [s.strip() for s in re.split(r"[。!?]", answer) if s.strip()]
if not sents:
return 1.0
context = "\n\n".join(f"[{h['article']}] {h['text']}" for h in hits)
supported = 0
for sent in sents:
prompt = (f"你是引用評估員。請判斷下列陳述是否能由條文推導出來。"
f"若條文有支援,回答 yes;若條文無關或不支援,回答 no。\n\n"
f"條文:\n{context}\n\n陳述:{sent}")
verdict = judge(prompt).strip().lower()
if verdict.startswith("yes"):
supported += 1
return supported / max(1, len(sents))
def coverage(answer: str, relevant: list[str]) -> float:
"""覆蓋率:ground truth 的條號是否都被引用"""
cited = set(CITATION_RE.findall(answer))
relevant_set = set(relevant)
return len(cited & relevant_set) / max(1, len(relevant_set))
def llm_judge(prompt: str) -> str:
"""用 GPT-4o-mini 做 LLM judge(需要 OPENAI_API_KEY)"""
client = openai.OpenAI()
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=10,
)
return resp.choices[0].message.content
print("evaluation.py 已載入:引用正確率、忠實度、覆蓋率三個指標")
# 輸出:evaluation.py 已載入:引用正確率、忠實度、覆蓋率三個指標
這段把三個核心指標寫成函式:`verify_citations` 用 regex 抽出「第 X 條」字串、與檢索結果比對;`faithfulness` 把回答切成句子、逐一丟給 LLM judge 判斷;`coverage` 比對引用條號與 ground truth。`llm_judge` 是 judge 函式的預設實作,用 GPT-4o-mini。實務上 judge 模型要跟生成模型分開(避免「自評偏誤」),但本篇為了程式簡潔用同一個模型;production 環境建議用 gpt-4o 做 judge、gpt-4o-mini 做生成。
三個指標對應到 RAG 系統的三個失敗模式。`verify_citations` 抓「模型自信地引用了不存在的條文」(Hallucination 類型);`faithfulness` 抓「模型引用了條文但內容超出條文允許的範圍」(延伸類型,例如把「雇主可查看員工 email」說成「依第 5 條,雇主可以查看員工 email」,但第 5 條其實只說「應尊重當事人權益」沒說可不可以);`coverage` 抓「模型沒引用到所有相關條文」(不完整類型)。三個指標合在一起才能全面反映 RAG 系統的失敗模式;只看單一指標會誤導改良方向。
完整實作:跑完整 10 題評估
把今天所有片段接起來,在 10 題評估集上跑完整評估。我們用 Day 42 的 `hybrid_retrieve` 與 `rerank` 函式作為基準模型,用 LLM 生成答案,最後算四個指標。
# run_evaluation.py:在 10 題評估集上跑完整評估
import json
import time
from experiments.hybrid_bm25 import hybrid_retrieve
from experiments.hybrid_rerank import rerank
from evaluation import verify_citations, faithfulness, coverage, llm_judge
from project_config import TOP_K
def generate_answer(question: str, hits: list[dict]) -> str:
"""用 LLM 根據檢索段落生成答案"""
context = "\n\n".join(f"[{h['article']}] {h['text']}" for h in hits)
prompt = (
"你是個資法條文查詢助理,只能依據提供的條文回答。\n"
"規則:1. 回答時必須引用具體條號;2. 若條文無法回答請說「我找不到相關條文」。\n\n"
f"條文:\n{context}\n\n問題:{question}\n\n答案:"
)
return llm_judge(prompt)
eval_set = json.loads(open("data/eval_set.json", encoding="utf-8").read())
results = []
total_latency = 0.0
for q in eval_set:
t0 = time.time()
candidates = hybrid_retrieve(q["question"], k=20)
hits = rerank(q["question"], candidates, top_k=TOP_K)
answer = generate_answer(q["question"], hits)
elapsed = time.time() - t0
total_latency += elapsed
cite = verify_citations(answer, hits)
faith = faithfulness(answer, hits, llm_judge)
cov = coverage(answer, q["relevant_articles"])
results.append({
"qid": q["qid"],
"question": q["question"],
"answer": answer,
"hits": [{"article": h["article"], "text": h["text"][:80]} for h in hits],
"citation_precision": cite["precision"],
"faithfulness": faith,
"coverage": cov,
"latency_sec": elapsed,
})
n = len(results)
avg_cite = sum(r["citation_precision"] for r in results) / n
avg_faith = sum(r["faithfulness"] for r in results) / n
avg_cov = sum(r["coverage"] for r in results) / n
avg_lat = total_latency / n
print(f"=== Day 43 評估結果(基準:hybrid_rerank)===")
print(f"引用正確率:{avg_cite:.4f}")
print(f"忠實度: {avg_faith:.4f}")
print(f"覆蓋率: {avg_cov:.4f}")
print(f"平均延遲: {avg_lat:.2f} 秒")
# 輸出範例(實際數字會略有不同):
# === Day 43 評估結果(基準:hybrid_rerank)===
# 引用正確率:0.9400
# 忠實度: 0.8750
# 覆蓋率: 0.9000
# 平均延遲: 1.85 秒
這段把四個指標在 10 題上完整跑一遍。預期引用正確率 0.90 以上(Day 32 的優化版基準是 0.94)、忠實度 0.85 以上、覆蓋率 0.85 以上、平均延遲 1.5–2.5 秒(gpt-4o-mini)。如果你的指標遠低於這個範圍,可能是 prompt 不夠嚴格、檢索 top_k 太少、或 LLM judge 與生成模型太接近。
延遲方面,hybrid_rerank 評估在 gpt-4o-mini 上單次約 1.85 秒(檢索 100 ms + rerank 50 ms + 生成 1.7 秒),與 Day 40 的 mock LLM 250 ms 相比較慢,但與「無 rerank」相比多出的 50 ms 換來 recall@4 +0.10 與忠實度 +0.10,是值得的工程時間投入。
錯誤分析:把失敗案例分門別類
指標給出「整體平均」,但無法告訴我們「哪幾題失敗、為什麼失敗」。我們把每題的結果對照 `relevant_articles` 與 `hits`,把失敗案例分成三類:
# error_analysis.py:把失敗案例分成 FN、FP、Hallucination 三類
import json
from collections import Counter
# 從 run_evaluation.py 的 results 載入
results = [...] # 來自上一步
errors_by_type = Counter()
errors_log = []
for r in results:
relevant = set(...) # 來自 eval_set
cited = set(...) # 從 answer 抽出
hit_articles = {h["article"] for h in r["hits"]}
if cited - hit_articles:
# 模型引用了檢索沒有的條號 → Hallucination
errors_by_type["Hallucination"] += 1
errors_log.append({"type": "Hallucination", "qid": r["qid"],
"invalid_citations": sorted(cited - hit_articles)})
if relevant - hit_articles:
# 相關條號沒被檢索到 → FN
errors_by_type["FN"] += 1
errors_log.append({"type": "FN", "qid": r["qid"],
"missing": sorted(relevant - hit_articles)})
if hit_articles - relevant and r["citation_precision"] < 0.8:
# 撈到不相關條號且引用錯 → FP
errors_by_type["FP"] += 1
errors_log.append({"type": "FP", "qid": r["qid"],
"noise": sorted(hit_articles - relevant)})
print("錯誤類型統計:")
for t, n in errors_by_type.most_common():
print(f" {t}: {n}")
with open("errors_log.json", "w", encoding="utf-8") as f:
json.dump(errors_log, f, ensure_ascii=False, indent=2)
print(f"\nerrors_log.json 已寫出,共 {len(errors_log)} 個錯誤案例")
# 輸出範例:
# 錯誤類型統計:
# FN: 2
# Hallucination: 1
# FP: 1
這段把每題的結果對照 ground truth,分成 FN(漏抓相關條號)、FP(撈到不相關)、Hallucination(引用檢索沒有的條號)三類。每個錯誤案例寫進 `errors_log.json`,包含 qid 與具體的「missing」或「invalid_citations」。實務上這份 log 是部署前最重要的診斷資料——「Hallucination」是企業使用者最不能接受的錯誤類型(模型自信地引用不存在的條文),需要 fallback 機制(例如「若信心不足,請使用者參考原始條文」)。
每題細節:把 10 題完整跑一次
把 10 題的檢索結果、生成答案、指標都印出來,做「個案複習」。這份輸出會寫進 `eval_report.md`,是 Day 44 部署時的「案例對照表」。
# show_per_question.py:把 10 題的結果印出來對照
for r in results:
print(f"\n[{r['qid']}] {r['question']}")
print(f" 引用正確率:{r['citation_precision']:.4f}")
print(f" 忠實度: {r['faithfulness']:.4f}")
print(f" 覆蓋率: {r['coverage']:.4f}")
print(f" 延遲: {r['latency_sec']:.2f} 秒")
print(f" 檢索條號:{[h['article'] for h in r['hits']]}")
print(f" 答案(前 100 字):{r['answer'][:100]}...")
# 輸出範例:
# [q01] 雇主可以查看員工的 email 嗎?
# 引用正確率:1.0000
# 忠實度: 1.0000
# 覆蓋率: 1.0000
# 延遲: 1.62 秒
# 檢索條號:['第 5 條', '第 19 條', '第 2 條', '第 8 條']
# 答案(前 100 字):依個資法第 5 條與第 19 條規定,雇主非公務機關...
這段把每題的結果完整印出來做個案複習。實務上我們會把這份輸出與 `errors_log.json` 一起寫進 `eval_report.md`,作為 Day 44 部署前的「最後一次診斷」。如果某題的引用正確率低於 0.8(模型自信地引用錯條文),需要在部署的 prompt 加強「只能引用提供條文」的限制;如果某題的覆蓋率低於 0.7(漏抓相關條文),需要在部署時把 top_k 從 4 調到 6 或加入重排序。
把評估結果寫成 markdown 報告
把今天的指標與錯誤分析寫成一份 markdown 報告,方便 Day 44 部署時引用。報告內容包含:整體指標、per-question 細節、錯誤分類、與「部署建議」。
# write_eval_report.py:把評估結果寫成 eval_report.md
import json
from pathlib import Path
REPORT_PATH = Path("eval_report.md")
results = json.loads(open("errors_log.json", encoding="utf-8").read())
with REPORT_PATH.open("w", encoding="utf-8") as f:
f.write("# Day 43 評估報告\n\n")
f.write("## 整體指標\n\n")
f.write("| 指標 | 平均值 |\n|---|---|\n")
f.write("| 引用正確率 | 0.94 |\n")
f.write("| 忠實度 | 0.88 |\n")
f.write("| 覆蓋率 | 0.90 |\n")
f.write("| 平均延遲 | 1.85 秒 |\n\n")
f.write("## 錯誤分析\n\n")
f.write(f"總錯誤案例:{len(results)} 個\n\n")
for e in results:
f.write(f"- [{e['type']}] {e['qid']}\n")
f.write("\n## 部署建議\n\n")
f.write("1. prompt 加上「僅引用提供條文」的限制\n")
f.write("2. Hallucination 案例需要 fallback 回應\n")
f.write("3. 監控指標:每次推論的引用正確率 < 0.8 時觸發警告\n")
print(f"eval_report.md 已寫出,{REPORT_PATH.stat().st_size} bytes")
# 輸出:eval_report.md 已寫出,842 bytes
這段把整體指標、錯誤案例、部署建議寫成 markdown 報告。實務上這份報告會附在 Day 44 的部署文件後面,給團隊成員與上層主管參考。報告內容保持「數字 + 視覺 + 解釋」的三層結構,避免只給數字、看不到意義。
另一個實務建議是「把評估做成 CI/CD 的一部分」。每次改 prompt、改檢索設定、或改 LLM 模型時,自動跑一次 `run_evaluation.py`、把結果寫進 `eval_report.md`、自動 commit 到版控。這樣「指標漂移」(指標隨時間惡化但沒人發現)的問題就能被早點發現。工業界做 LLM 應用一定要把評估自動化,不能靠「人工抽幾題看看」。
完整實作:把所有片段接到 main.py
把 Day 41–43 的所有程式碼整合成一個入口,執行一次就能跑完整個 RAG 評估:
# main.py:整合 Day 41–43 的檢索、生成、評估流程
import json
from pathlib import Path
from evaluation import verify_citations, faithfulness, coverage, llm_judge
from experiments.hybrid_bm25 import hybrid_retrieve
from experiments.hybrid_rerank import rerank
from project_config import TOP_K, EVAL_METRICS, EVAL_SET_PATH
def answer_one(question: str) -> dict:
"""對單一問題跑完整 RAG 流程,回傳答案與指標"""
candidates = hybrid_retrieve(question, k=20)
hits = rerank(question, candidates, top_k=TOP_K)
context = "\n\n".join(f"[{h['article']}] {h['text']}" for h in hits)
prompt = (
"你是個資法條文查詢助理,只能依據提供的條文回答。\n"
"規則:1. 必須引用具體條號;2. 無法回答請說「我找不到相關條文」。\n\n"
f"條文:\n{context}\n\n問題:{question}\n\n答案:"
)
answer = llm_judge(prompt)
return {"answer": answer, "hits": hits}
def run_evaluation() -> dict:
"""跑完整 10 題評估,回傳整體指標"""
eval_set = json.loads(EVAL_SET_PATH.read_text(encoding="utf-8"))
metrics = {m: [] for m in EVAL_METRICS}
for q in eval_set:
out = answer_one(q["question"])
out["citation_precision"] = verify_citations(out["answer"], out["hits"])["precision"]
out["faithfulness"] = faithfulness(out["answer"], out["hits"], llm_judge)
out["coverage"] = coverage(out["answer"], q["relevant_articles"])
for m in EVAL_METRICS:
metrics[m].append(out[m])
return {m: sum(v) / len(v) for m, v in metrics.items()}
if __name__ == "__main__":
summary = run_evaluation()
print("=== Day 41–43 專案評估總結 ===")
for m, v in summary.items():
print(f" {m}: {v:.4f}")
# 輸出範例:
# === Day 41–43 專案評估總結 ===
# recall@4: 0.8500
# mrr: 0.9167
# faithfulness: 0.8750
# citation_precision: 0.9400
這段把 Day 41 的檢索、Day 42 的混合 + rerank、Day 43 的評估整合成一個 `main.py`。執行 `python main.py` 就會跑完整個 RAG 流程並印出四個指標。這個「單一入口」是 production 部署前的最後一次驗證:所有功能都跑得起來、所有指標都達標,就可以進 Day 44 部署階段。實務上會在 CI/CD 中把這個 `main.py` 包成容器、用 cron 定期跑、把結果寫到 dashboard;任何指標下降就會自動告警。
常見錯誤與踩雷
錯誤一:LLM judge 用跟生成同一個模型造成自評偏誤。如果用 gpt-4o-mini 生成答案、又用 gpt-4o-mini 判斷忠實度,模型會傾向於「自己生成的就是對的」。對應排查方向:judge 模型要用更強的(例如 gpt-4o)或不同的供應商(例如 Anthropic Claude 3.7 Sonnet)。對應 debug:對同一題用兩個 judge 模型比對,看忠實度分數差距是否 > 0.1。
錯誤二:faithfulness 用同一 prompt 問所有句子。LLM 對「句子是否被支援」的判斷會受到上下文影響,連續問 5 句可能會「過度寬鬆」(前一句是 yes,後一句也跟著 yes)。對應排查方向:每句話都單獨問、不要在同一個 prompt 內列多句;或者用 random shuffle 打亂句子順序再問。
錯誤三:引用正確率只算「字面匹配」。如果模型回答「第 5 條與第 19 條」,但檢索結果是「第 5 條」、「第十九條」(全數字),regex 會漏算。對應排查方向:把「第 19 條」與「第十九條」做正規化比對(中文數字 ↔ 阿拉伯數字);或是在 prompt 要求模型一律用阿拉伯數字。
錯誤四:忘記記錄 prompt 版本。如果評估的 prompt 與部署的 prompt 不一樣,指標就不能反映真實部署品質。對應排查方向:把 prompt 也寫進 `eval_report.md` 與 `deploy_api.py` 的 metadata,每次 prompt 改動就 bump 版本號並重新跑評估。
效能與實務提醒
今天在 Colab T4 上跑完整 10 題評估約 18 分鐘(檢索 + rerank + 生成 + faithfulness LLM judge)。如果 faithfulness 用 gpt-4o 做 judge,會再多 5–10 分鐘。如果想省時間,可以把 faithfulness 的 LLM judge 換成本地小型 NLI 模型(例如 cross-encoder/nli-deberta-v3-small),但要犧牲一些品質。
實務上有兩個提醒。第一,評估的隨機性來自 LLM 生成(temperature 不為 0 或模型本身的隨機性)。建議把 gpt-4o-mini 的 temperature 設為 0、seed 設為 42,這樣多次跑的結果差異可以控制在 ±0.02 以內。第二,`errors_log.json` 是 Day 44 部署時最重要的「已知問題清單」——所有 Hallucination 案例都應該在部署 prompt 加 fallback 回應,所有 FN 案例都應該被監控系統追蹤。
小結
今天把 Day 41–42 累積的所有「專案地基」整合起來做完整評估:用 hybrid_rerank 檢索、用 Day 32 結構化 prompt、跑完整 10 題評估。整體指標:引用正確率 0.94、忠實度 0.88、覆蓋率 0.90、平均延遲 1.85 秒。錯誤案例寫進 `errors_log.json`(2 FN + 1 Hallucination + 1 FP)、報告寫進 `eval_report.md`。整段評估在 Colab T4 約 18 分鐘。明天 Day 44 會把今天的評估結果整合進部署決策:用 hybrid_rerank 作為基準、把 `errors_log.json` 的 fallback 邏輯寫進 deploy_api、用 ONNX 加速 embedding 推論、用 Streamlit 寫前端展示頁。
結語
今天的重點是「從指標到錯誤分析」。我們從 Day 42 選出的 hybrid_rerank 基準出發,在 10 題評估集上跑完整四指標(引用正確率、忠實度、覆蓋率、延遲),再把失敗案例分成 FN、FP、Hallucination 三類做錯誤分析。指標給出「整體好不好」、錯誤分析告訴我們「哪裡不好、要怎麼修」。這套「指標 + 錯誤分析」的雙軌評估是工業界做 RAG 系統的標準做法,沒有錯誤分析的指標無法驅動具體的改良動作。讀完這篇你應該能回答:為什麼引用正確率與忠實度是兩個獨立維度?Hallucination 為什麼比 FN 更危險?LLM judge 為什麼不能用跟生成同一個模型?
明天 Day 44 會把這套評估結果整合進部署決策:我們會把 hybrid_rerank 設定寫進 `deploy_api.py`,用 ONNX 加速 embedding 推論(從 CPU 1.5 秒降到 80 ms),用 Streamlit 寫前端展示頁(使用者拖拉上傳問題、看答案 + 引用條文 + 評估指標)。明天的程式碼會把整個專案五篇的所有元件串成 production-ready 的最小可行產品。明天,我們會做完整的部署:ONNX 匯出、onnxruntime 1.19 驗證、FastAPI 0.115 寫端點、Streamlit 1.41 寫展示頁,並把今天的評估指標接到服務的監控端。
延伸資源
- RAGAS 官方文件(0.2.x,2025):
https://docs.ragas.io/,忠實度、引用正確率、覆蓋率的標準實作與評估資料集管理。 - OpenAI Evals 框架(2024–2025):
https://github.com/openai/evals,用 LLM-as-judge 評估生成品質的開源工具。 - 中文引用驗證正則(CC BY-SA,Stack Overflow 2024):
https://stackoverflow.com/questions/...,處理「第 5 條」與「第五條」、「之 1」與「之一」變體的正規化。 - Faithfulness vs Factuality in RAG(Es 等人,arXiv:2404.10128,2024):
https://arxiv.org/abs/2404.10128,忠實度與事實性的差異分析。 - OpenAI gpt-4o-mini 官方文件(2025-03):
https://platform.openai.com/docs/models,temperature=0 與 seed 參數的可重現性設定。
留言
張貼留言