Published on

Day 12: NLP Fundamentals & Tokenizer

Authors

Mục tiêu

Sau bài này, bạn cần làm được các việc sau:

  • Giải thích được pipeline raw text -> normalized text -> tokens -> token ids -> tensors -> model.
  • Biết nên preprocessing text ở mức nào, không làm mất tín hiệu quan trọng của tiếng Việt.
  • Phân biệt được BPE, WordPiece, SentencePiece/Unigram ở mức ứng dụng.
  • Hiểu vocabulary, special tokens, OOV/UNK, padding, truncation, attention mask và offset mapping.
  • Ước lượng được token limit, latency và cost/request trước khi đưa NLP/LLM vào production.
  • Nhận diện được vì sao tiếng Việt có dấu, không dấu, tách âm tiết bằng khoảng trắng và word segmentation làm tokenization khó hơn English.
  • Dùng được tokenizer wrapper có config, batch tokenization, token stats, cost estimator và warning khi input quá dài.

Liên kết với các ngày trước và sau

Day 11 nói về training loop: model chỉ nhận tensor, loss chỉ tính trên tensor, optimizer chỉ update weights. Day 12 trả lời câu hỏi: text thật của user biến thành tensor như thế nào?

Day 13 nói về attention: attention làm việc trên sequence token embedding và cần mask đúng. Nếu tokenizer, padding hoặc truncation sai, attention phía sau cũng sai.

Day 16 sẽ fine-tune BERT/PhoBERT classifier. Nếu train và inference dùng khác tokenizer hoặc khác preprocessing, model có thể giảm chất lượng rất mạnh dù training loop không báo lỗi.

TL;DR

Tokenizer là contract giữa raw text và model weights. Cùng một câu nhưng dùng tokenizer khác sẽ ra token ids khác, nghĩa là model đang nhìn một input khác. Với LLM/RAG, token không chỉ là chi tiết kỹ thuật; token quyết định context budget, latency, memory, throughput và tiền.

Trong production, không được xem tokenizer là helper phụ. Nó là một phần của model artifact, phải được version cùng model, được test bằng dữ liệu thật và được monitor bằng token length distribution, truncation rate, OOV/UNK rate nếu có.

1. NLP pipeline nhìn từ góc Senior SE

Pipeline tối giản:

raw text
  -> input validation
  -> text preprocessing / normalization
  -> tokenization
  -> token ids + attention mask
  -> embedding lookup
  -> model forward
  -> output decoding / post-processing

Map sang backend:

NLP conceptSE analogy
Raw textHTTP request body chưa validate
PreprocessingInput validation + canonicalization
TokenizerParser + schema encoder
VocabularyDictionary/index mapping stable
Token idInternal enum/id mà model đã học
Attention maskMask/filter để bỏ qua padding
TruncationData loss policy
Context windowRequest payload limit
Token costBilling unit + compute unit

Điểm quan trọng: model không hiểu string trực tiếp. Model học embedding cho từng token id trong vocabulary. Nếu token id thay đổi, embedding lookup thay đổi; nếu preprocessing khác nhau giữa train và inference, đó là train-serving skew.

2. Text preprocessing: làm đủ, đừng làm quá tay

Preprocessing là bước biến raw text thành input ổn định hơn. Nhưng với Transformer/LLM, nhiều kỹ thuật "clean text" kiểu truyền thống có thể làm mất thông tin.

Step 1: xác định mục tiêu

Trước khi clean text, hỏi:

  • Đây là text classification, search, RAG, summarization hay generation?
  • Model là traditional ML, BERT-style encoder, PhoBERT, hay GPT-style LLM?
  • Có cần giữ case, punctuation, URL, code, số hợp đồng, emoji, markdown không?
  • Có được phép log raw text không, hay cần redaction PII?
  • Input user có tiếng Việt không dấu, có dấu, code-mixing Việt/English không?

Không có preprocessing tốt nhất cho mọi bài toán.

Step 2: những xử lý thường nên làm

Các bước sau thường an toàn nếu được test:

  • Validate input type, size và encoding.
  • Normalize Unicode về NFC để giảm khác biệt biểu diễn ký tự có dấu.
  • Normalize whitespace: chuyển nhiều khoảng trắng, tab, newline không cần thiết về một format nhất quán.
  • Loại bỏ control characters vô hình như zero-width space nếu chúng không có ý nghĩa nghiệp vụ.
  • Parse HTML/Markdown bằng parser nếu nguồn là HTML/Markdown thật.
  • Chuẩn hóa line ending nếu pipeline dùng text document.
  • Redact PII trong log/debug output, không nhất thiết trong input đưa vào model.

Step 3: những xử lý phải cân nhắc kỹ

Xử lýKhi có thể dùngRủi ro
LowercaseBaseline truyền thống, model uncasedMất entity, mã lỗi, SKU, tên riêng
Xóa dấu tiếng ViệtMatching thô hoặc search tolerantMất nghĩa: ma, , , , mạ
Xóa punctuationTF-IDF/simple text cleanupMất intent, code, URL, markdown, câu hỏi
Xóa stopwordsBag-of-words truyền thốngThường không cần với Transformer/LLM
Stemming/lemmatizationMột số pipeline cổ điểnTiếng Việt và code-mixing dễ sai
Dịch tiếng Việt sang EnglishDùng model chỉ mạnh EnglishThêm latency, cost, lỗi dịch, mất sắc thái

