iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0
AI Engineering

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

DAY27. 用 RAGAS 建立評估 baseline

  • 分享至 

  • xImage
  •  

昨天我們完成了第一版 RAG,但目前只能靠肉眼判斷答案好不好。今天要把「好不好」變成數字:準備一份有標準答案的測試集,讓 RAG 跑一遍,再用 RAGAS 的四個核心指標評分,存成第一份 baseline。之後每次調整系統,都拿新分數跟這份 baseline 比較。

今日目標

  1. 設計 20 題有標準答案的測試集
  2. 讓 RAG 跑完測試集,同時記錄回應時間
  3. 設定 RAGAS 的評審模型(全部使用本機 Ollama)
  4. 計算四個指標,儲存成 CSV baseline
  5. 學會讀懂分數,以及 NaN 代表什麼

Step 1:準備測試集

測試集是評估的地基。我們依 DAY23 準備的 5 份文件,每份出 4 題,放在 eval/testset.json。每一題的格式如下:

[
...
{
  "id": 10,
  "source": "http_rest_api.md",
  "type": "比較",
  "question": "401 和 403 狀態碼有什麼差別?",
  "reference": "401 Unauthorized 代表尚未通過身分驗證;403 Forbidden 代表已驗證身分但沒有權限存取。"
},
...
]
欄位 用途
question 丟給 RAG 的問題
reference 標準答案,Context Recall 與 Context Precision 需要它
source 答案應該出現的文件,DAY29 做錯誤分析時,用來判斷檢索有沒有撈對文件
type 題型(事實、比較、細節),之後可以看哪種題型表現較差

Step 2:讓 RAG 跑完測試集

新建 evaluate_rag.py,先寫前半段:載入測試集、讓 RAG 逐題回答,並記錄每題的回應時間。

import json
import time
from pathlib import Path

from langchain_ollama import ChatOllama, OllamaEmbeddings
from ragas import EvaluationDataset, evaluate
from ragas.dataset_schema import SingleTurnSample
from ragas.embeddings import LangchainEmbeddingsWrapper
from ragas.llms import LangchainLLMWrapper
from ragas.metrics import answer_relevancy, context_precision, context_recall, faithfulness
from ragas.run_config import RunConfig

from build_rag import EMBED_MODEL, LLM_MODEL, OLLAMA_URL, build_rag_chain, get_vectorstore

EVAL_DIR = Path("eval")
TESTSET_PATH = EVAL_DIR / "testset.json"
OUTPUTS_PATH = EVAL_DIR / "rag_outputs.json"
BASELINE_PATH = EVAL_DIR / "baseline.csv"

METRIC_NAMES = ["faithfulness", "answer_relevancy", "context_precision", "context_recall"]


def load_testset(limit=None):
    testset = json.loads(TESTSET_PATH.read_text(encoding="utf-8"))
    return testset[:limit] if limit else testset


def run_rag(chain, testset):
    rows = []
    for i, item in enumerate(testset, 1):
        start = time.perf_counter()
        result = chain.invoke(item["question"])
        latency = time.perf_counter() - start
        rows.append({
            "id": item["id"],
            "source": item["source"],
            "type": item["type"],
            "question": item["question"],
            "reference": item["reference"],
            "answer": result["answer"],
            "contexts": [doc.page_content for doc in result["context"]],
            "latency": round(latency, 2),
        })
        print(f"[{i}/{len(testset)}] {latency:5.1f}s | {item['question']}")
    OUTPUTS_PATH.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8")
    return rows

Step 3:設定評審模型

RAGAS 的指標本身是由 LLM 擔任「評審」:例如 Faithfulness 會請模型把答案拆成一條條說法,再逐條檢查是否有依據。所以我們要告訴 RAGAS 用哪個模型當評審:

def get_judge():
    llm = LangchainLLMWrapper(
        ChatOllama(model=LLM_MODEL, base_url=OLLAMA_URL, temperature=0, reasoning=False)
    )
    embeddings = LangchainEmbeddingsWrapper(
        OllamaEmbeddings(model=EMBED_MODEL, base_url=OLLAMA_URL)
    )
    return llm, embeddings

Step 4:執行評估

def score(rows):
    dataset = EvaluationDataset(samples=[
        SingleTurnSample(
            user_input=r["question"],
            response=r["answer"],
            retrieved_contexts=r["contexts"],
            reference=r["reference"],
        )
        for r in rows
    ])
    llm, embeddings = get_judge()
    result = evaluate(
        dataset=dataset,
        metrics=[faithfulness, answer_relevancy, context_precision, context_recall],
        llm=llm,
        embeddings=embeddings,
        run_config=RunConfig(timeout=600, max_workers=2),
        raise_exceptions=False,
    )
    df = result.to_pandas()
    df["id"] = [r["id"] for r in rows]
    df["source"] = [r["source"] for r in rows]
    df["latency"] = [r["latency"] for r in rows]
    return df

