Đánh giá RAG: đo retrieval trước, rồi mới đến generation
TL;DR
- Mỗi khâu RAG (tạo query → retrieve → rerank → generate) tự sinh lỗi và lỗi dồn xuống. Retrieval sai thì generation không cứu được → đo retrieval riêng, trước tiên.
- Dataset = cặp (query, chunk vàng); mở rộng bằng sinh tổng hợp, thêm câu hỏi "có mồi nhử" (distractor), rồi lọc câu phi thực tế. Luôn trộn thêm query thật từ log.
- Metric: Recall@k cho stage 1, NDCG@k cho rerank; MRR khi chỉ cần một sự thật; hit rate@k để khởi đầu. Đọc NDCG cùng recall.
- Chunking là tham số cần tune (grid search size × overlap). Generation đo bằng faithfulness + relevance; debug grounding bằng context swapping.
- Luôn chia trace vào ma trận 2×2 retrieve đúng/sai × trả lời đúng/sai để biết nút thắt nằm ở đâu.
Cách đọc trang này
Phần tóm tắt nội dung sách được diễn giải ngắn; phần lớn trang là tài liệu tự soạn để luyện tập: ví dụ tính tay (khung xanh dương), bài tập có lời giải (khung xanh lá), case study (khung tím), code Python chạy được và 4 lab tương tác kiểu pixel. Mọi con số trong ví dụ/case study là dữ liệu bịa để minh hoạ, không phải số liệu của sách.7.1Pipeline RAG hai tầng và chỗ đặt eval
RAG (Retrieval-Augmented Generation) = trước khi trả lời, hệ thống đi tìm đoạn văn liên quan trong kho tri thức (tài liệu, bảng, web) rồi nhét chúng vào prompt để LLM trả lời dựa trên bằng chứng. Điểm khác search truyền thống: "người đọc" kết quả không phải con người lướt 3 link đầu, mà là LLM — đọc được nhiều đoạn cùng lúc. Vì vậy mục tiêu retrieval dịch về phía recall: thà lấy dư còn hơn bỏ sót đoạn chứa đáp án.
Nhưng nhồi thật nhiều cũng có giá: context window có hạn, text nhiễu làm LLM trả lời kém đi, và API tính tiền theo token. Lời giải phổ biến là lấy rộng rồi lọc kỹ — pipeline hai tầng:
Quét toàn bộ corpus bằng phương pháp rẻ: embedding + chỉ mục ANN (FAISS, HNSW, vector DB) hoặc BM25 trên inverted index. Query và tài liệu được mã hoá độc lập nên tính trước được → nhanh. Mục tiêu: recall cao, chấp nhận nhiều nhiễu.
"Người đọc kỹ": nhìn query và từng ứng viên cùng lúc để chấm điểm → bắt được sắc thái mà so sánh hai vector bỏ qua, nhưng mỗi lần gọi đắt → chỉ chạy trên tập nhỏ. Mục tiêu: precision & thứ tự.
Bot HR nội bộ (bịa). Nhân viên hỏi: "Thử việc có được WFH không?"
- Tạo query: hệ thống giữ nguyên câu hỏi. Chữ "WFH" không xuất hiện trong tài liệu (tài liệu viết "làm việc từ xa") → đã có mầm lỗi ngay khâu đầu.
- Stage 1 (dense) trả 50 ứng viên. Chunk vàng
hr-12#4("nhân viên thử việc không được làm từ xa…") nằm ở hạng 31 — vẫn có mặt → Recall@50 = 1. - Rerank đẩy
hr-12#3("nhân viên chính thức được làm từ xa 2 ngày/tuần") lên #1 vì trùng nhiều từ;hr-12#4về hạng 7. Pipeline chỉ đưa top-5 cho LLM → Recall@5 = 0. - Generation trả lời trung thành với context nhận được: "Có, tối đa 2 ngày/tuần." — faithful nhưng sai.
- Nếu chỉ nhìn metric end-to-end ("câu trả lời sai"), ta dễ đi sửa prompt. Đo từng tầng cho thấy: stage 1 ổn, rerank là thủ phạm, và khâu tạo query (không mở rộng từ viết tắt) góp phần.
Bức tranh retrieval hiện đại (vài năm gần đây)
Gộp tìm keyword và tìm ngữ nghĩa bằng Reciprocal Rank Fusion — nay là mặc định ở nhiều hệ thống production vì phủ được cả khớp chính xác (mã lỗi, tên riêng) lẫn khớp ý.
Điểm giữa: nhanh gần như embedding (mã hoá riêng), tinh gần như reranker (so khớp ở mức token).
RAG vẫn đáng giá: có attribution (trích nguồn), rẻ hơn, và giữ context tập trung.
Model tự viết query, xem kết quả, viết lại. Thêm câu hỏi eval: truy xuất đúng lúc? query tốt? Nguyên tắc cốt lõi không đổi.
Query (bịa): "Lỗi ERR-4031 khi đăng nhập VPN". BM25 bắt được mã lỗi chính xác; dense bắt được ý "không vào được VPN". RRF cộng điểm 1 / (c + hạng) từ mỗi danh sách, thường c = 60.
| Hạng | BM25 | Dense |
|---|---|---|
| 1 | D7 | D4 |
| 2 | D2 | D3 |
| 3 | D9 | D7 |
| 4 | D4 | D8 |
| 5 | D1 | D2 |
- D7: hạng 1 (BM25) + hạng 3 (dense) → 1/61 + 1/63 = 0.01639 + 0.01587 = 0.03227.
- D4: hạng 4 + hạng 1 → 1/64 + 1/61 = 0.01563 + 0.01639 = 0.03202.
- D2: hạng 2 + hạng 5 → 1/62 + 1/65 = 0.03151.
- D3 chỉ có ở dense hạng 2 → 1/62 = 0.01613; D9 chỉ ở BM25 hạng 3 → 1/63 = 0.01587.
- Kết quả gộp: D7, D4, D2, D3, D9. Tài liệu được cả hai retriever tìm thấy nổi lên trên — đó là trực giác của RRF: không cần chuẩn hoá điểm BM25 và cosine vốn khác thang đo.
# rrf.py — Reciprocal Rank Fusion tối giản (tự viết, chạy được)
from collections import defaultdict
def rrf(rankings, c=60, top_n=5):
"""rankings: list các danh sách doc_id đã xếp hạng (mỗi retriever một list)."""
score = defaultdict(float)
for ranking in rankings:
for rank, doc in enumerate(ranking, start=1):
score[doc] += 1 / (c + rank)
return sorted(score, key=score.get, reverse=True)[:top_n], dict(score)
bm25 = ["D7", "D2", "D9", "D4", "D1"] # khớp đúng mã lỗi "ERR-4031"
dense = ["D4", "D3", "D7", "D8", "D2"] # khớp ý "không đăng nhập được VPN"
fused, s = rrf([bm25, dense])
print(fused)
for d in fused: print(d, round(s[d], 5))
Hiểu nhầm thường gặp
"Context 1 triệu token thì cứ nhét cả kho tài liệu vào, khỏi RAG." — Ba lý do vẫn cần retrieval: (1) chi phí và độ trễ tăng theo token mỗi request; (2) LLM không chú ý đều mọi vị trí trong context (xem "lost in the middle" ở 7.4); (3) mất khả năng chỉ ra đoạn nào là căn cứ (attribution) — điều mà người dùng doanh nghiệp và kiểm toán thường đòi hỏi. Và nếu vẫn dùng long-context, bạn vẫn phải eval liệu model có dùng đúng đoạn — tức là vẫn cần các khái niệm của chương này.[D3, D7, D5]. Với c = 60, thứ tự top-3 sau khi gộp cả ba danh sách là gì?
Xem lời giải
Cộng thêm: D3 += 1/61 → 0.01613 + 0.01639 = 0.03252; D7 += 1/62 → 0.03227 + 0.01613 = 0.04840; D5 = 1/63 = 0.01587. D4 giữ 0.03202, D2 giữ 0.03151.
Top-3: D7 (0.0484), D3 (0.0325), D4 (0.0320). D3 vượt D4 nhờ được hai retriever thấy ở hạng cao. Nhận xét: RRF thưởng cho sự đồng thuận giữa các retriever.
7.2Xây dataset đánh giá retrieval
Muốn đo retriever, cần danh sách query, mỗi query gắn với chunk chứa đáp án ("chunk vàng", gold chunk). Tự tay viết câu hỏi thực tế rồi map tới đúng chunk là chuẩn nhất nhưng đắt. Sách đề xuất mở rộng bằng LLM theo ba bước: sinh → làm khó → lọc.
kind để sau này tách metric theo lát cắt.Bước ②: sinh cặp QA tổng hợp
Cắt tài liệu thành chunk — đúng đơn vị mà retriever index (đổi cách chunk thì phải sinh lại hoặc ánh xạ lại nhãn!). Với mỗi chunk, nhờ LLM rút một sự thật cụ thể, tự đứng được, rồi viết câu hỏi mà chỉ sự thật đó trả lời. Kết quả là bộ ba (câu hỏi, đáp án, context).
Chunk fin-02#3: "Phụ cấp lưu trú khi công tác là 800 nghìn đồng/đêm tại Hà Nội và TP.HCM, 600 nghìn đồng/đêm tại các địa phương khác. Không áp dụng khi đối tác đã bao phòng."
- LLM rút sự thật: "Phụ cấp lưu trú ở địa phương ngoài Hà Nội/TP.HCM là 600 nghìn đồng/đêm."
- Câu hỏi sinh ra (bản "dễ"): "Phụ cấp lưu trú tại các địa phương khác là bao nhiêu mỗi đêm?" — lặp nguyên cụm "phụ cấp lưu trú", "địa phương khác".
- Viết lại cho giống người thật hơn: "Đi Đà Nẵng 3 đêm thì được bao nhiêu tiền phòng?" — không trùng từ nào quan trọng, cần hiểu Đà Nẵng ∈ "địa phương khác" và "tiền phòng" ≈ "phụ cấp lưu trú".
- Lưu:
{question, answer: "1,8 triệu (600k × 3)", gold: ["fin-02#3"], kind: "easy"}. Để ý: câu ở B3 thực ra cần một phép nhân — đã là câu suy luận nhẹ, không còn thuần trích xuất.
Bước ③: câu hỏi khó với chunk "mồi nhử"
Câu hỏi sinh từ một chunk hay mượn nguyên văn chunk → retriever khớp keyword là trúng, quá dễ so với query thật (diễn đạt khác, chạm tới chủ đề có ở nhiều tài liệu, hỏi so sánh). Để stress-test: chọn chunk đích A → tìm chunk B, C giống A (bằng embedding/keyword) nhưng không chứa đáp án → nhờ LLM viết câu hỏi chỉ A trả lời được nhưng mượn thuật ngữ của B, C.
- Chunk đích A
hr-12#3: "Nhân viên chính thức được làm remote tối đa 2 ngày mỗi tuần, đăng ký trên HRM trước 17h ngày làm việc liền trước." - Tìm mồi nhử bằng độ tương đồng:
hr-12#4(thử việc không được remote…) vàhr-07#2(ngày phép… đăng ký trên HRM trước 3 ngày làm việc). Cả hai trùng "nhân viên", "đăng ký", "HRM", "remote". - Câu hỏi phân biệt: "Muốn làm ở nhà thứ Sáu thì hạn chót đăng ký trên HRM là khi nào?" Đáp án: 17h thứ Năm. Một retriever khớp từ khoá dễ đưa
hr-07#2("đăng ký trên HRM trước 3 ngày") lên trên. - Ghi nhãn cả mồi nhử:
distractors: ["hr-12#4", "hr-07#2"]. Khi retriever trượt, bạn biết ngay nó trượt vào đâu — thông tin cực quý cho error analysis (Chương 3).
# synth.py — sinh câu hỏi khó có mồi nhử (tự viết; call_llm là hàm bạn cắm vào)
import json, random
GEN_PROMPT = """Đây là một đoạn tài liệu nội bộ (chunk ĐÍCH):
<<<{target}>>>
Và các đoạn DỄ NHẦM (không chứa đáp án):
<<<{distractors}>>>
Viết MỘT câu hỏi mà nhân viên có thể hỏi thật, CHỈ chunk ĐÍCH trả lời được,
nhưng dùng lại vài từ khoá của các đoạn DỄ NHẦM. Không chép nguyên câu của chunk ĐÍCH.
Trả JSON: {{"question": "...", "answer": "..."}}"""
def tokens(t): return set(t.lower().replace(",", " ").replace(".", " ").split())
def similar_chunks(target_id, chunks, n=2):
"""Thay cho embedding search: xếp chunk khác theo Jaccard từ vựng với chunk đích."""
t = tokens(chunks[target_id])
others = [(len(t & tokens(c)) / len(t | tokens(c)), cid)
for cid, c in chunks.items() if cid != target_id]
return [cid for _, cid in sorted(others, reverse=True)[:n]]
def build_hard_set(chunks, call_llm, n=10, seed=0):
rng, rows = random.Random(seed), []
for target_id in rng.sample(sorted(chunks), min(n, len(chunks))):
d_ids = similar_chunks(target_id, chunks)
out = json.loads(call_llm(GEN_PROMPT.format(
target=chunks[target_id], distractors="\n---\n".join(chunks[d] for d in d_ids))))
rows.append({"question": out["question"], "answer": out["answer"],
"gold": [target_id], "distractors": d_ids, "kind": "hard"})
return rows
if __name__ == "__main__":
chunks = {
"hr-12#3": "Nhân viên chính thức được làm remote tối đa 2 ngày mỗi tuần, đăng ký trên HRM trước 17h ngày làm việc liền trước.",
"hr-12#4": "Nhân viên thử việc không được đăng ký làm remote, trừ khi giám đốc khối phê duyệt bằng email.",
"it-03#1": "Khi làm remote, bắt buộc bật VPN công ty trước khi truy cập Jira và Git nội bộ.",
"hr-07#2": "Ngày phép năm của nhân viên chính thức là 15 ngày, đăng ký trên HRM trước 3 ngày làm việc.",
}
fake = lambda p: json.dumps({"question": "Muốn làm ở nhà thứ Sáu thì hạn chót đăng ký là khi nào?",
"answer": "17h thứ Năm, trên HRM"})
for r in build_hard_set(chunks, fake, n=1, seed=3): print(r)
print(similar_chunks("hr-12#3", chunks))
Bước ④: lọc câu hỏi phi thực tế
Câu hỏi tổng hợp đôi khi kỳ quặc hoặc lạc domain. Cách lọc giống cách lọc tuple ở Chương 3: tự chấm một tập nhỏ về độ thực tế & đúng domain → dùng làm few-shot cho một LLM chấm phần còn lại → giữ câu đạt ngưỡng. Thang 1–5 ở đây ổn vì mục tiêu chỉ là xếp hạng rồi loại dữ liệu yếu, không phải đo tỉ lệ lỗi của hệ thống (việc đó vẫn nên dùng nhãn nhị phân như Chương 4).
| Câu hỏi sinh ra | Điểm | Lý do |
|---|---|---|
| "Tháng này tôi còn mấy ngày phép?" | 5 | Tự nhiên, rất hay gặp (dù cần dữ liệu cá nhân → có thể ngoài phạm vi RAG, ghi chú lại) |
| "Đi công tác Cần Thơ có được tạm ứng không?" | 5 | Ngắn, cụ thể, đúng domain |
| "Theo mục 4.2.b phụ lục III, hệ số phụ cấp dòng thứ ba là gì?" | 2 | Người thật không biết số hiệu mục; câu này chép cấu trúc tài liệu |
| "Tổng số chữ trong chính sách công tác phí là bao nhiêu?" | 1 | Không ai hỏi vậy; không phải nhu cầu tra cứu |
| "So sánh chính sách remote năm 2023 và 2025?" | 4 | Thực tế nhưng multi-hop — cần gắn hai chunk vàng |
Ngưỡng giữ: ≥ 4. Câu 1 điểm/2 điểm dùng làm ví dụ "xấu" trong few-shot cho judge lọc.
Hiểu nhầm thường gặp
"Chunk vàng là duy nhất." — Sai khá thường xuyên. Chính sách hay được nhắc lại ở FAQ, email thông báo, bản cũ. Nếu chỉ ghi một chunk vàng, retriever trả đúng bản sao sẽ bị tính là trượt → Recall bị đánh giá thấp oan. Cách xử lý: cho phépgold là danh sách, và khi review các lần "trượt", kiểm tra xem tài liệu được trả về có thực ra cũng trả lời được không (nếu có → bổ sung nhãn). Ngược lại, bản cũ/hết hiệu lực nên được đánh dấu là mồi nhử, không phải vàng.
Xem lời giải
Một đáp án: "Đi công tác về được mấy ngày để nộp giấy tờ trước khi bị trừ lương?" Câu hỏi dùng "ngày", "chuyến đi/công tác" (có ở cả hai chunk) và tránh các từ đặc trưng của chunk đích như "hoá đơn", "tạm ứng". Retriever khớp từ khoá có thể kéo chunk vé máy bay lên vì trùng "ngày… chuyến đi" và con số. Đáp án đúng: 10 ngày làm việc.
Kiểm tra chất lượng câu hỏi: (1) chỉ chunk đích trả lời được? ✓ (2) người thật có hỏi thế không? ✓ (3) không chép nguyên câu? ✓
Xem lời giải
- ~25–30 query thật từ log (gắn nhãn chunk vàng bằng tay) — đây là "thước đo sự thật", phát hiện chỗ dữ liệu tổng hợp quá dễ.
- ~10–15 câu tay dành cho multi-hop / so sánh — loại mà sinh tổng hợp hay bỏ sót.
- ~100 câu tổng hợp sau lọc, trong đó ít nhất 1/4 là câu có mồi nhử.
- Ghi cột
kindvà báo cáo metric theo lát cắt (easy / hard / real / multi-hop). Một con số tổng sẽ bị câu dễ (nhiều nhất) chi phối.
7.3Precision@k và Recall@k
Hai metric cơ bản nhất, cùng nhìn vào top-k kết quả. Chúng khác nhau ở mẫu số:
"Trong k thứ tôi đưa cho LLM, bao nhiêu là có ích?" — đo lượng nhiễu lọt vào context.
"Trong tất cả thứ có ích cho câu hỏi này, tôi đã tìm được bao nhiêu?" — đo độ phủ. Bỏ sót ở đây thì generator bó tay.
Cả hai coi relevance là nhị phân. Nếu dataset chấm mức độ (0–3), phải chọn ngưỡng để nhị phân hoá (vd. "> 1 mới tính là liên quan") và ghi rõ ngưỡng trong báo cáo — đổi ngưỡng là đổi con số. k nên theo loại câu hỏi: tra một sự thật cần k = 1–2; câu hỏi tổng hợp ("tóm tắt các thay đổi chính sách năm nay") cần k = 5–10.
Ví dụ xuyên suốt 7.3–7.5 (bịa): query "Chính sách hoàn tiền của gói Pro?", hệ thống trả top-5 như dưới. Relevance chấm 0–3; query có tổng 4 tài liệu liên quan trong corpus (1 cái bị đẩy xuống hạng 8, ngoài top-5).
Cùng danh sách trên (ngưỡng rel ≥ 1, tổng liên quan = 4). Nhìn metric thay đổi khi nới k:
| k | liên quan trong top-k | Precision@k | Recall@k |
|---|---|---|---|
| 1 | 0 | 0/1 = 0.00 | 0/4 = 0.00 |
| 2 | 1 (#2) | 1/2 = 0.50 | 1/4 = 0.25 |
| 3 | 1 | 1/3 = 0.33 | 0.25 |
| 5 | 3 (#2, #4, #5) | 3/5 = 0.60 | 3/4 = 0.75 |
| 8 | 4 (+#8) | 4/8 = 0.50 | 4/4 = 1.00 |
- Recall@k không bao giờ giảm khi k tăng (chỉ thêm tài liệu). Precision@k thì lên xuống tuỳ tài liệu mới có liên quan không.
- Nếu pipeline chỉ đưa top-5 cho LLM, 25% "thông tin có ích" (tài liệu #8) không bao giờ tới được LLM — dù retriever "có tìm thấy" ở hạng 8.
- Đây là lý do metric phải đo tại đúng k mà khâu sau thực sự dùng: stage 1 đo Recall@50 nếu reranker nhận 50; stage cuối đo tại k = số chunk nhét vào prompt.
# metrics.py — Precision/Recall/RR/NDCG tự viết, không cần thư viện
import math
def precision_at_k(rels, k, thr=1):
top = rels[:k]
return sum(r >= thr for r in top) / k
def recall_at_k(rels, k, n_relevant_total, thr=1):
if n_relevant_total == 0:
return None # query không có tài liệu liên quan: bỏ khỏi trung bình
return sum(r >= thr for r in rels[:k]) / n_relevant_total
def reciprocal_rank(rels, k, thr=1):
for i, r in enumerate(rels[:k], start=1):
if r >= thr:
return 1 / i
return 0.0
def dcg(rels):
return sum(r / math.log2(i + 1) for i, r in enumerate(rels, start=1))
def ndcg_at_k(rels, k, all_grades=None):
"""all_grades=None -> IDCG = sắp lại chính top-k đã retrieve (cách của sách)
all_grades=[...] -> IDCG = sắp lại MỌI tài liệu có nhãn của query (cách IR cổ điển)"""
pool = rels[:k] if all_grades is None else all_grades
ideal = dcg(sorted(pool, reverse=True)[:k])
return dcg(rels[:k]) / ideal if ideal > 0 else 0.0
if __name__ == "__main__":
rels = [0, 2, 0, 3, 1] # top-5 của ví dụ 7.3 (dữ liệu bịa)
print("P@5 ", precision_at_k(rels, 5))
print("R@5 ", recall_at_k(rels, 5, n_relevant_total=4))
print("RR ", reciprocal_rank(rels, 5))
print("DCG ", round(dcg(rels), 4), "IDCG", round(dcg([3, 2, 1, 0, 0]), 4))
print("NDCG@5 (sách) ", round(ndcg_at_k(rels, 5), 4))
print("NDCG@5 (toàn bộ)", round(ndcg_at_k(rels, 5, all_grades=[0, 2, 0, 3, 1, 2]), 4))
print("P@5 thr=2", precision_at_k(rels, 5, thr=2), "R@5 thr=2", recall_at_k(rels,5,3,thr=2))
X = [1, 1, 1, 0, 0]; Y = [0, 0, 0, 3, 0]
print("trap X", ndcg_at_k(X, 5), "Y", round(ndcg_at_k(Y, 5), 4))
print("trap X full", round(ndcg_at_k(X,5,all_grades=[1,1,1,3]),4), "Y full", round(ndcg_at_k(Y,5,all_grades=[3,1,1,1]),4))
$ python3 metrics.py
P@5 0.6
R@5 0.75
RR 0.5
DCG 2.9407 IDCG 4.7619
NDCG@5 (sách) 0.6176
NDCG@5 (toàn bộ) 0.5166
P@5 thr=2 0.4 R@5 thr=2 0.6666666666666666
trap X 1.0 Y 0.4307
trap X full 0.4671 Y full 0.2832
Hiểu nhầm thường gặp
"Precision thấp là xấu." — Với câu hỏi một sự thật có đúng 1 tài liệu liên quan, Precision@5 tối đa chỉ là 0.2 dù retriever hoàn hảo. Precision@k bị chặn trên bởi min(số liên quan, k)/k. So sánh precision giữa các query có số tài liệu liên quan khác nhau là so sánh táo với cam.
"Recall@k tính trên tổng tài liệu liên quan trong top-k." — Không: mẫu số là tất cả tài liệu liên quan của query trong corpus. Vì vậy bạn cần biết hết nhãn vàng — lý do dataset phải cho phép nhiều chunk vàng.
Xem lời giải
Liên quan (rel ≥ 2) trong top-5: #2 (2) và #4 (3) → 2 tài liệu. #5 (rel 1) không còn tính.
Tổng liên quan trong corpus: #2, #4, #8 → 3. Precision@5 = 2/5 = 0.40; Recall@5 = 2/3 ≈ 0.67.
Bài học: cùng một danh sách, đổi ngưỡng làm P@5 từ 0.60 xuống 0.40. Luôn công bố ngưỡng cùng con số.
7.4MRR, hit rate và hiệu ứng "lost in the middle"
Reciprocal rank (RR) của một query = 1 / (vị trí tài liệu liên quan đầu tiên); không có trong top-k thì RR = 0. MRR@k = trung bình RR trên mọi query. Metric này chỉ quan tâm: "thứ có ích đầu tiên xuất hiện sớm cỡ nào?" — hợp với câu hỏi chỉ cần một sự thật.
Hit rate@k đơn giản hơn nữa: tỉ lệ query có ít nhất một tài liệu liên quan trong top-k. Sách coi đây thường là metric khởi đầu thực dụng nhất, vì với nhiều ứng dụng một tài liệu đúng là đủ để trả lời.
| Query | Hạng tài liệu liên quan đầu tiên (top-5) | RR | Hit@1 | Hit@5 |
|---|---|---|---|---|
| q1 "phụ cấp lưu trú Đà Nẵng" | 1 | 1.000 | ✓ | ✓ |
| q2 "hạn nộp hoá đơn" | 3 | 0.333 | ✗ | ✓ |
| q3 "quên thẻ ra vào" | không có | 0 | ✗ | ✗ |
| q4 "WFH thử việc" | 2 | 0.500 | ✗ | ✓ |
| q5 "số ngày phép năm" | 1 | 1.000 | ✓ | ✓ |
- Tổng RR = 1 + 0.333 + 0 + 0.5 + 1 = 2.833 → MRR@5 = 2.833 / 5 ≈ 0.567.
- Hit@5 = 4/5 = 0.80; Hit@1 = 2/5 = 0.40. Để ý: Hit@1 chính là trung bình của "RR = 1".
- Diễn giải: 80% câu hỏi có ít nhất một tài liệu đúng trong top-5, nhưng chỉ 40% có nó ở vị trí đầu. Nếu prompt chỉ dùng top-1 (vì hạn token), trải nghiệm sẽ tệ hơn nhiều so với con số Hit@5 gợi ý.
- q3 là ca nên đọc trace đầu tiên — không phải vì nó kéo MRR xuống nhiều nhất về số học, mà vì nó là thất bại hoàn toàn (generator bó tay).
Vì sao vị trí vẫn quan trọng: "lost in the middle"
Có người lập luận: "LLM đọc hết top-10, đứng #1 hay #7 thì khác gì?" Sách dẫn nghiên cứu của Liu et al. (2024): giấu đáp án giữa 20 tài liệu và đo độ chính xác theo vị trí — kết quả là đường cong chữ U, cao nhất khi đáp án ở đầu hoặc cuối, tụt hơn 30 điểm % khi ở giữa. LLM không chú ý đều toàn bộ context. Vậy retrieve được chưa đủ; vị trí cũng quan trọng — đó là lý do MRR vẫn có ích ngay cả khi có nhiều chunk liên quan.
Hiểu nhầm thường gặp
"MRR tốt là retrieval tốt cho mọi loại câu hỏi." — MRR chỉ nhìn tài liệu liên quan đầu tiên. Với câu hỏi multi-hop (cần ghép nhiều chunk, vd. "Trong các văn phòng ở miền Nam, văn phòng nào có phụ cấp gửi xe cao nhất?" cần danh sách văn phòng + mức phụ cấp từng nơi), tìm được 1/3 mảnh ghép ở #1 vẫn cho RR = 1 trong khi câu trả lời chắc chắn sai. Dùng Recall@k hoặc NDCG@k.
"MRR và hit rate là hai tên của một thứ." — Không. Hit@k là nhị phân (có/không trong top-k); MRR phân biệt #1 với #5. Hit@k = 1 cho cả hai.
Xem lời giải
(A): RR q3 từ 0 → 0.2 → MRR tăng 0.2/5 = +0.04. (B): RR q2 từ 0.333 → 1 → MRR tăng 0.667/5 ≈ +0.133. MRR "thích" B hơn.
Nhưng nếu LLM đọc cả top-5, (A) biến một câu chắc chắn sai thành câu có thể đúng (Hit@5 từ 0.8 → 1.0), còn (B) chỉ cải thiện thứ hạng của câu vốn đã có đáp án trong context. Thường nên chọn A trước. Bài học: metric là công cụ, không phải mục tiêu; chọn metric theo cách khâu sau tiêu thụ kết quả.
7.5NDCG@k — khi tài liệu có ích "nhiều ít" khác nhau
Precision, Recall, MRR đều coi relevance là nhị phân. Thực tế, với "Tóm tắt xu hướng lương ngành data năm nay", một báo cáo khảo sát lương mới là rất liên quan (3), một bài tổng quan thị trường lao động là hơi liên quan (1), một thông báo nghỉ lễ là không liên quan (0). NDCG@k dành cho relevance có mức độ (graded relevance) và dựa trên hai trực giác:
- Càng liên quan càng đóng góp nhiều — tài liệu rel 3 có giá trị gấp ba tài liệu rel 1.
- Càng ở trên càng giá trị — đóng góp ở vị trí i bị chia cho log₂(i+1): vị trí 1 giữ nguyên, vị trí 2 còn ~63%, vị trí 10 chỉ ~29%.
DCG@k = Σi=1..k reli / log₂(i+1). Con số này khó đọc (phụ thuộc số tài liệu liên quan), nên chuẩn hoá: IDCG@k = DCG của thứ tự lý tưởng, và NDCG@k = DCG@k / IDCG@k ∈ [0, 1]. NDCG = 1 nghĩa là xếp hoàn hảo; 0.5 nghĩa là mới thu được khoảng nửa giá trị có thể đạt nếu xếp tốt hơn. Nhờ chuẩn hoá, so sánh được giữa các query.
Top-5 rel = [0, 2, 0, 3, 1] (ví dụ xuyên suốt).
- Mẫu số log cho vị trí 1..5: log₂2 = 1, log₂3 ≈ 1.585, log₂4 = 2, log₂5 ≈ 2.322, log₂6 ≈ 2.585.
- DCG@5 = 0/1 + 2/1.585 + 0/2 + 3/2.322 + 1/2.585 = 0 + 1.262 + 0 + 1.292 + 0.387 = 2.941.
- Thứ tự lý tưởng (sắp lại chính 5 tài liệu đã retrieve): [3, 2, 1, 0, 0].
- IDCG@5 = 3/1 + 2/1.585 + 1/2 = 3 + 1.262 + 0.5 = 4.762.
- NDCG@5 = 2.941 / 4.762 ≈ 0.618. Mất điểm chủ yếu vì tài liệu rel 3 ở #4 thay vì #1: nếu hoán đổi #1 và #4 (rel = [3, 2, 0, 0, 1]) thì DCG = 3 + 1.262 + 0.387 = 4.649 → NDCG ≈ 0.976.
Sách tính IDCG bằng cách sắp lại chính các tài liệu đã retrieve trong top-k. NDCG khi đó đo thuần chất lượng thứ tự của những gì bạn đã lấy về: 0.618 ở ví dụ trên.
Hệ quả: tài liệu liên quan bị bỏ lỡ (#8) không ảnh hưởng NDCG. Đó là lý do phải đọc kèm recall.
Nhiều tài liệu IR (và thư viện như trec_eval) tính IDCG trên mọi tài liệu có nhãn của query trong corpus. Với #8 (rel 2) được tính vào: thứ tự lý tưởng = [3, 2, 2, 1, 0] → IDCG = 3 + 1.262 + 1 + 0.431 = 5.693 → NDCG@5 = 2.941 / 5.693 ≈ 0.517.
Cách này phạt cả việc bỏ sót. Không cách nào "đúng" hơn — nhưng hai con số không so sánh được. Ghi rõ convention trong báo cáo và trong code (xem tham số all_grades ở metrics.py).
Không phải chunk nào cũng hữu ích như nhau: báo cáo lương mới nhất nên đứng trên bài tổng quan chung chung.
Context window có hạn: nếu tổng kích thước tài liệu retrieve vượt giới hạn, chỉ vài tài liệu đầu được dùng — NDCG đảm bảo những cái giá trị nhất được ưu tiên.
Query cần đúng một tài liệu "đinh" (rel 3). Có thêm ba tài liệu hơi liên quan (rel 1).
- Hệ X trả [1, 1, 1, 0, 0]: toàn tài liệu hơi liên quan, xếp "hoàn hảo" trong phạm vi của nó. IDCG kiểu sách = DCG của chính nó → NDCG@5 = 1.00.
- Hệ Y trả [0, 0, 0, 3, 0]: tìm được tài liệu đinh nhưng xếp #4. DCG = 3/2.322 = 1.292; IDCG = 3 → NDCG@5 ≈ 0.43.
- Kể cả với IDCG kiểu IR cổ điển, X (0.47) vẫn thắng Y (0.28) — vì ba tài liệu rel 1 ở top cộng lại nhiều hơn một rel 3 bị chiết khấu ở #4.
- Nhưng với ngưỡng "rel ≥ 2 mới hữu ích": Recall@5 của X = 0/1 = 0, của Y = 1/1 = 1. Chỉ Y cho LLM cơ hội trả lời đúng.
- Kết luận: NDCG trả lời "thứ tự có tốt không", Recall trả lời "thứ cần có có mặt không". Luôn báo cáo cả hai.
Xem lời giải
[2, 3, 0]: DCG = 2/1 + 3/1.585 + 0 = 2 + 1.893 = 3.893. Lý tưởng [3, 2, 0]: IDCG = 3 + 2/1.585 = 3 + 1.262 = 4.262. NDCG@3 ≈ 0.913.
[3, 0, 2]: DCG = 3 + 0 + 2/2 = 4.0 → NDCG@3 = 4.0 / 4.262 ≈ 0.939 — tăng. Đưa tài liệu tốt nhất lên #1 đáng giá hơn việc giữ tài liệu rel 2 ở #2. Để ý Precision@3 và Recall@3 không đổi trong cả hai trường hợp — chỉ metric nhạy thứ hạng mới thấy khác biệt.
7.6Chọn metric nào? — kèm phòng lab tính tay
Mặc định an toàn theo sách: Recall@k cho stage 1, NDCG@k cho stage rerank; hit rate@k là điểm khởi đầu thực dụng; MRR khi chỉ cần một sự thật. Sơ đồ quyết định dưới đây là cách tự tổng hợp:
| Metric | Đo gì | Nhãn cần | Khi nào dùng | Mù với |
|---|---|---|---|---|
| Hit rate@k | Có ≥ 1 tài liệu đúng trong top-k? | nhị phân | Khởi đầu; câu hỏi một sự thật | vị trí, số mảnh |
| Precision@k | Tỉ lệ có ích trong top-k | nhị phân | Khi nhiễu/token tốn kém | tài liệu bị bỏ sót |
| Recall@k | Tỉ lệ tài liệu liên quan được tìm thấy | nhị phân + đủ nhãn vàng | Stage 1; multi-hop | thứ tự trong top-k |
| MRR@k | Tài liệu đúng đầu tiên sớm cỡ nào | nhị phân | Một sự thật, vị trí quan trọng | mảnh thứ 2, 3… |
| NDCG@k | Chất lượng thứ tự có trọng số mức độ | có mức độ (0–3) | Rerank; nhiều mức hữu ích | bỏ sót (convention sách) |
Bấm vào từng ô để đổi relevance 0 → 1 → 2 → 3 → 0. Ô mờ nằm ngoài top-k. Metric cập nhật tức thì, kèm công thức đã thay số.
Xem lời giải
Xếp 5 ô đầu thành [3, 2, 1, 0, 0] (giữ ô #8 = 2). NDCG@5 = 1.000; Recall@5 vẫn 3/4 = 0.75 vì tập tài liệu trong top-5 không đổi.
Với k = 3, thứ tự quyết định ai lọt vào top-3: [3, 2, 1, …] cho Recall@3 = 3/4, còn thứ tự gốc [0, 2, 0, …] chỉ cho 1/4. Recall không nhạy với thứ tự bên trong top-k, nhưng thứ tự quyết định ranh giới k.
7.7Chunking — một biến số cần tune
Trước khi retriever tìm được gì, tài liệu phải được cắt thành chunk — đơn vị mà retriever trả về. Chunk quá nhỏ: một ý bị cắt đôi, retriever chỉ thấy một nửa. Chunk quá lớn: phần có ích bị pha loãng bởi text thừa, embedding "nhoè" giữa nhiều chủ đề, và tốn token trong context. Cách đơn giản nhất: fixed-size + overlap — cắt theo số token cố định, các chunk liền kề chia sẻ một phần token để nội dung gần ranh giới xuất hiện trọn vẹn trong ít nhất một chunk.
Tài liệu 1.000 token. Bước nhảy (stride) = size − overlap. Chunk bắt đầu ở 0, stride, 2·stride… cho tới khi phủ hết.
- size 256, overlap 0 (stride 256): chunk [0,256), [256,512), [512,768), [768,1000) → 4 chunk, tổng 1.000 token phải embed.
- size 256, overlap 128 (stride 128): bắt đầu ở 0, 128, 256, …, 768 → 7 chunk: [0,256), [128,384), …, [768,1000). Tổng token embed = 6×256 + 232 = 1.768 → chi phí index ×1.77, số vector ×1.75.
- Đáp án nằm ở token 240–280 (40 token): overlap 0 → vắt qua ranh giới 256, không chunk nào chứa trọn. Overlap 128 → chunk [128,384) chứa trọn ✓.
- Quy tắc nhỏ tự rút ra: đoạn đáp án dài L token luôn nằm trọn trong ít nhất một chunk nếu L ≤ overlap + 1 (và L ≤ size). Đáp án dài hơn overlap thì có thể bị cắt, tuỳ vị trí.
- Cái giá: overlap 50% gần như nhân đôi số vector (lưu trữ, thời gian index) và làm top-k dễ chứa chunk gần trùng nhau — lãng phí chỗ trong context. Đây là đánh đổi phải đo, không phải "overlap càng nhiều càng tốt".
Văn bản bịa (mỗi từ coi là 1 token). Câu hỏi: "Đi Đà Nẵng thì phụ cấp lưu trú mỗi đêm bao nhiêu?" — đoạn đáp án được tô cam. Kéo thanh trượt để xem mỗi chunk phủ đoạn nào; hàng xanh lá = chunk chứa trọn đáp án.
Tune bằng grid search
Không có cấu hình tốt nhất cho mọi corpus. Cách thực dụng mà sách đề xuất: chọn vài giá trị kích thước × overlap, index lại corpus cho từng tổ hợp, đo Recall@k / NDCG@k trên cùng tập query, chọn cấu hình tốt nhất. Trong ví dụ giả định của sách, overlap giúp ở mọi kích thước và chunk rất lớn thua chunk trung bình — nhưng con số cụ thể tuỳ tài liệu và kiểu câu hỏi của bạn.
- Chọn lưới: vd. size ∈ {128, 256, 512} × overlap ∈ {0, 25%, 50%}.
- Index lại corpus cho từng cấu hình — và ánh xạ lại nhãn vàng: chunk vàng được định nghĩa theo đoạn văn bản (offset ký tự), rồi quy đổi sang chunk-id của mỗi cấu hình.
- Đo Recall@k, NDCG@k — tách theo lát cắt (easy/hard/real).
- Chọn cấu hình tốt nhất, cân nhắc thêm chi phí index và số token vào prompt.
Mẹo tự rút: nhãn vàng theo offset, không theo chunk-id
Nếu dataset lưugold: ["doc12#chunk3"], đổi kích thước chunk là nhãn mất nghĩa. Lưu gold_span: (doc_id, char_start, char_end) rồi định nghĩa "chunk liên quan" = chunk chứa trọn (hoặc phủ ≥ X%) span đó. Khi đó grid search không phải gắn nhãn lại, và bạn so sánh các cấu hình trên cùng một thước đo.
# chunking.py — chia chunk + "tỉ lệ đáp án bị cắt đôi" như một chẩn đoán rẻ trước khi chạy retriever
import itertools, math
def chunk_spans(n_tokens, size, overlap):
"""Trả về list (start, end) theo token, end không tính. overlap < size."""
stride = size - overlap
spans, start = [], 0
while True:
spans.append((start, min(start + size, n_tokens)))
if start + size >= n_tokens:
return spans
start += stride
def contains_span(spans, a, b):
"""Chunk đầu tiên chứa trọn đoạn đáp án [a, b), hoặc None nếu bị cắt đôi ở mọi chunk."""
return next((i for i, (s, e) in enumerate(spans) if s <= a and b <= e), None)
# gold set bịa: (độ dài tài liệu, vị trí bắt đầu đáp án, độ dài đáp án) — đơn vị token
gold = [(1000, 240, 40), (1000, 500, 60), (800, 120, 30), (1200, 760, 90),
(600, 250, 20), (900, 380, 70), (1500, 1010, 50), (700, 60, 25)]
if __name__ == "__main__":
total = sum(n for n, _, _ in gold)
print("size overlap chunks cost× split-rate")
for size, frac in itertools.product([128, 256, 512], [0, 0.25, 0.5]):
ov = int(size * frac)
n_chunks = indexed = split = 0
for n, a, L in gold:
spans = chunk_spans(n, size, ov)
n_chunks += len(spans)
indexed += sum(e - s for s, e in spans) # token phải embed + lưu
split += contains_span(spans, a, a + L) is None
print(f"{size:4d} {ov:6d} {n_chunks:6d} {indexed/total:6.2f} {split/len(gold):9.0%}")
$ python3 chunking.py # gold set bịa, đáp án cố tình đặt gần ranh giới
size overlap chunks cost× split-rate
128 0 64 1.00 88%
128 32 82 1.31 25%
128 64 116 1.90 25%
256 0 33 1.00 62%
256 64 40 1.27 25%
256 128 56 1.80 0%
512 0 18 1.00 25%
512 128 22 1.23 0%
512 256 25 1.57 0%
Hiểu nhầm thường gặp
"Split-rate = 0% nghĩa là cấu hình tốt nhất." — Bảng trên cho thấy chunk 512 gần như không bao giờ cắt đôi đáp án… vì chunk to thì hiển nhiên chứa được nhiều. Nhưng split-rate mù với độ loãng: chunk 512 token chứa một câu trả lời 30 token có tỉ lệ tín hiệu/nhiễu ~6%, embedding bị kéo về chủ đề chung, xếp hạng kém đi. Split-rate chỉ là chẩn đoán rẻ (không cần chạy retriever) để loại cấu hình tệ; quyết định cuối vẫn dựa vào Recall@k/NDCG@k đo thật.
"Tune chunking trên toàn bộ dataset rồi báo cáo trên chính dataset đó." — Đây là overfit tham số. Nếu grid lớn, giữ lại một phần query (held-out) để xác nhận cấu hình thắng.
Cắt theo heading, đoạn văn, chỗ chuyển chủ đề → mỗi chunk xoay quanh một chủ đề, kích thước không đều. Ví dụ: báo cáo có mục "Xu hướng" và "Giá" → mỗi mục một chunk.
Gắn tiêu đề tài liệu + heading vào chunk trước khi embed → chunk kiểu "Bảng 2" trở nên tự đứng được, retriever có tín hiệu mạnh hơn.
- Chunk gốc:
| Hà Nội, TP.HCM | 800.000 | Địa phương khác | 600.000 |— chỉ có số và địa danh. Query "phụ cấp lưu trú Đà Nẵng" gần như không khớp gì. - Sau khi thêm tiền tố:
[Chính sách công tác phí 2025 › Mục 3. Phụ cấp lưu trú (VNĐ/đêm)]+ bảng. Giờ chunk chứa "phụ cấp lưu trú", "công tác" → cả BM25 lẫn dense đều có tín hiệu. - Cách kiểm chứng: chạy lại Recall@5 trên lát cắt "câu hỏi về bảng" trước/sau. Nếu chỉ lát cắt này tăng mà lát cắt khác không giảm → giữ.
Xem lời giải
Theo quy tắc L ≤ overlap + 1 → overlap ≥ 59; làm tròn 64 token (25%). Stride = 256 − 64 = 192 → số chunk ≈ N/192 thay vì N/256 → chi phí ≈ 256/192 ≈ ×1.33. So với overlap 128 (×2.0), tiết kiệm đáng kể mà vẫn bảo đảm 95% đáp án không bị cắt. Sau đó vẫn phải đo Recall@k để xác nhận.
7.8Đánh giá generation: faithfulness & relevance
Retrieval hoàn hảo chưa đảm bảo câu trả lời tốt: bước generation vẫn có thể hỏng. Sách đo generation theo hai chiều, và xây evaluator bằng các kỹ thuật của Chương 5.
Câu trả lời có phản ánh đúng context đã retrieve — không dựa vào kiến thức nằm sẵn trong trọng số model (parametric knowledge), kể cả khi model "biết" đáp án?
Hallucination thêm thông tin không có · Omission bỏ qua thông tin liên quan có trong context · Misinterpretation bóp méo nội dung.
Đo: LLM-as-judge nhận context + câu trả lời, kiểm từng claim có được context ủng hộ.
Trung thành với context nhưng vẫn có thể lạc đề: hỏi phí gửi xe, context có cả phí gửi xe lẫn phụ cấp ăn trưa, câu trả lời lại nói về ăn trưa.
Đo: check bằng code (có nhắc đúng thực thể/chủ đề của query?) hoặc LLM judge cho câu hỏi mở.
Context: "Hỗ trợ gửi xe 300.000đ/tháng cho nhân viên làm tại văn phòng ≥ 3 ngày/tuần. Nhân viên remote toàn thời gian không được hỗ trợ."
| Câu trả lời | Loại lỗi | Vì sao |
|---|---|---|
| "Bạn được hỗ trợ 300.000đ/tháng, kể cả gửi ô tô ở toà nhà bên cạnh." | Hallucination | "kể cả ô tô ở toà nhà bên cạnh" không có trong context |
| "Bạn được hỗ trợ 300.000đ/tháng." (người hỏi là nhân viên remote toàn thời gian) | Omission | Bỏ qua điều kiện loại trừ — thông tin quan trọng có sẵn trong context |
| "Chỉ nhân viên làm remote 3 ngày/tuần mới được hỗ trợ." | Misinterpretation | Đảo ngược điều kiện (≥ 3 ngày tại văn phòng) |
| "Công ty có hỗ trợ ăn trưa 35.000đ/ngày." | Relevance | Có thể đúng theo một chunk khác, nhưng không trả lời câu hỏi gửi xe |
Viết judge faithfulness (theo tinh thần Chương 5)
Judge nhận context + câu trả lời, kiểm từng claim. Code dưới đây tự viết: tách câu thành claim, gọi judge cho từng claim, trả điểm liên tục (để debug) và nhãn PASS/FAIL nhị phân (để báo cáo tỉ lệ lỗi). Relevance kiểm bằng code rẻ khi query có thực thể rõ ràng.
# faith.py — faithfulness theo claim + relevance check bằng code (tự viết)
import json, re
JUDGE_PROMPT = """Bạn kiểm tra tính trung thành (faithfulness) của một câu trả lời RAG.
CONTEXT:
{context}
CLAIM: {claim}
Claim này có được CONTEXT ủng hộ trực tiếp không? Chỉ dựa vào CONTEXT,
không dùng hiểu biết riêng. Trả JSON: {{"verdict": "supported" | "unsupported"
| "contradicted", "evidence": "<trích ngắn từ context hoặc rỗng>"}}"""
def split_claims(answer):
# đơn giản: mỗi câu là một claim; production nên nhờ LLM tách claim nguyên tử
return [s.strip() for s in re.split(r"(?<=[.!?])\s+", answer) if s.strip()]
def faithfulness(answer, context, call_llm):
verdicts = []
for claim in split_claims(answer):
out = json.loads(call_llm(JUDGE_PROMPT.format(context=context, claim=claim)))
verdicts.append((claim, out["verdict"]))
ok = sum(v == "supported" for _, v in verdicts)
return ok / len(verdicts), verdicts # điểm liên tục để debug; PASS/FAIL = mọi claim supported
def mentions_entity(answer, required_terms):
"""Relevance check rẻ bằng code: câu trả lời có nhắc thực thể mà query hỏi không?"""
a = answer.lower()
return all(t.lower() in a for t in required_terms)
if __name__ == "__main__":
ctx = "Nhân viên chính thức có 15 ngày phép năm. Phép chưa dùng được chuyển sang quý 1 năm sau."
ans = ("Bạn có 15 ngày phép năm. Phép chưa dùng được chuyển sang quý 1 năm sau. "
"Nếu nghỉ việc, phép còn lại được trả bằng tiền.")
def fake_llm(prompt): # judge giả lập để chạy offline: claim có số/cụm khớp context?
claim = prompt.split("CLAIM: ")[1].split("\n")[0]
key = claim.split()[-4:]
hit = " ".join(key).rstrip(".") in ctx
return json.dumps({"verdict": "supported" if hit else "unsupported", "evidence": ""})
score, v = faithfulness(ans, ctx, fake_llm)
for c, x in v: print(f"[{x:11}] {c}")
print("faithfulness =", round(score, 2), "| PASS" if score == 1 else "| FAIL")
print("relevance(mentions 'phép'):", mentions_entity(ans, ["phép"]))
$ python3 faith.py
[supported ] Bạn có 15 ngày phép năm.
[supported ] Phép chưa dùng được chuyển sang quý 1 năm sau.
[unsupported] Nếu nghỉ việc, phép còn lại được trả bằng tiền.
faithfulness = 0.67 | FAIL
relevance(mentions 'phép'): True
Liên hệ Chương 5
Judge faithfulness cũng là một classifier — phải hiệu chỉnh trên một tập được người gắn nhãn (đo TPR/TNR), và báo cáo tỉ lệ lỗi đã hiệu chỉnh. Đừng tin một judge chưa được đo. Nhãn nhị phân (PASS/FAIL) cho độ đồng thuận cao hơn thang điểm (Chương 4).Context swapping — công cụ debug grounding
Nếu câu trả lời gần như không đổi khi context bị tráo → LLM đang dựa vào parametric knowledge. Nguy hiểm tinh vi: câu trả lời có thể tình cờ đúng, nhưng khi model không biết, nó sẽ bịa thay vì dùng bằng chứng. Theo sách, cách sửa thường nằm ở prompt (chỉ dựa vào tài liệu được cung cấp; không có thì nói không có), không phải retriever.
- Query: "Mỗi năm tôi được bao nhiêu ngày phép?". Chính sách công ty (bịa): 15 ngày. Bot trả lời: "12 ngày theo quy định" — con số tối thiểu phổ biến trong luật lao động mà model đã "học thuộc".
- Retrieval kiểm tra: chunk vàng
hr-07#2ở #1 → retrieval ổn. Nghi ngờ generation. - Tráo context bằng 5 chunk về VPN/IT, chạy lại: bot vẫn trả "12 ngày theo quy định". → Kết luận: model không dùng context cho câu này.
- Sửa prompt: "Chỉ trả lời dựa trên TÀI LIỆU; trích mã tài liệu cho mỗi ý; nếu tài liệu không nói, trả lời 'Tài liệu nội bộ không đề cập'." Chạy lại cả hai run: A → "15 ngày [hr-07#2]", B → "Tài liệu nội bộ không đề cập". ✓
- Thêm câu này vào bộ regression (Chương 9) với nhãn "phải trích hr-07#2".
Hiểu nhầm thường gặp
"Câu trả lời đúng sự thật ngoài đời thì faithful." — Faithfulness đo so với context, không so với thế giới. Một claim đúng ngoài đời mà context không nói vẫn là hallucination trong khung RAG — vì hôm nay nó tình cờ đúng, mai chính sách đổi thì nó sai mà không ai thấy.
"Faithful thì đúng." — Nếu retrieval đưa nhầm chunk (vd. chính sách cũ), câu trả lời trung thành tuyệt đối vẫn sai. Faithfulness và correctness là hai trục khác nhau — lý do cần ma trận 2×2 ở mục sau.
"So khớp với đáp án mẫu (BLEU/ROUGE, overlap) là đủ." — Trôi chảy và giống đáp án mẫu không có nghĩa là bám bằng chứng.
Xem lời giải
- C1 "tạm ứng 70% chi phí" — misinterpretation nhẹ: context nói tối đa 70%, câu trả lời biến thành mức cố định. Judge nghiêm sẽ chấm unsupported; ghi chú rõ trong rubric bạn muốn xử lý thế nào.
- C2 "10 ngày" — context nói "10 ngày làm việc". Bỏ chữ "làm việc" làm đổi nghĩa (10 ngày lịch ≠ 10 ngày làm việc) → cũng có thể là misinterpretation.
- C3 "phạt 5% lương" — hallucination rõ ràng.
Điểm debug: 0/3 đến 2/3 tuỳ độ nghiêm của rubric; nhãn nhị phân: FAIL (chắc chắn vì C3). Bài học: rubric phải định nghĩa trước chuyện "bỏ bớt điều kiện" có tính là lỗi không — đó là chỗ các annotator hay bất đồng (Chương 4).
7.9Chẩn đoán: lỗi retrieval hay lỗi generation?
Chẩn đoán có giá trị nhất của chương: với mỗi trace, hỏi hai câu độc lập — retrieval có đưa chunk vàng vào context không? và câu trả lời cuối có đúng không? Đếm trace vào 4 ô cho biết nên đầu tư vào đâu.
Bằng chứng đã nằm trong context mà model vẫn hỏng: hallucination, omission, misinterpretation, lạc đề, hoặc "lost in the middle". Nhìn prompt, thứ tự chunk, số chunk.
Nghe có vẻ vô hại, thực ra là bom hẹn giờ: model trả lời bằng kiến thức nội tại. Với câu hỏi nội bộ nó không thể biết, nó sẽ bịa. Dùng context swap để xác nhận.
# triage.py — xếp trace vào ma trận 2×2 (tự viết)
from collections import Counter
def quadrant(row, k=5):
retrieved_ok = any(g in row["retrieved"][:k] for g in row["gold"])
answer_ok = row["answer_pass"] # nhãn người hoặc judge đã hiệu chỉnh
return {(True, True): "OK", (True, False): "GEN_FAIL",
(False, True): "LUCKY", (False, False): "RET_FAIL"}[(retrieved_ok, answer_ok)]
def triage(rows, k=5):
c = Counter(quadrant(r, k) for r in rows)
n = len(rows)
for q in ["OK", "GEN_FAIL", "LUCKY", "RET_FAIL"]:
print(f"{q:9} {c[q]:4d} {c[q]/n:6.1%}")
# ưu tiên: khâu nào gây nhiều câu trả lời SAI hơn?
if c["RET_FAIL"] == c["GEN_FAIL"]:
return "ngang nhau — đọc trace để quyết"
return "retrieval" if c["RET_FAIL"] > c["GEN_FAIL"] else "generation"
rows = [
{"gold": ["hr-07#2"], "retrieved": ["hr-07#2", "hr-07#5"], "answer_pass": False},
{"gold": ["adm-04#1"], "retrieved": ["it-09#3", "hr-02#1"], "answer_pass": False},
{"gold": ["it-03#1"], "retrieved": ["hr-12#3", "hr-12#4"], "answer_pass": True},
{"gold": ["fin-02#3"], "retrieved": ["fin-02#3", "fin-02#1"], "answer_pass": True},
]
print("Ưu tiên sửa:", triage(rows))
Hiểu nhầm thường gặp
"Retrieve đúng = chunk vàng ở #1." — Định nghĩa phải khớp cách pipeline dùng kết quả: nếu prompt nhận top-5, "retrieve đúng" = chunk vàng nằm trong top-5 được đưa vào prompt (không phải top-50 của stage 1). Nếu câu hỏi multi-hop cần 2 chunk, "retrieve đúng" = đủ cả 2.
"Ô tím thì bỏ qua được, dù sao user cũng nhận câu đúng." — Ô tím chỉ ra rằng retrieval đang hỏng và model đang không bám context. Hai lỗi đang che nhau.
8 trace bịa của bot HR. Mỗi trace cho chunk vàng, top-3 đưa vào prompt, và câu trả lời. Bạn xếp nó vào ô nào của ma trận 2×2?
Xem lời giải
Retrieval hỏng ở 40 + 22 = 62/200 = 31% trace, trong đó 40 dẫn tới câu sai; generation hỏng 18/138 trace retrieve đúng (~13%). Ưu tiên retrieval (đọc 40 ca trượt, phân loại nguyên nhân: query viết tắt? chunk bảng? mồi nhử?).
Nhóm 22 "đúng nhờ may" cho thấy model hay trả lời từ kiến thức nội tại. Khi retrieval tốt lên, đa số sẽ chuyển sang ô OK — nhưng một số có thể chuyển sang ô lỗi generation nếu context thật mâu thuẫn với "kiến thức chung" của model (như ví dụ 12 vs 15 ngày phép). Vì vậy nên kèm context-swap trên vài ca của nhóm này và siết prompt grounding.
7.10Case study end-to-end: bot hỏi đáp tài liệu nội bộ
Bối cảnh. Công ty 600 nhân viên xây bot trả lời câu hỏi về chính sách HR, IT, tài chính trên Slack. Corpus: 420 tài liệu (quy chế, FAQ, email thông báo, bảng biểu), ~1,1 triệu token. Pipeline v0: dense retrieval, chunk 512 token không overlap, top-5 vào prompt. Tất cả số liệu dưới đây là bịa để minh hoạ.
Tuần 1 — Xây dataset (bước 7.2)
- Sinh 150 câu tổng hợp từ chunk ngẫu nhiên; LLM lọc (few-shot từ 25 câu người chấm 1–5) → giữ 90 câu (≥ 4 điểm).
- Sinh 20 câu khó có mồi nhử (chính sách cũ/mới, chính thức/thử việc, Hà Nội/tỉnh).
- Rút 30 câu thật từ kênh #hoi-hr, gắn nhãn chunk vàng bằng tay (mất ~2 giờ). Ví dụ: "OT cuối tuần tính hệ số mấy?", "WFH thứ 6 được ko", "mất thẻ gửi xe làm sao".
- Lưu nhãn theo offset ký tự để grid search sau này không phải gắn lại. Tổng: 140 query, cột
kind ∈ {easy, hard, real}.
Tuần 1 — Đo baseline retrieval (bước 7.3–7.6)
| Lát cắt | n | Recall@5 | MRR@5 |
|---|---|---|---|
| easy (tổng hợp) | 90 | 0.93 | 0.81 |
| hard (mồi nhử) | 20 | 0.60 | 0.42 |
| real (log) | 30 | 0.63 | 0.47 |
| Tổng (trọng số) | 140 | 0.82 | 0.68 |
Con số tổng 0.82 trông ổn — nhưng bị 90 câu dễ chi phối. Trên query thật chỉ 0.63. Kiểm tra (mỗi query có 1 chunk vàng nên Recall@5 = hit): easy 84/90, hard 12/20, real 19/30 → (84 + 12 + 19)/140 = 115/140 ≈ 0.82. Tổng cộng 25 ca trượt.
Tuần 2 — Error analysis trên 25 ca trượt (Chương 3)
| Nguyên nhân (mã hoá mở → gom nhóm) | Số ca | Ví dụ |
|---|---|---|
| Từ viết tắt / tiếng lóng không có trong tài liệu | 9 | "OT", "WFH", "ko" |
| Chunk bảng mất ngữ cảnh | 6 | bảng phụ cấp không có tiêu đề |
| Chunk 512 trộn nhiều chính sách, embedding nhoè | 5 | remote + thiết bị + VPN trong một chunk |
| Mồi nhử phiên bản cũ thắng | 3 | quy chế 2023 thay vì 2025 |
| Không phải lỗi: nhãn vàng thiếu (tài liệu trả về cũng trả lời đúng) | 2 | FAQ nhắc lại quy chế → bổ sung vào gold |
Tuần 2 — Grid search chunking + thử hybrid (bước 7.7)
Đo trên 50 câu hard + real (lát cắt khó, nơi có tín hiệu), giữ 10 câu held-out để xác nhận.
| Cấu hình | Recall@5 | NDCG@5 | Token index × |
|---|---|---|---|
| 512 / 0 (v0) | 0.62 | 0.51 | 1.00 |
| 512 / 128 | 0.66 | 0.54 | 1.33 |
| 256 / 0 | 0.66 | 0.56 | 1.00 |
| 256 / 64 | 0.72 | 0.61 | 1.33 |
| Cắt theo heading + tiền tố tiêu đề | 0.80 | 0.70 | ~1.05 |
| … + hybrid BM25/dense (RRF) | 0.86 | 0.74 | ~1.05 |
Hybrid xử lý phần lớn ca viết tắt vì BM25 bắt được "OT" xuất hiện nguyên văn trong vài FAQ. Held-out 10 câu xác nhận thứ tự các cấu hình không đổi.
Tuần 3 — Thêm reranker, đo đúng tầng
- Stage 1 lấy 50 ứng viên: Recall@50 = 0.96 — đây là trần cho mọi reranker (không rerank được thứ không có mặt).
- Cross-encoder rerank 50 → top-5: NDCG@5 từ 0.74 lên 0.82, Recall@5 từ 0.86 lên 0.90, MRR@5 từ 0.61 lên 0.73.
- Latency p95 +180 ms (chấp nhận được cho Slack bot). Ghi vào bảng quyết định: chất lượng vs độ trễ vs chi phí.
Tuần 3 — Generation (bước 7.8)
- Judge faithfulness theo claim, hiệu chỉnh trên 60 câu người gắn nhãn: TPR 0.90, TNR 0.85 (Chương 5).
- Baseline: 78% câu trả lời PASS faithfulness. Context-swap 10 ca FAIL: 4 ca câu trả lời không đổi khi tráo context — model trả lời bằng "kiến thức chung" (12 ngày phép, tỉ lệ OT theo luật thay vì theo quy chế công ty).
- Sửa prompt: chỉ dùng tài liệu, trích mã tài liệu, nói "không đề cập" khi thiếu; đặt chunk điểm cao nhất ở đầu. Faithfulness PASS: 78% → 91%. Relevance (code check thực thể + judge): 94%.
Kết quả tổng — ma trận 2×2 (140 query)
| Trước (v0) | Sau (v3) | |
|---|---|---|
| OK (retrieve ✓, trả lời ✓) | 92 | 118 |
| Lỗi generation (✓, ✗) | 23 | 13 |
| Đúng nhờ may (✗, ✓) | 8 | 3 |
| Lỗi retrieval (✗, ✗) | 17 | 6 |
| Tỉ lệ trả lời đúng | 100/140 ≈ 71% | 121/140 ≈ 86% |
Kiểm tra chéo: hàng "retrieve ✓" trước = 92 + 23 = 115 (khớp 25 ca trượt ở tuần 1); sau = 118 + 13 = 131. Tỉ lệ lỗi generation trong các ca retrieve đúng: 23/115 = 20% → 13/131 ≈ 10%, khớp với faithfulness PASS 78% → 91% (phần còn lại là lỗi relevance).
Bài học rút ra
- Con số tổng đánh lừa: 0.82 vs 0.63 trên query thật. Luôn báo cáo theo lát cắt.
- Đọc ca trượt trước khi tune: 9/25 ca là từ viết tắt — grid search chunking một mình không bao giờ sửa được; hybrid mới sửa.
- Chunk theo cấu trúc + tiền tố thắng mọi cấu hình fixed-size trên corpus giàu heading và bảng này — nhưng chỉ biết được nhờ đo.
- Đo đúng tầng: Recall@50 = 0.96 cho biết reranker còn dư địa; nếu Recall@50 chỉ 0.7 thì mua reranker xịn cũng vô ích.
- Nhóm "đúng nhờ may" giảm từ 8 xuống 3: một phần nhờ retrieval tốt lên (chuyển sang ô OK), một phần vì sau khi siết prompt model nói "không đề cập" thay vì đoán — lỗi trở nên lộ ra, dễ sửa hơn.
- Toàn bộ 140 query + nhãn trở thành bộ regression chạy trong CI mỗi khi đổi chunking, embedding model hay prompt (Chương 9).
Xem lời giải
- 0.62 → 0.86 là đo trên 50 câu hard + real, không phải toàn hệ thống. Lát cắt easy (vốn 0.93) có thể tăng ít hoặc thậm chí giảm — phải đo lại.
- Cấu hình được chọn trên chính 50 câu đó → con số lạc quan (overfit lựa chọn). Chỉ 10 câu held-out xác nhận thứ tự, chưa đủ để khẳng định biên độ.
- "Tốt hơn" về retrieval chưa chắc tốt hơn về trải nghiệm — phải nhìn tỉ lệ trả lời đúng end-to-end (71% → 86%) và ma trận 2×2.
- n = 50 nhỏ: mỗi query là 2 điểm %. Nên kèm khoảng tin cậy (bootstrap) trước khi tuyên bố biên độ.
7.11Cạm bẫy thường gặp
- Chỉ nhìn metric end-to-end. Một con số "đúng/sai" không cho biết lỗi ở retrieval hay generation — đo từng tầng: Recall@k cho retrieval, faithfulness cho generation. Triệu chứng: team sửa prompt ba vòng mà tỉ lệ đúng không nhúc nhích (vì chunk vàng không bao giờ vào context).
- Overfit dữ liệu tổng hợp. Câu hỏi mượn nguyên văn chunk → retrieval dễ giả tạo; chunk "vàng" có thể không duy nhất hoặc không tối ưu; thiếu câu multi-hop / cần phán đoán. Recall đẹp mà user vẫn kêu → bổ sung 20–30 query thật từ log trước khi kết luận. Triệu chứng: lát cắt easy 0.93, lát cắt real 0.63 (case study).
- Chọn sai metric. Cần một sự thật → MRR/hit rate; cần tổng hợp nhiều chunk → Recall@k/NDCG@k. Tối ưu sai metric = tối ưu hành vi không cải thiện câu trả lời. Triệu chứng: MRR tăng nhưng câu hỏi so sánh vẫn sai.
- Bỏ qua chunking như một biến số. Cùng một retriever, Recall@k có thể đổi đáng kể chỉ vì cách cắt chunk. Triệu chứng: đổi embedding model đắt tiền mà không đỡ, trong khi chunk bảng vẫn mất tiêu đề.
- Chấm generation mà không kiểm grounding. Trôi chảy và giống đáp án mẫu không có nghĩa là bám bằng chứng. Một câu trả lời nghe đúng nhưng mâu thuẫn context phá vỡ lý do tồn tại của RAG.
- Không tách "retrieve đúng" với "trả lời đúng". Bỏ lỡ tín hiệu quan trọng nhất về nút thắt — ma trận 2×2 cho biết đầu tư vào đâu.
- (Tự bổ sung) Đo sai k. Đo Recall@10 trong khi prompt chỉ nhận top-3 → metric đẹp hơn trải nghiệm thật.
- (Tự bổ sung) Quên tầng tạo query. Trong hội thoại nhiều lượt (Chương 6), câu "còn thử việc thì sao?" phải được viết lại thành query đầy đủ. Hãy log cả query đã viết lại để đánh giá khâu này riêng.
7.12Quy trình tổng hợp & checklist
- Dataset: query + chunk vàng (thủ công, tổng hợp, câu hỏi khó, đã lọc) — lưu nhãn theo offset, ghi cột lát cắt.
- Đo retrieval: hit rate/Recall@k cho độ phủ; MRR/NDCG@k cho thứ hạng — tại đúng k mà khâu sau tiêu thụ.
- Đo generation riêng: faithfulness (judge theo claim, đã hiệu chỉnh) + relevance.
- Chẩn đoán: xếp trace vào ma trận 2×2; context swap cho các ca nghi ngờ.
- Tune chunking: kích thước, overlap, ranh giới cấu trúc, tiền tố ngữ cảnh — đo tác động lên metric retrieval, xác nhận trên held-out.
☐ k khớp với số chunk vào prompt?
☐ Ngưỡng nhị phân hoá được ghi rõ?
☐ Convention IDCG được ghi rõ?
☐ Tách theo lát cắt easy / hard / real / multi-hop?
☐ Có query thật từ log?
☐ n bao nhiêu, có khoảng tin cậy?
☐ Judge faithfulness đã đo TPR/TNR?
☐ Nhãn nhị phân PASS/FAIL (không phải thang 1–10)?
☐ Tách lỗi faithfulness và relevance?
☐ Đã context-swap vài ca nghi ngờ?
☐ Chỉ tính trên trace retrieve đúng, hay cả tất cả? (ghi rõ)
7.13Áp dụng cho dự án model-routing
Hệ thống của bạn gửi mỗi request LLM tới model phù hợp, cân bằng chất lượng / chi phí / độ trễ. Nhìn kỹ, router là một retriever: "corpus" là danh sách model, "query" là request, "tài liệu liên quan" là các model trả lời đạt chuẩn. Toàn bộ bộ công cụ của chương dùng lại được gần như nguyên vẹn.
| Khái niệm RAG | Bản tương ứng trong model-routing |
|---|---|
| Corpus / chunk | Danh sách model (kèm cấu hình: temperature, reasoning effort…) |
| Chunk vàng | Các model PASS request đó (từ chạy offline mọi model + judge đã hiệu chỉnh) |
| Relevance 0–3 | 3 = PASS & rẻ nhất · 2 = PASS nhưng đắt hơn · 1 = gần đạt (lỗi nhỏ) · 0 = FAIL |
| Recall@k stage 1 | Bộ lọc cứng (context window, capability) có loại oan model PASS nào không |
| MRR / Hit@1 | Lần gọi đầu tiên có PASS không / phải fallback mấy lần |
| NDCG@k | Thứ tự có đưa model "vừa đủ tốt và rẻ" lên đầu không |
| Câu hỏi có mồi nhử | Request trông dễ (từ khoá quen) nhưng cần model mạnh, hoặc ngược lại |
| Chunking | Cách mô tả request cho router (đặc trưng: độ dài, loại tác vụ, embedding) — cũng là tham số cần tune |
| Ma trận 2×2 | Router chọn đúng/sai × câu trả lời đạt/không — lỗi router hay lỗi model? |
5 model với giá (bịa, $/1M token): nano 0.1 · mini 0.4 · mid 1.5 · large 5 · xl 15. Chạy offline cả 5 model trên 4 request, judge đã hiệu chỉnh cho biết model nào PASS. Router trả thứ hạng; cascade gọi lần lượt tới khi PASS.
| Request | Model PASS | Router xếp | grades | RR | NDCG@3 | Regret |
|---|---|---|---|---|---|---|
| r1 tóm tắt email | tất cả | mini, nano, mid… | [2,3,2,2,2] | 1.00 | 0.93 | +$0.3 |
| r2 SQL join 3 bảng | mid, large, xl | mini, mid, large… | [0,3,2,2,0] | 0.50 | 0.55 | +$0.4 |
| r3 RAG 80k token | large, xl | xl, large, mid… | [2,3,0,0,0] | 1.00 | 0.91 | +$10.0 |
| r4 phân loại ý định | mini → xl | mini, mid, nano… | [3,2,0,2,2] | 1.00 | 0.81 | $0 |
- Nhãn có mức độ: với r2, mid là PASS rẻ nhất → 3; large, xl PASS nhưng đắt → 2; mini, nano FAIL → 0.
- RR r2 = 1/2 vì model PASS đầu tiên ở hạng 2 (mini hỏng, phải fallback). MRR = (1 + 0.5 + 1 + 1)/4 = 0.875; Hit@1 = 3/4.
- NDCG@3 r3: DCG = 2 + 3/1.585 = 3.893; lý tưởng [3, 2, 0] → IDCG = 4.262 → 0.913. Trung bình 4 request = 0.80.
- Cascade regret = tiền thực trả (gồm các lần hỏng) − giá model PASS rẻ nhất. r2: mini 0.4 + mid 1.5 − 1.5 = +0.4. r3: xl 15 − large 5 = +10.
- Bài học: r3 có RR = 1 và Hit@1 ✓ — MRR khen router hoàn hảo, trong khi đây là request đốt tiền nhất. MRR mù chi phí; NDCG có mức độ và regret thì thấy. Giống hệt "NDCG phải đọc kèm recall" — ở đây "chất lượng phải đọc kèm chi phí".
# router_eval.py — đánh giá router bằng metric xếp hạng (tự viết, dữ liệu bịa)
import math
COST = {"nano": 0.1, "mini": 0.4, "mid": 1.5, "large": 5.0, "xl": 15.0} # $/1M token (bịa)
# Nhãn offline: model nào PASS mỗi request (chấm bằng judge đã hiệu chỉnh, Ch.5)
PASS = {
"r1 tóm tắt email": {"nano", "mini", "mid", "large", "xl"},
"r2 SQL 3 bảng join": {"mid", "large", "xl"},
"r3 RAG 80k token context": {"large", "xl"},
"r4 phân loại ý định": {"mini", "mid", "large", "xl"},
}
ROUTER = { # thứ hạng router đưa ra (ứng viên #1 được gọi trước, lỗi thì fallback)
"r1 tóm tắt email": ["mini", "nano", "mid", "large", "xl"],
"r2 SQL 3 bảng join": ["mini", "mid", "large", "xl", "nano"],
"r3 RAG 80k token context": ["xl", "large", "mid", "mini", "nano"],
"r4 phân loại ý định": ["mini", "mid", "nano", "large", "xl"],
}
def grade(model, passing):
"""3 = model PASS rẻ nhất, 2 = PASS nhưng đắt hơn, 0 = FAIL."""
if model not in passing: return 0
return 3 if COST[model] == min(COST[m] for m in passing) else 2
def dcg(g): return sum(x / math.log2(i + 2) for i, x in enumerate(g))
rows = []
for req, ranking in ROUTER.items():
g = [grade(m, PASS[req]) for m in ranking]
ideal = sorted((grade(m, PASS[req]) for m in COST), reverse=True)
first_ok = next(i for i, m in enumerate(ranking, 1) if m in PASS[req])
# cascade: gọi lần lượt theo thứ hạng tới khi PASS -> trả tiền cho cả các lần hỏng
spent = sum(COST[m] for m in ranking[:first_ok])
regret = spent - min(COST[m] for m in PASS[req])
rows.append((1 / first_ok, ranking[0] in PASS[req], dcg(g[:3]) / dcg(ideal[:3]), regret))
print(f"{req:26} grades={g} RR={1/first_ok:.2f} NDCG@3={rows[-1][2]:.2f} regret=${regret:+.1f}")
n = len(rows)
print(f"MRR={sum(r[0] for r in rows)/n:.3f} Hit@1={sum(r[1] for r in rows)/n:.2f} "
f"NDCG@3={sum(r[2] for r in rows)/n:.3f} mean cascade regret=${sum(r[3] for r in rows)/n:.2f}/1M tok")
$ python3 router_eval.py
r1 tóm tắt email grades=[2, 3, 2, 2, 2] RR=1.00 NDCG@3=0.93 regret=$+0.3
r2 SQL 3 bảng join grades=[0, 3, 2, 2, 0] RR=0.50 NDCG@3=0.55 regret=$+0.4
r3 RAG 80k token context grades=[2, 3, 0, 0, 0] RR=1.00 NDCG@3=0.91 regret=$+10.0
r4 phân loại ý định grades=[3, 2, 0, 2, 2] RR=1.00 NDCG@3=0.81 regret=$+0.0
MRR=0.875 Hit@1=0.75 NDCG@3=0.801 mean cascade regret=$2.67/1M tok
Chọn request, dùng ▲▼ để sắp thứ tự gọi model. Viền xanh lá = PASS rẻ nhất (grade 3), xanh dương = PASS đắt hơn (2), đỏ = FAIL (0). Metric và tiền thực trả của cascade cập nhật ngay.
Routing cho request RAG theo độ dài context
Request RAG có đặc điểm riêng: độ dài prompt phụ thuộc k và kích thước chunk. Ba hệ quả cho eval:
- Bộ lọc cứng = stage 1: model có context window nhỏ hơn prompt phải bị loại. Đo "Recall" của bộ lọc: tỉ lệ request mà model PASS rẻ nhất vẫn còn sau lọc. Nếu bộ lọc dùng ước lượng token thô (ký tự/4) có thể loại oan hoặc để lọt model gây lỗi cắt context.
- Lost in the middle phụ thuộc model: model nhỏ thường chịu context dài kém hơn. Tạo lát cắt dataset theo độ dài (≤ 4k, 4–32k, > 32k token) và theo vị trí chunk vàng trong context, rồi đo pass-rate của từng model trên từng ô → router học được "request dài + chunk vàng ở giữa → cần model lớn".
- Router và retriever tương tác: đổi k từ 5 lên 10 làm prompt dài gấp đôi → có thể đẩy request sang model đắt hơn. Khi tune chunking/k (7.7), đo luôn chi phí routing kéo theo, không chỉ Recall@k.
Kế hoạch eval cụ thể cho router (gợi ý)
- Dataset: lấy ~300 request thật từ log, chia lát cắt theo loại tác vụ và độ dài; thêm ~50 request "mồi nhử" tự viết (vd. câu hỏi ngắn nhưng cần suy luận nhiều bước; prompt dài nhưng tác vụ chỉ là trích xuất).
- Nhãn vàng: chạy mọi model trên mọi request, chấm PASS/FAIL bằng judge đã hiệu chỉnh (Chương 5) → tập "model liên quan" cho từng request. Lặp 2–3 lần với request có output ngẫu nhiên để ước lượng P(pass) thay vì một lần may rủi.
- Metric chính: Hit@1 (lần gọi đầu PASS), NDCG@3 với grade theo chi phí, cascade regret ($ và ms), cùng tỉ lệ PASS end-to-end. Báo cáo theo lát cắt.
- Ma trận 2×2 của router: router chọn model PASS? × câu trả lời cuối đạt? Ô "router chọn đúng mà vẫn trượt" = model không ổn định (variance) → vấn đề của model, không phải router.
- Baseline bắt buộc: "luôn gọi model rẻ nhất", "luôn gọi model đắt nhất", "random" — router phải thắng trên đường cong chất lượng–chi phí, không chỉ một điểm.
Xem lời giải
A: grades [2, 3, 2]; RR = 1 (large PASS ngay). DCG = 2 + 3/1.585 + 2/2 = 2 + 1.893 + 1 = 4.893; IDCG [3, 2, 2] = 3 + 1.262 + 1 = 5.262 → NDCG@3 ≈ 0.93. Regret = 5 − 1.5 = +$3.5.
B: grades [0, 3, 2]; RR = 0.5. DCG = 0 + 1.893 + 1 = 2.893 → NDCG@3 ≈ 0.55. Regret = 0.4 + 1.5 − 1.5 = +$0.4 (nhưng thêm một lần gọi hỏng → latency cao hơn).
RR và NDCG thích A; regret chi phí thích B. Chọn router nào tuỳ trọng số chi phí vs độ trễ của sản phẩm — đó là lý do nên báo cáo nhiều metric, hoặc thiết kế grade phản ánh đúng hàm mục tiêu (vd. grade phạt thêm lần gọi hỏng theo latency SLA).