Với BERT/PhoBERT/LLM, nguyên tắc mặc định là normalize nhẹ, giữ thông tin, rồi để tokenizer/model xử lý. Chỉ xóa hoặc biến đổi mạnh khi bạn có benchmark chứng minh tốt hơn.

3. Tokenization là gì?

Tokenization biến text thành chuỗi token. Token có thể là:

  • Word: xin, chào.
  • Subword: token, ##izer, ngôn, _ngữ.
  • Character hoặc byte.
  • Special token: [CLS], [SEP], [PAD], [UNK], <s>, </s>, <mask>.

Pipeline:

"Tôi đang học NLP"
  -> ["Tôi", "đang", "học", "NL", "##P"]
  -> [101, 1945, 1234, 5678, 9101, 102]
  -> embedding vectors

Các token string chỉ để người đọc debug. Model thật dùng token ids.

4. Vocabulary, token ids và special tokens

Vocabulary là mapping ổn định từ token sang id:

"[PAD]" -> 0
"[UNK]" -> 100
"[CLS]" -> 101
"xin"   -> 12345

Embedding matrix của model có một row cho mỗi token id. Vì vậy:

  • Đổi tokenizer là breaking change với model.
  • Đổi thứ tự vocabulary là breaking change.
  • Thêm token mới cần resize embedding và fine-tune/retrain phù hợp.
  • Cùng một text, token ids phải giống giữa training, evaluation và inference.

Special tokens thường gặp:

TokenVai trò
[PAD]Pad batch về cùng chiều dài
[UNK]Token unknown nếu tokenizer không encode được
[CLS]Đại diện sequence trong BERT classifier
[SEP]Ngăn cách sentence/pair input trong BERT
[MASK]Masked language modeling
<s>, </s>Begin/end sequence trong một số model
<bos>, <eos>Begin/end of sequence trong nhiều LLM

5. OOV và UNK

OOV là out-of-vocabulary: text chứa từ hoặc ký tự tokenizer không có trong vocabulary.

Word-level tokenizer dễ OOV:

"tokenizerization" -> [UNK]

Subword tokenizer giảm OOV:

"tokenizerization" -> ["token", "##izer", "##ization"]

Byte-level tokenizer còn giảm OOV hơn nữa vì gần như mọi text có thể biểu diễn bằng byte. Nhưng "không bị UNK" không có nghĩa model hiểu tốt. Model có thể encode được mã lỗi lạ, tên sản phẩm mới hoặc tiếng Việt không dấu, nhưng semantic quality vẫn phụ thuộc dữ liệu pretraining/fine-tuning.

Production signal:

  • UNK rate tăng sau release có thể báo domain drift.
  • Nhiều token rất nhỏ cho cùng một từ có thể làm token count tăng, latency tăng và quality giảm.
  • Không nên chỉ kiểm tra câu English sạch; cần sample tiếng Việt thật từ production.

6. BPE, WordPiece và SentencePiece

BPE

BPE là subword algorithm bắt đầu từ vocabulary nhỏ như character/byte, rồi học merge rules bằng cách ghép các cặp token xuất hiện thường xuyên.

Ví dụ trực giác:

["t", "o", "k", "e", "n"]
-> merge "t" + "o" = "to"
-> merge "to" + "k" = "tok"
-> ...

Khi dùng:

  • GPT-style tokenizer thường dùng byte-level BPE.
  • RoBERTa-style tokenizer dùng BPE.
  • PhoBERT là RoBERTa-style cho tiếng Việt và thường cần đúng preprocessing/word segmentation theo model.

Trade-off:

  • Ít OOV, hợp open vocabulary.
  • Token preview có thể khó đọc.
  • Token count khó đoán bằng word count, nhất là với tiếng Việt có dấu hoặc text code-mixed.

WordPiece

WordPiece phổ biến trong BERT. Nó học subword theo scoring khác BPE, và khi encode thường chọn subword dài nhất có trong vocabulary từ trái sang phải. Continuation token hay có prefix ##.

Ví dụ trực giác:

"tokenizer" -> ["token", "##izer"]

Khi dùng:

  • BERT và nhiều BERT-style encoder.
  • Text classification, NER, reranking, embedding model kiểu encoder.

Trade-off:

  • Dễ debug hơn byte-level BPE trong nhiều trường hợp.
  • Có thể sinh [UNK] nếu gặp input quá lạ tùy tokenizer.
  • Với tiếng Việt, từ có dấu, không dấu và cách viết khác nhau có thể tách rất khác.

SentencePiece và Unigram

SentencePiece là framework/tokenizer library thường train trực tiếp trên raw text và xem whitespace như một symbol, ví dụ token có ký hiệu để đánh dấu đầu từ. SentencePiece có thể dùng Unigram hoặc BPE bên dưới.

