跳到主要內容

NLP Day 42 檢索優化與迭代

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 的設計理念與權重調整指引。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...