토크나이저(Tokenizers) — BPE, WordPiece, SentencePiece

LLM은 영어를 읽지 않습니다. 정수(integer)를 읽습니다. 토크나이저(tokenizer)는 그 정수가 의미를 담을지, 의미를 낭비할지를 결정합니다.

유형: Build 언어: Python 선수 지식: Phase 05 (NLP 기초) 예상 시간: 약 90분

학습 목표

  • BPE, WordPiece, Unigram 토큰화(tokenization) 알고리즘을 직접 구현하고 각각의 병합(merge) 전략을 비교합니다.
  • 어휘 크기(vocabulary size)가 모델 효율에 미치는 영향을 설명합니다. 너무 작으면 시퀀스(sequence)가 길어지고, 너무 크면 임베딩 파라미터(embedding parameter)를 낭비합니다.
  • 여러 언어와 코드 전반에 걸친 토큰화의 결과물을 분석하고, 특정 토크나이저가 어디에서 한계를 드러내는지 찾아냅니다.
  • tiktokensentencepiece 라이브러리로 텍스트를 토큰화(tokenize)하고 결과로 나오는 토큰 ID(token ID)를 살펴봅니다.

문제

LLM은 영어를 읽지 않습니다. 사실 어떤 언어도 읽지 않습니다. LLM이 읽는 것은 숫자입니다.

"Hello, world!"[15496, 11, 995, 0] 사이의 간극을 메우는 것이 바로 토크나이저입니다. 모델이 텍스트를 처리하기 전에 모든 단어, 모든 공백, 모든 구두점(punctuation mark)이 정수로 변환되어야 합니다. 이 변환은 결코 중립적이지 않습니다. 변환 단계에서 모델에 새겨진 가정은 이후에 되돌릴 수 없습니다.

이 단계를 잘못하면 모델은 흔한 단어를 여러 개의 토큰으로 인코딩(encoding)하느라 용량을 낭비하게 됩니다. 예를 들어 "unfortunately"가 한 개가 아니라 네 개의 토큰이 됩니다. 음절이 많은 단어가 가득한 텍스트라면 128K 컨텍스트 창(context window)이 사실상 75% 줄어드는 셈입니다. 반대로 잘 설계하면 같은 컨텍스트 창에 두 배의 의미를 담을 수 있습니다. "이 모델은 코드를 잘 다룬다"와 "이 모델은 Python에서 막힌다"의 차이는 결국 토크나이저가 어떻게 학습되었는가에서 비롯되는 경우가 많습니다.

GPT-4나 Claude에 보내는 모든 API 호출은 토큰 단위로 가격이 매겨집니다. 모델이 생성하는 모든 토큰은 연산 비용을 발생시킵니다. 같은 출력을 표현하는 데 필요한 토큰 수가 적을수록 처음부터 끝까지의 추론(inference)이 더 빠릅니다. 토큰화는 단순한 전처리(preprocessing)가 아닙니다. 토큰화는 곧 모델의 아키텍처(architecture)입니다.

사전 테스트

2문제 · 이 강의를 시작하기 전에 얼마나 알고 있는지 확인해보세요

1.LLM 파이프라인(pipeline)에서 토크나이저(tokenizer)의 주된 목적은 무엇인가요?

2.BPE(Byte Pair Encoding)는 어휘(vocabulary)를 만들기 위해 무엇을 하나요?

0/2 답변 완료

개념

실패한 세 접근과 살아남은 한 접근

텍스트를 숫자로 바꾸는 직관적인 방법은 세 가지입니다. 그러나 그중 두 가지는 대규모에서 작동하지 않습니다.

단어 단위 토큰화(Word-level tokenization)는 공백과 구두점에서 텍스트를 나눕니다. "The cat sat"["The", "cat", "sat"]이 됩니다. 단순합니다. 하지만 "tokenization"은 어떻게 처리해야 할까요? "GPT-4o"는요? "Geschwindigkeitsbegrenzung" 같은 독일어 합성어(compound word)는요? 단어 단위 방식으로 모든 언어의 모든 단어를 다루려면 거대한 어휘가 필요합니다. 단어 하나라도 놓치면 두려운 [UNK] 토큰이 나타나게 됩니다. 이는 모델이 "이게 뭔지 모르겠다"고 말하는 방식입니다. 영어 하나만 해도 단어 형태(word form)가 백만 개가 넘습니다. 여기에 코드, URL, 과학적 표기법(scientific notation), 100개 이상의 다른 언어를 더하면 사실상 무한한 어휘가 필요해집니다.