Unigram thường bắt đầu từ vocabulary lớn, rồi loại dần token để tối ưu loss trên corpus. Khi encode, nó chọn segmentation có xác suất tốt nhất theo model.

Khi dùng:

  • Nhiều multilingual model, T5-style model và một số LLaMA-style tokenizer.
  • Bài toán có nhiều ngôn ngữ, không muốn phụ thuộc pre-tokenizer theo whitespace truyền thống.

Trade-off:

  • Hợp multilingual và raw text.
  • Token nhìn lạ hơn với người debug.
  • Vẫn phải dùng đúng tokenizer đi kèm model.

7. Padding, truncation, attention mask và offsets

Model training/inference theo batch cần tensor cùng shape. Text thì dài ngắn khác nhau, nên cần padding:

input_ids:
  [101, 10, 11, 102,   0,   0]
  [101, 20, 21,  22,  23, 102]

attention_mask:
  [  1,  1,  1,   1,   0,   0]
  [  1,  1,  1,   1,   1,   1]

attention_mask = 0 cho model biết vị trí đó là padding, không phải nội dung thật.

Truncation là cắt input nếu vượt max_length. Đây là nơi dễ có bug production:

  • Ticket dài bị cắt mất error code ở cuối.
  • Contract bị cắt mất điều khoản quan trọng.
  • Chat history bị cắt mất system instruction.
  • RAG context bị cắt mất citation/source.

Các policy thường dùng:

PolicyKhi dùngRủi ro
RejectAPI cần độ chính xác cao, input vượt budget là lỗi rõ ràngUser phải sửa input hoặc hệ thống phải hướng dẫn
Truncate tailThông tin quan trọng thường ở đầuMất kết luận/cuối tài liệu
Truncate headChat history, log tail quan trọng hơnMất instruction hoặc background
Sliding windowDocument dài, cần xử lý từng cửa sổTăng compute và cần merge result
Summarize/compressLLM/RAG input dàiThêm model call, có thể mất chi tiết

Offset mapping ánh xạ token về vị trí ký tự trong text gốc. Nó hữu ích cho NER, highlight citation, debug chunking. Với Hugging Face, return_offsets_mapping chỉ hoạt động với fast tokenizer.

8. Token limit, latency và cost

Token budget trong một LLM request:

total_tokens =
  system_prompt_tokens
  + user_message_tokens
  + chat_history_tokens
  + retrieved_context_tokens
  + tool_schema_tokens
  + reserved_output_tokens
  + special_tokens_overhead

Nếu context window là 8,192 tokens và bạn reserve 1,000 output tokens, input budget thực tế không phải 8,192 mà khoảng 7,192 trừ overhead.

Cost estimate:

cost_usd =
  input_tokens  / 1_000_000 * input_price_per_1m
  + output_tokens / 1_000_000 * output_price_per_1m

Latency và memory:

  • Tokenization tốn CPU, đặc biệt khi QPS cao hoặc document dài.
  • Model inference tăng theo input length; Day 13 sẽ giải thích self-attention có chi phí gần O(n^2) theo sequence length cho nhiều kiến trúc.
  • Decoder-only LLM còn tốn KV cache theo số token đã xử lý.
  • Padding quá dài làm lãng phí compute; dynamic padding theo batch thường tốt hơn padding toàn bộ về max length cố định.

Production metrics nên log:

  • input_tokens, output_tokens, total_tokens.
  • p50/p95/p99 input_tokens.
  • truncation_count, truncation_rate.
  • rejected_too_long_count.
  • cost_per_request, cost_per_tenant, cost_per_feature.
  • tokenization_latency_ms, model_latency_ms.

Không log raw text nếu có PII hoặc dữ liệu khách hàng nhạy cảm.

9. Tiếng Việt bị tokenize như thế nào?

Tiếng Việt có vài điểm khác English:

Khoảng trắng thường tách âm tiết, không chắc tách từ

Trong English, whitespace thường gần với word boundary:

"natural language processing" -> 3 words

Trong tiếng Việt:

"xử lý ngôn ngữ tự nhiên"

Chuỗi này có nhiều âm tiết, nhưng concept có thể là:

"xử_lý" "ngôn_ngữ" "tự_nhiên"

Một số model tiếng Việt như PhoBERT được train với text đã word-segmented, thường nối các âm tiết trong cùng một từ bằng _. Nếu fine-tune hoặc inference PhoBERT cho classifier, bạn cần theo đúng hướng dẫn preprocessing/tokenizer của model đang dùng.

Có dấu và không dấu là input khác

"ma" "má" "mà" "mã" "mạ"

Các chuỗi này khác Unicode, khác token và khác nghĩa. Xóa dấu có thể giúp một số search tolerant, nhưng với classification/LLM thường làm mất semantic signal.

Code-mixing rất phổ biến

Ticket thật có thể như sau:

"Khách hàng báo lỗi thanh toán PAY_403 lúc 09:30, cần retry idempotent."

Tokenizer có thể tách PAY_403, số giờ, dấu _, English term và tiếng Việt thành nhiều token nhỏ. Nếu hệ thống support/RAG dùng nhiều mã lỗi, SKU, log line, bạn phải test token count trên dữ liệu thật.

