Phần III · Measure Chương 7

Đánh giá RAG: đo retrieval trước, rồi mới đến generation

Chapter 7 — Evaluating Retrieval-Augmented 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 được4 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:

Corpus hàng triệu chunk Stage 1: retrieve embedding + ANN / BM25 nhanh, rẻ, quét rộng → ~50–100 ứng viên Stage 2: rerank cross-encoder / LLM xét (query, doc) cùng lúc → top-k nhỏ Generation LLM đọc top-k + câu hỏi → trả lời Ưu tiên RECALL Recall@k · hit rate@k Ưu tiên THỨ HẠNG NDCG@k · MRR · P@k Faithfulness + Relevance eval từng tầng →
Mỗi tầng một mục tiêu, một metric. Đo từng tầng riêng thì mới biết tầng nào đang là nút thắt.
Stage 1 — first-pass retrieval
bi-encoder / sparse

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.

Stage 2 — reranking
cross-encoder / LLM-as-reranker

"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ự.

Ví dụ 7.1 · Một câu hỏi đi qua pipeline — và lỗi dồn xuống thế nào

Bot HR nội bộ (bịa). Nhân viên hỏi: "Thử việc có được WFH không?"

  1. 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.
  2. 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.
  3. 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#4 về hạng 7. Pipeline chỉ đưa top-5 cho LLM → Recall@5 = 0.
  4. 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.
  5. 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)

Hybrid retrieval
BM25 + dense · RRF

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 ý.

Late interaction
ColBERT

Điểm giữa: nhanh gần như embedding (mã hoá riêng), tinh gần như reranker (so khớp ở mức token).

Long-context ≠ hết RAG
128k+ tokens

RAG vẫn đáng giá: có attribution (trích nguồn), rẻ hơn, và giữ context tập trung.

Agentic RAG
→ Chương 8

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.

Ví dụ 7.2 · Reciprocal Rank Fusion tính tay

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ạngBM25Dense
1D7D4
2D2D3
3D9D7
4D4D8
5D1D2
  1. D7: hạng 1 (BM25) + hạng 3 (dense) → 1/61 + 1/63 = 0.01639 + 0.01587 = 0.03227.
  2. D4: hạng 4 + hạng 1 → 1/64 + 1/61 = 0.01563 + 0.01639 = 0.03202.
  3. D2: hạng 2 + hạng 5 → 1/62 + 1/65 = 0.03151.
  4. D3 chỉ có ở dense hạng 2 → 1/62 = 0.01613; D9 chỉ ở BM25 hạng 3 → 1/63 = 0.01587.
  5. 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.
Bài tập 7.1 · Tính RRF
Thêm retriever thứ ba (ví dụ tìm theo tiêu đề) trả về [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.

① Chunk cùng đơn vị retriever index ② Sinh QA LLM rút 1 sự thật → viết câu hỏi ③ Làm khó thêm chunk "mồi nhử" ④ Lọc LLM chấm 1–5 few-shot từ người Dataset (q, đáp án, chunk vàng) ⑤ Trộn query thật từ log 20–30 câu, gắn nhãn chunk vàng bằng tay Khung dữ liệu mỗi dòng: {question, answer, gold:[ids], kind: easy|hard|real, source}
Chunk vàng được biết sẵn "theo cách xây dựng" ở bước ②–③; dữ liệu thật ở bước ⑤ phải gắn nhãn tay. Ghi cột 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).

Ví dụ 7.3 · Từ một chunk ra một bộ ba (dữ liệu bịa)

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."

  1. 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."
  2. 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".
  3. 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ú".
  4. 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 A — chứa đáp án "Từ 3/2024, gói Pro được hoàn tiền trong 30 ngày kể từ ngày gia hạn." ② Chunk B — mồi nhử "Năm 2023, gói Pro đổi chính sách gia hạn; hoàn tiền xử lý qua ticket hỗ trợ." ③ Câu hỏi phân biệt "Sau khi gia hạn gói Pro, tôi có bao nhiêu ngày để xin hoàn tiền?" chỉ A trả lời được B trùng "Pro, gia hạn, hoàn tiền"
