[Nanochat 분석하기] 2. 토크나이저의 모든 것
토크나이저의 모든 것: 텍스트를 숫자로 변환하는 마법
**시리즈**: 나노챗(nanochat)으로 배우는 LLM 구축 - Part 2/9
왜 토크나이제이션이 중요한가?¶

신경망은 텍스트를 직접 이해할 수 없습니다. "Hello world"라는 문자열을 보면 우리는 의미를 이해하지만, 컴퓨터에게는 그저 바이트의 나열일 뿐입니다.
# 신경망이 이해 못하는 형태
text = "Hello world"  # ❌
# 신경망이 이해하는 형태
tokens = [15496, 995]  # ✅토크나이저는 이 변환을 담당하는 핵심 컴포넌트입니다. 좋은 토크나이저는:
- 효율적인 압축 (적은 토큰 수)
- 의미 보존 (단어 경계 존중)
- 범용성 (모든 언어, 이모지 지원)
세 가지 접근법 비교¶

1. Character-level (문자 단위)¶
가장 단순한 방법입니다:
text = "cat"
chars = ['c', 'a', 't']
tokens = [99, 97, 116]  # ASCII codes장점:
- 구현이 매우 간단
- 작은 어휘 크기 (~100개)
- OOV (Out-of-Vocabulary) 문제 없음
단점:
- 시퀀스가 너무 길어짐 (100단어 = 500 문자)
- 단어 의미 학습이 어려움
- 훈련 및 추론 속도 저하
2. Word-level (단어 단위)¶
단어를 기본 단위로 사용:
text = "I love cats"
words = ['I', 'love', 'cats']
tokens = [1234, 5678, 9012]장점:
- 짧은 시퀀스
- 단어가 의미의 기본 단위
단점:
- 거대한 어휘 (수백만 개)
- 형태소 처리 불가 ("running" ≠ "run")
- 신조어, 오타 처리 불가
3. BPE (Byte Pair Encoding) ⭐¶
nanochat가 사용하는 방법:
text = "running"
tokens = ['run', 'ning']  # 의미 있는 subword로 분할장점:
- 균형잡힌 어휘 크기 (~50K)
- 희귀 단어도 subword로 처리
- 모든 유니코드 지원
BPE 알고리즘 구현하기¶
BPE의 핵심 아이디어는 가장 빈번한 바이트 쌍을 반복적으로 병합하는 것입니다.
Step 1: 바이트 쌍 통계¶
def get_stats(ids):
    """연속된 ID 쌍의 빈도를 계산"""
    counts = {}
    for pair in zip(ids, ids[1:]):
        counts[pair] = counts.get(pair, 0) + 1
    return counts
# 예시
ids = [1, 2, 3, 1, 2]
stats = get_stats(ids)
# {(1,2): 2, (2,3): 1, (3,1): 1}
# (1,2)가 2번 나타남 → 가장 빈번!Step 2: 병합 수행¶
def merge(ids, pair, new_id):
    """특정 쌍을 새로운 ID로 병합"""
    new_ids = []
    i = 0
    while i < len(ids):
        # 현재 위치가 찾는 쌍인지 확인
        if i < len(ids) - 1 and ids[i] == pair[0] and ids[i+1] == pair[1]:
            new_ids.append(new_id)
            i += 2  # 두 토큰을 건너뜀
        else:
            new_ids.append(ids[i])
            i += 1
    return new_ids
# 예시
ids = [1, 2, 3, 1, 2]
result = merge(ids, (1, 2), 4)
# [4, 3, 4]  ← (1,2) 쌍이 4로 병합됨중요: Non-overlapping merge! 겹치는 병합은 하지 않습니다:
ids = [1, 2, 2, 3]
merge(ids, (2,2), 4)
# [1, 4, 3] ✅ 올바름
# [1, 4, 4, 3] ❌ 틀림 (겹침)Step 3: 완전한 BPE 훈련¶
class SimpleBPE:
    def __init__(self):
        self.merges = {}  # (pair) -> new_id 매핑
    def train(self, text, vocab_size):
        """텍스트로부터 BPE 학습"""
        # 1. UTF-8 바이트로 변환
        tokens = list(text.encode('utf-8'))
        # 2. 필요한 병합 횟수 계산
        num_merges = vocab_size - 256  # 256개 바이트는 base
        # 3. 반복적으로 병합
        ids = list(tokens)
        for i in range(num_merges):
            # 가장 빈번한 쌍 찾기
            stats = get_stats(ids)
            if not stats:
                break
            pair = max(stats, key=stats.get)
            new_id = 256 + i
            # 병합 수행
            ids = merge(ids, pair, new_id)
            self.merges[pair] = new_id
            if i % 100 == 0:
                print(f"Merge {i}: {pair} -> {new_id} (count: {stats[pair]})")
    def encode(self, text):
        """텍스트를 토큰 ID로 변환"""
        tokens = list(text.encode('utf-8'))
        # 학습된 병합을 순서대로 적용
        while len(tokens) >= 2:
            stats = get_stats(tokens)
            # 학습된 병합 중 가장 우선순위 높은 것 찾기
            pair = min(stats, key=lambda p: self.merges.get(p, float('inf')))
            if pair not in self.merges:
                break
            idx = self.merges[pair]
            tokens = merge(tokens, pair, idx)
        return tokens
    def decode(self, tokens):
        """토큰 ID를 텍스트로 복원"""
        # 역병합 수행 (생략 - 복잡함)
        pass실행 예제¶