Token count tiếng Việt có thể cao hơn bạn đoán

Không nên dùng character count hoặc word count để suy ra token count. Một đoạn 500 từ tiếng Việt có thể có token count khác nhiều so với 500 từ English, tùy tokenizer.

Best practice:

  • Đếm token bằng đúng tokenizer của model sẽ dùng.
  • Benchmark câu có dấu, không dấu, code-mixed, markdown, bảng, JSON, log line.
  • Với RAG, chunk theo token và validate retrieval quality, không chunk cứng theo số ký tự.

10. Best solution theo context

ContextKhuyến nghịLý do
Fine-tune BERT/PhoBERT classifierDùng đúng AutoTokenizer và preprocessing của model; max_length 128/256/512 sau khi đo p95Chất lượng phụ thuộc train/inference consistency
Vietnamese enterprise RAGChunk theo token của embedding/generator, giữ metadata, overlap vừa đủ, reserve output budgetTránh overflow context và mất citation
LLM chatbot có chat historyĐếm token mỗi turn, trim/summarize history có policy, không cắt system promptGiữ instruction và kiểm soát cost
Search baseline nhanhCó thể dùng lowercase/normalize nhẹ + TF-IDF/BM25Rẻ, explainable, đủ tốt cho baseline
NER/highlight citationDùng fast tokenizer và offset mappingCần map token về text gốc
High-QPS serviceBatch tokenization, dynamic padding, cache tokenized static docsGiảm CPU và waste compute

11. Dùng được trong production không?

Có, tokenizer và preprocessing pipeline dùng được trong production, nhưng cần các điều kiện sau:

  • Version tokenizer cùng model artifact, config, label mapping và preprocessing code.
  • Train, validation, batch inference và online inference dùng cùng contract.
  • Có hard limit cho input tokens và reserved output tokens.
  • Không silent truncation; nếu truncate phải log và có policy rõ.
  • Có token stats trên dữ liệu thật: p50/p95/p99, max, too-long rate.
  • Có test cho tiếng Việt có dấu, không dấu, code-mixed, mã lỗi, markdown, JSON/log.
  • Không log raw PII; chỉ log length, hash/request id hoặc sample đã redact.
  • Có cost estimator theo model price hiện tại và budget theo tenant/feature.
  • Có fallback: reject rõ ràng, ask user shorten, summarize/compress, hoặc split document.

Không nên đưa vào production nếu:

  • Chưa biết tokenizer đang dùng có đúng với model không.
  • Đang rely vào default truncation mà không log.
  • Chunking dựa hoàn toàn vào character count.
  • Chưa test tiếng Việt thật.
  • Không có giới hạn cost/request.

12. Thứ tự học và thực hành

  1. Đọc lại mental model raw text -> token ids -> embedding.
  2. Đọc bảng BPE/WordPiece/SentencePiece và tự giải thích bằng lời của mình.
  3. Chạy code trong document.md với 3 tokenizer: bert-base-multilingual-cased, gpt2, vinai/phobert-base.
  4. Làm bài tập token stats trong exercise.md.
  5. Ghi lại quyết định tokenizer/preprocessing để dùng lại ở Day 16.

Tự kiểm tra nhanh

  • Vì sao tokenizer phải version cùng model weights?
  • Vì sao xóa dấu tiếng Việt có thể làm model tệ hơn?
  • Khi nào nên reject input quá dài thay vì truncate?
  • Attention mask khác gì token type ids?
  • Vì sao return_offsets_mapping quan trọng cho NER/citation?
  • Nếu p95 input tokens vượt max_length, bạn sẽ sửa dữ liệu, model, chunking hay policy?

Tài liệu

1. Pipeline tham chiếu

Client text
  -> validate size/type
  -> normalize Unicode + whitespace
  -> optional redaction for logs
  -> tokenizer(texts, padding, truncation, max_length)
  -> input_ids + attention_mask + optional offsets
  -> model
  -> decode / classify / rank / generate

Trong production, tokenizer wrapper nên là một dependency rõ ràng, có config và test. Không nên rải AutoTokenizer.from_pretrained(...) khắp codebase.

2. API notes từ Hugging Face

Các điểm cần nhớ khi dùng Hugging Face:

  • AutoTokenizer.from_pretrained(model_name, use_fast=True) load tokenizer theo model repository.
  • Tokenizer có thể nhận một string hoặc một batch list[str].
  • padding="longest" pad theo sequence dài nhất trong batch; padding="max_length" pad cố định theo max_length.
  • truncation=True hoặc strategy như "longest_first", "only_first", "only_second" sẽ cắt input vượt max_length.
  • return_attention_mask=True trả mask để model bỏ qua padding.
  • return_offsets_mapping=True trả (char_start, char_end) cho token, nhưng chỉ hỗ trợ fast tokenizer.
  • return_length=True trả length sau khi tokenization/padding/truncation.
  • pad_to_multiple_of=8 có thể hữu ích cho tensor cores/GPU khi có padding.
  • Với thư viện tokenizers, encode_batch xử lý batch song song và trả tokens, ids, attention_mask, offsets, special_tokens_mask.