Ví dụ tự đặt. Chọn chunk đích → tìm chunk tương tự nhưng không có đáp án → viết câu hỏi mượn từ ngữ của mồi nhử.
Ví dụ 7.4 · Làm khó từng bước trên bot HR (dữ liệu bịa)
  1. 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."
  2. 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".
  3. 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.
  4. 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).

Ví dụ 7.5 · Chấm độ thực tế cho bot HR (ví dụ tự đặt)
Câu hỏi sinh raĐiểmLý do
"Tháng này tôi còn mấy ngày phép?"5Tự 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?"5Ngắ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ì?"2Ngườ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?"1Khô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?"4Thự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ép golddanh 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.
Bài tập 7.2 · Tự viết một câu hỏi khó
Chunk đích: "Hoá đơn công tác phải nộp trong 10 ngày làm việc sau chuyến đi; quá hạn, khoản tạm ứng bị trừ vào lương tháng kế tiếp." Mồi nhử: "Vé máy bay phải đặt qua cổng mua sắm nội bộ ít nhất 5 ngày trước chuyến đi." Viết một câu hỏi phân biệt, và nói retriever khớp từ khoá dễ nhầm ở đâu.
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? ✓

Bài tập 7.3 · Thiết kế thành phần dataset
Bạn có ngân sách gắn nhãn tay 40 câu và có thể sinh tổng hợp không giới hạn. Đề xuất cơ cấu dataset v1 cho bot hỏi đáp tài liệu nội bộ, và giải thích vì sao.
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 kindbá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ố:

Precision@k
relevant in top-k / k

"Trong k thứ tôi đưa cho LLM, bao nhiêu là có ích?" — đo lượng nhiễu lọt vào context.

Recall@k
relevant in top-k / all relevant

"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).

HẠNG · TÀI LIỆU rel log₂(i+1) đóng góp DCG = rel / log₂(i+1) #1 Bảng giá gói Free 0 1.000 0 #2 FAQ hoàn tiền chung 2 1.585 1.26 #3 Blog ra mắt tính năng 0 2.000 0 #4 Điều khoản hoàn tiền Pro 3 2.322 1.29 ← tốt nhất lại ở #4 #5 Thread hỗ trợ về refund 1 2.585 0.39 #8 Changelog chính sách (rel 2, bị bỏ lỡ) Precision@5 3 / 5 = 0.60 Recall@5 3 / 4 = 0.75 Reciprocal rank 1 / 2 = 0.50 NDCG@5 2.94 / 4.76 ≈ 0.62
Ô xanh = tài liệu liên quan (ngưỡng rel ≥ 1). DCG = 1.26 + 1.29 + 0.39 ≈ 2.94. IDCG = DCG của thứ tự lý tưởng [3, 2, 1, 0, 0] = 3 + 1.26 + 0.5 ≈ 4.76.
Ví dụ 7.6 · Precision@k và Recall@k theo từng k

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:

