NHT

Chiến lược chunking giúp RAG chính xác hơn

So sánh fixed, recursive và semantic chunking trên cùng một corpus — kèm số liệu recall cho thấy vì sao cách chia tài liệu quan trọng hơn cả model embedding.

Nguyen Hoang TuanNguyen Hoang Tuan26 thg 6, 20269 phút đọc

Bài hybrid search đã tinh chỉnh cách truy xuất chunk. Nhưng truy xuất chỉ trả về được những chunk đang tồn tại — nếu một tài liệu bị chia sao cho câu trả lời rải ra hai mảnh, hoặc chìm trong một khối 2.000 token kèm chín đoạn không liên quan, thì không công thức fusion nào cứu được. Chunking là bước ai cũng làm qua loa rồi đổ lỗi cho model embedding. Bài này so sánh ba chiến lược quan trọng nhất — fixed, recursive, semantic — trên cùng một corpus, kèm số liệu recall cho thấy nó tạo khác biệt lớn cỡ nào.

Ta giữ ví dụ xuyên suốt series: bảng documents chứa các trang tài liệu API GoGoDuk và kho kiến thức địa chỉ tiếng Việt, embed thành vector 1536 chiều cho pgvector. Câu hỏi không còn là "lưu và tìm thế nào" — mà là "chính xác cái gì đi vào mỗi dòng."

Vì sao chunking quyết định chất lượng RAG

Chunk là đơn vị truy xuất. Sai theo hai hướng ngược nhau:

  • Quá lớn. Một chunk 2.000 token chứa câu trả lời nhưng cũng chứa hàng chục chủ đề khác. Embedding của nó là trung bình của tất cả, nên nó xếp hạng thấp hơn cho câu hỏi cụ thể — tín hiệu bị pha loãng. Và kể cả khi được truy xuất, bạn đã tốn ngân sách context (token, latency) cho nhiễu mà LLM phải đọc lướt qua.
  • Quá nhỏ. Một chunk 50 token cắt một quy trình giữa câu. Retriever tìm thấy "bước 3" nhưng bước 3 tham chiếu "token từ bước 1" nằm ở chunk khác không được truy xuất. Câu trả lời về kỹ thuật là có trong corpus mà vẫn không với tới được.

Nhiệm vụ của chiến lược chunking là giữ một ý mạch lạc mỗi chunk — đủ lớn để đứng độc lập, đủ nhỏ để embedding chỉ mang một nghĩa. Ba chiến lược dưới đây là những cách ngày càng khéo để tìm ranh giới đó.

Fixed-size chunking: mức cơ bản

Chia mỗi N ký tự (hoặc token), bỏ qua hoàn toàn cấu trúc. Đây là mặc định trong hầu hết tutorial vì nó cực đơn giản:

function fixedChunks(text: string, size = 1000, overlap = 150): string[] {
  const chunks: string[] = [];
  for (let i = 0; i < text.length; i += size - overlap) {
    chunks.push(text.slice(i, i + size));
  }
  return chunks;
}

Nó chạy được, kiểu kiểu vậy. Nhược điểm lộ ra ngay khi bạn nhìn output: nó cắt giữa câu, giữa code-block, giữa bảng. Một trang doc GoGoDuk viết "...endpoint /v1/suggest trả về tối đa 7 prediction; mỗi cái có placeId..." bị cắt ngay sau "mỗi cái có" vì đó là chỗ ký tự thứ 1.000 rơi vào. Embedding của mảnh đó là nửa ý nghĩ. Dùng nó làm mốc để vượt qua, đừng làm đích đến.

Recursive chunking: chia theo cấu trúc trước

Insight: tài liệu vốn đã có ranh giới sẵn — đoạn văn, heading, mục danh sách, code fence. Recursive chunking thử chia theo dấu phân tách lớn nhất trước, chỉ rớt xuống dấu nhỏ hơn khi một mảnh vẫn vượt giới hạn kích thước:

function recursiveChunks(text: string, max = 1000): string[] {
  const separators = ["\n## ", "\n\n", "\n", ". ", " "];

  function split(input: string, depth: number): string[] {
    if (input.length <= max) return [input];
    const sep = separators[depth] ?? "";
    const parts = sep ? input.split(sep) : [input];

    const out: string[] = [];
    let buffer = "";
    for (const part of parts) {
      const candidate = buffer ? buffer + sep + part : part;
      if (candidate.length <= max) {
        buffer = candidate;
      } else {
        if (buffer) out.push(buffer);
        // Mảnh vẫn quá lớn -> đệ quy với dấu phân tách mịn hơn.
        out.push(...(part.length > max ? split(part, depth + 1) : [part]));
        buffer = "";
      }
    }
    if (buffer) out.push(buffer);
    return out;
  }

  return split(text, 0);
}

Bây giờ điểm cắt chunk rơi giữa các đoạn văn hoặc trước một ## H2, không cắt giữa câu. Với tài liệu kỹ thuật có cấu trúc, đây là bước nhảy chất lượng lớn nhất chỉ trong một thay đổi, mà lại rẻ — thuần xử lý chuỗi, không gọi model. Đây là mặc định đúng cho 90% corpus.

Semantic chunking: chia nơi ý nghĩa đổi

Recursive chia theo định dạng. Semantic chunking chia theo ý nghĩa: embed từng câu, rồi bắt đầu chunk mới ở chỗ các câu liên tiếp ngừng giống nhau — một sự đổi chủ đề mà định dạng không đánh dấu.

// embed() -> number[]; cosine() -> độ tương đồng trong [0,1].
async function semanticChunks(sentences: string[], threshold = 0.45): Promise<string[]> {
  const vecs = await Promise.all(sentences.map(embed));
  const chunks: string[] = [];
  let current = [sentences[0]];

  for (let i = 1; i < sentences.length; i++) {
    if (cosine(vecs[i - 1], vecs[i]) < threshold) {
      chunks.push(current.join(" ")); // độ giống tụt -> ranh giới chủ đề
      current = [];
    }
    current.push(sentences[i]);
  }
  if (current.length) chunks.push(current.join(" "));
  return chunks;
}

Cách này bắt được trường hợp một đoạn văn xuôi trôi qua hai chủ đề mà không có heading ở giữa. Cái giá là thật: bạn embed mọi câu ngay từ đầu (một lần gọi embedding cho mỗi câu lúc ingest), và threshold là núm phải tinh chỉnh theo từng corpus — quá thấp thì mọi thứ thành một chunk, quá cao thì lại quay về các mảnh vụn từng câu. Chỉ dùng khi chunk recursive vẫn còn trộn chủ đề, đừng dùng trước đó.

Overlap và kích thước chunk: hai núm vặn

Hai tham số cắt ngang cả ba chiến lược:

  • Overlap lặp lại ~10–15% cuối của chunk này ở đầu chunk kế, để một dữ kiện nằm trên ranh giới còn nguyên vẹn trong ít nhất một chunk. Tốn chút lưu trữ và một ít trùng lặp trong kết quả; là khoản bảo hiểm rẻ chống lại lỗi "bước 3 tham chiếu bước 1". Overlap bằng 0 là thủ phạm giết recall thầm lặng phổ biến nhất.
  • Kích thước chunk chính là cái tradeoff pha-loãng-vs-vụn-vặt. Với embedding 1536 chiều phổ thông, 256–512 token là điểm ngọt cho phần lớn văn bản — đủ dài để giữ một ý, đủ ngắn để vector vẫn cụ thể. Code và bảng muốn lớn hơn; chat log muốn nhỏ hơn.

Những thứ này không độc lập với truy xuất: chunk nhỏ hơn nghĩa là bạn nên truy xuất top-k lớn hơn để ghép lại câu trả lời, và điều đó quay thẳng về cái k bạn đã tinh chỉnh ở bước hybrid search.