Context7 library IDs đã dùng khi viết bài:

  • /websites/huggingface_co_transformers_main
  • /huggingface/tokenizers
  • /huggingface/course

3. Preprocessing helper tối thiểu

Helper này cố tình normalize nhẹ. Nó không xóa dấu, không lowercase mặc định, không xóa punctuation.

from __future__ import annotations

import re
import unicodedata


ZERO_WIDTH_CHARS = "\u200b\u200c\u200d\ufeff"


def normalize_text(text: str, *, unicode_form: str = "NFC") -> str:
    if not isinstance(text, str):
        raise TypeError(f"text must be str, got {type(text)!r}")

    normalized = unicodedata.normalize(unicode_form, text)
    normalized = normalized.translate({ord(ch): None for ch in ZERO_WIDTH_CHARS})
    normalized = re.sub(r"[ \t\r\f\v]+", " ", normalized)
    normalized = re.sub(r"\n{3,}", "\n\n", normalized)
    return normalized.strip()

Nếu cần parse HTML, dùng parser như BeautifulSoup/lxml ở ingestion layer. Không nên dùng regex tùy tiện cho HTML phức tạp.

4. Production-oriented tokenizer wrapper

Ví dụ dưới đây tập trung vào phần tokenizer, không gọi model. Nó phù hợp để dùng trong API service, batch inference hoặc ingestion pipeline sau khi bạn bổ sung test, metric exporter và model-specific preprocessing.

from __future__ import annotations

import logging
import re
import statistics
import time
import unicodedata
from dataclasses import dataclass
from typing import Any, Literal

from transformers import AutoTokenizer, PreTrainedTokenizerBase


logger = logging.getLogger("day12.tokenizer")


ZERO_WIDTH_CHARS = "\u200b\u200c\u200d\ufeff"
TruncationPolicy = Literal["reject", "truncate"]
PaddingPolicy = Literal[False, "longest", "max_length"]


class TokenBudgetError(ValueError):
    def __init__(self, *, max_length: int, too_long_count: int, max_seen: int) -> None:
        super().__init__(
            f"input exceeds token budget: max_length={max_length}, "
            f"too_long_count={too_long_count}, max_seen={max_seen}"
        )
        self.max_length = max_length
        self.too_long_count = too_long_count
        self.max_seen = max_seen


@dataclass(frozen=True)
class TokenizerConfig:
    model_name: str
    max_length: int
    truncation_policy: TruncationPolicy = "reject"
    truncation_strategy: Literal["longest_first", "only_first", "only_second"] = "longest_first"
    padding: PaddingPolicy = "longest"
    pad_to_multiple_of: int | None = 8
    add_special_tokens: bool = True
    return_offsets: bool = False
    use_fast: bool = True
    warning_ratio: float = 0.9
    allow_eos_as_pad: bool = False


@dataclass(frozen=True)
class PriceConfig:
    input_usd_per_1m_tokens: float
    output_usd_per_1m_tokens: float


@dataclass(frozen=True)
class TokenStats:
    count: int
    min_tokens: int
    max_tokens: int
    mean_tokens: float
    p50_tokens: int
    p95_tokens: int
    p99_tokens: int
    too_long_count: int


@dataclass(frozen=True)
class BatchTokenizationResult:
    encoded: dict[str, Any]
    raw_token_lengths: list[int]
    stats: TokenStats
    tokenization_latency_ms: float


def normalize_text(text: str, *, unicode_form: str = "NFC") -> str:
    if not isinstance(text, str):
        raise TypeError(f"text must be str, got {type(text)!r}")

    normalized = unicodedata.normalize(unicode_form, text)
    normalized = normalized.translate({ord(ch): None for ch in ZERO_WIDTH_CHARS})
    normalized = re.sub(r"[ \t\r\f\v]+", " ", normalized)
    normalized = re.sub(r"\n{3,}", "\n\n", normalized)
    return normalized.strip()


def percentile(values: list[int], pct: float) -> int:
    if not values:
        return 0
    ordered = sorted(values)
    idx = round((len(ordered) - 1) * pct)
    return ordered[idx]


def build_stats(lengths: list[int], *, max_length: int) -> TokenStats:
    if not lengths:
        return TokenStats(0, 0, 0, 0.0, 0, 0, 0, 0)

    too_long = sum(length > max_length for length in lengths)
    return TokenStats(
        count=len(lengths),
        min_tokens=min(lengths),
        max_tokens=max(lengths),
        mean_tokens=statistics.fmean(lengths),
        p50_tokens=percentile(lengths, 0.50),
        p95_tokens=percentile(lengths, 0.95),
        p99_tokens=percentile(lengths, 0.99),
        too_long_count=too_long,
    )


def estimate_cost_usd(
    *,
    input_tokens: int,
    output_tokens: int,
    price: PriceConfig,
    requests: int = 1,
) -> float:
    per_request = (
        input_tokens / 1_000_000 * price.input_usd_per_1m_tokens
        + output_tokens / 1_000_000 * price.output_usd_per_1m_tokens
    )
    return per_request * requests