kliên quan trong top-kPrecision@kRecall@k
100/1 = 0.000/4 = 0.00
21 (#2)1/2 = 0.501/4 = 0.25
311/3 = 0.330.25
53 (#2, #4, #5)3/5 = 0.603/4 = 0.75
84 (+#8)4/8 = 0.504/4 = 1.00
  1. 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.
  2. 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.
  3. Đâ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.

Bài tập 7.4 · Đổi ngưỡng nhị phân hoá
Với danh sách ví dụ (top-5 rel = [0, 2, 0, 3, 1], tài liệu #8 có rel 2), tính lại Precision@5 và Recall@5 nếu ngưỡng là rel ≥ 2.
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.

Ví dụ 7.7 · MRR và hit rate trên 5 query (dữ liệu bịa)
QueryHạng tài liệu liên quan đầu tiên (top-5)RRHit@1Hit@5
q1 "phụ cấp lưu trú Đà Nẵng"11.000
q2 "hạn nộp hoá đơn"30.333
q3 "quên thẻ ra vào"không có0
q4 "WFH thử việc"20.500
q5 "số ngày phép năm"11.000
  1. Tổng RR = 1 + 0.333 + 0 + 0.5 + 1 = 2.833 → MRR@5 = 2.833 / 5 ≈ 0.567.
  2. Hit@5 = 4/5 = 0.80; Hit@1 = 2/5 = 0.40. Để ý: Hit@1 chính là trung bình của "RR = 1".
  3. 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 ý.
  4. 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.

cao thấp độ chính xác đáp án ở đầu đáp án ở giữa: tụt mạnh đáp án ở cuối #1 #10 #20 vị trí của tài liệu chứa đáp án trong context (sơ đồ định tính, không vẽ theo số liệu)
Hình dạng chữ U theo mô tả trong sách về Liu et al. (2024). Hệ quả thực tế: đưa chunk tốt nhất lên đầu (rerank) và đừng nhồi context quá dài.

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.

Bài tập 7.5 · Cải thiện nào đáng hơn?
Từ ví dụ 7.7, team có hai lựa chọn: (A) sửa q3 để tài liệu đúng lên hạng 5; (B) sửa q2 để tài liệu đúng lên hạng 1 (từ hạng 3). Phương án nào tăng MRR nhiều hơn? Phương án nào bạn chọn nếu prompt đưa top-5 cho LLM?
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:

  1. 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.
  2. 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.

100% 0 100%63%50%43%39%36%33%32%30%29% #1#2#3#4#5#6#7#8#9#10 vị trí i trong danh sách — hệ số 1 / log₂(i+1)
Tụt mạnh ở vài vị trí đầu, rồi phẳng dần: đưa tài liệu tốt từ #4 lên #1 đáng giá hơn nhiều so với từ #10 lên #7.
Ví dụ 7.8 · NDCG@5 tính tay, từng bước

Top-5 rel = [0, 2, 0, 3, 1] (ví dụ xuyên suốt).

  1. 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.
  2. 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.
  3. Thứ tự lý tưởng (sắp lại chính 5 tài liệu đã retrieve): [3, 2, 1, 0, 0].
  4. IDCG@5 = 3/1 + 2/1.585 + 1/2 = 3 + 1.262 + 0.5 = 4.762.
  5. 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_gradesmetrics.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.

Ví dụ 7.9 · Cái bẫy của NDCG (hai hệ thống bịa)

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).

  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.
  2. 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.
  3. 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.
  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.
  5. 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.
"Use Recall to check whether the right documents made it into the candidate set; use NDCG to check whether they ended up at the top."— Shankar & Husain, Chương 7 (mẹo chọn metric)
Bài tập 7.6 · NDCG@3
Reranker trả top-3 rel = [2, 3, 0]. Tính NDCG@3 (IDCG kiểu sách). Sau đó: nếu đổi thành [3, 0, 2] thì NDCG@3 tăng hay giảm?
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.939tă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:

Đang đo tầng nào? Stage 1 (tập ứng viên) Recall@k, k = số ứng viên sang rerank Tầng cuối (vào prompt) Câu hỏi cần mấy mảnh thông tin? Một sự thật Hit@k để bắt đầu MRR nếu vị trí quan trọng (k nhỏ: 1–3) Nhiều mảnh / tổng hợp Recall@k (đủ mảnh chưa?) + NDCG@k nếu nhãn 0–3 (k lớn: 5–10) Luôn kèm theo Precision@k nếu token/nhiễu đắt Báo cáo theo lát cắt (easy/hard/real)
Sơ đồ tự tổng hợp. Chọn k theo đúng số tài liệu mà khâu sau thực sự tiêu thụ.
MetricĐo gìNhãn cầnKhi nào dùngMù với
Hit rate@kCó ≥ 1 tài liệu đúng trong top-k?nhị phânKhởi đầu; câu hỏi một sự thậtvị trí, số mảnh
Precision@kTỉ lệ có ích trong top-knhị phânKhi nhiễu/token tốn kémtài liệu bị bỏ sót
Recall@kTỉ lệ tài liệu liên quan được tìm thấynhị phân + đủ nhãn vàngStage 1; multi-hopthứ tự trong top-k
MRR@kTài liệu đúng đầu tiên sớm cỡ nàonhị phânMột sự thật, vị trí quan trọngmảnh thứ 2, 3…
NDCG@kChất lượng thứ tự có trọng số mức độcó mức độ (0–3)Rerank; nhiều mức hữu íchbỏ sót (convention sách)
▶ LAB 1 · Máy tính metric xếp hạng🎮

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ố.

Màu: nhạt = 0 · vàng = 1 · xanh nhạt = 2 · xanh đậm = 3
Bài tập 7.7 · Dùng Lab 1
Trong Lab 1, bấm "Ví dụ 7.3–7.8" rồi tìm cách chỉ đổi thứ tự (không thêm/bớt relevance) để đạt NDCG@5 = 1.000. Recall@5 có đổi không? Sau đó đặt k = 3: vì sao Recall@3 thay đổi theo thứ tự còn Recall@5 thì không?
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 (token) một "ý" nằm vắt qua ranh giới 256 size 256, overlap 128: chunk 1 · 0–256 chunk 2 · 128–384 ✓ chứa trọn ý chunk 3 · 256–512 chunk 4 · 384–640 không overlap: ý bị cắt đôi ở chunk 1 & 2
Overlap giúp nội dung ở gần ranh giới xuất hiện trọn vẹn trong ít nhất một chunk (kích thước minh hoạ).
Ví dụ 7.10 · Đếm chunk và cái giá của overlap

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.

  1. 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.
  2. 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.
  3. Đá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 ✓.
  4. 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í.
  5. 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".
▶ LAB 2 · Máy cắt chunk🎮

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.

  1. Chọn lưới: vd. size ∈ {128, 256, 512} × overlap ∈ {0, 25%, 50%}.
  2. 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.
  3. Đo Recall@k, NDCG@k — tách theo lát cắt (easy/hard/real).
  4. 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ưu gold: ["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.

Semantic / structural chunking

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.

Contextual augmentation

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.

Ví dụ 7.11 · Contextual augmentation cứu một chunk bảng
  1. 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ì.
  2. 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.
  3. 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ữ.
Bài tập 7.8 · Chọn overlap tối thiểu
Phân tích dataset cho thấy 95% đoạn đáp án dài ≤ 60 token. Bạn dùng chunk 256 token. Overlap tối thiểu bao nhiêu để 95% đáp án chắc chắn nằm trọn trong ít nhất một chunk? Chi phí index tăng khoảng bao nhiêu lần so với không overlap (tài liệu dài)?
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.

Faithfulness
grounded in retrieved context

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ộ.

Relevance
answers the actual question

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ở.

Câu trả lời "Bạn có 15 ngày phép năm. Phép dư chuyển sang Q1. Nghỉ việc thì phép được trả bằng tiền." tách C1: 15 ngày phép năm C2: phép dư chuyển sang Q1 C3: nghỉ việc → trả bằng tiền ✓ supported ✓ supported ✗ unsupported (context im lặng) Context đã retrieve "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." Kết quả 2/3 claim được ủng hộ → điểm debug 0.67 Nhãn nhị phân: FAIL (có claim không căn cứ) C3 có thể đúng ngoài đời — vẫn là hallucination theo RAG
Ví dụ tự đặt. Judge nhìn từng claim chứ không chấm cảm tính cả đoạn → dễ chỉ ra lỗi, dễ hiệu chỉnh judge.
Ví dụ 7.12 · Phân loại lỗi faithfulness (bot HR, dữ liệu bịa)

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ờiLoại lỗiVì 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)OmissionBỏ 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."RelevanceCó 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

Query "mấy ngày phép?" Run A · context thật chính sách HR: 15 ngày Run B · context tráo tài liệu IT về VPN So sánh A vs B B khác hẳn / "không có" → có dùng context B gần như A → phớt lờ context sửa ở PROMPT
Thay tài liệu đã retrieve bằng tài liệu chủ đề khác và chạy lại. Công cụ debug trên vài trace, không phải metric chạy hàng loạt.

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.

Ví dụ 7.13 · Context swap bắt được "kiến thức chung" lấn át chính sách công ty
  1. 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".
  2. Retrieval kiểm tra: chunk vàng hr-07#2 ở #1 → retrieval ổn. Nghi ngờ generation.
  3. 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.
  4. 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". ✓
  5. 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â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.

Bài tập 7.9 · Chấm claim
Context: "Tạm ứng công tác tối đa 70% chi phí dự kiến. Hoá đơn nộp trong 10 ngày làm việc sau chuyến đi." Câu trả lời: "Bạn được tạm ứng 70% chi phí. Hoá đơn nộp trong 10 ngày. Nếu nộp trễ sẽ bị phạt 5% lương." Tách claim, gán nhãn, cho điểm và nhãn nhị phân.
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?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.

Câu trả lời cuối đúng sai Retrieval đúng Retrieval sai Hệ thống chạy đúng giữ làm test hồi quy Lỗi GENERATION sửa prompt, faithfulness judge Đúng do may / parametric rủi ro ẩn: kiểm grounding Lỗi RETRIEVAL sửa retriever, chunking, query
Đếm trace vào 4 ô này cho biết nên đầu tư cải thiện vào retrieval hay generation.
Ô vàng — retrieve đúng, trả lời sai

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.

Ô tím — retrieve sai, trả lời đúng

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 model đang không bám context. Hai lỗi đang che nhau.

▶ LAB 3 · Ai làm hỏng? Retrieval hay Generation🎮

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?


Bài tập 7.10 · Đọc ma trận để quyết định đầu tư
Trên 200 trace: OK = 120, lỗi generation = 18, đúng nhờ may = 22, lỗi retrieval = 40. Team muốn làm một việc trong sprint này. Bạn đề xuất gì? Nếu sửa xong retrieval, điều gì có thể xảy ra với nhóm "đúng nhờ may"?
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ộ

Case study (hư cấu) · "Hỏi Chị HR" của công ty ACME-VN

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)

  1. 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).
  2. 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).
  3. 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".
  4. 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ắtnRecall@5MRR@5