문자 단위 토큰화(Character-level tokenization)는 정반대 방향으로 갑니다. "hello"["h", "e", "l", "l", "o"]가 됩니다. 어휘는 매우 작게 유지됩니다. 몇백 개의 문자만 있으면 됩니다. 알 수 없는 토큰(unknown token)은 절대 나오지 않습니다. 그러나 시퀀스가 극단적으로 길어집니다. 단어 단위로는 10개 토큰인 문장이 문자 단위로는 50개 토큰이 됩니다. 모델은 "t", "h", "e"가 함께 모이면 "the"가 된다는 사실까지 배워야 합니다. 사람이 세 살에 배우는 사실을 어텐션 용량(attention capacity)으로 태우게 되는 셈입니다.

부분단어 토큰화(Subword tokenization)가 그 절묘한 균형점을 찾아냅니다. 흔한 단어는 통째로 남깁니다. "the"는 토큰 하나입니다. 드문 단어는 의미 있는 조각으로 분해됩니다. 예를 들어 "unhappiness"["un", "happi", "ness"]가 됩니다. 어휘는 관리 가능한 크기(30K-128K 토큰)에서 유지됩니다. 시퀀스는 짧게 유지됩니다. 어떤 단어든 부분단어 조각으로 조립할 수 있으므로 알 수 없는 토큰은 사실상 사라집니다.

오늘날의 모든 LLM은 부분단어 토큰화를 사용합니다. GPT-2, GPT-4, BERT, Llama 3, Claude 모두 마찬가지입니다. 진짜 질문은 "어떤 알고리즘을 쓰는가"입니다.

graph TD
    A["Text: 'unhappiness'"] --> B{"Tokenization Strategy"}
    B -->|Word-level| C["['unhappiness']\n1 token if in vocab\n[UNK] if not"]
    B -->|Character-level| D["['u','n','h','a','p','p','i','n','e','s','s']\n11 tokens"]
    B -->|Subword BPE| E["['un','happi','ness']\n3 tokens"]

    style C fill:#ff6b6b,color:#fff
    style D fill:#ffa500,color:#fff
    style E fill:#51cf66,color:#fff

BPE(Byte Pair Encoding; 바이트 쌍 인코딩)

BPE는 본래 압축 알고리즘이었던 것을 토큰화 용도로 다시 활용한 탐욕적 압축 알고리즘(greedy compression algorithm)입니다. 아이디어 자체는 색인 카드 한 장에 적을 수 있을 만큼 단순합니다.

먼저 개별 문자에서 시작합니다. 학습 말뭉치(training corpus) 안의 모든 인접 쌍(adjacent pair)을 세어 봅니다. 가장 자주 등장한 쌍을 새로운 토큰으로 병합합니다. 목표 어휘 크기에 도달할 때까지 이 과정을 반복합니다.

아래는 "lower", "lowest", "newest"로 이루어진 작은 말뭉치에서 BPE가 동작하는 예입니다.

Corpus (with word frequencies):
  "lower"  x5
  "lowest" x2
  "newest" x6

Step 0 -- Start with characters:
  l o w e r       (x5)
  l o w e s t     (x2)
  n e w e s t     (x6)

Step 1 -- Count adjacent pairs:
  (e,s): 8    (s,t): 8    (l,o): 7    (o,w): 7
  (w,e): 13   (e,r): 5    (n,e): 6    ...

Step 2 -- Merge most frequent pair (w,e) -> "we":
  l o we r        (x5)
  l o we s t      (x2)
  n e we s t      (x6)

Step 3 -- Recount and merge (e,s) -> "es":
  l o we r        (x5)
  l o we s t      (x2)    <- 'es' only forms from 'e'+'s', not 'we'+'s'
  n e we s t      (x6)    <- wait, the 'e' before 'we' and 's' after 'we'