class TokenizerService:
    def __init__(self, config: TokenizerConfig) -> None:
        self.config = config
        self.tokenizer: PreTrainedTokenizerBase = AutoTokenizer.from_pretrained(
            config.model_name,
            use_fast=config.use_fast,
        )

        if config.return_offsets and not self.tokenizer.is_fast:
            raise ValueError("return_offsets=True requires a fast tokenizer")

        if self.tokenizer.pad_token is None:
            if config.allow_eos_as_pad and self.tokenizer.eos_token is not None:
                self.tokenizer.pad_token = self.tokenizer.eos_token
            else:
                raise ValueError(
                    f"{config.model_name} has no pad_token. Set a pad token explicitly "
                    "or enable allow_eos_as_pad for GPT-style inference-only batching."
                )

    @property
    def vocab_size(self) -> int:
        return int(self.tokenizer.vocab_size)

    @property
    def special_tokens(self) -> dict[str, str | list[str]]:
        return dict(self.tokenizer.special_tokens_map)

    def count_tokens(self, texts: list[str]) -> list[int]:
        normalized = [normalize_text(text) for text in texts]
        encoded = self.tokenizer(
            normalized,
            add_special_tokens=self.config.add_special_tokens,
            padding=False,
            truncation=False,
            return_attention_mask=False,
            verbose=False,
        )
        return [len(input_ids) for input_ids in encoded["input_ids"]]

    def encode_batch(self, texts: list[str]) -> BatchTokenizationResult:
        if not texts:
            raise ValueError("texts must not be empty")

        started = time.perf_counter()
        normalized = [normalize_text(text) for text in texts]
        raw_lengths = self.count_tokens(normalized)
        stats = build_stats(raw_lengths, max_length=self.config.max_length)

        warn_threshold = int(self.config.max_length * self.config.warning_ratio)
        near_limit_count = sum(length >= warn_threshold for length in raw_lengths)
        if near_limit_count:
            logger.warning(
                "token budget near limit: model=%s max_length=%s near_limit_count=%s "
                "max_seen=%s p95=%s",
                self.config.model_name,
                self.config.max_length,
                near_limit_count,
                stats.max_tokens,
                stats.p95_tokens,
            )

        if stats.too_long_count:
            logger.warning(
                "token budget exceeded: model=%s max_length=%s too_long_count=%s "
                "max_seen=%s action=%s",
                self.config.model_name,
                self.config.max_length,
                stats.too_long_count,
                stats.max_tokens,
                self.config.truncation_policy,
            )
            if self.config.truncation_policy == "reject":
                raise TokenBudgetError(
                    max_length=self.config.max_length,
                    too_long_count=stats.too_long_count,
                    max_seen=stats.max_tokens,
                )

        should_truncate = self.config.truncation_policy == "truncate"
        encoded = self.tokenizer(
            normalized,
            add_special_tokens=self.config.add_special_tokens,
            padding=self.config.padding,
            truncation=self.config.truncation_strategy if should_truncate else False,
            max_length=self.config.max_length if should_truncate or self.config.padding == "max_length" else None,
            pad_to_multiple_of=self.config.pad_to_multiple_of,
            return_attention_mask=True,
            return_offsets_mapping=self.config.return_offsets,
            return_length=True,
        )

        latency_ms = (time.perf_counter() - started) * 1000
        return BatchTokenizationResult(
            encoded=dict(encoded),
            raw_token_lengths=raw_lengths,
            stats=stats,
            tokenization_latency_ms=latency_ms,
        )


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)

    samples = [
        "Tôi đang học xử lý ngôn ngữ tự nhiên và tokenizer cho hệ thống RAG.",
        "Toi dang hoc xu ly ngon ngu tu nhien va tokenizer cho he thong RAG.",
        "Khách hàng báo lỗi thanh toán PAY_403 lúc 09:30, cần retry idempotent.",
        "Tôi đang_học xử_lý ngôn_ngữ tự_nhiên với PhoBERT.",
    ]

    service = TokenizerService(
        TokenizerConfig(
            model_name="bert-base-multilingual-cased",
            max_length=64,
            truncation_policy="reject",
            padding="longest",
            return_offsets=True,
        )
    )

    result = service.encode_batch(samples)
    print("model:", service.config.model_name)
    print("vocab_size:", service.vocab_size)
    print("special_tokens:", service.special_tokens)
    print("raw_token_lengths:", result.raw_token_lengths)
    print("stats:", result.stats)
    print("latency_ms:", round(result.tokenization_latency_ms, 2))

    first_ids = result.encoded["input_ids"][0]
    print("first_tokens:", service.tokenizer.convert_ids_to_tokens(first_ids))

    demo_price = PriceConfig(input_usd_per_1m_tokens=0.15, output_usd_per_1m_tokens=0.60)
    print(
        "estimated_monthly_cost_usd:",
        round(
            estimate_cost_usd(
                input_tokens=result.stats.p95_tokens,
                output_tokens=700,
                price=demo_price,
                requests=1_000_000,
            ),
            2,
        ),
    )

5. Cách chạy thử

Cài dependency:

pip install transformers tokenizers sentencepiece

Chạy với các tokenizer khác nhau bằng cách đổi model_name:

TokenizerConfig(model_name="bert-base-multilingual-cased", max_length=128)
TokenizerConfig(model_name="vinai/phobert-base", max_length=256)
TokenizerConfig(model_name="gpt2", max_length=256, allow_eos_as_pad=True)

Ghi chú:

  • gpt2 là ví dụ byte-level BPE theo phong cách GPT cũ, hữu ích để quan sát tokenization, không đại diện cho toàn bộ tokenizer của các LLM mới.
  • Với PhoBERT, hãy kiểm tra model card/preprocessing chính thức. Nhiều workflow PhoBERT yêu cầu Vietnamese word segmentation trước khi tokenize.
  • Với tokenizer không có pad_token, không nên âm thầm dùng eos_token làm pad trong mọi trường hợp. Chỉ bật allow_eos_as_pad=True khi bạn hiểu tác động với model và task.

6. Công thức chọn max_length

Quy trình thực tế:

  1. Lấy sample production hoặc staging đủ đại diện.
  2. Normalize đúng như inference.
  3. Đếm token bằng đúng tokenizer của model.
  4. Xem p50/p95/p99 và max.
  5. Chọn max_length theo SLA, VRAM/CPU, quality và cost.
  6. Quyết định policy cho phần vượt budget: reject, truncate, sliding window hoặc summarize.
  7. Thêm metric và alert cho too-long rate.

Ví dụ:

p50 = 72 tokens
p95 = 238 tokens
p99 = 480 tokens
max = 3,900 tokens

Nếu classifier dùng BERT/PhoBERT:

  • max_length=256 có thể cover khoảng 95%.
  • 5% còn lại cần policy rõ: reject, truncate hoặc split.
  • Nếu 5% này là nhóm ticket VIP/complex case, truncate có thể làm giảm business quality.

Nếu RAG ingestion:

  • Đừng dùng max_length=256 để cắt document gốc.
  • Split document thành chunk 300-800 tokens tùy embedding model và retrieval benchmark.
  • Giữ overlap vừa đủ, ví dụ 50-120 tokens, nhưng phải evaluate.

7. Trade-off nhanh

Quyết địnhLợi íchChi phí/rủi ro
Dynamic paddingÍt waste computeShape thay đổi giữa batch
Padding max_lengthShape ổn địnhLãng phí nếu text ngắn
Reject quá dàiKhông mất dữ liệu âm thầmUser cần retry hoặc hệ thống cần hướng dẫn
TruncateDễ vận hànhCó thể mất thông tin quyết định
Sliding windowXử lý document dài tốt hơnTăng compute, cần aggregate output
Cache tokenized chunksTăng throughput ingestion/RAGCache invalidation khi tokenizer đổi
Offset mappingDebug/citation/NER tốtChỉ fast tokenizer, thêm payload
Xóa dấuCó thể tăng recall search thôGiảm semantic quality cho NLP/LLM

8. Production checklist riêng cho tokenizer

  • Tokenizer, model weights, config, preprocessing code và label mapping cùng version.
  • Unit test cho token count và special tokens với sample cố định.
  • Contract test: batch size 1 và batch size N cho output tương thích.
  • Test tiếng Việt có dấu, không dấu, word-segmented, code-mixed, markdown, JSON/log.
  • Log token length, truncation, rejection, latency và cost; không log raw PII.
  • Alert khi p95 token tăng bất thường hoặc rejection rate tăng.
  • Benchmark dynamic padding vs max padding với QPS thật.
  • Document rõ policy khi input vượt budget.

Bài tập

Mục tiêu thực hành

Sau bài tập này, bạn phải có một notebook hoặc script nhỏ trả lời được:

  • Tokenizer nào tạo bao nhiêu token cho tiếng Việt có dấu, không dấu và code-mixed?
  • p95 token count của sample domain của bạn là bao nhiêu?
  • max_length nên đặt thế nào cho classifier hoặc RAG chunk?
  • Request vượt token budget sẽ bị reject, truncate hay split?
  • Cost ước lượng trên 1,000,000 requests là bao nhiêu?

Chuẩn bị

Cài dependency:

pip install transformers tokenizers sentencepiece

Tạo file script hoặc notebook trong môi trường học của bạn. Nếu tạo script minh họa trong repo này, đặt trong folder:

lessions/day-12-nlp-fundamentals-tokenizer/

Không cần train model ở Day 12. Mục tiêu là hiểu và kiểm soát tokenizer.

Bài 1: So sánh tokenizer

Dùng các model/tokenizer sau:

  • bert-base-multilingual-cased: WordPiece, BERT-style.
  • gpt2: byte-level BPE, GPT-style cũ để quan sát hành vi byte/subword.
  • vinai/phobert-base: Vietnamese RoBERTa/PhoBERT-style.

Input mẫu:

texts = {
    "vi_with_diacritics": "Tôi đang học xử lý ngôn ngữ tự nhiên và tokenizer cho hệ thống RAG.",
    "vi_no_diacritics": "Toi dang hoc xu ly ngon ngu tu nhien va tokenizer cho he thong RAG.",
    "support_ticket": "Khách hàng báo lỗi thanh toán PAY_403 lúc 09:30, cần retry idempotent.",
    "markdown": "## Lỗi thanh toán\n- Mã lỗi: PAY_403\n- User nói: không thanh toán được.",
    "phobert_segmented": "Tôi đang_học xử_lý ngôn_ngữ tự_nhiên với PhoBERT.",
}

