[Nanochat 분석하기] 2. 토크나이저의 모든 것

Read time: 2 minutes

토크나이저의 모든 것: 텍스트를 숫자로 변환하는 마법

**시리즈**: 나노챗(nanochat)으로 배우는 LLM 구축 - Part 2/9

왜 토크나이제이션이 중요한가?

post02 tokenization comparison

신경망은 텍스트를 직접 이해할 수 없습니다. "Hello world"라는 문자열을 보면 우리는 의미를 이해하지만, 컴퓨터에게는 그저 바이트의 나열일 뿐입니다.

  • # 신경망이 이해 못하는 형태
    text = "Hello world"  # ❌
    
    # 신경망이 이해하는 형태
    tokens = [15496, 995]  # ✅

    토크나이저는 이 변환을 담당하는 핵심 컴포넌트입니다. 좋은 토크나이저는:
    - 효율적인 압축 (적은 토큰 수)
    - 의미 보존 (단어 경계 존중)
    - 범용성 (모든 언어, 이모지 지원)

    세 가지 접근법 비교

    post02 bpe algorithm

    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