Actually tracking this precisely:
  After "we" merge, remaining pairs:
  (l,o): 7   (o,we): 7   (we,r): 5   (we,s): 8
  (s,t): 8   (n,e): 6    (e,we): 6

Step 3 -- Merge (we,s) -> "wes" or (s,t) -> "st" (tied at 8, pick first):
  Merge (we,s) -> "wes":
  l o we r        (x5)
  l o wes t       (x2)
  n e wes t       (x6)

Step 4 -- Merge (wes,t) -> "west":
  l o we r        (x5)
  l o west        (x2)
  n e west        (x6)

...continue until target vocab size reached.

병합 테이블(merge table)이 곧 토크나이저입니다. 새로운 텍스트를 인코딩할 때는 학습된 순서 그대로 병합을 적용합니다. 학습 말뭉치는 어떤 병합이 존재하게 될지를 결정하며, 그 선택은 모델이 앞으로 보게 될 세계를 영구적으로 형성합니다.

graph LR
    subgraph Training["BPE Training Loop"]
        direction TB
        T1["Start: character vocabulary"] --> T2["Count all adjacent pairs"]
        T2 --> T3["Merge most frequent pair"]
        T3 --> T4["Add merged token to vocab"]
        T4 --> T5{"Reached target\nvocab size?"}
        T5 -->|No| T2
        T5 -->|Yes| T6["Done: save merge table"]
    end

바이트 단위 BPE(Byte-Level BPE) — GPT-2, GPT-3, GPT-4

표준 BPE는 유니코드 문자(Unicode character) 위에서 동작합니다. 바이트 단위 BPE는 원시 바이트(raw byte; 0-255) 위에서 동작합니다. 이렇게 하면 기본 어휘(base vocabulary)가 정확히 256개로 정해지고, 어떤 언어나 인코딩 방식이든 모두 다룰 수 있으며, 알 수 없는 토큰이 절대 발생하지 않습니다.

이 접근은 GPT-2가 처음 도입했습니다. 기본 어휘는 가능한 모든 바이트 값을 다 덮습니다. BPE 병합은 그 위에 차곡차곡 쌓아 올려집니다. OpenAI의 tiktoken 라이브러리는 다음과 같은 어휘 크기로 바이트 단위 BPE를 구현하고 있습니다.

  • GPT-2: 50,257 tokens
  • GPT-3.5/GPT-4: 약 100,256 tokens (cl100k_base encoding)
  • GPT-4o: 200,019 tokens (o200k_base encoding)

WordPiece (BERT)

WordPiece는 BPE와 비슷해 보이지만 어떤 병합을 고를지 결정하는 방식이 다릅니다. 단순한 빈도(raw frequency)가 아니라, 학습 데이터의 가능도(likelihood)를 최대화하는 쪽을 고릅니다.

BPE merge criterion:      count(A, B)
WordPiece merge criterion: count(AB) / (count(A) * count(B))

BPE는 "어떤 쌍이 가장 자주 등장하는가?"를 묻습니다. WordPiece는 "어떤 쌍이 우연히 기대되는 수준보다 더 자주 함께 등장하는가?"를 묻습니다. 이 미묘한 차이가 서로 다른 어휘를 만들어냅니다. WordPiece는 단순히 자주 등장하는 쌍이 아니라, 함께 등장(co-occurrence)하는 빈도가 놀라울 정도로 큰 병합을 선호합니다.

WordPiece는 또한 이어지는 부분단어에 "##" 접두사(prefix)를 붙입니다.

"unhappiness" -> ["un", "##happi", "##ness"]
"embedding"   -> ["em", "##bed", "##ding"]

"##" 접두사는 이 조각이 앞 토큰을 이어 붙인 것이라는 사실을 알려줍니다. BERT는 30,522개 토큰의 어휘를 가진 WordPiece를 사용합니다. BERT 계열의 모든 변형이 같은 토크나이저를 쓰는 것은 아닙니다. RoBERTa의 토크나이저는 사실 BPE이지만, BERT 자체는 WordPiece입니다.

SentencePiece (Llama, T5)

