iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
AI Engineering

從零搞懂 RAG 評測之旅系列 第 28 篇

DAY28. 自訂評估指標

  • 分享至 

  • xImage
  •  

昨天的四個指標衡量的是「通用」的 RAG 品質,但每個系統都有自己在意的事。我們的技術問答助理至少有三個需求,是 Faithfulness 與 Context Recall 看不出來的:答案要用繁體中文、知識庫沒有的問題要拒答、比較題要把兩邊都講到。今天就把這三個需求變成可量化的自訂指標。

今日目標

  1. 用規則型指標檢查「繁體中文」與「拒答行為」
  2. 用 AspectCritic 做一個是非判斷型指標
  3. 用 RubricsScore 做一個分級評分型指標
  4. 整理出一份自訂指標報表

我們要做哪些指標?

業務需求 指標 做法
回答要用繁體中文 traditional_chinese 規則型
知識庫沒有的問題要拒答 refused,再算出「正確拒答率」與「誤拒率」 規則型
比較題要兩邊都講到 comparison_complete AspectCritic
回答要完整涵蓋重點 completeness RubricsScore

Step 1:補上「知識庫以外」的測試題

要量測拒答能力,需要一批「知識庫裡沒有答案」的問題。新增 eval/testset_oos.json,oos 是 out-of-scope 的縮寫:

[
  {"id": 101, "question": "今天台中的天氣如何?"},
  {"id": 102, "question": "Kubernetes 的 Pod 是什麼?"},
  {"id": 103, "question": "Python 的 GIL 是什麼?"},
  {"id": 104, "question": "OAuth 2.0 的授權碼流程是什麼?"},
  {"id": 105, "question": "Redis 為什麼這麼快?"}
]

這些題目都沒有 reference,因為「正確答案」本來就是拒答。請確認這些主題真的不在你的 data/ 文件裡,否則系統照實回答反而是對的。

另外要注意,Kubernetes、GIL 這類題目是 qwen3:8b 自己「懂」的內容。這正是考驗重點:模型有能力回答,但 prompt 要求它只能根據資料,看它守不守規矩。

Step 2:寫規則型指標

新建 custom_metrics.py。RAGAS 提供 discrete_metric 裝飾器,讓你把一個普通函式包裝成指標:

from ragas.metrics import AspectCritic, RubricsScore, discrete_metric
from ragas.metrics.result import MetricResult

# 常見的「簡體專用字」(繁體寫法不同,所以出現就代表用了簡體)
SIMPLIFIED_CHARS = set(
    "们这为个说时来学国对会过还发实现开关种应经与机动样请问题"
    "无数据软储库档户统网络码认证权错误资处并将从该让则条约设计读写类"
)
REFUSAL_KEYWORD = "無法回答"


@discrete_metric(name="traditional_chinese", allowed_values=["pass", "fail"])
def traditional_chinese(response: str) -> MetricResult:
    found = sorted({c for c in response if c in SIMPLIFIED_CHARS})
    if found:
        return MetricResult(value="fail", reason=f"出現簡體字:{''.join(found)}")
    return MetricResult(value="pass", reason="未發現簡體字")


@discrete_metric(name="refused", allowed_values=["yes", "no"])
def refused(response: str) -> MetricResult:
    if REFUSAL_KEYWORD in response:
        return MetricResult(value="yes", reason="回答中包含拒答用語")
    return MetricResult(value="no", reason="回答中沒有拒答用語")

拆解這段程式:

片段 說明
@discrete_metric(name=..., allowed_values=[...]) 宣告這是一個「離散值」指標,輸出只能是 allowed_values 裡的其中一個
函式的參數 response 參數名稱就是這個指標需要的資料,呼叫時用同名參數傳入
MetricResult(value=..., reason=...) 回傳結果與原因,reason 在除錯時很有用
使用方式 traditional_chinese.score(response="...")

兩個規則的誠實限制:

  • 簡體字清單只有 60 個常見字,不是完整的簡繁偵測。它能抓到明顯的簡體輸出,但漏掉清單以外的簡體字是有可能的。這是用「簡單、透明、可解釋」換取「完整度」的取捨。若需要更嚴謹,可以改用 OpenCC 這類簡繁轉換套件,不過那會多一個相依套件。
  • 拒答判斷只看關鍵字「無法回答」,因為我們的 prompt 要求模型用這個說法拒答。如果日後你改了 prompt 的拒答用語,這裡也要同步修改。