easy (tổng hợp)900.930.81
hard (mồi nhử)200.600.42
real (log)300.630.47
Tổng (trọng số)1400.820.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ố caVí dụ
Từ viết tắt / tiếng lóng không có trong tài liệu9"OT", "WFH", "ko"
Chunk bảng mất ngữ cảnh6bảng phụ cấp không có tiêu đề
Chunk 512 trộn nhiều chính sách, embedding nhoè5remote + thiết bị + VPN trong một chunk
Mồi nhử phiên bản cũ thắng3quy 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)2FAQ 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ìnhRecall@5NDCG@5Token index ×
512 / 0 (v0)0.620.511.00
512 / 1280.660.541.33
256 / 00.660.561.00
256 / 640.720.611.33
Cắt theo heading + tiền tố tiêu đề0.800.70~1.05
… + hybrid BM25/dense (RRF)0.860.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

  1. 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).
  2. 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.
  3. 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)

  1. 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).
  2. 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).
  3. 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 ✓)92118
Lỗi generation (✓, ✗)2313
Đúng nhờ may (✗, ✓)83
Lỗi retrieval (✗, ✗)176
Tỉ lệ trả lời đúng100/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).
Bài tập 7.11 · Phản biện case study
Một đồng nghiệp nói: "Recall@5 lên 0.86 trên lát cắt khó, vậy ta đã tốt hơn 24 điểm so với v0 trên toàn hệ thống." Chỉ ra ít nhất hai điểm sai trong câu đó.
Xem lời giải
  1. 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.
  2. 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 độ.
  3. "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.
  4. 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