SentencePiece는 공백을 포함한 유니코드 문자의 원시 스트림(raw stream)으로 입력을 바라봅니다. 사전 토큰화(pre-tokenization) 단계가 없습니다. 단어 경계(word boundary)에 대한 언어별 규칙도 없습니다. 그래서 진정한 의미에서 언어 비의존적(language-agnostic)으로 동작합니다. 중국어, 일본어, 태국어처럼 공백으로 단어를 구분하지 않는 언어에서도 그대로 동작합니다.

SentencePiece는 두 가지 알고리즘을 지원합니다.

  • BPE 모드: 표준 BPE와 동일한 병합 논리를 원시 문자 시퀀스(raw character sequence)에 적용합니다.
  • Unigram 모드: 큰 어휘에서 시작해, 전체 가능도에 가장 적게 영향을 주는 토큰을 반복적으로 제거합니다. BPE와 정반대 방향입니다. 병합 대신 가지치기(prune)를 합니다.

Llama 2는 32,000개 토큰 어휘를 가진 SentencePiece BPE를 사용합니다. T5는 32,000개 토큰의 SentencePiece Unigram을 사용합니다. 참고로 Llama 3는 128,256개 토큰을 가진 tiktoken 기반 바이트 단위 BPE 토크나이저로 바뀌었습니다.

어휘 크기의 트레이드오프(Vocabulary Size Tradeoffs)

이는 실제 엔지니어링 의사결정이며, 그 결과를 측정할 수 있습니다.

graph LR
    subgraph Small["Small Vocab (32K)\ne.g., BERT, T5"]
        S1["More tokens per text"]
        S2["Longer sequences"]
        S3["Smaller embedding matrix"]
        S4["Better rare-word handling"]
    end
    subgraph Large["Large Vocab (128K+)\ne.g., Llama 3, GPT-4o"]
        L1["Fewer tokens per text"]
        L2["Shorter sequences"]
        L3["Larger embedding matrix"]
        L4["Faster inference"]
    end

구체적인 숫자를 살펴봅니다. 어휘가 128K이고 임베딩 차원이 4,096이라면 임베딩 행렬(embedding matrix) 하나만으로도 128,000 x 4,096 = 524M 파라미터가 필요합니다. 어휘가 32K라면 131M 파라미터입니다. 토크나이저 선택 하나만으로 400M 파라미터의 차이가 발생합니다.

반면, 큰 어휘는 텍스트를 더 공격적으로 압축합니다. 32K 어휘에서 100 토큰이 되는 같은 영어 문단이 128K 어휘에서는 70 토큰으로 줄어들 수 있습니다. 즉 생성 중 순전파(forward pass) 횟수가 30% 줄어든다는 뜻입니다. 매일 수백만 건의 요청을 처리하는 모델이라면 그만큼 연산 비용이 곧바로 줄어듭니다.

흐름은 분명합니다. 어휘 크기는 점점 커지고 있습니다. GPT-2는 50,257개였습니다. GPT-4는 약 100K입니다. Llama 3는 128K입니다. GPT-4o는 200K입니다.

ModelVocab SizeTokenizer TypeAvg Tokens per English Word
BERT30,522WordPiece~1.4
GPT-250,257Byte-level BPE~1.3
Llama 232,000SentencePiece BPE~1.4
GPT-4~100,256Byte-level BPE~1.2
Llama 3128,256Byte-level BPE (tiktoken)~1.1
GPT-4o200,019Byte-level BPE~1.0

다국어 세금(The Multilingual Tax)

주로 영어로 학습된 토크나이저는 다른 언어에 가혹합니다. GPT-2의 토크나이저에서 한국어 텍스트는 평균적으로 단어당 2-3개의 토큰을 차지합니다. 중국어는 그보다 더 나쁠 수도 있습니다. 즉, 한국어 사용자는 영어 사용자와 똑같은 가격을 내면서도 사실상 절반 크기의 컨텍스트 창을 쓰는 셈이며, 같은 비용으로 더 적은 정보 밀도를 얻습니다.

이것이 바로 Llama 3가 어휘를 32K에서 128K로 네 배 늘린 이유입니다. 비영어 문자 체계(non-English script)에 더 많은 토큰을 할당하면 언어 사이의 압축이 더 공정해집니다.