Step 3:寫評審型指標

在同一個檔案加入兩個由評審模型判斷的指標:

comparison_complete = AspectCritic(
    name="comparison_complete",
    definition=(
        "問題是在比較兩個事物的差異。回答是否同時說明了兩邊各自的特點?"
        "如果回答只說明其中一邊,或沒有做出比較,請判定為不符合。"
    ),
)

completeness = RubricsScore(
    name="completeness",
    rubrics={
        "score1_description": "回答與標準答案幾乎無關,或完全沒有回答問題。",
        "score2_description": "回答只涵蓋標準答案的一小部分,遺漏大部分重點。",
        "score3_description": "回答涵蓋標準答案約一半的重點,仍有明顯遺漏。",
        "score4_description": "回答涵蓋標準答案大部分重點,僅遺漏少數細節。",
        "score5_description": "回答完整涵蓋標準答案的所有重點,沒有遺漏。",
    },
)

AspectCritic 只需要「名稱」與「一句定義」。評審模型讀完定義後,對每筆資料判斷符不符合,輸出 1 或 0。寫定義有幾個訣竅:

  • 把判斷標準寫成可以回答是或否的問題
  • 把「什麼情況算不符合」明講出來(例如「只說明其中一邊」)
  • 一個指標只檢查一件事,不要把兩三個標準塞在同一句

RubricsScore 則要你替每個分數寫出描述(score1_description 到 score5_description)。評審模型會對照這份量表選出最符合的分數。量表的每一級要互相有明顯區別,如果 3 分和 4 分的描述聽起來差不多,評審模型的判斷就會搖擺。我們的量表以「涵蓋標準答案多少重點」為軸,所以評分時會參考測試集的 reference。

Step 4:執行並整理報表

新建 evaluate_custom.py,這支程式會重用昨天的成果:讀取昨天存下來的 eval/rag_outputs.json(避免重跑 20 題生成),再把「知識庫外」的 5 題跑一遍。

import json
from pathlib import Path

import pandas as pd
from ragas import EvaluationDataset, evaluate
from ragas.dataset_schema import SingleTurnSample
from ragas.run_config import RunConfig

from build_rag import build_rag_chain, get_vectorstore
from custom_metrics import comparison_complete, completeness, refused, traditional_chinese
from evaluate_rag import EVAL_DIR, OUTPUTS_PATH, get_judge

OOS_PATH = EVAL_DIR / "testset_oos.json"


def answer_questions(chain, questions):
    rows = []
    for item in questions:
        result = chain.invoke(item["question"])
        rows.append({"question": item["question"], "answer": result["answer"]})
        print(f"[知識庫外] {item['question']} → {result['answer'][:40]}")
    return rows

規則型指標直接逐筆呼叫 .score(),整理成表格:

def run_rule_metrics(in_scope, out_of_scope):
    records = []
    for group, rows in [("知識庫內", in_scope), ("知識庫外", out_of_scope)]:
        for r in rows:
            tc = traditional_chinese.score(response=r["answer"])
            rf = refused.score(response=r["answer"])
            records.append({
                "group": group,
                "question": r["question"],
                "traditional_chinese": tc.value,
                "tc_reason": tc.reason,
                "refused": rf.value,
            })
    return pd.DataFrame(records)

評審型指標沿用昨天的 evaluate() 與評審模型設定:

def run_judge_metric(rows, metric):
    if not rows:
        return pd.DataFrame()
    dataset = EvaluationDataset(samples=[
        SingleTurnSample(user_input=r["question"], response=r["answer"], reference=r["reference"])
        for r in rows
    ])
    llm, embeddings = get_judge()
    result = evaluate(
        dataset=dataset,
        metrics=[metric],
        llm=llm,
        embeddings=embeddings,
        run_config=RunConfig(timeout=600, max_workers=2),
        raise_exceptions=False,
    )
    return result.to_pandas()

接著是報表與主程式:

def report(rule_df, comparison_df, completeness_df):
    inside = rule_df[rule_df["group"] == "知識庫內"]
    outside = rule_df[rule_df["group"] == "知識庫外"]

    print("\n===== 自訂指標(規則型)=====")
    print(f"繁體中文合規率:{(rule_df['traditional_chinese'] == 'pass').mean():.0%}(共 {len(rule_df)} 題)")
    print(f"誤拒率(知識庫內卻拒答):{(inside['refused'] == 'yes').mean():.0%}(共 {len(inside)} 題)")
    print(f"正確拒答率(知識庫外有拒答):{(outside['refused'] == 'yes').mean():.0%}(共 {len(outside)} 題)")

    print("\n===== 自訂指標(評審型)=====")
    if not comparison_df.empty:
        print(f"比較題兩邊兼顧:{comparison_df['comparison_complete'].mean():.2f}"
              f"(共 {len(comparison_df)} 題,NaN {comparison_df['comparison_complete'].isna().sum()} 題)")
    print(f"回答完整度(1–5 分):{completeness_df['completeness'].mean():.2f}"
          f"(共 {len(completeness_df)} 題,NaN {completeness_df['completeness'].isna().sum()} 題)")


if __name__ == "__main__":
    in_scope = json.loads(OUTPUTS_PATH.read_text(encoding="utf-8"))
    chain = build_rag_chain(get_vectorstore())
    out_of_scope = answer_questions(chain, json.loads(OOS_PATH.read_text(encoding="utf-8")))

    rule_df = run_rule_metrics(in_scope, out_of_scope)
    comparison_df = run_judge_metric([r for r in in_scope if r["type"] == "比較"], comparison_complete)
    completeness_df = run_judge_metric(in_scope, completeness)

    rule_df.to_csv(EVAL_DIR / "custom_rule_results.csv", index=False, encoding="utf-8-sig")
    comparison_df.to_csv(EVAL_DIR / "custom_comparison_results.csv", index=False, encoding="utf-8-sig")
    completeness_df.to_csv(EVAL_DIR / "custom_completeness_results.csv", index=False, encoding="utf-8-sig")
    report(rule_df, comparison_df, completeness_df)

幾個設計重點:

  • comparison_complete 只套用在「比較」題(type == "比較",共 4 題)。對「ACID 有哪四個特性」這種題目問「有沒有比較兩邊」沒有意義,會產生誤導性的低分。指標要用在它適用的資料上,這也是昨天在測試集裡加 type 欄位的原因。
  • 「正確拒答率」與「誤拒率」要一起看。 只看正確拒答率,一個「什麼都拒答」的系統會拿滿分;只看誤拒率,一個「什麼都回答」的系統會拿滿分。兩個數字同時好,才代表系統真的會判斷。
  • 昨天必須用 LIMIT = None 跑完 20 題,這支程式才會讀到完整的 rag_outputs.json。如果昨天只跑了 3 題,請先把 LIMIT 改成 None 重跑一次。

執行:

uv run python evaluate_custom.py

規則型指標瞬間就會算完,評審型指標則需要等本地模型。不過這次的呼叫量比昨天少很多:比較題 4 題、完整度 20 題,每題大約各一次模型呼叫。

如何解讀結果

指標 理想狀態 如果偏低,優先檢查
繁體中文合規率 接近 100% 打開 custom_rule_results.csv 看 tc_reason,確認是哪些簡體字;考慮在 prompt 強調繁體中文,或換模型
正確拒答率 接近 100% 檢查 prompt 的拒答指令;看模型是否「忍不住」用自己的知識回答
誤拒率 接近 0% 通常是檢索沒撈到該撈的內容,模型因此拒答,回頭看 Context Recall
比較題兩邊兼顧 越接近 1 越好 檢索可能只撈到比較的其中一邊,考慮調高 k
回答完整度 越高越好 對照 Context Recall:如果兩者都低,是沒撈到;只有完整度低,是模型回答得太簡略

最後再提醒一次評審型指標的老問題:NaN 要先看,本地 8B 模型的判斷有雜訊,分數適合拿來比較前後版本,不適合當絕對標準。規則型指標則沒有這個問題,這也是它們值得優先寫的原因。

小結

今天為系統量身打造了四個指標,明天會用這些指標做錯誤分析與調參,挑出低分的題目、判斷問題出在檢索還是生成,調整 chunk_size 與 k,再與 baseline 比較,驗證改動到底有沒有用。


上一篇
DAY27. 用 RAGAS 建立評估 baseline
下一篇
DAY29. 錯誤分析與調參:用 baseline 驗證改動有沒有用
系列文
從零搞懂 RAG 評測之旅 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言