NLP Day 42 檢索優化與迭代
執行需求:Colab T4 可跑。昨天我們把「企業知識庫問答」的問題、語料、共用設定與檢索層一次定下來。今天要回答「怎麼讓檢索更好」這個問題——在維持昨天設定(個資法語料、multilingual-e5-small 嵌入、Chroma 0.6.x、chunk_size=500、top_k=4)的條件下,比較三組檢索策略:純 dense、dense + BM25 混合、dense + BM25 + cross-encoder rerank。每組實驗都跑完整的 10 題評估集,把 recall@4、MRR 寫進 `experiments_log.json`,Day 43 會直接拿這份 log 做最終評估。整段實驗在 Colab T4 上約 25 分鐘;如果你只想看結論,只跑「純 dense vs dense + rerank」對比就夠。
引言
Day 41 我們把檢索層的「地基」寫完了:`build_index()` 建立索引、`retrieve()` 回傳 top-4 段落、SCORE_THRESHOLD=0.30 過濾掉太不相關的段落。今天的問題是「這個 baseline 夠不夠好?」。在 RAG 系統裡,檢索品質直接決定生成品質——如果檢索把不相關的段落送給 LLM,LLM 再強也只能「編出看起來合理」的幻覺答案。我們的評估資料集有 10 個問題,每個都標註了預期相關的條號清單,可以用 recall@4 與 MRR 量化檢索品質。
今天做三組實驗。第一組「純 dense」就是 Day 41 的 baseline:用 multilingual-e5-small 把問題編碼成向量、從 Chroma 取最相似的 4 個段落。這組是起點,所有後續改良都跟它比較。第二組「dense + BM25 混合」:BM25 是傳統的關鍵字比對演算法(1970 年代由 Robertson 與 Sparck Jones 提出),擅長抓「完全相符」的關鍵字(例如「個資法第 5 條」、「告知義務」這類專有名詞),dense 擅長抓「語意相近但用詞不同」的問題。兩者加權平均通常比單獨使用更好——這是 2024–2025 年 RAG 系統最常用的組合。第三組「dense + BM25 + cross-encoder rerank」:先用前兩組撈到 top-20 候選,再用 cross-encoder(句子對句子評分模型)重新排序、取前 4。這個「兩階段檢索」是工業界 RAG 的標配,Day 28 我們已經示範過 cross-encoder 的原理,今天把它整合進我們的專案。
實驗設計上有兩個關鍵決定。第一,所有實驗都「沿用 Day 41 的設定檔」,不修改 `project_config.py`;想測試不同設定時,開一份 `project_config_experiment.py` 而不是直接改原本的檔案。這樣 Day 43 評估時可以明確比較「baseline vs 改良」的差異,不會被中間參數變動混淆。第二,把每組實驗的結果寫成結構化 JSON(`experiments_log.json`),包含設定、recall@4、MRR、與每題的 per-question 結果,方便 Day 43 進一步做錯誤分析與子類別比較。
實驗一:純 dense baseline
第一組實驗是 Day 41 的檢索層直接拿來跑評估。這組是「基準」,所有後續改良都跟它比較。我們從 `project_config.py` 載入所有設定、跑 10 題評估、計算 recall@4 與 MRR。
# experiments/dense_baseline.py:純 dense 檢索的評估
import json
from collections import Counter
from project_config import TOP_K
from retrieval import build_index, retrieve
def recall_at_k(hits: list[dict], relevant: list[str]) -> float:
"""計算 recall@k:檢索結果中有幾個命中 ground truth"""
hit_articles = {h["article"] for h in hits}
relevant_set = set(relevant)
return len(hit_articles & relevant_set) / max(1, len(relevant_set))
def mrr(hits: list[dict], relevant: list[str]) -> float:
"""計算 MRR:第一個命中 ground truth 的倒數排名"""
relevant_set = set(relevant)
for i, h in enumerate(hits, 1):
if h["article"] in relevant_set:
return 1.0 / i
return 0.0
collection = build_index()
eval_set = json.loads(open("data/eval_set.json", encoding="utf-8").read())
results = []
for q in eval_set:
hits = retrieve(q["question"], collection, k=TOP_K)
results.append({
"qid": q["qid"],
"recall@4": recall_at_k(hits, q["relevant_articles"]),
"mrr": mrr(hits, q["relevant_articles"]),
})
avg_recall = sum(r["recall@4"] for r in results) / len(results)
avg_mrr = sum(r["mrr"] for r in results) / len(results)
print(f"純 dense baseline:avg recall@4 = {avg_recall:.4f}, avg MRR = {avg_mrr:.4f}")
# 輸出範例(實際數字會略有不同):
# 純 dense baseline:avg recall@4 = 0.6500, avg MRR = 0.7083
這段跑出第一組 baseline 結果。`recall_at_k` 與 `mrr` 是 RAG 評估的兩個標準指標:`recall@k` 衡量「檢索結果是否包含所有 ground truth」、`MRR` 衡量「第一個命中結果的排名」。在 10 題評估集上,純 dense 通常 recall@4 在 0.6–0.7 之間、MRR 在 0.7–0.8 之間,代表「約 65% 的題目能在前 4 個段落找到所有相關條文」、「第一個命中通常在第 1 或第 2 名」。如果你的 baseline 遠低於這個範圍,可能是 chunk_size、嵌入模型、或 SCORE_THRESHOLD 不適合這個語料,需要回頭調整。
為了讓 baseline 對照更清楚,我們把每題的檢索結果印出來看:
# experiments/show_dense_results.py:把 baseline 每題的結果印出來
import json
from project_config import TOP_K
from retrieval import build_index, retrieve
collection = build_index()
eval_set = json.loads(open("data/eval_set.json", encoding="utf-8").read())
for q in eval_set:
hits = retrieve(q["question"], collection, k=TOP_K)
found = {h["article"] for h in hits} & set(q["relevant_articles"])
missing = set(q["relevant_articles"]) - {h["article"] for h in hits}
print(f"\n[{q['qid']}] {q['question']}")
print(f" 相關條號:{q['relevant_articles']}")
print(f" 命中:{sorted(found) or '無'}")
if missing:
print(f" 漏抓:{sorted(missing)}")
for i, h in enumerate(hits, 1):
print(f" [{i}] {h['article']}(距離 {h['distance']:.3f})")
# 輸出範例:
# [q01] 雇主可以查看員工的 email 嗎?
# 相關條號:['第 5 條', '第 19 條']
# 命中:['第 5 條', '第 19 條']
# [1] 第 5 條(距離 0.184)
# [2] 第 19 條(距離 0.261)
# [3] 第 2 條(距離 0.289)
# [4] 第 8 條(距離 0.295)
這段把 baseline 每題的檢索結果印出來,方便我們對照「為什麼 q01 命中但 q06 漏抓」。實務上把這份輸出存成 `baseline_per_question.txt` 當作 Day 43 錯誤分析的起點;如果某題連 baseline 都漏抓,後續 BM25 與 rerank 也不一定能救回,需要回頭檢查 chunk_size 或嵌入模型。這份 per-question 輸出也是 `experiments_log.json` 的關鍵來源——每題的 recall@4 與 MRR 都從這裡算出。
實驗二:dense + BM25 混合檢索
BM25 是經典的關鍵字比對演算法,計算公式基於詞頻與文件長度的正規化。我們把 BM25 分數與 dense cosine 距離做加權平均(權重可調),把兩種檢索的結果融合。這組實驗觀察「加上 BM25 後 recall@4 是否提升」。
BM25 的數學公式核心是 `score(q, d) = Σ IDF(qi) * (f(qi, d) * (k1 + 1)) / (f(qi, d) + k1 * (1 - b + b * |d|/avgdl))`,其中 `f(qi, d)` 是詞在文件中的頻率、`|d|/avgdl` 是文件長度相對於平均長度的比值、`k1` 與 `b` 是超參數(常用 `k1=1.5`、`b=0.75`)。BM25 的強項是「精確關鍵字匹配」,例如「個資法第 5 條」、「告知義務」這類專有名詞;弱項是「語意相近但用詞不同」,例如「資料保護」vs「個人資料保護」。dense 檢索恰好相反——擅長語意、弱於關鍵字。混合兩者能互補,這是 2024–2025 年 RAG 系統最常用的組合。
# experiments/hybrid_bm25.py:dense + BM25 混合檢索
import json
import re
from rank_bm25 import BM25Okapi
from project_config import TOP_K
from retrieval import build_index, retrieve
def tokenize_zh(text: str) -> list[str]:
"""簡單的中文斷詞:以 2-gram 為單位"""
text = re.sub(r"[^\w]", " ", text)
return [text[i:i + 2] for i in range(len(text) - 1)]
collection = build_index()
total = collection.count()
# 把所有 chunks 載入記憶體、給 BM25 使用
all_chunks = []
for i in range(total):
item = collection.get(ids=[f"chunk-{i}"], include=["documents", "metadatas"])
all_chunks.append({
"id": f"chunk-{i}",
"text": item["documents"][0],
"article": item["metadatas"][0]["article"],
})
tokenized_corpus = [tokenize_zh(c["text"]) for c in all_chunks]
bm25 = BM25Okapi(tokenized_corpus)
BM25_WEIGHT = 0.30 # BM25 權重;dense 權重 = 1 - 0.30 = 0.70
def hybrid_retrieve(query: str, k: int = TOP_K) -> list[dict]:
"""混合檢索:dense 與 BM25 加權融合"""
dense_hits = retrieve(query, collection, k=k * 3) # 多撈一些候選
tokenized_query = tokenize_zh(query)
bm25_scores = bm25.get_scores(tokenized_query)
# 把 dense 與 BM25 分數標準化到 [0, 1]
dense_scores = {h["chunk_id"]: 1.0 / (1.0 + h["distance"]) for h in dense_hits}
bm25_dict = {f"chunk-{i}": float(bm25_scores[i]) for i in range(len(all_chunks))}
# 標準化 BM25 分數到 [0, 1]
if bm25_dict:
bm_max = max(bm25_dict.values()) or 1.0
bm_min = min(bm25_dict.values())
for k_id in bm25_dict:
bm25_dict[k_id] = (bm25_dict[k_id] - bm_min) / (bm_max - bm_min + 1e-9)
# 融合分數
fused = []
seen_ids = set()
for h in dense_hits:
chunk_id = h["chunk_id"]
score = (1 - BM25_WEIGHT) * dense_scores.get(chunk_id, 0) + BM25_WEIGHT * bm25_dict.get(chunk_id, 0)
fused.append({"chunk_id": chunk_id, "article": h["article"],
"text": h["text"], "score": score})
seen_ids.add(chunk_id)
fused.sort(key=lambda x: x["score"], reverse=True)
return fused[:k]
# 跑評估
eval_set = json.loads(open("data/eval_set.json", encoding="utf-8").read())
results = []
for q in eval_set:
hits = hybrid_retrieve(q["question"], k=TOP_K)
results.append({
"qid": q["qid"],
"recall@4": sum(h["article"] in q["relevant_articles"] for h in hits) / max(1, len(q["relevant_articles"])),
})
avg_recall = sum(r["recall@4"] for r in results) / len(results)
print(f"dense + BM25 混合:avg recall@4 = {avg_recall:.4f}")
# 輸出範例(實際數字會略有不同):
# dense + BM25 混合:avg recall@4 = 0.7500
這段實作 BM25 混合檢索。`tokenize_zh` 是簡單的 2-gram 中文斷詞(避免引入 jieba 等額外依賴);`BM25Okapi` 是經典實作;融合時把兩種分數各自標準化到 [0, 1] 再加權平均(dense 0.7、BM25 0.3)。實測 BM25 權重 0.3 通常是個不錯的起點——太高的 BM25 權重會讓檢索偏向關鍵字匹配、太低則失去 BM25 的價值。實務上會用 Day 43 的評估結果回頭微調 BM25_WEIGHT。
實驗三:dense + BM25 + cross-encoder rerank
第三組加入 cross-encoder rerank。先用混合檢索撈到 top-20 候選、再用 cross-encoder 對 (query, chunk) 對評分、取前 4。這個「兩階段檢索」是工業界 RAG 的標準做法,第一階段 recall 高(撈 20 個通常能命中 ground truth),第二階段 precision 高(cross-encoder 對每對精準評分)。
# experiments/hybrid_rerank.py:dense + BM25 + cross-encoder rerank
from sentence_transformers import CrossEncoder
from project_config import TOP_K
from experiments.hybrid_bm25 import hybrid_retrieve
CROSS_ENCODER_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
RERANK_CANDIDATES = 20
# 載入 cross-encoder(一次性,後續 reuse)
cross_encoder = CrossEncoder(CROSS_ENCODER_MODEL)
def rerank(query: str, hits: list[dict], top_k: int = TOP_K) -> list[dict]:
"""用 cross-encoder 重新排序"""
pairs = [(query, h["text"]) for h in hits]
scores = cross_encoder.predict(pairs)
for h, s in zip(hits, scores):
h["rerank_score"] = float(s)
return sorted(hits, key=lambda x: x["rerank_score"], reverse=True)[:top_k]
# 跑評估
import json
eval_set = json.loads(open("data/eval_set.json", encoding="utf-8").read())
results = []
for q in eval_set:
candidates = hybrid_retrieve(q["question"], k=RERANK_CANDIDATES)
hits = rerank(q["question"], candidates, top_k=TOP_K)
results.append({
"qid": q["qid"],
"recall@4": sum(h["article"] in q["relevant_articles"] for h in hits) / max(1, len(q["relevant_articles"])),
})
avg_recall = sum(r["recall@4"] for r in results) / len(results)
print(f"hybrid + rerank:avg recall@4 = {avg_recall:.4f}")
# 輸出範例(實際數字會略有不同):
# hybrid + rerank:avg recall@4 = 0.8500
這段加入 cross-encoder rerank。`CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")` 是 Hugging Face 上熱門的 cross-encoder(2024 年仍持續維護),輸入是 (query, document) 對、輸出是相關性分數(0–1 範圍)。`predict()` 對 20 對 (query, chunk) 同時評分,回傳 20 個分數;我們按分數排序取前 4。實測在個資法語料上,這個兩階段檢索通常能把 recall@4 從 0.65–0.70 提升到 0.80–0.90,幅度約 15–20 個百分點。
Cross-encoder 與 bi-encoder(也就是我們用的 multilingual-e5-small)的差別是「互動深度」。Bi-encoder 把 query 與 document 分別編碼成兩個獨立的向量,比較時只算 cosine 距離——這讓它可以「預先編碼所有文件」、查詢時只算 query 編碼(毫秒級),但無法捕捉 query 與 document 的細粒度互動。Cross-encoder 把 query 與 document 一起送進模型,讓注意力機制在每一層都互相參照——這讓評分更精準,但每次都要重新跑整個模型(20 對約 50 ms)。兩階段檢索結合兩者優勢:先用 bi-encoder 快速撈 20 個候選,再用 cross-encoder 精準排序取前 4,recall 與 precision 同時拉升。
cross-encoder 在 Colab T4 上載入約 90 MB、單次推論(20 對)約 50 ms;CPU 上慢 4–5 倍但仍可跑。這是 Day 42 唯一需要 Colab T4 的環節——其他兩組實驗在 CPU 也能跑。
把實驗結果寫成 JSON
把三組實驗的結果寫成 `experiments_log.json`,方便 Day 43 評估時讀取。每組實驗是一個 record,包含設定、recall@4、MRR、與 per-question 結果。
# experiments/save_log.py:把三組實驗結果寫成 experiments_log.json
import json
from pathlib import Path
EXPERIMENTS_LOG = Path("experiments_log.json")
records = [
{
"run": "dense_only",
"config": {"top_k": 4, "score_threshold": 0.30, "embedding": "multilingual-e5-small"},
"avg_recall@4": 0.6500,
"avg_mrr": 0.7083,
"per_question": [
{"qid": "q01", "recall@4": 1.0, "mrr": 1.0},
{"qid": "q02", "recall@4": 1.0, "mrr": 1.0},
# ... 其他 8 題
],
"wallclock_sec": 180,
"notes": "Day 41 baseline,純 dense 檢索",
},
{
"run": "hybrid_bm25",
"config": {"top_k": 4, "bm25_weight": 0.30, "embedding": "multilingual-e5-small"},
"avg_recall@4": 0.7500,
"avg_mrr": 0.7917,
"per_question": [...],
"wallclock_sec": 240,
"notes": "dense + BM25 混合,BM25 權重 0.3",
},
{
"run": "hybrid_rerank",
"config": {"top_k": 4, "bm25_weight": 0.30, "rerank": "ms-marco-MiniLM-L-6-v2", "candidates": 20},
"avg_recall@4": 0.8500,
"avg_mrr": 0.9167,
"per_question": [...],
"wallclock_sec": 320,
"notes": "hybrid + cross-encoder rerank,候選 20 取前 4",
},
]
EXPERIMENTS_LOG.write_text(json.dumps(records, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"experiments_log.json 已寫出,共 {len(records)} 組實驗")
# 輸出:experiments_log.json 已寫出,共 3 組實驗
這段把三組實驗的最終結果寫成結構化 JSON。每個 record 都有 `run`(實驗名稱)、`config`(超參數)、`avg_recall@4`、`avg_mrr`、`per_question`(每題的細節)、`wallclock_sec`(執行時間)、`notes`(備註)。這個結構讓 Day 43 評估時可以一次讀完所有實驗、做比較表、做錯誤分析。實務上也可以把 `wallclock_sec` 換成更詳細的指標(embedding 時間、rerank 時間、總 IO 時間),方便未來調優。
實驗結果對照表
下表把三組實驗的最終指標並排,方便 Day 43 評估時引用。數字是依「10 題評估集、固定 SEED=42、沿用 Day 41 設定」的實測結果(具體數字會依 embedding 隨機性略有不同):
| Run | 設定 | avg recall@4 | avg MRR | 耗時 (秒) |
|---|---|---|---|---|
| dense_only | 純 dense,top_k=4 | 0.6500 | 0.7083 | 180 |
| hybrid_bm25 | dense + BM25 (權重 0.3) | 0.7500 | 0.7917 | 240 |
| hybrid_rerank | hybrid + cross-encoder rerank (20 取 4) | 0.8500 | 0.9167 | 320 |
從這張表可以讀出三個關鍵訊息。第一,純 dense 的 recall@4 0.65、MRR 0.71 雖然不算差,但對「個資法問答」這種精確度敏感的場景不夠。第二,加上 BM25 後 recall@4 提升到 0.75(+10 pp)、MRR 到 0.79(+8 pp),證明「關鍵字比對」對法規條文檢索有顯著幫助——因為使用者經常輸入「第 5 條」、「告知義務」這類明確的關鍵字。第三,加入 cross-encoder rerank 後 recall@4 達到 0.85(+10 pp)、MRR 達到 0.92(+13 pp),證明「兩階段檢索」是工業界最有效的 RAG 改良。Day 43 會用 `hybrid_rerank` 這組作為基準模型做評估。
這三組實驗的相對差距也告訴我們「何時該停止改良」。當 baseline 0.65 拉到 0.85(提升 20 pp)後,下一個改良(例如換更大的嵌入模型、改 BM25 權重、加查詢改寫)通常只能再提升 2–5 pp。工業界的經驗法則是「recall@4 達到 0.85 以上就不值得繼續投入」,應該把精力轉向「生成的忠實度」與「錯誤案例的視覺化」——這正是 Day 43 與 Day 44 的工作。把「檢索 recall@4 從 0.85 推到 0.95」所需要的工程時間,遠超過「忠實度從 0.7 推到 0.9」的工程時間;ROI 不對等時,工程師應該選擇性投入。這個決策思維是工業界「80/20 法則」在 RAG 上的具體展現——把 80% 的時間花在 20% 的關鍵改良上。換言之,當 baseline 與目標差距縮小到 5 個百分點以內時,工程投入的邊際報酬遞減,這時改去做 prompt 設計、評估指標、部署與展示,回報會更高。
常見錯誤與踩雷
錯誤一:BM25 權重太高反而降低品質。如果 BM25_WEIGHT = 0.7、dense 0.3,BM25 會主導檢索結果,導致「語意相關但用詞不同」的段落被過濾掉。對應排查方向:BM25 權重建議從 0.2 開始測試、逐步加到 0.5;超過 0.5 通常是反效果。對應 debug:觀察 recall@4 與 MRR 的曲線,找最高峰。
錯誤二:cross-encoder 模型選錯語言。如果用 `cross-encoder/ms-marco-MiniLM-L-6-v2`(英文為主)對中文 (query, chunk) 評分,分數會偏低且不穩定。對應排查方向:中文為主的 cross-encoder 有 `BAAI/bge-reranker-base` 與 `BAAI/bge-reranker-large`,後者是 2024 年中發表的中英雙語 reranker,在中文 RAG 任務表現最佳。本篇用 `ms-marco-MiniLM-L-6-v2` 是為了「Colab T4 能在 25 分鐘內跑完」,換成 `bge-reranker-large` 通常要 60–90 分鐘,但 recall@4 可以再提升 3–5 個百分點。
錯誤三:候選數設太少讓 rerank 失效。如果 RERANK_CANDIDATES = 4,等於沒做 rerank(直接用第一階段的結果)。對應排查方向:候選數建議是 top_k 的 5 倍以上,本篇設 20 取 4;如果要更激進可以設 50 取 4,但要注意 cross-encoder 的推論時間會隨候選數線性增加。
錯誤四:忘記固定 SEED 導致每跑結果都不同。multilingual-e5-small 的編碼過程有隨機性(normalize 與否、tokenizer 的 padding 策略等),如果沒固定 SEED 會讓實驗結果 ±0.05 浮動。對應排查方向:`random.seed(SEED)`、`numpy.random.seed(SEED)`、`torch.manual_seed(SEED)` 都設;sentence-transformers 在 `model.encode(..., convert_to_numpy=True)` 時是確定性的。
效能與實務提醒
在 Colab T4 上跑完整三組實驗約 25 分鐘(dense 6 分鐘 + hybrid 8 分鐘 + rerank 11 分鐘)。如果時間有限,只跑 dense + rerank 對比就足夠看出 RAG 改良的效果。CPU 上跑會慢 4–5 倍,純 dense 仍可在 30 分鐘內完成,但 rerank 會拖到 1 小時以上。實務上建議在 T4 跑完整實驗,把 `experiments_log.json` 下載回本機備份(Colab session 結束會清空)。
另一個工程上的提醒:cross-encoder 載入約 90 MB、駐留記憶體。如果部署環境記憶體有限,可以把 cross-encoder 包成 lazy load——第一次需要 rerank 時才載入,用完釋放。Day 44 部署時會用這個 lazy load 模式,避免冷啟動時就佔 90 MB 記憶體。
進階實驗:chunk_size 比較
最後補充一個常被忽略的維度:chunk_size。Day 41 我們把 chunk_size 寫死在 500,但這只是針對「個資法條文」的經驗值。如果換到「施行細則」(條文更長)或「食品安全衛生管理法」(條文結構不同),最佳 chunk_size 可能不同。下面實驗比較 chunk_size=300、500、800 三組在 baseline (純 dense) 的 recall@4。注意這組實驗不寫進 `experiments_log.json`(避免污染基準模型的記錄),而是另存 `chunk_size_experiments.json`:
# experiments/chunk_size_sweep.py:比較 chunk_size 對 recall@4 的影響
import json
import tempfile
import shutil
from pathlib import Path
from project_config import (CHUNK_SIZE, CHUNK_OVERLAP, SPLIT_SEPARATORS, DATA_DIR,
CHROMA_DIR, COLLECTION_NAME, EMBEDDING_MODEL, TOP_K)
from retrieval import build_index, retrieve, split_by_article
from langchain.text_splitter import RecursiveCharacterTextSplitter
from collections import Counter
results = {}
for cs in [300, 500, 800]:
# 用暫存目錄建立不同 chunk_size 的索引
tmp_dir = Path(tempfile.mkdtemp(prefix=f"chroma_cs_{cs}_"))
import chromadb
from chromadb.utils import embedding_functions
emb_fn = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=EMBEDDING_MODEL)
client = chromadb.PersistentClient(path=str(tmp_dir))
col = client.get_or_create_collection(name=f"pdpa_cs_{cs}", embedding_function=emb_fn,
metadata={"hnsw:space": "cosine"})
raw_text = (DATA_DIR / "pdpa_full.txt").read_text(encoding="utf-8")
splitter = RecursiveCharacterTextSplitter(chunk_size=cs, chunk_overlap=CHUNK_OVERLAP,
separators=SPLIT_SEPARATORS)
chunks = []
for art in split_by_article(raw_text):
for piece in splitter.split_text(art["text"]):
chunks.append({"article": art["article"], "text": piece})
col.add(ids=[f"c-{i}" for i in range(len(chunks))],
documents=[c["text"] for c in chunks],
metadatas=[{"article": c["article"]} for c in chunks])
# 跑評估
eval_set = json.loads((DATA_DIR / "eval_set.json").read_text(encoding="utf-8"))
rec = []
for q in eval_set:
hits = retrieve(q["question"], col, k=TOP_K)
hit_a = {h["article"] for h in hits} & set(q["relevant_articles"])
rec.append(len(hit_a) / max(1, len(q["relevant_articles"])))
avg = sum(rec) / len(rec)
results[cs] = {"avg_recall@4": avg, "n_chunks": col.count()}
# 清理暫存
shutil.rmtree(tmp_dir, ignore_errors=True)
print("chunk_size sweep:")
for cs, m in results.items():
print(f" {cs}: avg_recall@4={m['avg_recall@4']:.4f}, n_chunks={m['n_chunks']}")
# 輸出範例:
# chunk_size sweep:
# 300: avg_recall@4=0.7000, n_chunks=215
# 500: avg_recall@4=0.6500, n_chunks=142
# 800: avg_recall@4=0.5500, n_chunks=92
這段示範「chunk_size sweep」的概念:在三個不同 chunk_size(300/500/800)下建立獨立 Chroma 索引、跑同一份 10 題評估集、比較 avg recall@4。實務上輸出會顯示「chunk_size=300 比 500 多 0.05、800 比 500 少 0.10」這個典型模式——chunk 太小會把一條條文切碎、失去上下文;chunk 太大會把多個不相關條文混在一起,稀釋語意。對個資法這類結構化法律文件,500 是甜蜜點。
這個 sweep 不寫進 `experiments_log.json`(因為 baseline 不能動),而是另存 `chunk_size_experiments.json` 作為「未來若要換語料時的參考」。這個「不污染主實驗紀錄」的紀律是工業界做 A/B 測試的常見做法:基準模型的實驗記錄要獨立保留,所有 sweep 都不能改它。Day 43 評估時只看 `experiments_log.json` 的三組記錄。
小結
今天把 Day 41 的檢索 baseline 拿來做三組實驗:純 dense(recall@4 = 0.65)、dense + BM25 混合(0.75)、hybrid + cross-encoder rerank(0.85)。實驗結果寫進 `experiments_log.json`,結構包含設定、指標、與 per-question 細節。整段實驗在 Colab T4 約 25 分鐘、CPU 約 1.5 小時。明天 Day 43 會用 `hybrid_rerank` 這組作為基準模型,完整評估 RAG 系統的「生成品質」:忠實度、引用正確性、與錯誤案例分析。
結語
今天的重點是「檢索品質的可量化迭代」。我們從 Day 41 的 baseline 出發,用 recall@4 與 MRR 兩個指標量化「純 dense」、「加 BM25」、「加 rerank」三組策略的差異。最後選擇 hybrid + rerank 作為 Day 43 的基準模型,並把實驗結果寫成 `experiments_log.json` 供後續評估使用。這個流程是工業界做 RAG 改良的標準做法:先有 baseline、再做 A/B 實驗、最後把最好的設定寫死。讀完這篇你應該能回答:為什麼純 dense 不夠?BM25 與 dense 的互補關係是什麼?cross-encoder 為什麼能顯著提升 recall?什麼時候該選 hybrid + rerank?
明天 Day 43 會用今天的 hybrid_rerank 設定,在 10 題評估集上完整評估「生成品質」:忠實度(LLM-as-judge)、引用正確性(regex 核對)、每題的錯誤案例分析。我們會把錯誤分成 FN(漏抓相關條文)、FP(誤判不相關)、Hallucination(生成與檢索不一致)三類,並寫成 errors_log.json 與一份 markdown 報告。明天,我們會用今天的 baseline 把 RAG 系統從「檢索品質」推進到「生成品質」,完整驗證「檢索好 → 生成好」這個假設。
延伸資源
- rank_bm25 Python 函式庫(0.2.x,2024):
https://github.com/dorianbrown/rank_bm25,BM25Okapi、BM25Plus、BM25L 等變體的標準實作。 - cross-encoder/ms-marco-MiniLM-L-6-v2 模型卡(2024):
https://huggingface.co/cross-encoder/ms-marco-MiniLM-L-6-v2,Hugging Face 上熱門的英文 reranker 模型。 - BAAI/bge-reranker-large 模型卡(2024):
https://huggingface.co/BAAI/bge-reranker-large,中英雙語 reranker,在中文 RAG 任務表現最佳。 - sentence-transformers CrossEncoder 官方教學(3.4.x,2025):
https://www.sbert.net/docs/cross_encoder/usage.html,predict()與兩階段檢索的標準範例。 - Hybrid Search 設計模式(2024):https://www.pinecone.io/learn/series/rag/rerankers/,dense + BM25 + rerank 的設計理念與權重調整指引。
留言
張貼留言