직접 만들기

Step 1: 문자 단위 토크나이저(Character-Level Tokenizer)

가장 기초부터 시작합니다. 문자 단위 토크나이저는 각 문자를 유니코드 코드 포인트(Unicode code point)에 매핑합니다. 학습이 필요 없고, 알 수 없는 토큰도 없습니다. 단순한 직접 매핑일 뿐입니다.

class CharTokenizer:
    def encode(self, text):
        return [ord(c) for c in text]

    def decode(self, tokens):
        return "".join(chr(t) for t in tokens)

"hello"[104, 101, 108, 108, 111]이 됩니다. 모든 문자가 각자의 토큰이 됩니다. 이제부터 개선해 나갈 기준선(baseline)입니다.

Step 2: BPE 토크나이저 직접 구현(BPE Tokenizer from Scratch)

실제 구현 단계입니다. GPT-2처럼 원시 바이트 위에서 학습하고, 쌍을 세고, 가장 자주 나오는 쌍을 병합한 뒤, 모든 병합을 순서대로 기록합니다. 이 병합 테이블이 곧 토크나이저입니다.

from collections import Counter

class BPETokenizer:
    def __init__(self):
        self.merges = {}
        self.vocab = {}

    def _get_pairs(self, tokens):
        pairs = Counter()
        for i in range(len(tokens) - 1):
            pairs[(tokens[i], tokens[i + 1])] += 1
        return pairs

    def _merge_pair(self, tokens, pair, new_token):
        merged = []
        i = 0
        while i < len(tokens):
            if i < len(tokens) - 1 and tokens[i] == pair[0] and tokens[i + 1] == pair[1]:
                merged.append(new_token)
                i += 2
            else:
                merged.append(tokens[i])
                i += 1
        return merged

    def train(self, text, num_merges):
        tokens = list(text.encode("utf-8"))
        self.vocab = {i: bytes([i]) for i in range(256)}

        for i in range(num_merges):
            pairs = self._get_pairs(tokens)
            if not pairs:
                break
            best_pair = max(pairs, key=pairs.get)
            new_token = 256 + i
            tokens = self._merge_pair(tokens, best_pair, new_token)
            self.merges[best_pair] = new_token
            self.vocab[new_token] = self.vocab[best_pair[0]] + self.vocab[best_pair[1]]

        return self

    def encode(self, text):
        tokens = list(text.encode("utf-8"))
        for pair, new_token in self.merges.items():
            tokens = self._merge_pair(tokens, pair, new_token)
        return tokens

    def decode(self, tokens):
        byte_sequence = b"".join(self.vocab[t] for t in tokens)
        return byte_sequence.decode("utf-8", errors="replace")

학습 루프(training loop)가 BPE의 핵심입니다. 쌍을 세고, 가장 빈도가 높은 쌍을 병합하고, 다시 반복합니다. 각 병합은 전체 토큰 수를 줄입니다. num_merges 번의 반복이 끝나면 어휘는 256개(기본 바이트)에서 256 + num_merges 개로 커집니다.

인코딩은 학습된 그 순서 그대로 병합을 적용합니다. 이 순서가 중요합니다. 만약 첫 번째 병합이 "th"를 만들고 다섯 번째 병합이 "the"를 만들었다면, 인코딩에서도 첫 번째 병합을 먼저 적용해야 다섯 번째 단계에서 "th" + "e""the"로 합쳐질 수 있습니다.

디코딩은 그 역과정입니다. 각 토큰 ID를 어휘에서 찾고, 그 바이트들을 이어 붙인 뒤, UTF-8로 디코딩합니다.

Step 3: 인코딩과 디코딩 왕복 변환(Encode and Decode Roundtrip)

corpus = (
    "The cat sat on the mat. The cat ate the rat. "
    "The dog sat on the log. The dog ate the frog. "
    "Natural language processing is the study of how computers "
    "understand and generate human language. "
    "Tokenization is the first step in any NLP pipeline."
)

tokenizer = BPETokenizer()
tokenizer.train(corpus, num_merges=40)

