評估 RAG:答對了嗎?有引用嗎?
問幾題覺得「好像不錯」,不等於系統真的可靠。這堂課帶你建立一份小小的測試集,分開檢查「有沒有找到對的資料」和「有沒有答對、有沒有引用」,再用一次改一件事的方式,讓數字告訴你怎麼改善。
上一堂結束時,你的問答小幫手已經能回答、能引用、找不到時會說找不到。你可能也記下了幾題它答得怪怪的問題。
接下來最自然的反應是:改改系統提示、調調參數,再問幾題看看。但這樣做有個陷阱:你修好了這一題,可能悄悄弄壞了另一題,而你不會發現。
這堂課要建立一套「健康檢查」,讓每次修改都有數字可以比較。
測試集:一份有答案的考卷
測試集(test set)就是一組你事先準備好、知道正確答案的問題。每題記下三件事:
- 問題:用使用者真的會用的說法,不要照抄文件用字。
- 應該出自哪份文件:用來檢查檢索有沒有找對。
- 參考答案:用一兩句話寫下正確答案的重點。
而且一定要放幾題手冊裡沒有答案的問題。上一堂說過,誠實說找不到是重要的功能,它也需要被測試。
不用一開始就寫一百題。十到二十題就很有用了,之後每遇到一個答錯的真實問題,就把它加進去。測試集會跟著你的系統一起長大。
分兩層檢查
RAG 有兩個步驟,就可能在兩個地方出錯:
分開檢查,才知道該修哪裡。
- 檢索層:正確的資料有沒有被找出來?如果沒有,模型再聰明也只能說找不到,或者更糟,根據錯的資料回答。
- 生成層:資料找對了,模型有沒有答對?有沒有標引用?有沒有加油添醋?
寫一個評估程式
建立 eval.py,和 rag.py 放在同一個資料夾:
from pydantic import BaseModel
from rag import answer, client, MODEL, NOT_FOUND, TOP_K
from store import search
# expected_source 是 None 的題目,代表手冊裡沒有答案,系統應該說找不到
TEST_SET = [
{"question": "特休沒休完可以留到明年嗎?", "expected_source": "leave.md",
"reference": "可以遞延到隔年六月底前使用,逾期未休的天數依規定折算工資。"},
{"question": "病假請了四天要附什麼?", "expected_source": "leave.md",
"reference": "連續病假超過三天,需要附醫療院所開立的證明。"},
{"question": "搭計程車可以報帳嗎?", "expected_source": "expense.md",
"reference": "只限晚上十點後下班,或需要攜帶大型器材時。"},
{"question": "發票不見了怎麼辦?", "expected_source": "expense.md",
"reference": "填寫支出證明單並請主管簽名,每人每年最多使用三次。"},
{"question": "報帳的錢什麼時候會拿到?", "expected_source": "expense.md",
"reference": "主管審核後,財務部在次月十號匯入薪資帳戶。"},
{"question": "一週可以在家上班幾天?", "expected_source": "remote.md",
"reference": "通過試用期後,每週最多兩天。"},
{"question": "在家工作的時候什麼時段一定要在線?", "expected_source": "remote.md",
"reference": "上午十點到下午四點之間要保持通訊軟體在線。"},
{"question": "公司尾牙在哪裡辦?", "expected_source": None, "reference": None},
{"question": "育嬰假可以請多久?", "expected_source": None, "reference": None},
]
class Verdict(BaseModel):
correct: bool # 內容和參考答案一致
cited: bool # 重點都有標出 [編號]
reason: str # 一句話說明理由
JUDGE_SYSTEM = """你是嚴格的評分員,比較「系統回答」和「參考答案」。
- correct:系統回答的重點和參考答案一致,而且沒有加入參考答案以外的規定,才是 true。用字不同沒關係。
- cited:系統回答的每個重點都有用 [編號] 標出來源,才是 true。
- reason:用一句繁體中文說明你的判斷。"""
def judge(case: dict, reply: str) -> Verdict | None:
content = (
f"問題:{case['question']}\n\n"
f"參考答案:{case['reference']}\n\n"
f"系統回答:{reply}"
)
response = client.messages.parse(
model=MODEL,
max_tokens=16000,
system=JUDGE_SYSTEM,
messages=[{"role": "user", "content": content}],
output_format=Verdict,
)
return response.parsed_output
def main():
answerable = [c for c in TEST_SET if c["expected_source"]]
unanswerable = [c for c in TEST_SET if not c["expected_source"]]
# 第一層:檢索命中率(不需要呼叫 Claude)
hits = 0
for case in answerable:
sources = [h["source"] for h in search(case["question"], k=TOP_K)]
if case["expected_source"] in sources:
hits += 1
else:
print(f"[檢索失敗] {case['question']},找到的是 {sources}")
print(f"\n檢索命中率:{hits}/{len(answerable)}\n")
# 第二層:回答是否正確、是否有引用(請 Claude 當評分員)
correct = cited = 0
for case in answerable:
reply = answer(case["question"])["answer"]
verdict = judge(case, reply)
if verdict is None:
print(f"[無法評分] {case['question']}")
continue
correct += verdict.correct
cited += verdict.cited
mark = "通過" if verdict.correct and verdict.cited else "失敗"
print(f"[{mark}] {case['question']}\n {verdict.reason}")
print(f"\n答對:{correct}/{len(answerable)} 有引用:{cited}/{len(answerable)}\n")
# 拒答檢查:用程式比對就好,不需要評分員
refused = 0
for case in unanswerable:
reply = answer(case["question"])["answer"]
if NOT_FOUND in reply:
refused += 1
else:
print(f"[該拒答卻回答了] {case['question']}\n {reply}")
print(f"正確拒答:{refused}/{len(unanswerable)}")
if __name__ == "__main__":
main()
執行:
python eval.py
你會得到一份成績單,例如:
檢索命中率:7/7
[通過] 特休沒休完可以留到明年嗎?
回答提到可遞延到隔年六月底與折算工資,並標出 [1]。
[失敗] 搭計程車可以報帳嗎?
回答只說可以報帳,漏掉晚上十點後或攜帶器材的條件。
...
答對:6/7 有引用:7/7
正確拒答:2/2
這份成績單就是你改善系統的起點。
逐段看懂
檢索命中率:只看「正確的文件有沒有出現在前 k 塊」,完全不用呼叫 Claude,跑起來又快又免費。如果這個數字不好看,先別碰系統提示,回頭調整切塊或 k。
拒答檢查:判斷回答裡有沒有那句固定的「找不到」文字,用一行程式就能做到。能用程式判斷的,就不要麻煩模型:程式不會累、不會心情不好、也不收錢。
LLM 評分員:「回答和參考答案意思是否一致」很難用程式判斷,因為用字可以完全不同。這時才請模型當評分員,這種做法叫 LLM-as-judge。我們用 LLM API 單元學過的結構化輸出,讓它回傳固定格式的判斷,程式就能直接計算分數。
小心使用 LLM 評分員
LLM 評分員很方便,但它也是模型,也會犯錯。幾個保護自己的習慣:
- 標準寫成是非題:「correct 是 true 還是 false」比「請打 1 到 10 分」穩定得多。分數題常常每次給的都不一樣。
- 給它參考答案:讓評分員比對你寫好的答案,而不是讓它自己判斷規定是什麼。
- 要它說理由:
reason欄位讓你看得懂它為什麼這樣判,也方便抓出它判錯的地方。 - 定期人工抽查:每次跑完,挑幾題自己讀一遍,看你和評分員的判斷一不一致。如果常常不一致,先修評分標準,再相信它的分數。
- 注意成本:每跑一次,每題都會呼叫兩次 API(回答一次、評分一次)。測試集維持小而精,簡單的評分也可以考慮用比較小、比較便宜的模型。
一次只改一件事
有了成績單,就可以開始改善了。關鍵原則只有一條:
同時改兩件事,就不知道是哪一個造成的變化。
準備一張簡單的紀錄表,每次修改都記一行:
| 版本 | 改了什麼 | 檢索命中 | 答對 | 有引用 | 正確拒答 |
|---|---|---|---|---|---|
| v1 | 起始設定:150 字、重疊 1 段、k=4 | 7/7 | 6/7 | 7/7 | 2/2 |
| v2 | 系統提示加上「條件和例外都要寫出來」 | 7/7 | 7/7 | 7/7 | 2/2 |
| v3 | k 從 4 改成 2 | 6/7 | 6/7 | 6/7 | 2/2 |
(表格裡的數字只是示範,請填你自己跑出來的結果。)
從這張表可以讀出:v2 的修改有幫助,保留;v3 讓檢索變差,改回來。這就是用數據做決定,而不是靠「感覺好像比較好」。
這是我的 eval.py。請幫我加一個功能:每次執行完,把這次的設定(MAX_CHARS、OVERLAP、TOP_K、MIN_SCORE)和四個分數,連同日期時間,附加一行到 results.csv。如果檔案不存在就先寫標題列。設定值請從 chunking.py 和 rag.py 匯入,不要寫死。
這是本單元的最後一個練習,把五堂課串起來:
- 執行
eval.py,得到你的第一份成績單,記在紀錄表的 v1。 - 把上一堂記下的「答得不好的問題」加進
TEST_SET,並寫好參考答案。再多加兩題「手冊裡沒有答案」的問題。 - 挑三題,自己讀回答並判斷對錯,和評分員的判斷比一比。
- 選一個你最想改善的地方,只改一個設定(例如
MAX_CHARS、TOP_K,或系統提示的一句話),再跑一次,記下 v2。如果改的是切塊設定,記得先刪掉chroma_db重建索引。 - 進階:把
docs換成你自己的文件(例如課程講義、產品說明、社團規章),為它寫十題測試題,跑出它的第一份成績單。恭喜,你做出了一個屬於自己、可以被評估的知識庫問答系統。
讀完了嗎?
標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。
留言與討論
卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。