6.7 實踐:分析 RAG 輸出品質
用一份小測試集,實際計算檢索的 Hit Rate 和 MRR,並分析「找不到」門檻的取捨。
以下用一份小型測試集計算檢索指標,再檢查相似度門檻能不能可靠地分開「有答案」與「沒有答案」的問題。
先說執行狀況
- 這份程式我實際跑過,下面的輸出是真的,而且重跑一次結果相同。
- 只評估檢索,沒有評估生成,因為生成需要接 LLM(我沒有金鑰)。生成的評估方法在 6.3,那段提示詞我沒有實際跑。
- 知識庫的文件和 14 個問題都是我編的,目的是示範方法,不代表任何真實公司。
測試集
知識庫有 3 份虛構文件,一共切成 10 塊:員工手冊(特別休假、加班規定、病假與事假、薪資發放)、報帳規定(差旅費、報帳期限、發票規定)、資訊安全規定(密碼規定、遠端工作、設備借用)。
可回答的問題 14 題,每題標好正確答案在哪一塊,包括口語和換句話說的問法,例如:
| 問題 | 正確答案所在的塊 |
|---|---|
| 沒休完的特休假會怎麼處理? | 員工手冊 > 特別休假 |
| 薪水幾號會匯進來? | 員工手冊 > 薪資發放 |
| 發票可以開我個人的名字嗎? | 報帳規定 > 發票規定 |
| 在家上班要注意什麼? | 資訊安全規定 > 遠端工作 |
沒有答案的問題 4 題:公司有提供員工旅遊補助嗎?公司裡有健身房嗎?年終獎金怎麼發?員工可以拿到公司的股票選擇權嗎?
完整程式
存成 eval_demo.py:
"""評估 RAG 的檢索品質:Hit Rate、MRR,以及「找不到」門檻的取捨"""
import chromadb
from sentence_transformers import SentenceTransformer
EMBED_MODEL = "paraphrase-multilingual-MiniLM-L12-v2"
# ---------- 知識庫:三份虛構的文件 ----------
DOCS = {
"員工手冊.md": """# 員工手冊
## 特別休假
員工在同一公司服務滿六個月未滿一年者,每年有三天特別休假。滿一年未滿兩年者,有七天。滿兩年未滿三年者,有十天。
特別休假的日期,由員工和公司協商安排。年度結束時沒有休完的天數,公司應該折算成工資發給員工。
## 加班規定
加班必須事先經主管同意。平日加班前兩小時,以平日每小時工資的 1.34 倍計算。第三小時起,以 1.67 倍計算。
## 病假與事假
病假一年內累計未超過三十日,工資折半發給。事假一年內以十四日為限,不發給工資。
## 薪資發放
每月五日發放上個月的薪資,遇到假日順延至下一個工作日。
""",
"報帳規定.md": """# 報帳規定
## 差旅費
出差的住宿費,每晚上限新台幣 2,500 元。交通費以實支實付為原則,搭乘高鐵須附上票根。
## 報帳期限
出差結束後 14 天內必須完成報帳。逾期未報帳者,須先向主管說明原因。
## 發票規定
報帳必須附上抬頭為公司的統一發票,個人抬頭的發票不予報銷。
""",
"資訊安全規定.md": """# 資訊安全規定
## 密碼規定
密碼至少 12 個字元,每 90 天必須更換一次,新密碼不得與前三次相同。
## 遠端工作
遠端工作必須連線公司 VPN,且不得在公共電腦上開啟公司文件。
## 設備借用
借用公司筆電最長七天,歸還前必須清除個人資料。
""",
}
# ---------- 測試集:問題 → 正確答案所在的那一塊 ----------
ANSWERABLE = [
("沒休完的特休假會怎麼處理?", "員工手冊.md > 特別休假"),
("年資滿一年可以休幾天假?", "員工手冊.md > 特別休假"),
("平日加班費怎麼算?", "員工手冊.md > 加班規定"),
("加班要先跟誰講?", "員工手冊.md > 加班規定"),
("生病請假有薪水嗎?", "員工手冊.md > 病假與事假"),
("事假最多可以請幾天?", "員工手冊.md > 病假與事假"),
("薪水幾號會匯進來?", "員工手冊.md > 薪資發放"),
("出差住宿一晚最多報多少?", "報帳規定.md > 差旅費"),
("搭高鐵要留什麼文件?", "報帳規定.md > 差旅費"),
("出差回來多久內要報帳?", "報帳規定.md > 報帳期限"),
("發票可以開我個人的名字嗎?", "報帳規定.md > 發票規定"),
("密碼多久要換一次?", "資訊安全規定.md > 密碼規定"),
("在家上班要注意什麼?", "資訊安全規定.md > 遠端工作"),
("筆電可以借多久?", "資訊安全規定.md > 設備借用"),
]
UNANSWERABLE = [
"公司有提供員工旅遊補助嗎?",
"公司裡有健身房嗎?",
"年終獎金怎麼發?",
"員工可以拿到公司的股票選擇權嗎?",
]
def heading_chunks(text):
chunks, path, body = [], [], []
def flush():
content = "\n".join(body).strip()
if content:
chunks.append({"headings": " > ".join(path[1:]), "text": content})
body.clear()
for line in text.splitlines():
if line.startswith("#"):
flush()
level = len(line) - len(line.lstrip("#"))
path[:] = path[: level - 1] + [line.lstrip("# ").strip()]
else:
body.append(line)
flush()
return chunks
embedder = SentenceTransformer(EMBED_MODEL)
chunks = []
for source, content in DOCS.items():
for c in heading_chunks(content):
chunks.append({"key": f"{source} > {c['headings']}", "text": c["text"], "headings": c["headings"]})
print(f"知識庫:{len(chunks)} 塊;可回答的問題 {len(ANSWERABLE)} 題;沒有答案的問題 {len(UNANSWERABLE)} 題\n")
def build_index(with_heading, name):
texts = [f"{c['headings']}\n{c['text']}" if with_heading else c["text"] for c in chunks]
col = chromadb.Client().create_collection(name, metadata={"hnsw:space": "cosine"})
col.add(ids=[str(i) for i in range(len(chunks))], embeddings=embedder.encode(texts).tolist(), documents=[c["text"] for c in chunks])
return col
def search(col, question, k):
r = col.query(query_embeddings=embedder.encode([question]).tolist(), n_results=k)
return [(int(i), d) for i, d in zip(r["ids"][0], r["distances"][0])]
def evaluate(col, label, k_max=5):
hits = {1: 0, 3: 0, 5: 0}
rr = 0.0
misses = []
for question, expected in ANSWERABLE:
ranked = [chunks[i]["key"] for i, _ in search(col, question, k_max)]
if expected in ranked:
rank = ranked.index(expected) + 1
rr += 1 / rank
for k in hits:
if rank <= k:
hits[k] += 1
else:
rank = None
if rank != 1:
misses.append((question, expected, ranked[0], rank))
n = len(ANSWERABLE)
print(f"[{label}] Hit@1 {hits[1]/n:.2f}|Hit@3 {hits[3]/n:.2f}|Hit@5 {hits[5]/n:.2f}|MRR {rr/n:.3f}")
for q, exp, top, rank in misses:
print(f" 沒排第一:{q}|正確={exp}|排名={rank}|第一名={top}")
return col
print("=== 檢索品質:標題放不放進嵌入內容 ===")
with_heading = evaluate(build_index(True, "with-heading"), "標題+內文")
without_heading = evaluate(build_index(False, "text-only"), "只有內文")
print("\n=== 「找不到」的門檻:最近一筆的距離 ===")
ans_d = [search(with_heading, q, 1)[0][1] for q, _ in ANSWERABLE]
un_d = [search(with_heading, q, 1)[0][1] for q in UNANSWERABLE]
print("有答案的問題:最小 %.3f,最大 %.3f" % (min(ans_d), max(ans_d)))
print("沒答案的問題:最小 %.3f,最大 %.3f" % (min(un_d), max(un_d)))
print("門檻|有答案卻被擋掉|沒答案卻放行")
for thr in (0.3, 0.4, 0.5, 0.6, 0.7):
blocked = sum(d > thr for d in ans_d)
leaked = sum(d <= thr for d in un_d)
print(f" {thr:.1f} | {blocked}/{len(ans_d)} | {leaked}/{len(un_d)}")
執行:
uv run --with chromadb --with sentence-transformers eval_demo.py
執行結果
知識庫:10 塊;可回答的問題 14 題;沒有答案的問題 4 題
=== 檢索品質:標題放不放進嵌入內容 ===
[標題+內文] Hit@1 0.86|Hit@3 0.86|Hit@5 1.00|MRR 0.889
沒排第一:事假最多可以請幾天?|正確=員工手冊.md > 病假與事假|排名=4|第一名=員工手冊.md > 特別休假
沒排第一:在家上班要注意什麼?|正確=資訊安全規定.md > 遠端工作|排名=5|第一名=員工手冊.md > 加班規定
[只有內文] Hit@1 0.79|Hit@3 0.86|Hit@5 0.93|MRR 0.824
沒排第一:事假最多可以請幾天?|正確=員工手冊.md > 病假與事假|排名=5|第一名=員工手冊.md > 特別休假
沒排第一:薪水幾號會匯進來?|正確=員工手冊.md > 薪資發放|排名=3|第一名=員工手冊.md > 病假與事假
沒排第一:在家上班要注意什麼?|正確=資訊安全規定.md > 遠端工作|排名=None|第一名=員工手冊.md > 加班規定
=== 「找不到」的門檻:最近一筆的距離 ===
有答案的問題:最小 0.184,最大 0.683
沒答案的問題:最小 0.536,最大 0.684
門檻|有答案卻被擋掉|沒答案卻放行
0.3 | 11/14 | 0/4
0.4 | 6/14 | 0/4
0.5 | 3/14 | 0/4
0.6 | 2/14 | 2/4
0.7 | 0/14 | 4/4
結果分析
一、檢索品質
標題加內文的版本:Hit@5 是 1.00,14 題的正確答案都在前 5 名;但 Hit@1 只有 0.86,有兩題沒排第一。
兩個沒排第一的題目,剛好是兩種典型的故障(6.5):
- 語意太相近的詞混淆:「事假最多可以請幾天?」找到的第一名是「特別休假」,我推測是「請假」和「休假」在語意上很接近(這是猜測,沒有另外驗證)。正確的那一塊排到第 4 名才出現。
- 用詞落差:「在家上班」和文件裡的「遠端工作」,字面完全不同。
二、標題的影響
| Hit@1 | Hit@5 | MRR | |
|---|---|---|---|
| 標題+內文 | 0.86 | 1.00 | 0.889 |
| 只有內文 | 0.79 | 0.93 | 0.824 |
加標題的版本比較好,但差距只有一題:Hit@1 是 12/14 對 11/14,Hit@5 是 14/14 對 13/14。測試集太小,不能因此斷定標題一定有用,只能說在這份資料上沒有變差。
三、門檻:沒有一個數字可以完美分開
這是最重要的觀察。看「最近一筆的距離」:
- 有答案的問題:0.184 到 0.683。
- 沒答案的問題:0.536 到 0.684。
兩個範圍重疊了,所以不管怎麼設,都會有取捨:
| 門檻 | 結果 |
|---|---|
| 0.5 | 沒答案的 4 題都擋下了,但也擋掉了 3 題其實有答案的 |
| 0.6 | 只擋掉 2 題有答案的,但放行了 2 題沒答案的 |
| 0.7 | 全部放行,等於沒有門檻 |
回頭看第 5 章:5.6 用 3 個問題調出的 0.5,在這 18 題的測試集上,會誤擋 3 題有答案的問題。這正是我當時警告的:用很少的樣本得到的門檻,不能直接拿去用。
這代表什麼
- 單靠距離門檻無法可靠地判斷「有沒有答案」,尤其是嵌入模型本身分辨力有限的時候。
- 所以提示詞裡「資料沒有就說沒有」的規則,仍然是必要的第二道防線(4.2、5.5)。
- 可以考慮的改進:重排序模型(它的分數通常比向量距離更能分辨相不相關)、更好的嵌入模型、或讓 LLM 先判斷檢索到的資料「夠不夠回答這個問題」。
這份實驗的限制
- 知識庫只有 10 塊、測試集只有 18 題,樣本很小,指標上的小差距沒有統計意義。
- 文件和問題是我編的,口吻比較整齊,真實使用者的問題會更亂。
- 我只用了一個嵌入模型(
paraphrase-multilingual-MiniLM-L12-v2),換模型結果可能不同。 - 只評估了檢索,沒有評估生成。
自己試試看
- 擴充測試集:加到 50 題以上,加入更口語、更簡短、更模糊的問法。
- 換嵌入模型:比較另一個支援中文的模型,看 Hit@1 和 MRR 怎麼變。
- 調整分塊:把每一節切得更細,或合併,看指標變化。
- 試試重排序或混合檢索:看能不能把「事假」那題排到第一。
- 做一個查詢改寫:把「在家上班」改寫成「遠端工作」再檢索。
- 加入生成評估:接上 LLM,用 6.3 的方法檢查忠實度和引用。
- 畫出距離的分布:把有答案和沒有答案的距離畫成直方圖,看重疊在哪裡。