test_sentences = [
    "The cat sat on the mat.",
    "Natural language processing",
    "tokenization pipeline",
    "unhappiness",
]

for sentence in test_sentences:
    encoded = tokenizer.encode(sentence)
    decoded = tokenizer.decode(encoded)
    raw_bytes = len(sentence.encode("utf-8"))
    ratio = len(encoded) / raw_bytes
    print(f"'{sentence}'")
    print(f"  Tokens: {len(encoded)} (from {raw_bytes} bytes) -- ratio: {ratio:.2f}")
    print(f"  Roundtrip: {'PASS' if decoded == sentence else 'FAIL'}")

압축 비율(compression ratio)은 토크나이저가 얼마나 효과적인지 알려주는 지표입니다. 비율이 0.50이라면 원시 바이트의 절반에 해당하는 토큰 수로 압축했다는 뜻입니다. 낮을수록 좋습니다. 학습 말뭉치 안의 문장에서는 비율이 좋게 나옵니다. 반면 말뭉치에 없는 "unhappiness" 같은 분포 밖(out-of-distribution) 텍스트에서는 비율이 더 나빠집니다. 토크나이저가 보지 못한 패턴에 대해서는 사실상 문자 단위 인코딩으로 되돌아가기 때문입니다.

Step 4: tiktoken과 비교하기

import tiktoken

enc = tiktoken.get_encoding("cl100k_base")

texts = [
    "The cat sat on the mat.",
    "unhappiness",
    "Hello, world!",
    "def fibonacci(n): return n if n < 2 else fibonacci(n-1) + fibonacci(n-2)",
    "Geschwindigkeitsbegrenzung",
]

for text in texts:
    our_tokens = tokenizer.encode(text)
    tiktoken_tokens = enc.encode(text)
    tiktoken_pieces = [enc.decode([t]) for t in tiktoken_tokens]
    print(f"'{text}'")
    print(f"  Our BPE:   {len(our_tokens)} tokens")
    print(f"  tiktoken:  {len(tiktoken_tokens)} tokens -> {tiktoken_pieces}")

tiktoken은 정확히 같은 알고리즘을 사용하지만, 수백 기가바이트 규모의 텍스트와 100,000번의 병합으로 학습되었습니다. 알고리즘은 똑같습니다. 차이는 학습 데이터와 병합 횟수일 뿐입니다. 한 문단 분량의 텍스트와 40번의 병합으로 학습된 토크나이저가, 거대한 말뭉치와 100K 병합을 거친 tiktoken과 경쟁할 수는 없습니다. 그러나 그 작동 원리는 정확히 같습니다.

Step 5: 어휘 분석(Vocabulary Analysis)

def analyze_vocabulary(tokenizer, test_texts):
    total_tokens = 0
    total_chars = 0
    token_usage = Counter()

    for text in test_texts:
        encoded = tokenizer.encode(text)
        total_tokens += len(encoded)
        total_chars += len(text)
        for t in encoded:
            token_usage[t] += 1

    print(f"Vocabulary size: {len(tokenizer.vocab)}")
    print(f"Total tokens across all texts: {total_tokens}")
    print(f"Total characters: {total_chars}")
    print(f"Avg tokens per character: {total_tokens / total_chars:.2f}")

    print(f"\nMost used tokens:")
    for token_id, count in token_usage.most_common(10):
        token_bytes = tokenizer.vocab[token_id]
        display = token_bytes.decode("utf-8", errors="replace")
        print(f"  Token {token_id:4d}: '{display}' (used {count} times)")

    unused = [t for t in tokenizer.vocab if t not in token_usage]
    print(f"\nUnused tokens: {len(unused)} out of {len(tokenizer.vocab)}")

이 분석은 어휘 안에 자리잡고 있는 지프 분포(Zipf distribution)를 드러냅니다. 공백, "the", "e"처럼 소수의 토큰이 사용 빈도를 지배합니다. 나머지 대부분의 토큰은 거의 쓰이지 않습니다. 실제 서비스에 쓰는 토크나이저는 이 분포에 맞춰 최적화됩니다. 흔한 패턴에는 짧은 토큰 ID를 부여하고, 드문 패턴에는 더 긴 표현(representation)을 부여합니다.

