Bỏ qua để đến nội dung
Search & RAG

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, idf từ đâ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/b mớ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/b là 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àm k1/b trở nên không đáng quan tâm, không phải “chunk thế nào cũng được, cứ tinh chỉnh k1/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ênViệcTác động đã đo
1Tokenizer không ăn mất định danh (mã lỗi, endpoint, số)+4,1% tổng, +12,4% ở lớp A
2Giữ nguyên stopwordtránh mất −3,0% tổng, −8,0% ở lớp B
3Thêm bigram âm tiết+1,4% tổng, +4,0% ở lớp C
4Quyế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
5Tinh 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.

Đ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 \ b00.250.50.751.0
00.7370.7370.7370.7370.737
0.30.7560.7610.7610.7610.761
0.60.7590.7630.7670.7670.767
0.90.7640.7680.7800.7800.774
1.20.7790.7830.7830.779 ← mặc định0.775
1.50.7790.7830.7810.7790.778
2.00.7790.7820.7850.7780.778
2.50.7860.7950.799 ← tối ưu0.7820.769
3.00.7910.7950.7980.7920.778
5.00.7950.7980.7970.7920.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ổngA (exact)B (paraphrase)C (multi-hop)D (phủ định)
(1.2, 0.75) mặc định0.7790.9710.4350.8190.893
(2.5, 0.5) tối ưu0.7990.9710.4590.8720.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 embeddingreranker.

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ột b phẳng, hàng k1=0 sậ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”

k1b 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 khiGiảm khiKhoảng thực nghiệm phổ biến
k1tài liệu ngắn và đồng đều (chunk chuẩn hoá), lặp từ = tín hiệu thậttà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:

  1. k1 = 0 là vực. Hàng đó phẳng tuyệt đối (0.737 ở cả 5 cột) vì khi k1 = 0, phần tần suất triệt tiêu và b mất hoàn toàn tác dụng — điểm thu về đúng bằng tổng idf. Đại số nói trước, số đo xác nhận sau.
  2. b gần như vô nghĩa ở đây.k1 = 1.2, cả dải b từ 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).
  3. b = 1.0 là cột tệ nhất ở mọi k1 cao. 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àidao động nDCG@5 theo bb tối ưu
câu lẻ11623.60.300.0291.0
tài liệu (~44 từ)5048.50.140.0080.25
gộp 2 tài liệu2596.90.110.0170.25
gộp 4 tài liệu13186.40.150.0150
gộp 10 tài liệu5484.60.080.0030

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:

bbăng dán cho chunking kém. Nếu bạn phải tinh chỉnh b để hệ chạy được, thứ cần sửa là pipeline chunking, không phải b.

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ị indexkrecall@k (mức tài liệu)nDCG@ktừ thực trả
câu lẻ90.778 (tinh chỉnh: 0.840)0.667224
tài liệu (~44 từ)40.7080.758197
gộp 2 (~97 từ)20.7430.778198
gộp 4 (~186 từ)10.6460.819189

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:

Tokenizertoken / âm tiết tiếng Việt512 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ị indexavgdl(k1, b) đỉnhnDCG@5 đỉnhmặc định (1.2, 0.75)k1 trong 1% đỉnhb trong 1% đỉnh
câu lẻ23.6(1.5, 1.0)0.6310.6200.9–3.00.5–1.0
tài liệu ~44 từ48.5(2.5, 0.5)0.7990.7792.5–3.00–0.75
gộp 2 ~97 từ96.9(2.5, 1.0)0.8450.8092.0–3.00.25–1.0
gộp 4 ~186 từ186.4(3.0, 0)0.8330.8321.2–3.00–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ố:

  1. 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.
  2. 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.
  3. 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ệuChunk sizeNguồn
Câu hỏi factoid, đáp án ngắn64–128 tokenRethinking Chunk Size, 2025
Cần ngữ cảnh rộng, đáp án mô tả512–1024 tokenRethinking Chunk Size, 2025
Khởi điểm chung, chưa biết gì về truy vấn256–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:

TokenizernDCG@5 (1.2, 0.75)ABCD
code-aware (giữ E2003, /v1/, 99,9%, AES-256)0.7790.9710.4350.8190.893
chỉ chữ cái [^\W\d_]+0.7480.8640.4170.8190.893
code-aware + bigram âm tiết0.7900.9710.4440.8520.893
code-aware, bỏ dấu0.7690.9710.4400.7760.888
code-aware, bỏ stopword0.7560.9710.4000.7650.888

Sáu quy tắc, tất cả đều có số đỡ lưng:

  1. 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).
  2. Đừ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%.
  3. Đừ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.
  4. 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.
  5. 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.
  6. 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.

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

  • Tinh chỉnh k1/b trê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.
  • k1 rấ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 similarity củ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).
Phần 3 — Nền tảng kỹ thuật