# 훈련
bpe = SimpleBPE()
training_text = "aaabdaaabac" * 1000  # 반복되는 패턴
bpe.train(training_text, vocab_size=512)
# 출력:
# Merge 0: (97, 97) -> 256 (count: 4000)    # 'aa'
# Merge 1: (256, 97) -> 257 (count: 2000)   # 'aaa'
# Merge 2: (257, 98) -> 258 (count: 2000)   # 'aaab'
# ...
# 인코딩
text = "aaabdaaabac"
tokens = bpe.encode(text)
print(tokens)  # [258, 100, 258, 97, 99]
# 원래 11 바이트 → 5 토큰 (압축!)Byte-level BPE: 유니코드의 해결사¶
Character-level BPE는 유니코드 문제가 있습니다:
- 유니코드 문자: 149,813개
- 대부분 희귀 문자
- 거대한 어휘 필요
해결책: Byte-level BPE!
모든 유니코드를 UTF-8 바이트로 변환:
# 영어 (1 byte per char)
"Hello".encode('utf-8')
# b'Hello' = [72, 101, 108, 108, 111]
# 한글 (3 bytes per char)
"안녕".encode('utf-8')
# b'\xec\x95\x88\xeb\x85\x95' = [236, 149, 136, 235, 133, 149]
# 이모지 (4 bytes)
"🚀".encode('utf-8')
# b'\xf0\x9f\x9a\x80' = [240, 159, 154, 128]장점:
- Base vocabulary는 항상 256개 (모든 바이트)
- 모든 텍스트를 표현 가능 (No UNK token!)
- 언어 중립적
GPT-4 스타일 Regex Splitting¶
단순 BPE의 문제: 단어 경계를 무시합니다.
# 문제 예시
"I can't wait"
# BPE might merge: ["I", " ca", "n't", " wa", "it"]
# 'can't'를 하나의 단위로 보지 못함!해결책: Pre-tokenization with regex!
GPT-4가 사용하는 정규식:
GPT4_SPLIT_PATTERN = r"""'(?i:[sdmt]|ll|ve|re)|[^\r\n\p{L}\p{N}]?+\p{L}+|\p{N}{1,3}| ?[^\s\p{L}\p{N}]++[\r\n]*|\s*[\r\n]|\s+(?!\S)|\s+"""복잡해 보이지만, 목적은 명확합니다:
1. 축약형 보존: 't, 'll, 've, 're
2. 단어 단위: \p{L}+ (모든 유니코드 문자)
3. 숫자 청크: \p{N}{1,3} (1-3자리씩)
4. 구두점 분리
import regex as re
def split_text(text):
    return re.findall(GPT4_SPLIT_PATTERN, text)