7.12Quy trình tổng hợp & checklist

  1. 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.
  2. Đ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ụ.
  3. Đo generation riêng: faithfulness (judge theo claim, đã hiệu chỉnh) + relevance.
  4. Chẩn đoán: xếp trace vào ma trận 2×2; context swap cho các ca nghi ngờ.
  5. 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.
Checklist trước khi báo cáo một con số retrieval

☐ 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?

Checklist trước khi báo cáo một con số generation

☐ 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.

Request prompt, độ dài, loại tác vụ Tầng 1: lọc ứng viên context window ≥ độ dài? hỗ trợ tool/vision/JSON? luật rẻ, ưu tiên RECALL Tầng 2: xếp hạng classifier / LLM nhỏ dự đoán P(pass) × cost ưu tiên THỨ HẠNG Gọi / cascade #1 trước; hỏng thì fallback #2, #3… Recall@|ứng viên| model PASS có bị loại oan ở bộ lọc không? NDCG@3 · MRR · Hit@1 nhãn có mức độ theo chất lượng + chi phí Cascade regret $ + latency thực trả so với model tối ưu eval từng tầng →
Ánh xạ trực tiếp từ RAG hai tầng sang router: lọc (recall) → xếp hạng (NDCG/MRR) → "generation" là lần gọi thật (chi phí thực trả).
Khái niệm RAGBản tương ứng trong model-routing
Corpus / chunkDanh sách model (kèm cấu hình: temperature, reasoning effort…)
Chunk vàngCác model PASS request đó (từ chạy offline mọi model + judge đã hiệu chỉnh)
Relevance 0–33 = PASS & rẻ nhất · 2 = PASS nhưng đắt hơn · 1 = gần đạt (lỗi nhỏ) · 0 = FAIL
Recall@k stage 1Bộ lọc cứng (context window, capability) có loại oan model PASS nào không
MRR / Hit@1Lần gọi đầu tiên có PASS không / phải fallback mấy lần
NDCG@kThứ 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
ChunkingCá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×2Router chọn đúng/sai × câu trả lời đạt/không — lỗi router hay lỗi model?
Ví dụ 7.14 · Đo router như đo retriever (dữ liệu bịa)

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.