사용해보기

직접 만든 BPE가 잘 동작합니다. 이제 실제 서비스에 쓰이는 도구들이 어떻게 생겼는지 확인합니다.

tiktoken (OpenAI)

import tiktoken

enc = tiktoken.get_encoding("cl100k_base")

text = "Tokenizers convert text to integers"
tokens = enc.encode(text)
print(f"Tokens: {tokens}")
print(f"Pieces: {[enc.decode([t]) for t in tokens]}")
print(f"Roundtrip: {enc.decode(tokens)}")

tiktoken은 Rust로 작성되었고 Python 바인딩을 제공합니다. 초당 수백만 개의 토큰을 인코딩할 수 있습니다. 같은 BPE 알고리즘이지만 산업 수준의 견고함을 갖춘 구현입니다.

Hugging Face tokenizers

from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import ByteLevel

tokenizer = Tokenizer(BPE())
tokenizer.pre_tokenizer = ByteLevel()

trainer = BpeTrainer(vocab_size=1000, special_tokens=["<pad>", "<eos>", "<unk>"])
tokenizer.train(["corpus.txt"], trainer)

output = tokenizer.encode("The cat sat on the mat.")
print(f"Tokens: {output.tokens}")
print(f"IDs: {output.ids}")

Hugging Face tokenizers 라이브러리 역시 내부적으로는 Rust를 사용합니다. 기가바이트 규모의 말뭉치에서 BPE를 몇 초 만에 학습할 수 있습니다. 자체 모델을 학습할 때 사용하게 되는 도구입니다.

Llama 토크나이저 불러오기

from transformers import AutoTokenizer

tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-3.1-8B")

text = "Tokenizers are the unsung heroes of LLMs"
tokens = tokenizer.encode(text)
print(f"Token IDs: {tokens}")
print(f"Tokens: {tokenizer.convert_ids_to_tokens(tokens)}")
print(f"Vocab size: {tokenizer.vocab_size}")

multilingual = ["Hello world", "Hola mundo", "Bonjour le monde"]
for text in multilingual:
    ids = tokenizer.encode(text)
    print(f"'{text}' -> {len(ids)} tokens")

Llama 3의 128K 어휘는 GPT-2의 50K 어휘보다 비영어 텍스트를 훨씬 잘 압축합니다. 같은 문장을 여러 언어로 인코딩한 뒤 토큰 수를 직접 세어 보면서 이 차이를 확인할 수 있습니다.

산출물 만들기

이 강의에서는 outputs/prompt-tokenizer-analyzer.md를 만듭니다. 어떤 텍스트와 모델 조합이 들어와도 토큰화 효율(tokenization efficiency)을 분석해 주는 재사용 가능한 프롬프트(prompt)입니다. 텍스트 샘플을 넣으면 어떤 모델의 토크나이저가 그 텍스트를 가장 잘 처리하는지 알려줍니다.

연습문제

  1. (쉬움) BPE 토크나이저를 수정해 각 병합 단계마다 어휘를 출력하도록 만듭니다. "t" + "h""th"가 되고, "th" + "e""the"가 되는 과정을 직접 살펴봅니다. 흔한 영어 단어가 조각 단위로 조립되어 가는 과정을 따라가 봅니다.
  2. (중간) BPE 토크나이저에 특수 토큰(<pad>, <eos>, <unk>)을 추가합니다. 각각에 ID 0, 1, 2를 부여하고 나머지 토큰들의 ID를 그만큼 미룹니다. 이어서 BPE를 실행하기 전에 공백 단위로 나누는 사전 토큰화 단계를 구현합니다.
  3. (중간) WordPiece의 병합 기준을 직접 구현합니다. 빈도 대신 가능도 비율(likelihood ratio)을 사용합니다. 같은 말뭉치와 같은 병합 횟수로 BPE와 WordPiece를 함께 학습시킵니다. 그 결과로 만들어진 어휘를 비교합니다. 어느 쪽이 더 언어학적으로 의미 있는 부분단어를 만들어내나요?
  4. (중간) 다국어 토크나이저 효율 벤치마크(multilingual tokenizer efficiency benchmark)를 만듭니다. 영어, 스페인어, 중국어, 한국어, 아랍어로 된 문장을 각각 10개씩 준비합니다. tiktoken (cl100k_base)으로 토큰화한 뒤 문자당 평균 토큰 수(tokens per character)를 측정합니다. 각 언어의 "다국어 세금"을 수치로 표현해 봅니다.
  5. (어려움) 더 큰 말뭉치(예: Wikipedia 문서 한 편 다운로드)에서 BPE 토크나이저를 학습시킵니다. 같은 텍스트에서 tiktoken 대비 압축 비율이 10% 이내로 들어오도록 병합 횟수를 조정합니다. 이 과정을 통해 말뭉치 크기, 병합 횟수, 압축 품질이 어떻게 연결되어 있는지 직접 이해하게 됩니다.

