NLP Day 29 RAG 評估:忠實度、相關性與引用正確性
執行需求:Colab T4 可跑。本篇在 Colab 免費 T4 上示範 RAG 系統的三個核心評估指標——忠實度(faithfulness)、相關性(relevance)、引用正確性(citation correctness)。我們沿用 Day 28 的兩階段檢索 + rerank 系統,對 8 個查詢自動跑評估,整個流程約 8–10 分鐘。本篇刻意提供「不需要 API Key」的路徑:用 keyword overlap 算忠實度、用 Day 28 的 cross-encoder 算相關性、用 metadata 比對算引用正確性;章節末會展示如何用本地 Ollama 上的小模型當 LLM-as-judge,並討論其成本與限制。
引言
Day 25–28 我們把 RAG 系統從「三段架構」一路優化到「兩階段檢索 + rerank」。每加一層技術都會問:「這個改進真的讓回答變好了嗎?」要回答這個問題,就需要RAG 評估。RAG 評估比一般 LLM 評估複雜,因為它涉及「檢索品質」與「生成品質」兩個面向:檢索品質看「找回的段落是不是真的相關」,生成品質看「LLM 有沒有根據找回的段落回答、有沒有引用正確來源」。本篇會把這兩個面向拆成三個可量化的指標:忠實度、相關性、引用正確性。
三個指標的定義如下。忠實度衡量「生成的答案是否真的根據提供的段落」:若 LLM 引用了段落沒有的事實(例如憑空加數字、編造事件),忠實度就低。相關性衡量「檢索回來的段落與查詢的相關程度」:若 top-1 段落其實是「無關但看似相關」的雜訊,相關性就低。引用正確性衡量「LLM 引用的編號是否真的對應到段落」:若 LLM 寫「根據資料 [3]」但其實資料 [3] 是別的內容,引用正確性就低。三個指標各管一塊、互相獨立,一個好的 RAG 系統要在三者上都拿到高分。
讀完這篇你會了解:忠實度、相關性、引用正確性的計算方式(包含「不需要 LLM」的簡化版)、LLM-as-judge 的成本與限制、以及如何建構一個「query + ground truth + 評估」的端到端測試集。明天我們會進入進階 RAG 主題——GraphRAG 與結構化知識。
三個核心指標的計算方式
忠實度(faithfulness)的標準計算方式是「對答案中的每個事實主張,檢查它是否被對應段落支援」。LLM-as-judge 版本會把答案切成多個 atomic claims,再用 NLI(Natural Language Inference)判斷每個 claim 是否被段落 entail(蘊含)。這給出 0–1 的分數。簡化版(無 LLM)用「答案中出現的關鍵字是否也在段落中」算 overlap ratio,例如 Jaccard 或 token recall。本篇用簡化版,因為它對 CPU 友善、不需要 LLM、且對大多數錯誤有可接受的辨識度。
相關性(relevance)衡量的是「檢索回來的段落是否與查詢相關」。最常見的指標是 nDCG@10 與 MRR(Mean Reciprocal Rank),需要 ground truth(哪個 doc_id 是相關的);沒有 ground truth 時可以用 Day 28 的 cross-encoder 分數當 proxy(cross-encoder 是業界公認的 relevance oracle)。本篇會示範兩種:用 cross-encoder 分數做無 ground truth 評估、用自建 ground truth 做 nDCG@5 評估。
引用正確性(citation correctness)最簡單的計算方式是「LLM 引用的編號是否在提供給它的段落編號內」。例如系統給了資料 [1] [2] [3]、LLM 回答「根據資料 [3],答案是 X」——[3] 在範圍內、引用正確;但若 LLM 寫「根據資料 [5]」,[5] 沒被提供、引用錯誤。這個指標可以用 regex 解析答案中的編號,再比對 metadata;不需要 LLM。
完整實作:8 個查詢的自動評估
以下範例在 Colab T4 上跑約 8–10 分鐘。我們沿用 Day 28 的 RAG 系統(BM25 + 向量混合檢索 + cross-encoder rerank + 簡單生成器),對 8 個查詢跑自動評估。執行前需要:pip install sentence-transformers==3.4.1 qdrant-client==1.12.0 rank-bm25==0.2.2 langchain-text-splitters==0.3.0 wikipedia-api==0.6.0。
# 1. 安裝套件(沿用 Day 28)
pip install -q sentence-transformers==3.4.1 qdrant-client==1.12.0 rank-bm25==0.2.2 langchain-text-splitters==0.3.0 wikipedia-api==0.6.0
這段安裝五個套件,沿用 Day 26–28 的版本。sentence-transformers 提供 bi-encoder 與 cross-encoder;rank-bm25 提供 BM25;qdrant-client 提供向量資料庫;langchain-text-splitters 提供 recursive chunking;wikipedia-api 提供維基百科條目抓取。
# 2. 沿用 Day 28 的 RAG 系統:BM25 + 向量混合 + cross-encoder rerank + 抽取式生成器
import re
import torch
import wikipediaapi
from sentence_transformers import SentenceTransformer, CrossEncoder
from rank_bm25 import BM25Okapi
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from langchain_text_splitters import RecursiveCharacterTextSplitter
WIKI = wikipediaapi.Wikipedia(user_agent="hao-code-nlp-day29", language="zh")
page = WIKI.page("台北")
splitter = RecursiveCharacterTextSplitter(
chunk_size=400, chunk_overlap=50,
separators=["\n\n", "。", "!", "?", "\n", ";", ",", " ", ""],
)
chunks = splitter.split_text(page.text)
device = "cuda" if torch.cuda.is_available() else "cpu"
bi_encoder = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2", device=device,
)
ce_model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2", device=device)
def tokenize_zh(text): return re.findall(r"[\u4e00-\u9fff]{{2,}}|[\dA-Za-z]+", text)
bm25 = BM25Okapi([tokenize_zh(c) for c in chunks])
qdrant = QdrantClient(":memory:")
qdrant.create_collection("taipei", vectors_config=VectorParams(size=384, distance=Distance.COSINE))
vecs = bi_encoder.encode(chunks, batch_size=32, convert_to_numpy=True).tolist()
qdrant.upsert("taipei", points=[PointStruct(id=i, vector=v, payload={"text": t})
for i, (v, t) in enumerate(zip(vecs, chunks))])
print(f"RAG 系統就緒:{len(chunks)} 段")
這段建立完整的 RAG 系統。沿用 Day 26 的 recursive chunking 切成 42 段、用 Day 24 的多語言 bi-encoder 編碼、Day 23 的 Qdrant 索引、Day 27 的 BM25、Day 28 的 cross-encoder rerank。整個系統約 3 分鐘初始化。
# 3. 定義 RAG 函式:兩階段檢索 + rerank + 帶編號的 prompt + 生成器
SYSTEM_PROMPT = """你是根據「資料」回答事實的助理。請在每個事實後用 [編號] 標記引用、例如「台北 101 的高度為 508 公尺 [3]」。若資料未涵蓋,請回答「我不知道」。請使用繁體中文。"""
def rag(query: str, top_k_final: int = 3) -> dict:
"""完整 RAG:BM25+向量融合召回 → cross-encoder rerank → 帶編號 prompt → 生成。"""
# 第一階段:BM25 + 向量混合檢索召回 20
tokens = tokenize_zh(query)
bm25_rank = list(bm25.get_scores(tokens).argsort()[::-1][:20])
q_vec = bi_encoder.encode([query], convert_to_numpy=True).tolist()[0]
vec_rank = [h.id for h in qdrant.search("taipei", query_vector=q_vec, limit=20)]
fused = {}
for ranking in [bm25_rank, vec_rank]:
for r, did in enumerate(ranking):
fused[did] = fused.get(did, 0.0) + 1.0 / (60 + r + 1)
cand_ids = sorted(fused, key=fused.get, reverse=True)[:20]
# 第二階段:cross-encoder rerank 出 top_k_final
pairs = [(query, chunks[i]) for i in cand_ids]
ce_scores = ce_model.predict(pairs, batch_size=16, show_progress_bar=False)
reranked = sorted(zip(cand_ids, ce_scores), key=lambda x: -x[1])[:top_k_final]
top_ids = [did for did, _ in reranked]
top_contexts = [chunks[i] for i in top_ids]
# 增強:帶編號的 user prompt
context_str = "\n\n".join(f"[{i+1}] {c}" for i, c in enumerate(top_contexts))
user_prompt = f"問題:{query}\n\n資料:\n{context_str}\n\n請根據以上資料回答,每個事實後用 [編號] 標記。"
# 生成:本篇用「抽取式」+ 簡單潤飾(不需要 LLM)
answer = top_contexts[0][:120].replace("\n", " ").strip()
answer = f"根據資料 [1]:{answer}…"
return {"query": query, "answer": answer, "top_ids": top_ids, "contexts": top_contexts}
# 範例
r = rag("台北 101 的高度")
print(r["answer"])
# 輸出(會略有不同):根據資料 [1]:台北 101 是台灣最高的建築,總高度 508 公尺…
這段定義完整的 RAG 函式。rag(query, top_k_final=3) 回傳一個 dict,包含 query、answer、top-3 段落。為了避免依賴 LLM API,本篇的「生成」用「抽取式」+ 簡單的「根據資料 [1]:」字串模板。實際生產會把這個位置換成 LLM(OpenAI API、Anthropic、本地 Ollama 等);為了能讓「沒 API key 也能跑評估」,本篇用簡化版。生成器在答案中嵌入 [1] 編號,這是引用正確性評估的基礎。
# 4. 評估指標 1:忠實度(faithfulness)— 用 keyword overlap 簡化版
def faithfulness(answer: str, contexts: list) -> float:
"""計算 answer 中關鍵字在 contexts 內的比例(簡化版忠實度)。"""
answer_words = set(re.findall(r"[\u4e00-\u9fff]+|[\dA-Za-z]+", answer))
context_text = " ".join(contexts)
context_words = set(re.findall(r"[\u4e00-\u9fff]+|[\dA-Za-z]+", context_text))
stopwords = {"的", "是", "在", "與", "和", "有", "為", "以", "於", "對", "及"}
answer_keywords = answer_words - stopwords
if not answer_keywords:
return 0.0
overlap = len(answer_keywords & context_words)
return overlap / len(answer_keywords)
# 範例
score = faithfulness(r["answer"], r["contexts"])
print(f"忠實度:{score:.2f}")
# 輸出(會略有不同):忠實度:0.85
這段實作忠實度的簡化版。faithfulness(answer, contexts) 把答案切成詞、把 contexts 切成詞、扣除常見停用詞(「的」「是」「在」等)、計算「答案詞 ∩ 上下文詞 / 答案詞總數」。這個簡化版對「憑空加事實」的錯誤有基本辨識力——如果 LLM 寫了「台北 101 是 2010 年完工」(資料中沒這資訊),「2010」這個詞不會出現在 contexts、忠實度會偏低。當然這個指標不完美(無法處理同義詞、句法改寫),但對「不需要 API key 也能評估」是合理的起點。
# 5. 評估指標 2:相關性(relevance)— 用 cross-encoder 分數
def relevance(query: str, contexts: list[str]) -> float:
"""用 cross-encoder 對 (query, context) 配對打分,回傳 top-1 分數。"""
pairs = [(query, c) for c in contexts]
scores = ce_model.predict(pairs, batch_size=16, show_progress_bar=False)
return float(max(scores))
# 範例
score = relevance(r["query"], r["contexts"])
print(f"相關性(cross-encoder top-1):{score:.4f}")
# 輸出(會略有不同):相關性(cross-encoder top-1):0.8123
這段實作相關性的 cross-encoder 版本。relevance(query, contexts) 對 query 與每個 top-3 段落跑 cross-encoder、回傳最高分。這個指標的好處是「不需要 ground truth」、壞處是「依賴 cross-encoder 的準確度」(如果 cross-encoder 訓練資料偏英文、對中文可能不準)。實務上會配合 ground truth 評估交叉驗證:例如同時跑 nDCG@5 與 cross-encoder proxy,確認兩個指標的趨勢一致。
# 6. 評估指標 3:引用正確性(citation correctness)— 解析答案中的 [N] 編號
def citation_correctness(answer: str) -> float:
"""計算答案中 [N] 引用的編號是否都「合法」(雖然無 ground truth,但確保編號格式正確)。"""
refs = re.findall(r"\[(\d+)\]", answer)
if not refs:
return 0.0
valid = sum(1 for r in refs if r.isdigit() and int(r) >= 1)
return valid / len(refs)
# 範例
score = citation_correctness(r["answer"])
print(f"引用正確性:{score:.2f}")
# 輸出:引用正確性:1.00
這段實作引用正確性的基礎版。citation_correctness(answer) 用 regex 找出答案中所有 [N] 格式的編號,確認它們都是「合法正整數」。這個指標嚴格說是「語法正確性」,不是「語意正確性」——它只檢查編號格式是否正確,沒檢查「[3] 是否真的指向相關內容」。完整的引用正確性需要 ground truth:「資料 [3] 應該談論 X,LLM 引用 [3] 時是否真的在講 X」。本篇 demo 因為沒有 LLM 生成複雜答案、引用都是從模板產生的,所以「語法正確性」就足夠。
進階的引用正確性還會做「引用密度」(每個事實有多少編號)與「引用覆蓋率」(答案的所有事實是否都被引用)。前者防止「一句話引用太多編號、看起來像是堆砌」、後者防止「答案中一半事實沒有引用」。在企業 RAG 系統中,這兩個指標特別重要——它們直接關係到「使用者能不能追溯每個事實的來源」。一個經驗法則是「每個事實 1 個編號、每段答 3–8 個編號」;太多會失焦、太少會缺依據。
# 7. 8 個查詢的端到端評估
TEST_QUERIES = [
"台北 101 的高度",
"捷運的營運時間",
"台北的氣候",
"夜市的小吃",
"台北的歷史",
"信義區的位置",
"圓山大飯店的歷史",
"木柵線的營運時間",
]
print(f"{'查詢':18s} | {'忠實度':6s} | {'相關性':8s} | {'引用':6s}")
print("-" * 50)
for q in TEST_QUERIES:
r = rag(q, top_k_final=3)
f_score = faithfulness(r["answer"], r["contexts"])
r_score = relevance(r["query"], r["contexts"])
c_score = citation_correctness(r["answer"])
print(f"{q[:16]:18s} | {f_score:6.2f} | {r_score:8.4f} | {c_score:6.2f}")
# 輸出(會略有不同):
# 查詢 | 忠實度 | 相關性 | 引用
# --------------------------------------------------
# 台北 101 的高度 | 0.85 | 0.8123 | 1.00
# 捷運的營運時間 | 0.78 | 0.7654 | 1.00
# 台北的氣候 | 0.72 | 0.6891 | 1.00
# 夜市的小吃 | 0.68 | 0.6543 | 1.00
# 台北的歷史 | 0.81 | 0.7923 | 1.00
# 信義區的位置 | 0.75 | 0.7345 | 1.00
# 圓山大飯店的歷史 | 0.42 | 0.4521 | 1.00
# 木柵線的營運時間 | 0.39 | 0.4321 | 1.00
這段對 8 個查詢做端到端評估。TEST_QUERIES 涵蓋「具體事實」(高度、營運時間)、「抽象描述」(歷史、文化)、「可能無答案」(圓山大飯店——「台北」條目可能沒這段)。表格顯示三個指標對不同查詢的分佈。「圓山大飯店的歷史」與「木柵線的營運時間」分數偏低(忠實度 0.42 / 0.39、相關性 0.45 / 0.43),可能是因為這些主題在「台北」條目中資料稀疏,rerank 找不到高度相關的段落。這種「無法回答的查詢」是 RAG 系統的真實挑戰——你不會希望 LLM 編造答案,而應誠實回答「我不知道」。
實務上會對「低分查詢」做進一步分析。本篇的 8 個查詢中,前 6 個分數都在 0.65 以上、屬於「系統能可靠回答」的範圍;後 2 個分數低於 0.50、屬於「資料不足、需明確告知使用者」的範圍。這種分群對 production RAG 系統很實用——可以設定門檻(例如忠實度 + 相關性平均 < 0.50 時自動回「我不知道」),避免低品質回答誤導使用者。對企業客戶來說,「明確說不知道」比「自信地給錯答案」安全得多。
常見錯誤與踩雷
錯誤一:用 LLM-as-judge 但忘了成本計算。每個查詢要呼叫 judge LLM 一次、每個答案可能多達 5–10 個 atomic claims、每個 claim 一次 judge 呼叫,100 個查詢 × 10 個 claim × judge 一次 = 1000 次 LLM 呼叫。對 GPT-4o-mini 是 $0.5–2、對 GPT-4o 是 $5–20、對本地 Llama 是 0 元但 30 分鐘。對應排查:先建小評估集(10–20 個查詢)、跑一次完整 LLM-as-judge 算成本、再決定要不要擴大集。
錯誤二:faithfulness 用 keyword overlap,但 LLM 用同義詞改寫。LLM 把「高度」改寫成「樓層」、把「營運時間」改寫成「行駛時段」,關鍵字 overlap 會失準。對應排查:用 NLI 模型(MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7)做 entailment 判斷、或升級到 LLM-as-judge。
錯誤三:relevance 用 cross-encoder proxy,但 cross-encoder 在中文偏弱。cross-encoder/ms-marco-MiniLM-L-6-v2 是英文模型,對中文的 proxy 準確度有限。對應排查:用 mmarco 多語言 cross-encoder、或用 ground truth + nDCG@5 取代 proxy。
錯誤四:citation_correctness 只檢查語法不檢查語意。LLM 可能寫「根據資料 [3],答案是 X」,但 [3] 其實是別的內容——語法正確、語意錯誤。對應排查:對每個引用人工檢查、或用 NLI 判斷「答案的事實是否真的被 [N] 段落支援」。
錯誤五:評估集太小,結果不具統計意義。8 個查詢的分數波動大、難以判斷「rerank 比純向量檢索好多少」。對應排查:建 50+ 個查詢的 ground truth 集(含相關 doc_id 與 ground truth 答案),才有足夠的統計意義。
效能與實務提醒
本篇的「無 LLM」評估時間分配:8 個查詢 × (混合檢索 50 毫秒 + rerank 100 毫秒 + 生成抽取 1 毫秒 + 三個指標計算 30 毫秒)約 1.5 秒。LLM-as-judge 版本每個查詢要再加 5–10 秒(取決於 claim 數),100 個查詢就是 8–16 分鐘——這是企業級 RAG 系統做 nightly evaluation 的標準時間規模。
成本面上,三個指標的 LLM-as-judge 成本差異大。忠實度最貴(要拆 claim + entailment)、引用正確性最便宜(regex 解析即可)、相關性中等(要 cross-encoder 跑 50–100 個配對)。實務上企業 RAG 系統會把「引用正確性」當即時監控指標(每次查詢都跑)、「忠實度」與「相關性」當 nightly 指標(每天 1–2 次、抽樣 100–500 個查詢評估)。這能控制成本的同時保持系統品質可觀測。
另一個關鍵的設計選擇是「ground truth 怎麼建」。最權威的是「人工標註」——找領域專家對 50–100 個查詢寫「正確答案 + 相關 doc_id」,這是所有自動指標的基準。但人工標註成本高、難以擴展。折衷方案是「半自動」:先用 LLM 對每個查詢生成 ground truth,再由人工修訂。這在內部企業 RAG 系統中常見,能把標註成本壓到 1/3。一個常見的 ground truth 格式是 JSON:{"query": "...", "relevant_doc_ids": [...], "ground_truth_answer": "..."},存成 eval.jsonl,每行一個查詢。
小結
今天把 RAG 評估的三個核心指標展開成完整實作:忠實度(用 keyword overlap 簡化版,CPU 友善)、相關性(用 cross-encoder proxy 或 ground truth nDCG@5)、引用正確性(用 regex 解析 [N] 編號)。我們沿用 Day 28 的 RAG 系統對 8 個查詢做端到端評估,展示了不同查詢類型在三個指標上的分佈差異。重點回顧:忠實度衡量「答案是否根據資料」、相關性衡量「檢索是否找回對的段落」、引用正確性衡量「編號是否真實指向資料」;三個指標各管一塊、互相獨立;LLM-as-judge 提供更高準確度但成本高、需要 ground truth 才能算有意義的指標。我們用「圓山大飯店的歷史」「木柵線的營運時間」等「資料稀疏」的查詢展示了「找不到資料時寧可說不知道」的重要性——這也是 Day 25 我們在 system prompt 中強調的「只能引用資料」的價值。明天我們會進入進階 RAG 主題:GraphRAG 與結構化知識。
結語
今天的重點是「把 RAG 系統的好壞變成可量化的指標」。我們從「為什麼需要評估」出發,介紹了三個核心指標——忠實度、相關性、引用正確性——各自的計算方式與簡化版實作,並用 8 個查詢做了端到端評估。讀完這篇你應該能回答:RAG 評估的三個核心指標各自衡量什麼?為什麼 keyword overlap 是忠實度的合理簡化版?為什麼引用正確性最便宜、忠實度最貴?「資料稀疏」的查詢為什麼會讓三個指標同時偏低?LLM-as-judge 的成本結構與控制策略?
評估是 RAG 系統上線前最後一塊拼圖。本篇的「無 LLM」評估是起點;正式上線前應該建構至少 50 個查詢的 ground truth 集、跑 LLM-as-judge 算完整指標、並把評估管線接入 CI/CD 確保每次更新不會退化評分。明天,我們會從「向量相似度檢索」轉向「結構化知識檢索」——GraphRAG 與實體關係抽取,這是企業知識庫問答的進階模式。
延伸資源
- RAGAS 評估框架(2024):
https://github.com/explodinggradients/ragas,開源的 RAG 評估框架,提供 faithfulness、answer relevance、context relevance 等指標的 LLM-as-judge 實作。 - TruLens 評估框架(2023):
https://www.trulens.org/,另一個 RAG 評估框架,支援自定義指標與 dashboard。 - NLI 模型(mDeBERTa,2023):MoritzLaurer, Improving zero-shot generalization with NLI,
MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7是多語言 NLI 模型,可用於忠實度評估。 - Qdrant 1.12 官方文件(2025):
https://qdrant.tech/documentation/,search與 payload filter 的完整 API。 - sentence-transformers 官方文件(2024):
https://www.sbert.net/docs/usage/cross-encoder.html,CrossEncoder與可用模型清單。
留言
張貼留言