Yêu cầu:

  1. In tokens, input_ids, attention_mask cho từng text.
  2. In len(input_ids) trước padding.
  3. Với fast tokenizer, in offset_mapping cho ít nhất một câu.
  4. Ghi nhận tokenizer nào tách tiếng Việt thành nhiều token hơn.
  5. Ghi nhận khác biệt giữa có dấu và không dấu.

Câu hỏi tự trả lời:

  • Token preview của WordPiece khác byte-level BPE như thế nào?
  • Có tokenizer nào không có pad_token không? Bạn xử lý ra sao?
  • PhoBERT khác gì khi input đã word-segmented bằng _?

Bài 2: Token stats cho domain thật

Tạo ít nhất 30 câu/ticket/document snippet tiếng Việt từ domain bạn quan tâm. Ví dụ:

  • Support ticket.
  • Log lỗi thanh toán.
  • FAQ nội bộ.
  • Mô tả sản phẩm.
  • Đoạn hợp đồng.
  • Comment user có tiếng Việt không dấu.

Yêu cầu:

  1. Normalize text bằng helper trong document.md.
  2. Đếm token bằng đúng tokenizer bạn định dùng.
  3. Tính min, mean, p50, p95, p99, max.
  4. In 5 sample dài nhất cùng token count, nhưng redact PII nếu có.
  5. Đề xuất max_length cho classifier.

Gợi ý quyết định:

Kết quả đoQuyết định khả thi
p95 <= 128, max <= 256max_length=256 thường ổn cho classifier
p95 <= 256, p99 caomax_length=256 + policy cho outlier
p95 > 512Cần split/summarize hoặc đổi task design
Có nhiều mã lỗi/SKU bị tách nhỏCần test quality riêng cho nhóm token này

Bài 3: Token budget và cost

Giả sử hệ thống RAG có:

context_window = 8192
reserved_output_tokens = 1000
system_prompt_tokens = 350
tool_schema_tokens = 500
chat_history_tokens = 1200

Yêu cầu:

  1. Tính budget còn lại cho retrieved context.
  2. Nếu mỗi chunk là 450 tokens, tính số chunk tối đa có thể nhét vào context.
  3. Nếu input price là 0.15 USD / 1M tokens, output price là 0.60 USD / 1M tokens, ước lượng cost cho 1 request.
  4. Ước lượng cost cho 1,000,000 requests/tháng với p95 input tokens của bạn.
  5. Đề xuất hard limit theo tenant hoặc feature.

Công thức:

available_context =
  context_window
  - reserved_output_tokens
  - system_prompt_tokens
  - tool_schema_tokens
  - chat_history_tokens

cost_usd =
  input_tokens / 1_000_000 * input_price
  + output_tokens / 1_000_000 * output_price

Bài 4: Implement policy khi input quá dài

Dùng TokenizerService trong document.md, viết 3 test case:

  1. Input ngắn hơn max_length: encode thành công.
  2. Input vượt max_length với truncation_policy="reject": raise TokenBudgetError.
  3. Input vượt max_length với truncation_policy="truncate": encode thành công nhưng có warning log.

Acceptance criteria:

  • Không có silent truncation.
  • Log có model, max_length, too_long_count, max_seen, action.
  • Test không assert raw text trong log.
  • Batch size 1 và batch size N đều chạy.

Bài 5: Mini design review

Viết một đoạn design note ngắn cho use case của bạn:

Use case:
Model/tokenizer:
Preprocessing:
max_length:
Reserved output tokens:
Truncation policy:
Fallback khi quá dài:
Metrics:
PII/logging policy:
Cost estimate:

Trả lời bắt buộc:

  • Dùng được trong production không?
  • Nếu có, cần điều kiện gì?
  • Trade-off lớn nhất là gì?
  • Bạn sẽ monitor metric nào đầu tiên sau release?

Quiz

  1. Vì sao đổi tokenizer có thể làm hỏng model dù code inference không lỗi?
  2. Vì sao tiếng Việt không nên chunk bằng số từ tách theo whitespace?
  3. Khi nào padding="max_length" tốt hơn padding="longest"?
  4. OOV/UNK rate cao báo hiệu vấn đề gì?
  5. Vì sao offset_mapping cần thiết cho NER hoặc citation highlight?
  6. Silent truncation có thể gây bug nghiệp vụ nào trong RAG?
  7. Vì sao token count ảnh hưởng cả latency, memory và cost?

Kết quả mong đợi

Bạn hoàn thành Day 12 khi có:

  • Bảng so sánh token count của BERT, GPT-style và PhoBERT trên text tiếng Việt.
  • Thống kê p50/p95/p99 token count cho sample domain.
  • Một quyết định max_length có lý do.
  • Một policy rõ cho input quá dài.
  • Một ước lượng cost/request và cost/tháng.
  • Một câu trả lời production readiness cụ thể, không chung chung.