RequestModel PASSRouter xếpgradesRRNDCG@3Regret
r1 tóm tắt emailtất cảmini, nano, mid…[2,3,2,2,2]1.000.93+$0.3
r2 SQL join 3 bảngmid, large, xlmini, mid, large…[0,3,2,2,0]0.500.55+$0.4
r3 RAG 80k tokenlarge, xlxl, large, mid…[2,3,0,0,0]1.000.91+$10.0
r4 phân loại ý địnhmini → xlmini, mid, nano…[3,2,0,2,2]1.000.81$0
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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
▶ LAB 4 · Bạn là router🎮

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:

Kế hoạch eval cụ thể cho router (gợi ý)

  1. 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).
  2. 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.
  3. 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.
  4. 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.
  5. 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.
Bài tập 7.12 · Thiết kế grade cho router
Với r2 (PASS: mid, large, xl), router A xếp [large, mid, xl, …], router B xếp [mini, mid, large, …]. Tính RR, NDCG@3 và cascade regret cho mỗi router. Router nào tốt hơn? Metric nào "không đồng ý" với nhau?
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).

Tự kiểm tra

1. Retriever trả top-5 có relevance [1, 0, 0, 0, 0], query có 1 tài liệu liên quan. Precision@5, Recall@5, reciprocal rank?
P@5 = 1/5 = 0.2; R@5 = 1/1 = 1.0; RR = 1/1 = 1.0. Với câu hỏi một sự thật, đây là kết quả hoàn hảo dù precision thấp (precision bị chặn trên bởi 1/5).
2. Stage 1 nên tối ưu metric nào, stage rerank nên tối ưu metric nào? Vì sao?
Stage 1: Recall@k — tài liệu đúng phải có mặt trong tập ứng viên; thiếu là không tầng nào cứu được. Rerank: NDCG@k — tài liệu đúng phải lên đầu, vì context có hạn và LLM chú ý không đều.
3. Vì sao NDCG cao chưa chắc hệ thống tốt?
Hệ thống có thể chỉ lấy toàn tài liệu "hơi liên quan" nhưng xếp đúng thứ tự → NDCG cao (tới 1.0 theo convention của sách), trong khi tài liệu rất liên quan bị bỏ lỡ. Luôn đọc cùng recall.
4. Thay context bằng tài liệu khác chủ đề mà câu trả lời vẫn y nguyên. Điều đó nói lên gì và sửa ở đâu?
LLM đang phớt lờ context, dùng parametric knowledge — vấn đề grounding/faithfulness. Thường sửa ở prompt (chỉ dựa vào tài liệu, không có thì nói không có), không phải retriever.
5. Recall@5 trên dữ liệu tổng hợp là 0.95 nhưng user liên tục phàn nàn câu trả lời sai. Nghi ngờ gì đầu tiên?
Dữ liệu tổng hợp quá dễ (mượn nguyên văn chunk). Bổ sung 20–30 query thật từ log trước khi kết luận; đồng thời kiểm tra lỗi có nằm ở generation không (ma trận 2×2).
6. Câu hỏi "Văn phòng nào ở miền Nam có phụ cấp gửi xe cao nhất?" cần ghép danh sách văn phòng + mức phụ cấp. Dùng MRR có hợp không?
Không. Đây là câu multi-hop — MRR chỉ nhìn tài liệu liên quan đầu tiên. Dùng Recall@k hoặc NDCG@k, và định nghĩa "retrieve đúng" = có đủ mọi mảnh.
7. Top-3 rel = [0, 3, 2]. NDCG@3 (IDCG sắp lại chính top-3) bằng bao nhiêu?
DCG = 0 + 3/1.585 + 2/2 = 1.893 + 1 = 2.893. Lý tưởng [3, 2, 0]: IDCG = 3 + 2/1.585 = 4.262. NDCG@3 ≈ 0.68.
8. Chunk 256 token, overlap 64. Đoạn đáp án dài 50 token có bao giờ bị cắt đôi ở mọi chunk không? Còn đoạn dài 100 token?
50 ≤ 64 + 1 → luôn nằm trọn trong ít nhất một chunk. 100 > 65 → có thể bị cắt, tuỳ vị trí (nếu nó bắt đầu ngay trước điểm khởi đầu một chunk mới).
9. Trong ma trận 2×2, ô "retrieve sai nhưng trả lời đúng" nguy hiểm ở chỗ nào?
Model đang trả lời bằng kiến thức nội tại chứ không dùng bằng chứng. Hôm nay tình cờ đúng; với câu hỏi mà model không biết (tài liệu nội bộ, chính sách mới), nó sẽ bịa. Hai lỗi (retrieval + grounding) đang che nhau.
10. Router của bạn có MRR = 1.0 trên tập eval. Có thể kết luận nó tối ưu không?
Không. MRR = 1 chỉ nói model PASS được gọi đầu tiên — kể cả khi đó là model đắt nhất. Cần metric có mức độ theo chi phí (NDCG với grade 3 = PASS rẻ nhất) và cascade regret; và so với baseline "luôn gọi model rẻ nhất / đắt nhất".