핵심 용어

용어흔한 설명실제 의미
토큰(Token)"단어"모델 어휘의 단위다. 문자, 부분단어, 단어, 여러 단어로 이루어진 덩어리 중 무엇이든 될 수 있다.
BPE"어떤 압축 방식"Byte Pair Encoding이다. 목표 어휘 크기에 도달할 때까지 가장 자주 등장하는 인접 토큰 쌍을 반복적으로 병합한다.
WordPiece"BERT의 토크나이저"BPE와 비슷하지만, 단순 빈도가 아니라 count(AB)/(count(A)*count(B))의 가능도 비율이 가장 큰 쌍을 병합한다.
SentencePiece"토크나이저 라이브러리"사전 토큰화 없이 원시 유니코드 위에서 동작하는 언어 비의존적 토크나이저다. BPE와 Unigram 알고리즘을 모두 지원한다.
어휘 크기(Vocabulary size)"아는 단어의 개수"고유한 토큰의 총 개수다. GPT-2는 50,257개, BERT는 30,522개, Llama 3는 128,256개를 가진다.
Fertility(다산성)"토크나이저 용어 같지 않다"단어당 평균 토큰 수다. 언어별 토크나이저 효율을 측정하는 데 쓰인다. 1.0이면 완벽하고, 3.0이면 모델이 세 배 더 일을 한다는 뜻이다.
바이트 단위 BPE(Byte-level BPE)"GPT의 토크나이저"유니코드 문자가 아니라 원시 바이트(0-255) 위에서 동작하는 BPE다. 어떤 입력이 들어와도 알 수 없는 토큰이 나오지 않음을 보장한다.
병합 테이블(Merge table)"토크나이저 파일"학습 중에 얻은 쌍 병합의 순서 있는 목록이다. 이것이 곧 토크나이저 그 자체이며, 순서가 핵심이다.
사전 토큰화(Pre-tokenization)"공백에서 나누기"부분단어 토큰화 이전에 적용하는 규칙들이다. 공백 분리, 숫자 분리, 구두점 처리 등이 여기에 해당한다.
압축 비율(Compression ratio)"토크나이저의 효율"입력 바이트 수 대비 생성된 토큰 수의 비율이다. 낮을수록 압축이 좋고 추론이 빠르다.

더 읽을거리

실습 코드

이 강의의 실습 코드 3개

bpe
Code
bpe
Code
main
Code

산출물

이 강의에서 생성된 프롬프트, 스킬, 코드 산출물 2개

skill-tokenizer

Choosing and building tokenizers for LLM projects

Skill
prompt-tokenizer-analyzer

Analyze tokenization efficiency for a given text across different models and tokenizer types

Prompt

확인 문제

3문제 · 모두 맞추면 완료 표시가 가능합니다

1.어휘 크기(vocabulary size)가 LLM 설계(design)에서 트레이드오프(trade-off)를 만드는 이유는 무엇인가요?

2.토큰화(tokenization)에서 바이트 단위 폴백(byte-level fallback)은 어떤 문제를 해결하나요?

3.토크나이저는 비영어(non-English) 언어 성능(language performance)에 어떤 영향을 주나요?

0/3 답변 완료

추가 문제 풀기

AI가 강의 내용을 바탕으로 새로운 문제를 생성합니다