SingleTurnSample 的四個欄位,正是 RAGAS 需要的四樣東西:問題、答案、檢索到的內容、標準答案。

RunConfig 這一行在本地模型上很重要:

參數 為什麼這樣設
max_workers=2 RAGAS 預設會同時發出大量請求,雲端 API 沒問題,但本機的 Ollama 同時處理太多會變得很慢甚至逾時。限制成 2 比較穩
timeout=600 本地 8B 模型單次呼叫可能要好幾秒,預設的逾時時間容易不夠,結果變成無法計算
raise_exceptions=False 某一題評分失敗時不要讓整個程式中斷,該題記為 NaN,其他題繼續

再加上報表與主程式:

def report(df):
    print("\n===== Baseline =====")
    print(df[METRIC_NAMES].mean().round(3).to_string())
    print("\n無法計算(NaN)的題數:")
    print(df[METRIC_NAMES].isna().sum().to_string())
    print(f"\n平均回應時間:{df['latency'].mean():.1f} 秒(最慢 {df['latency'].max():.1f} 秒)")


LIMIT = 3  # 先用少量題目確認流程,確認無誤後改成 None(跑全部 20 題)
RERUN_RAG = True  # 只想重新評分、不想重新生成答案時,改成 False

if __name__ == "__main__":
    EVAL_DIR.mkdir(exist_ok=True)

    if RERUN_RAG or not OUTPUTS_PATH.exists():
        chain = build_rag_chain(get_vectorstore())
        rows = run_rag(chain, load_testset(LIMIT))
    else:
        rows = json.loads(OUTPUTS_PATH.read_text(encoding="utf-8"))

    df = score(rows)
    df.to_csv(BASELINE_PATH, index=False, encoding="utf-8-sig")
    report(df)
    print(f"\n已儲存:{BASELINE_PATH}")

CSV 使用 utf-8-sig 編碼,直接用 Excel 開啟中文才不會變亂碼。

先跑 3 題,再跑 20 題。 本地模型評分很耗時:每一題每個指標都會呼叫好幾次 LLM(Context Precision 甚至每個檢索到的 chunk 都要判斷一次),20 題大約是兩百次以上的模型呼叫。先用 LIMIT = 3 確認整條流程沒問題,再改成 None 跑完整測試集,執行期間可能要等十幾到三十分鐘以上,視你的硬體而定。

執行:

uv run python evaluate_rag.py

如何解讀結果

跑完會得到每個指標的平均分數(0 到 1,越高越好)、NaN 的題數與平均回應時間,並產生 eval/baseline.csv。讀結果時,請依序做三件事:

1. 先看 NaN 的數量。 NaN 代表評審模型這題沒有產生可解析的結果,常見訊息是 failed to parse output。平均分數會自動略過 NaN,所以如果某個指標有一半的題目是 NaN,那個平均值幾乎沒有參考價值。遇到這種情況,優先調整 RunConfig 或換評審模型,而不是急著解讀分數。

2. 看哪個指標最低。 不同指標偏低,指向不同的問題:

偏低的指標 優先懷疑 DAY29 可以調整
Context Recall 沒撈到該撈的內容 k、chunk_size
Context Precision 撈到太多不相關內容 k、chunk_size
Faithfulness 模型編造或超出資料 prompt 的限制條件
Answer Relevancy 答非所問 prompt、模型

3. 打開 CSV,看分數最低的幾題。 平均數會掩蓋問題,真正的線索在個別題目裡。

重要提醒:baseline 是用來比較的

請把 eval/baseline.csv 好好保留,之後的改動都跟它比。同時記下當時的設定,例如 chunk_size=500、chunk_overlap=50、k=4、模型是 qwen3:8b。沒有記錄設定的分數,之後就無法解釋為什麼變好或變差。

另外,本地小模型評審有隨機性,同一份資料重跑兩次,分數可能有小幅差異。看到 0.02 之類的微小變化,不要急著下結論,差距要夠大、或在多題上一致出現,才有意義。

小結

今天建立了評估流程,明天我們要補上 RAGAS 內建指標涵蓋不到的面向:自訂評估指標,例如檢查答案是否使用繁體中文、遇到知識庫以外的問題是否正確拒答。


上一篇
DAY26. 檢索器與生成鏈,完成第一版 RAG
下一篇
DAY28. 自訂評估指標
系列文
從零搞懂 RAG 評測之旅 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言