EN

6.7 實踐:分析 RAG 輸出品質

用一份小測試集,實際計算檢索的 Hit Rate 和 MRR,並分析「找不到」門檻的取捨。

RAG

以下用一份小型測試集計算檢索指標,再檢查相似度門檻能不能可靠地分開「有答案」與「沒有答案」的問題。

先說執行狀況

  • 這份程式我實際跑過,下面的輸出是真的,而且重跑一次結果相同。
  • 只評估檢索,沒有評估生成,因為生成需要接 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),換模型結果可能不同。
  • 只評估了檢索,沒有評估生成。

自己試試看

  1. 擴充測試集:加到 50 題以上,加入更口語、更簡短、更模糊的問法。
  2. 換嵌入模型:比較另一個支援中文的模型,看 Hit@1 和 MRR 怎麼變。
  3. 調整分塊:把每一節切得更細,或合併,看指標變化。
  4. 試試重排序或混合檢索:看能不能把「事假」那題排到第一。
  5. 做一個查詢改寫:把「在家上班」改寫成「遠端工作」再檢索。
  6. 加入生成評估:接上 LLM,用 6.3 的方法檢查忠實度和引用。
  7. 畫出距離的分布:把有答案和沒有答案的距離畫成直方圖,看重疊在哪裡。
← 全部文章