所有入門單元免費開放 · 課程內容持續更新中

評估 RAG:答對了嗎?有引用嗎?

問幾題覺得「好像不錯」,不等於系統真的可靠。這堂課帶你建立一份小小的測試集,分開檢查「有沒有找到對的資料」和「有沒有答對、有沒有引用」,再用一次改一件事的方式,讓數字告訴你怎麼改善。

第 12.5 堂約 15 分鐘

上一堂結束時,你的問答小幫手已經能回答、能引用、找不到時會說找不到。你可能也記下了幾題它答得怪怪的問題。

接下來最自然的反應是:改改系統提示、調調參數,再問幾題看看。但這樣做有個陷阱:你修好了這一題,可能悄悄弄壞了另一題,而你不會發現。

這堂課要建立一套「健康檢查」,讓每次修改都有數字可以比較。

測試集:一份有答案的考卷

測試集(test set)就是一組你事先準備好、知道正確答案的問題。每題記下三件事:

  • 問題:用使用者真的會用的說法,不要照抄文件用字。
  • 應該出自哪份文件:用來檢查檢索有沒有找對。
  • 參考答案:用一兩句話寫下正確答案的重點。

而且一定要放幾題手冊裡沒有答案的問題。上一堂說過,誠實說找不到是重要的功能,它也需要被測試。

不用一開始就寫一百題。十到二十題就很有用了,之後每遇到一個答錯的真實問題,就把它加進去。測試集會跟著你的系統一起長大。

分兩層檢查

RAG 有兩個步驟,就可能在兩個地方出錯:

判斷流程:一題答錯時,先看正確的塊有沒有被找到;沒有就是檢索層的問題,可以調整切塊、k、門檻或換 embedding 模型;有找到卻答錯就是生成層的問題,可以改善系統提示、附上範例、減少干擾的塊

分開檢查,才知道該修哪裡。

  • 檢索層:正確的資料有沒有被找出來?如果沒有,模型再聰明也只能說找不到,或者更糟,根據錯的資料回答。
  • 生成層:資料找對了,模型有沒有答對?有沒有標引用?有沒有加油添醋?

寫一個評估程式

建立 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 讓檢索變差,改回來。這就是用數據做決定,而不是靠「感覺好像比較好」。

可以這樣問 AI

這是我的 eval.py。請幫我加一個功能:每次執行完,把這次的設定(MAX_CHARS、OVERLAP、TOP_K、MIN_SCORE)和四個分數,連同日期時間,附加一行到 results.csv。如果檔案不存在就先寫標題列。設定值請從 chunking.py 和 rag.py 匯入,不要寫死。

行動練習

這是本單元的最後一個練習,把五堂課串起來:

  1. 執行 eval.py,得到你的第一份成績單,記在紀錄表的 v1。
  2. 把上一堂記下的「答得不好的問題」加進 TEST_SET,並寫好參考答案。再多加兩題「手冊裡沒有答案」的問題。
  3. 挑三題,自己讀回答並判斷對錯,和評分員的判斷比一比。
  4. 選一個你最想改善的地方,只改一個設定(例如 MAX_CHARS、TOP_K,或系統提示的一句話),再跑一次,記下 v2。如果改的是切塊設定,記得先刪掉 chroma_db 重建索引。
  5. 進階:把 docs 換成你自己的文件(例如課程講義、產品說明、社團規章),為它寫十題測試題,跑出它的第一份成績單。恭喜,你做出了一個屬於自己、可以被評估的知識庫問答系統。

讀完了嗎?

標記完成後,單元與專案的進度會自動更新(只存在這台裝置的瀏覽器)。

卡住了?讓 AI 當你的家教複製這段提問範本,貼到 Claude 或其他 AI 工具,再補上你卡住的地方。

留言與討論

卡關了、有心得,或發現內容需要更新?都歡迎留言。留言使用 GitHub 帳號登入。

留言區即將開放。現在可以先到 GitHub 討論區 發問,或看看 交流頁 的其他管道。