Benchmark: recall theo từng chiến lược

Cùng corpus doc/địa chỉ GoGoDuk (~1M chunk sau khi chia, embedding 1536 chiều, index HNSW nóng trong RAM), cùng bộ 200 query gán nhãn tay từ bài hybrid, giữ truy xuất cố định ở hybrid RRF top-k=10. Chỉ thay đổi chunking. Chỉ số là recall@10 — chunk chứa câu trả lời có lọt top 10 không — kèm số token trung vị mỗi context truy xuất:

| Chiến lược | Recall@10 | Token trung vị / chunk | Chi phí ingest | | --- | --- | --- | --- | | Fixed (1000c, không overlap) | 0.71 | ~240 | mốc cơ sở | | Fixed (1000c, overlap 150) | 0.79 | ~240 | +15% lưu trữ | | Recursive (max 512t, overlap 64) | 0.90 | ~180 | +15% lưu trữ | | Semantic (threshold 0.45) | 0.92 | ~150 | +1 embed / câu |

Bước nhảy quan trọng là fixed → recursive: +0.11 recall với chi phí model bằng 0 — chỉ là chia theo cấu trúc thay vì đếm byte. Thêm overlap vào fixed thuần tự nó đem lại +0.08, xác nhận mất mát ranh giới đúng là một phần thật trong các ca trượt. Semantic nhỉnh hơn recursive +0.02 nhưng trả giá bằng một lần gọi embedding mỗi câu lúc ingest — trên corpus này, không đáng; trên văn xuôi lộn xộn ít heading, có thể đáng. Coi các con số tuyệt đối là định hướng (chúng đổi theo corpus và model embedding), nhưng thứ tự — recursive ≫ fixed, semantic ≈ recursive với chi phí ingest cao hơn nhiều — lặp lại ổn định.

Nên làm gì trong thực tế

  • Bắt đầu bằng recursive, 512 token, overlap ~12%. Đây là điểm recall-trên-công-sức tốt nhất và không cần gọi model. Riêng thay đổi này thường thắng lớn hơn cả việc đổi model embedding.
  • Chỉ thêm semantic chunking khi bạn đo được chunk recursive vẫn còn trộn chủ đề — và chỉ trên những phần cần, không phải cả corpus.
  • Đừng bao giờ ship overlap bằng 0. Đó là recall rẻ nhất bạn từng mua được.
  • Đo, đừng đoán. Dựng bộ 200 query gán nhãn một lần; mọi thay đổi chunking trở thành một con số, không phải cảm tính — đúng kỷ luật mà pipeline RAG từ đầu cần và bài đánh giá sắp tới sẽ chuẩn hoá.

Chunking là bước đòn bẩy cao nhất, hào nhoáng thấp nhất trong RAG: không thêm dependency, không GPU, chỉ là chia văn bản đúng chỗ ý kết thúc. Làm đúng thì truy xuất có nguyên liệu tốt để tìm; làm sai thì reranker xịn nhất cũng chỉ đang đánh bóng các mảnh vụn. Bài tiếp trong series ta đẩy chất lượng truy xuất xa hơn bằng reranking, và đặt số liệu thật cho câu hỏi "hệ RAG này có ổn không." Bản đồ đầy đủ là bài trụ AI cho Backend Engineer.

Hệ thống của bạn đang gặp vấn đề hiệu năng hay mở rộng tải?

Tôi chuyên xây dựng hạ tầng bản đồ độ trễ thấp, streaming pipeline thời gian thực (Kafka, ClickHouse) và các hệ thống backend tối ưu. Hãy cùng hợp tác để nâng cấp sản phẩm của bạn.

Hợp tác ngay

Tác giả

Nguyen Hoang Tuan

Nguyen Hoang Tuan

Full-stack developer focused on practical backend architecture, web performance, and production delivery.

Bài viết liên quan