3.15 BM25 tiếng Việt: chọn tham số và kích thước chunk
Trang này là phần tinh chỉnh. Nếu bạn đang muốn biết
k1,b,idftừ đâu ra và mỗi cái giả định gì về dữ liệu: 3.4b.
Câu hỏi thường được đặt sai thứ tự: “k1, b, chunk size nên là bao nhiêu?”.
Trả lời ngắn: k1 = 1.2, b = 0.75, chunk 256–512 token — rồi đừng chạm vào
nữa cho đến khi ba thứ ở mục 1 đã xong. Toàn bộ khoảng cách giữa bộ tham số tệ nhất
và tốt nhất trên corpus của repo này là +8,4%; sửa một tokenizer sai là +20,8%.
Đừng đọc trang này thành “chunk size không quan trọng,
k1/bmới quan trọng”. Ngược lại hoàn toàn. Chunk size là quyết định lớn — nó đổi đơn vị bạn tìm được, đổi thứ bạn nhét vào prompt, đổi cả recall của dense retrieval.k1/blà núm nhỏ:+2,6%, và càng nhỏ dần khi chunk càng lớn và càng đều (mục 4.1). Kết luận thật của mục 4 là: chunk tốt làmk1/btrở nên không đáng quan tâm, không phải “chunk thế nào cũng được, cứ tinh chỉnhk1/b”.
1. Thứ tự ưu tiên (phần quan trọng nhất của trang này)
Phần tiêu đề “1. Thứ tự ưu tiên (phần quan trọng nhất của trang này)”| Ưu tiên | Việc | Tác động đã đo |
|---|---|---|
| 1 | Tokenizer không ăn mất định danh (mã lỗi, endpoint, số) | +4,1% tổng, +12,4% ở lớp A |
| 2 | Giữ nguyên stopword | tránh mất −3,0% tổng, −8,0% ở lớp B |
| 3 | Thêm bigram âm tiết | +1,4% tổng, +4,0% ở lớp C |
| 4 | Quyết định bỏ dấu dựa trên p thật (3.10) | tránh sập −15,5% khi truy vấn mất dấu |
| 5 | Tinh chỉnh k1, b | +2,6% |
Elastic nói thẳng điều tương tự: “picking b and k1 are generally not the first thing to do when your users aren’t finding documents quickly” — trước đó hãy làm phrase match, đồng nghĩa, fuzzy, chuẩn hoá.
Cách đọc bảng: bốn dòng đầu là quyết định về dữ liệu; dòng cuối là siêu tham số. Cộng bốn dòng đầu lớn hơn dòng cuối gần một bậc độ lớn.
2. k1 và b — grid đo thật
Phần tiêu đề “2. k1 và b — grid đo thật”Đo trên data/corpus.jsonl (50 tài liệu tiếng Việt, trung bình 43,6 từ/tài liệu) và
data/goldenset.jsonl (24 truy vấn, 4 lớp), metric nDCG@5 với relevance có mức
(2 = chính, 1 = phụ), tokenizer code-aware, index = title + text:
k1 \ b | 0 | 0.25 | 0.5 | 0.75 | 1.0 |
|---|---|---|---|---|---|
| 0 | 0.737 | 0.737 | 0.737 | 0.737 | 0.737 |
| 0.3 | 0.756 | 0.761 | 0.761 | 0.761 | 0.761 |
| 0.6 | 0.759 | 0.763 | 0.767 | 0.767 | 0.767 |
| 0.9 | 0.764 | 0.768 | 0.780 | 0.780 | 0.774 |
| 1.2 | 0.779 | 0.783 | 0.783 | 0.779 ← mặc định | 0.775 |
| 1.5 | 0.779 | 0.783 | 0.781 | 0.779 | 0.778 |
| 2.0 | 0.779 | 0.782 | 0.785 | 0.778 | 0.778 |
| 2.5 | 0.786 | 0.795 | 0.799 ← tối ưu | 0.782 | 0.769 |
| 3.0 | 0.791 | 0.795 | 0.798 | 0.792 | 0.778 |
| 5.0 | 0.795 | 0.798 | 0.797 | 0.792 | 0.763 |
Mặc định (1.2, 0.75) → 0.779. Tối ưu (2.5, 0.5) → 0.799, tức +2,6%.
Khoảng cách tệ nhất → tốt nhất: 0.737 → 0.799 = +8,4%, và gần như toàn bộ khoảng
đó nằm ở việc đừng để k1 gần 0, không phải ở việc tìm đúng số thập phân.
Chia theo lớp truy vấn, chỗ tinh chỉnh thực sự có tác dụng rất hẹp:
| bộ tham số | tổng | A (exact) | B (paraphrase) | C (multi-hop) | D (phủ định) |
|---|---|---|---|---|---|
(1.2, 0.75) mặc định | 0.779 | 0.971 | 0.435 | 0.819 | 0.893 |
(2.5, 0.5) tối ưu | 0.799 | 0.971 | 0.459 | 0.872 | 0.893 |
Toàn bộ +2,6% đến từ lớp C (multi-hop). Lớp A và D không nhúc nhích — tham số
ranking không chữa được vocabulary mismatch (lớp B), việc đó là của
embedding và
reranker.
Cảnh báo về con số này. 24 truy vấn là mẫu rất nhỏ;
+2,6%nằm trong tầm nhiễu của một golden set cỡ này (C.6 — ý nghĩa thống kê). Bảng trên đáng tin ở hình dạng (cộtbphẳng, hàngk1=0sập, tối ưu là một vùng rộng chứ không phải một điểm), không đáng tin ở chữ số thập phân thứ ba. Mức tuyệt đối lệch ~0.008 so với baseline 0.787 ở Tầng 0 vì regex tokenizer không giống hệt — kết luận không đổi.
3. Đọc grid: chọn k1, b theo tính chất dữ liệu
Phần tiêu đề “3. Đọc grid: chọn k1, b theo tính chất dữ liệu”k1 và b không phụ thuộc ngôn ngữ. Không có “k1 cho tiếng Việt”. Chúng phụ
thuộc vào hai thứ: tần suất lặp từ có phải tín hiệu thật, và độ dài tài liệu có đều.
| Tham số | Tăng khi | Giảm khi | Khoảng thực nghiệm phổ biến |
|---|---|---|---|
k1 | tài liệu ngắn và đồng đều (chunk chuẩn hoá), lặp từ = tín hiệu thật | tài liệu dài, có spam/boilerplate lặp từ khoá | 0.5–2.0 (Elastic); corpus này tối ưu ở 2.5 vì tài liệu chỉ ~44 từ |
b | độ dài lệch nhiều, tài liệu dài vì nói nhiều chủ đề | chunk đã đều, hoặc tài liệu dài vì chi tiết về đúng một chủ đề | 0.3–0.9 (Elastic); corpus này tối ưu ở 0.5 |
Ba dấu hiệu đọc được ngay từ grid:
k1 = 0là vực. Hàng đó phẳng tuyệt đối (0.737 ở cả 5 cột) vì khik1 = 0, phần tần suất triệt tiêu vàbmất hoàn toàn tác dụng — điểm thu về đúng bằng tổngidf. Đại số nói trước, số đo xác nhận sau.bgần như vô nghĩa ở đây. Ởk1 = 1.2, cả dảibtừ 0 đến 1 chỉ dao động 0.008. Đó không phải BM25 hỏng — đó là dấu hiệu chunk đã đều (mục 4).b = 1.0là cột tệ nhất ở mọik1cao. Chuẩn hoá độ dài tối đa trên corpus đã đều nghĩa là phạt oan.
Cách tinh chỉnh đúng: grid search k1 ∈ [0.6, 3.0] × b ∈ [0, 1] trên golden
set, chia train/test hoặc ít nhất bootstrap khoảng tin cậy, rồi chọn vùng ổn
định thay vì đỉnh đơn lẻ. Đỉnh đơn lẻ trên 24 truy vấn là overfit, không phải tối ưu.
4. Chunk size — can thiệp vào dl/avgdl ở tầng dữ liệu
Phần tiêu đề “4. Chunk size — can thiệp vào dl/avgdl ở tầng dữ liệu”BM25 không có “context window”, nên chunk size không ảnh hưởng tới BM25 theo kiểu
ảnh hưởng tới embedding. Nó ảnh hưởng qua đúng một đường: dl/avgdl, tức là qua b.
Đo trên cùng corpus, gộp/tách tài liệu để mô phỏng các mức chunk, giữ k1 = 1.2:
| đơn vị index | #đơn vị | avgdl (từ) | CV độ dài | dao động nDCG@5 theo b | b tối ưu |
|---|---|---|---|---|---|
| câu lẻ | 116 | 23.6 | 0.30 | 0.029 | 1.0 |
| tài liệu (~44 từ) | 50 | 48.5 | 0.14 | 0.008 | 0.25 |
| gộp 2 tài liệu | 25 | 96.9 | 0.11 | 0.017 | 0.25 |
| gộp 4 tài liệu | 13 | 186.4 | 0.15 | 0.015 | 0 |
| gộp 10 tài liệu | 5 | 484.6 | 0.08 | 0.003 | 0 |
Quan hệ đọc được: chunk càng đều (CV thấp) thì b càng không quan trọng, và b
tối ưu tụt về 0. Nói cách khác:
blà băng dán cho chunking kém. Nếu bạn phải tinh chỉnhbđể hệ chạy được, thứ cần sửa là pipeline chunking, không phảib.
So sánh chất lượng giữa các mức chunk thì phải cố định ngân sách context — nếu
không, chunk to tự thắng chỉ vì top-5 phủ nhiều corpus hơn. Cố định ~215 từ trả về
(k điều chỉnh theo avgdl):
| đơn vị index | k | recall@k (mức tài liệu) | nDCG@k | từ thực trả |
|---|---|---|---|---|
| câu lẻ | 9 | 0.778 (tinh chỉnh: 0.840) | 0.667 | 224 |
| tài liệu (~44 từ) | 4 | 0.708 | 0.758 | 197 |
| gộp 2 (~97 từ) | 2 | 0.743 | 0.778 | 198 |
| gộp 4 (~186 từ) | 1 | 0.646 | 0.819 | 189 |
Căng thẳng hiện ra rõ và nó không giải được bằng một con số duy nhất: chunk nhỏ thắng ở recall của đúng mẩu thông tin, chunk to thắng ở thứ tự và ngữ cảnh. Đây chính xác là lý do parent-child chunking tồn tại: index nhỏ để tìm, trả về to để đọc — lấy cả hai cột thay vì chọn một.
4.1 Chunk ~512 token thì k1, b đặt bao nhiêu?
Phần tiêu đề “4.1 Chunk ~512 token thì k1, b đặt bao nhiêu?”Trước hết phải quy đổi, vì BM25 đếm term sau tokenize (dl), không đếm token của
LLM. Đo trên data/corpus.jsonl (50 tài liệu tiếng Việt), số token LLM trên mỗi
âm tiết tiếng Việt:
| Tokenizer | token / âm tiết tiếng Việt | 512 token ≈ |
|---|---|---|
o200k_base (GPT-4o và sau) | 1.30 | ~395 âm tiết |
cl100k_base (GPT-3.5 / GPT-4 cũ) | 2.07 | ~247 âm tiết |
Nên “chunk 512 token” nghĩa là dl ≈ 250–400 term — gấp 6–9 lần tài liệu trong corpus
này, tức nằm hẳn trong vùng dài và đều. Ở vùng đó, đo được điều này: cao nguyên
quanh đỉnh rộng ra rất nhanh khi chunk to lên. Cùng grid k1 ∈ [0.6, 3.0] ×
b ∈ [0, 1], nDCG@5, cột cuối là tập giá trị nằm trong 1% của đỉnh:
| đơn vị index | avgdl | (k1, b) đỉnh | nDCG@5 đỉnh | mặc định (1.2, 0.75) | k1 trong 1% đỉnh | b trong 1% đỉnh |
|---|---|---|---|---|---|---|
| câu lẻ | 23.6 | (1.5, 1.0) | 0.631 | 0.620 | 0.9–3.0 | 0.5–1.0 |
| tài liệu ~44 từ | 48.5 | (2.5, 0.5) | 0.799 | 0.779 | 2.5–3.0 | 0–0.75 |
| gộp 2 ~97 từ | 96.9 | (2.5, 1.0) | 0.845 | 0.809 | 2.0–3.0 | 0.25–1.0 |
| gộp 4 ~186 từ | 186.4 | (3.0, 0) | 0.833 | 0.832 | 1.2–3.0 | 0–1.0 |
Đọc dòng cuối: ở avgdl ≈ 186, mặc định (1.2, 0.75) cho 0.832 còn đỉnh cho 0.833 —
chênh 0.001, và toàn bộ grid k1 1.2–3.0 × b 0–1 đều nằm trong 1% của đỉnh.
Chunk càng to và càng đều thì k1/b càng không còn gì để tinh chỉnh. Ngoại suy lên
dl ≈ 250–400, kết luận thực dụng:
Chunk ~512 token: để nguyên
k1 = 1.2,b = 0.75. Đừng tốn thời gian grid search — ở kích thước đó nó không mua được gì. Tiền nằm ở tokenizer, bigram, hybrid và reranker.
Cẩn thận: cao nguyên rộng ra chỉ vì chunk đều. Nó không phải luật vật lý — nếu
corpus của bạn trộn chunk 512 token với tài liệu gốc 5000 token thì b quan trọng lại
ngay, và câu trả lời đúng là đừng trộn.
Cái bẫy thật của fixed-size 512, và nó không phải k1/b: cắt cố định thì
chunk cuối của mỗi tài liệu bị ngắn — 30–80 token, cạnh những chunk 512 token.
BM25 với b > 0 ưu ái tài liệu ngắn (cùng số lần xuất hiện, dl nhỏ hơn thì điểm
cao hơn), nên mấy mẩu đuôi đó leo lên top một cách vô cớ và trong corpus lớn thì đủ
nhiều để thấy được. Chữa ở tầng chunker, không ở tầng tham số:
- Gộp đuôi: chunk cuối ngắn hơn ~40% kích thước đích thì nhập vào chunk trước.
- Ngưỡng độ dài tối thiểu: bỏ hẳn chunk ngắn hơn ~50 token (thường là mục lục, tiêu đề lẻ, dòng bảng) hoặc gắn chúng vào chunk cha.
- Chỉ khi vẫn còn lệch nhiều thì mới hạ
b— và đó là dấu hiệu bước 1–2 chưa xong.
Và nhớ vì sao 512 lại là con số hay được chọn: nó là ràng buộc của dense retrieval và context window, không phải của BM25. BM25 xử lý chunk dài không vấn đề gì; vấn đề là bạn đưa cho LLM 512 token mà chỉ ~30 token trong đó liên quan.
Corpus 50 tài liệu × 44 từ quá nhỏ để kết luận về chunk size (ở mức “gộp 4”,
k tụt xuống 1 và metric thành nhiễu). Số dẫn từ nguồn ngoài, có benchmark thật:
| Loại truy vấn / dữ liệu | Chunk size | Nguồn |
|---|---|---|
| Câu hỏi factoid, đáp án ngắn | 64–128 token | Rethinking Chunk Size, 2025 |
| Cần ngữ cảnh rộng, đáp án mô tả | 512–1024 token | Rethinking Chunk Size, 2025 |
| Khởi điểm chung, chưa biết gì về truy vấn | 256–512 token, overlap 10–20% | thực hành phổ biến; xem Tầng 4 |
| Văn bản pháp lý (RAG, corpus lớn) | ~500 ký tự | Legal RAG, 2025 |
5. Phần thật sự là “tiếng Việt”: tokenizer
Phần tiêu đề “5. Phần thật sự là “tiếng Việt”: tokenizer”Đây là chỗ tiếng Việt khác tiếng Anh, và là chỗ đáng bỏ công. Cùng grid, chỉ đổi tokenizer:
| Tokenizer | nDCG@5 (1.2, 0.75) | A | B | C | D |
|---|---|---|---|---|---|
code-aware (giữ E2003, /v1/, 99,9%, AES-256) | 0.779 | 0.971 | 0.435 | 0.819 | 0.893 |
chỉ chữ cái [^\W\d_]+ | 0.748 | 0.864 | 0.417 | 0.819 | 0.893 |
| code-aware + bigram âm tiết | 0.790 | 0.971 | 0.444 | 0.852 | 0.893 |
| code-aware, bỏ dấu | 0.769 | 0.971 | 0.440 | 0.776 | 0.888 |
| code-aware, bỏ stopword | 0.756 | 0.971 | 0.400 | 0.765 | 0.888 |
Sáu quy tắc, tất cả đều có số đỡ lưng:
- Chuẩn hoá NFC trước mọi thứ.
ếdựng sẵn ≠e+dấu tổ hợp. Bỏ bước này thì hai chuỗi trông giống nhau không khớp nhau, và không có lỗi nào được ném (3.1). - Đừng bỏ stopword.
idfđã tự lo: từ xuất hiện ở gần mọi tài liệu cóidf ≈ 0. Bỏ tay làm lớp B tụt −8,0%. - Đừng stemming. Tiếng Việt không biến hình. Stemmer tiếng Anh áp lên tiếng Việt chỉ tạo nhiễu.
- Thêm bigram âm tiết (
cài,đặt,cài_đặt).+1,4%tổng,+4,0%lớp C, không cần model nào, giá là index to ~2×. Đây là món rẻ nhất còn lại sau tokenizer. - Bỏ dấu là quyết định về p, không phải về chất lượng. Index bỏ dấu mất 1,3% ở trường hợp lý tưởng nhưng bất biến với truy vấn mất dấu. Điểm hoà vốn p ≈ 17% truy vấn không dấu (Tầng 0). Production đúng: index cả hai field, field có dấu trọng số cao hơn.
- Tokenizer của index và của query phải là cùng một hàm. Không phải “giống nhau” — cùng một hàm, gọi từ cùng một chỗ.
Về word segmentation (vi_analyzer, VnCoreNLP, underthesea, pyvi) so với
âm tiết + bigram: chưa đo trên corpus này. Đánh đổi đã biết — segmenter đúng hơn
về ngôn ngữ nhưng thêm một phụ thuộc và một nguồn lỗi khi gặp từ mới (tên riêng, thuật
ngữ nội bộ), và buộc query phải đi qua đúng segmenter đó. Âm tiết + bigram không
bao giờ tách sai vì nó không tách.
6. Cấu hình mẫu (Elasticsearch)
Phần tiêu đề “6. Cấu hình mẫu (Elasticsearch)”{ "settings": { "index": { "similarity": { "default": { "type": "BM25", "k1": 1.2, "b": 0.75 } } }, "analysis": { "analyzer": { "vi_có_dấu": { "tokenizer": "vi_tokenizer", "filter": ["lowercase"] }, "vi_không_dấu": { "tokenizer": "vi_tokenizer", "filter": ["lowercase", "icu_folding"] } } } }, "mappings": { "properties": { "text": { "type": "text", "analyzer": "vi_có_dấu", "fields": { "nodia": { "type": "text", "analyzer": "vi_không_dấu" } } } } }}Truy vấn thì multi_match với ["text^3", "text.nodia^1"] — có dấu ăn điểm cao hơn,
không dấu vẫn tìm ra. Lưu ý: vi_tokenizer là plugin ngoài, phải cài riêng, và
mặc định nó bỏ stopword (vi_stop) — theo quy tắc 2 ở trên, hãy tắt.
7. Bẫy
Phần tiêu đề “7. Bẫy”- Tinh chỉnh
k1/btrên chính golden set rồi báo cáo con số đó. Đó là con số train, không phải test. Trên 24 truy vấn,+2,6%overfit rất dễ. - Đổi
bđể chữa chunking lệch. Sửa chunking. k1rất cao (5.0) trông tốt trên corpus tài liệu ngắn. Nó sẽ sập ngay khi có tài liệu dài hoặc boilerplate lặp từ khoá vào index.- Đổi
similaritycủa index đang chạy. Điểm đổi nghĩa với mọi truy vấn, không chỉ truy vấn bạn đang test; và ES cần reindex để một số thay đổi analyzer có hiệu lực. - So chunk size mà không cố định ngân sách context. Chunk to sẽ tự thắng vì lý do sai (mục 4).