# 예시
text = "I can't wait! It's 2025."
chunks = split_text(text)
print(chunks)
# ['I', ' can', "'t", ' wait', '!', ' It', "'s", ' ', '20', '25', '.']이제 BPE는 각 청크 내에서만 병합합니다:
# "can't"를 하나의 청크로 처리
chunk = " can't"
# → BPE merges within this chunk
# → Better tokenization!Special Tokens: 구조를 알려주는 신호¶
LLM에게 특별한 의미를 가진 토큰:
SPECIAL_TOKENS = [
    "<|bos|>",              # Beginning of sequence
    "<|user_start|>",       # User message start
    "<|user_end|>",         # User message end
    "<|assistant_start|>",  # Assistant message start
    "<|assistant_end|>",    # Assistant message end
]사용 예시:
conversation = """
<|bos|>
<|user_start|>What is the capital of France?<|user_end|>
<|assistant_start|>The capital of France is Paris.<|assistant_end|>
"""왜 필요한가?
# Without special tokens:
"User: Hello Assistant: Hi User: How are you"
# → 모델이 누가 말하는지 모름 ❌
# With special tokens:
"<|user|>Hello<|end|><|assistant|>Hi<|end|>"
# → 모델이 역할 구분 가능 ✅Rust로 10배 빠른 토크나이저¶
Python BPE는 느립니다:
- 2GB 텍스트 훈련: ~30분
Rust 구현: ~3분! (10배 빠름)
// rustbpe/src/lib.rs (simplified)
use pyo3::prelude::*;
#[pyclass]
pub struct Tokenizer {
    merges: HashMap<(u32, u32), u32>,
}
#[pymethods]
impl Tokenizer {
    #[new]
    pub fn new() -> Self {
        Tokenizer {
            merges: HashMap::new(),
        }
    }
    pub fn train(&mut self, text: &str, vocab_size: u32) {
        // 병렬 처리로 속도 향상
        let chunks: Vec<_> = split_text(text);
        for merge_idx in 256..vocab_size {
            // 가장 빈번한 쌍 찾기 (병렬)
            let pair = find_best_pair_parallel(&chunks);
            self.merges.insert(pair, merge_idx);
            // 병합 적용 (병렬)
            apply_merge_parallel(&mut chunks, pair, merge_idx);
        }
    }
    pub fn encode(&self, text: &str) -> Vec<u32> {
        // 빠른 인코딩
    }
}Python에서 사용:
import rustbpe
# Rust 토크나이저 초기화
tokenizer = rustbpe.Tokenizer()
# 훈련 (빠름!)
tokenizer.train(large_text, vocab_size=50304)
# 인코딩
tokens = tokenizer.encode("Hello world")
print(tokens)  # [15496, 995]nanochat의 Tokenizer¶
실제 구현을 살펴봅시다:
# nanochat/tokenizer.py
import tiktoken
class Tokenizer:
    def __init__(self):
        # tiktoken 사용 (OpenAI의 빠른 C++ 구현)
        self._tiktoken = tiktoken.get_encoding("gpt2")
        # nanochat special tokens 추가
        self.special_tokens = {
            "<|bos|>": 50296,
            "<|user_start|>": 50297,
            "<|user_end|>": 50298,
            "<|assistant_start|>": 50299,
            "<|assistant_end|>": 50300,
            # ... more tokens
        }
    def encode(self, text: str, add_special_tokens=False) -> list[int]:
        # 특수 토큰 체크
        if text in self.special_tokens:
            return [self.special_tokens[text]]
        # 일반 인코딩
        tokens = self._tiktoken.encode(text, allowed_special="all")
        if add_special_tokens:
            tokens = [self.special_tokens["<|bos|>"]] + tokens
        return tokens
    def decode(self, tokens: list[int]) -> str:
        # 특수 토큰 필터링
        tokens = [t for t in tokens if t < 50296]
        return self._tiktoken.decode(tokens)통계:
tokenizer = Tokenizer()
text = "I can't believe it's already 2025! 🚀"
tokens = tokenizer.encode(text)
print(f"Text length: {len(text)} chars")
print(f"Token count: {len(tokens)} tokens")
print(f"Compression ratio: {len(text)/len(tokens):.2f} chars/token")
# 출력:
# Text length: 38 chars
# Token count: 9 tokens
# Compression ratio: 4.22 chars/token핵심 요약¶
✅ BPE = Byte Pair Encoding
- 가장 빈번한 쌍을 반복적으로 병합
- 균형잡힌 vocabulary (50K)
✅ Byte-level BPE
- UTF-8 바이트 기반 (256 base)
- 모든 유니코드 지원
- 언어 중립적
✅ Regex splitting
- 의미 단위 보존 (단어, 축약형)
- GPT-4 스타일 패턴
✅ Special tokens
- 대화 구조 표현
- Role 구분 (user/assistant)
✅ Rust 구현
- 10배 빠른 훈련
- Python binding으로 쉽게 사용
다음 단계¶
Part 3: "Transformer의 핵심"에서 다룰 내용:
- Self-Attention 메커니즘 완벽 이해
- Query, Key, Value의 의미
- Multi-head attention 구현
- Causal masking으로 미래 정보 차단
토큰을 숫자로 바꿨으니, 이제 이 숫자들로 뭔가 의미있는 일을 해봅시다! 🚀
---
📘 참고¶
본 포스트는 Andrej Karpathy의 nanochat 오픈소스 프로젝트를 기반으로, 코드 구조와 학습 과정을 분석/설명하기 위해 작성되었습니다. 원본 코드는 MIT License 하에 배포됩니다. 모든 코드 예제는 교육 목적으로 단순화되었습니다.
참고 자료:
- [Neural Machine Translation of Rare Words with Subword Units (BPE 논문)](https://arxiv.org/abs/1508.07909)
- [tiktoken GitHub](https://github.com/openai/tiktoken)
- [rustbpe 구현](https://github.com/karpathy/nanochat/tree/main/rustbpe)
태그: #Tokenization #BPE #NLP #LLM #Rust #nanochat