이미지 로딩 중...

바닥부터 만드는 ChatGPT 2편 BPE 토크나이저 이론과 구현 - 슬라이드 1/11
A

AI Generated

2025. 11. 12. · 5 Views

바닥부터 만드는 ChatGPT 2편 BPE 토크나이저 이론과 구현

ChatGPT의 핵심 기술인 BPE(Byte Pair Encoding) 토크나이저를 직접 구현하며 배웁니다. 텍스트를 토큰으로 변환하는 원리부터 실제 코드 구현까지, 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.


목차

  1. 토크나이저의 개념 - 왜 텍스트를 토큰으로 나눠야 할까
  2. BPE 알고리즘의 핵심 원리 - 자주 나오는 문자 쌍을 합치기
  3. BPE 학습 과정 구현 - 병합 규칙 만들기
  4. BPE 토크나이저 적용 - 새 텍스트를 토큰으로 변환하기
  5. 바이트 레벨 BPE - 모든 문자를 표현하는 비법
  6. 토크나이저 성능 측정 - 압축률과 효율성
  7. 특수 토큰 처리 - 시작, 끝, 패딩 토큰
  8. 실전 토크나이저 저장과 로드 - 재사용 가능하게 만들기
  9. 토크나이저 디버깅 - 토큰화 결과 시각화하기
  10. HuggingFace Tokenizers 라이브러리 활용하기

1. 토크나이저의 개념 - 왜 텍스트를 토큰으로 나눠야 할까

시작하며

여러분이 ChatGPT에 "안녕하세요"라고 입력했을 때, AI는 이 문장을 어떻게 이해할까요? 사람처럼 글자를 그대로 읽을까요?

아니면 단어 단위로 쪼갤까요? 실제로 AI 모델은 우리가 생각하는 것과는 전혀 다른 방식으로 텍스트를 처리합니다.

컴퓨터는 문자를 직접 이해할 수 없기 때문에, 텍스트를 숫자로 변환하는 과정이 필요합니다. 이때 등장하는 것이 바로 토크나이저입니다.

토크나이저는 텍스트를 의미 있는 작은 단위(토큰)로 나누고, 각 토큰에 고유한 숫자를 부여하는 역할을 합니다.

개요

간단히 말해서, 토크나이저는 텍스트를 AI가 이해할 수 있는 숫자 형태로 변환하는 번역기입니다. 왜 이런 변환이 필요할까요?

AI 모델은 내부적으로 수학적 연산을 수행합니다. 텍스트를 그대로는 계산할 수 없기 때문에, 각 단어나 문자 조각을 숫자(토큰 ID)로 바꿔야 합니다.

예를 들어, ChatGPT에 질문을 던지면 먼저 여러분의 질문이 토큰화되고, 이 토큰들이 모델에 입력되어 처리됩니다. 전통적인 방법으로는 단어 단위로 쪼개거나(Word Tokenization), 문자 단위로 쪼개는(Character Tokenization) 방식이 있었습니다.

하지만 단어 단위는 어휘 사전이 너무 커지고, 문자 단위는 의미를 잃어버리는 문제가 있었습니다. 토크나이저의 핵심 특징은 세 가지입니다.

첫째, 텍스트를 일관되게 작은 단위로 분할합니다. 둘째, 각 토큰에 고유한 ID를 부여합니다.

셋째, 나중에 이 ID들을 다시 원래 텍스트로 복원할 수 있습니다. 이러한 특징들이 AI 모델이 자연어를 효과적으로 처리할 수 있게 만드는 핵심입니다.

코드 예제

# 간단한 토크나이저 개념 예제
text = "Hello, world! 안녕하세요"

# 방법 1: 문자 단위 토크나이저
char_tokens = list(text)
print(f"문자 토큰: {char_tokens}")
# ['H', 'e', 'l', 'l', 'o', ',', ' ', 'w', ...]

# 방법 2: 단어 단위 토크나이저
word_tokens = text.split()
print(f"단어 토큰: {word_tokens}")
# ['Hello,', 'world!', '안녕하세요']

# 토큰을 ID로 변환 (간단한 예시)
vocab = {token: idx for idx, token in enumerate(set(word_tokens))}
token_ids = [vocab[token] for token in word_tokens]
print(f"토큰 ID: {token_ids}")  # [0, 1, 2]

설명

이것이 하는 일: 위 코드는 텍스트를 토큰으로 나누는 세 가지 기본 방식을 보여줍니다. 첫 번째로, 문자 단위 토크나이저는 텍스트를 한 글자씩 쪼갭니다.

이 방식은 매우 간단하지만, "Hello"라는 단어를 5개의 개별 문자로 나누기 때문에 단어의 의미가 흩어집니다. 또한 공백이나 특수문자도 모두 개별 토큰이 되어 토큰 시퀀스가 매우 길어집니다.

두 번째로, 단어 단위 토크나이저는 공백을 기준으로 텍스트를 나눕니다. 이 방식은 의미 단위를 유지하지만, "Hello,"와 "Hello"를 다른 토큰으로 인식합니다.

또한 모든 가능한 단어를 사전에 담아야 하므로 어휘 사전 크기가 수십만 개 이상으로 커집니다. 마지막으로, 토큰을 ID로 변환하는 과정을 보여줍니다.

각 고유한 토큰에 숫자 ID를 부여하여 딕셔너리(vocab)를 만들고, 원본 토큰 리스트를 이 ID들의 리스트로 변환합니다. 이렇게 숫자로 변환된 데이터가 실제로 AI 모델에 입력됩니다.

여러분이 이 코드를 실행하면 같은 텍스트를 서로 다른 방식으로 토큰화했을 때 결과가 얼마나 달라지는지 직접 확인할 수 있습니다. 문자 단위는 토큰이 너무 많고, 단어 단위는 특수문자 처리가 까다롭다는 것을 알 수 있죠.

바로 이런 문제를 해결하기 위해 BPE 같은 서브워드 토크나이저가 등장했습니다.

실전 팁

💡 실제 운영 환경에서는 토크나이저를 직접 구현하기보다 HuggingFace의 transformers 라이브러리를 사용하세요. 검증된 구현체를 사용하면 버그와 성능 문제를 피할 수 있습니다.

💡 토큰화할 때 특수문자와 공백 처리에 주의하세요. "Hello,"와 "Hello"를 같은 토큰으로 인식하려면 전처리가 필요합니다. 구두점을 분리하거나 정규화하는 과정을 추가하세요.

💡 다국어 텍스트를 처리할 때는 유니코드 정규화를 먼저 수행하세요. 같은 글자도 다른 유니코드로 표현될 수 있어 토큰화 결과가 달라질 수 있습니다.

💡 토큰 개수는 비용과 직결됩니다. OpenAI API는 토큰 단위로 요금을 부과하므로, 입력 텍스트를 토큰화해서 미리 개수를 확인하면 예상 비용을 계산할 수 있습니다.


2. BPE 알고리즘의 핵심 원리 - 자주 나오는 문자 쌍을 합치기

시작하며

여러분이 "machine learning"이라는 단어를 자주 사용한다고 가정해봅시다. 이걸 매번 "m", "a", "c", "h", "i", "n", "e" 같은 개별 문자로 쪼개면 너무 비효율적이겠죠?

BPE(Byte Pair Encoding)는 바로 이 문제를 해결합니다. 자주 함께 등장하는 문자나 문자 조합을 찾아서 하나의 토큰으로 합쳐나가는 방식입니다.

마치 퍼즐 조각을 모아 큰 그림을 만드는 것과 비슷합니다. 이 알고리즘은 원래 데이터 압축 기법이었지만, 지금은 GPT와 ChatGPT의 표준 토크나이저로 자리잡았습니다.

왜 이렇게 널리 쓰일까요?

개요

간단히 말해서, BPE는 텍스트에서 가장 빈번하게 나타나는 문자 쌍을 반복적으로 찾아서 병합하는 알고리즘입니다. 왜 이 방식이 효과적일까요?

단어 수준과 문자 수준의 장점을 모두 가져올 수 있기 때문입니다. 자주 나오는 단어는 하나의 토큰으로 학습되고, 드문 단어는 서브워드로 쪼개집니다.

예를 들어, "learning"은 하나의 토큰이 될 수 있지만, "antidisestablishmentarianism" 같은 긴 단어는 여러 조각으로 나뉩니다. 기존 방식과 비교하면 차이가 명확합니다.

문자 단위 토크나이저는 모든 것을 낱글자로 쪼개지만, BPE는 데이터를 학습하면서 자주 쓰이는 패턴을 자동으로 발견합니다. 단어 단위 토크나이저는 미리 정해진 어휘만 사용하지만, BPE는 새로운 단어도 기존 서브워드 조합으로 표현할 수 있습니다.

BPE의 핵심 특징은 세 가지입니다. 첫째, 데이터 기반 학습으로 어휘 사전을 자동으로 구축합니다.

둘째, 어휘 사전 크기를 미리 정할 수 있어 메모리 효율적입니다. 셋째, 알려지지 않은 단어(OOV)가 거의 발생하지 않습니다.

이런 특징들이 현대 LLM의 필수 요소가 된 이유입니다.

코드 예제

# BPE의 기본 원리를 보여주는 간단한 구현
from collections import Counter

# 초기 단어와 빈도수
vocab = {'l o w </w>': 5, 'l o w e r </w>': 2,
         'n e w e s t </w>': 6, 'w i d e s t </w>': 3}

# 모든 문자 쌍의 빈도 계산
def get_pairs(vocab):
    pairs = Counter()
    for word, freq in vocab.items():
        symbols = word.split()
        for i in range(len(symbols)-1):
            pairs[symbols[i], symbols[i+1]] += freq
    return pairs

# 가장 빈번한 쌍 찾기
pairs = get_pairs(vocab)
best_pair = pairs.most_common(1)[0]
print(f"가장 빈번한 쌍: {best_pair}")  # (('e', 's'), 9)

설명

이것이 하는 일: 위 코드는 BPE의 핵심 단계인 "빈번한 문자 쌍 찾기"를 구현합니다. 첫 번째로, 초기 어휘 사전을 준비합니다.

각 단어는 문자 단위로 쪼개져 있고(공백으로 구분), 단어의 끝을 나타내는 특수 토큰 </w>가 붙어 있습니다. 숫자는 각 단어가 말뭉치에 등장하는 빈도입니다.

예를 들어 "low"는 5번, "newest"는 6번 등장했습니다. 두 번째로, get_pairs 함수는 모든 인접한 문자 쌍을 찾고 빈도를 계산합니다.

"l o w"에서는 ("l", "o")와 ("o", "w")가 추출됩니다. 이 쌍들의 빈도는 해당 단어의 빈도를 곱한 값입니다.

예를 들어 "low"가 5번 나왔다면 ("l", "o")의 빈도에 5가 더해집니다. 세 번째로, Counter 객체의 most_common(1) 메서드로 가장 빈번한 쌍을 찾습니다.

이 예제에서는 ("e", "s")가 9번으로 가장 많이 등장합니다. 왜냐하면 "newest"(6번)와 "widest"(3번)에 모두 포함되어 있기 때문입니다.

실제 BPE 알고리즘은 여기서 한 단계 더 나아갑니다. 가장 빈번한 쌍을 찾으면, 이 쌍을 하나의 새로운 토큰으로 병합합니다.

예를 들어 ("e", "s")를 "es"로 합치고, 어휘 사전을 업데이트합니다. 이 과정을 원하는 어휘 사전 크기에 도달할 때까지 반복하면, 데이터에 최적화된 토크나이저가 완성됩니다.

여러분이 이 코드를 확장하여 병합 과정을 반복 실행하면, 점차 단어가 의미 있는 서브워드 단위로 조합되는 것을 볼 수 있습니다. 처음엔 "n e w e s t"였던 것이 "n e w es t"가 되고, 나중엔 "new est" 또는 "newest"가 될 수 있습니다.

실전 팁

💡 BPE 학습 시 어휘 사전 크기를 너무 작게 설정하면 토큰 시퀀스가 길어져 모델 성능이 떨어집니다. 일반적으로 30,000~50,000 사이가 적절하며, GPT-3는 50,257개를 사용합니다.

💡 BPE를 학습할 때는 충분히 큰 말뭉치를 사용하세요. 데이터가 적으면 자주 나오는 패턴을 제대로 학습하지 못하고, 일반화 성능이 떨어집니다. 최소 수백만 문장 이상 권장합니다.

💡 특수 토큰(예: </w>, <UNK>, <PAD>)을 미리 예약하세요. 이 토큰들은 단어 경계, 미지의 단어, 패딩을 표현하는 데 필수적입니다. 학습 전에 어휘 사전에 추가해야 합니다.

💡 BPE 병합 규칙을 파일로 저장하세요. 학습이 끝나면 병합 순서를 기록한 파일을 만들어야 나중에 동일한 방식으로 새 텍스트를 토큰화할 수 있습니다.

💡 디버깅할 때는 작은 데이터셋으로 시작하세요. 위 예제처럼 단어 4~5개 정도로 테스트하면 각 단계에서 어떤 쌍이 병합되는지 눈으로 확인할 수 있습니다.


3. BPE 학습 과정 구현 - 병합 규칙 만들기

시작하며

여러분이 직접 ChatGPT의 토크나이저를 만든다고 상상해보세요. 수백만 개의 문장을 어떻게 효율적인 토큰으로 변환할 수 있을까요?

BPE 학습 과정은 생각보다 직관적입니다. 가장 자주 나타나는 문자 쌍을 찾고, 그것을 병합하고, 다시 빈도를 계산하는 과정을 반복하는 것입니다.

마치 레고 블록을 조립하듯이 작은 조각들이 점점 큰 의미 단위로 합쳐집니다. 이번 섹션에서는 실제로 작동하는 BPE 학습 코드를 단계별로 구현해봅니다.

코드는 간결하지만, 실제 프로덕션 토크나이저의 핵심 로직과 동일합니다.

개요

간단히 말해서, BPE 학습은 문자 쌍을 반복적으로 병합하여 최적의 어휘 사전을 구축하는 과정입니다. 왜 이 학습 과정이 중요할까요?

토크나이저의 품질이 모델 성능을 직접적으로 좌우하기 때문입니다. 잘 학습된 토크나이저는 적은 토큰으로 더 많은 정보를 표현할 수 있고, 이는 모델이 더 긴 문맥을 이해할 수 있게 만듭니다.

예를 들어, "인공지능"을 3개의 문자로 쪼개는 것보다 하나의 토큰으로 표현하는 것이 훨씬 효율적입니다. 전통적인 방법과 비교하면, 수동으로 어휘 사전을 만드는 것은 사실상 불가능합니다.

어떤 단어와 서브워드를 포함할지 사람이 결정할 수 없습니다. BPE는 데이터에서 자동으로 최적의 분할을 학습하므로, 도메인 특성을 자연스럽게 반영합니다.

BPE 학습의 핵심 단계는 네 가지입니다. 첫째, 모든 단어를 문자 단위로 초기화합니다.

둘째, 가장 빈번한 문자 쌍을 찾습니다. 셋째, 그 쌍을 새로운 토큰으로 병합하고 어휘 사전을 업데이트합니다.

넷째, 원하는 어휘 크기에 도달할 때까지 2-3단계를 반복합니다. 이 과정이 완료되면 병합 규칙 리스트가 생성되며, 이것이 바로 토크나이저의 핵심입니다.

코드 예제

import re
from collections import defaultdict, Counter

def learn_bpe(words, num_merges):
    # 단어를 문자 단위로 분할 (빈도 포함)
    vocab = defaultdict(int)
    for word, freq in words.items():
        vocab[' '.join(word) + ' </w>'] = freq

    merges = []  # 병합 규칙을 저장

    for i in range(num_merges):
        # 모든 문자 쌍의 빈도 계산
        pairs = Counter()
        for word, freq in vocab.items():
            symbols = word.split()
            for j in range(len(symbols)-1):
                pairs[symbols[j], symbols[j+1]] += freq

        if not pairs:
            break

        # 가장 빈번한 쌍 선택
        best = max(pairs, key=pairs.get)
        merges.append(best)

        # 어휘 사전 업데이트 (쌍을 병합)
        new_vocab = {}
        bigram = ' '.join(best)
        replacement = ''.join(best)
        for word in vocab:
            new_word = word.replace(bigram, replacement)
            new_vocab[new_word] = vocab[word]
        vocab = new_vocab

        print(f"병합 {i+1}: {best} -> {replacement}")

    return merges

# 사용 예시
words = {'low': 5, 'lower': 2, 'newest': 6, 'widest': 3}
merges = learn_bpe(words, num_merges=10)

설명

이것이 하는 일: 위 코드는 완전한 BPE 학습 알고리즘을 구현하여 병합 규칙을 생성합니다. 첫 번째로, 초기화 단계에서 모든 단어를 문자 단위로 분할합니다.

vocab 딕셔너리는 분할된 단어와 그 빈도를 저장합니다. 예를 들어 "low"는 "l o w </w>"로 변환되고, 원본 빈도 5를 유지합니다.

</w> 토큰은 단어의 끝을 표시하여 "low"와 "lower"의 "low" 부분을 구분할 수 있게 합니다. 두 번째로, 메인 루프에서 지정된 횟수만큼 병합을 수행합니다.

각 반복마다 현재 어휘의 모든 인접 문자 쌍을 찾고, Counter로 빈도를 집계합니다. 이때 각 쌍의 빈도는 그것을 포함한 단어의 빈도를 모두 합한 값입니다.

max(pairs, key=pairs.get)으로 가장 빈번한 쌍을 찾아 best에 저장하고, 이것을 merges 리스트에 추가합니다. 세 번째로, 어휘 사전 업데이트 단계입니다.

선택된 문자 쌍(예: "e s")을 하나의 토큰(예: "es")으로 교체합니다. 모든 단어를 순회하면서 replace 메서드로 쌍을 찾아 병합합니다.

예를 들어 "n e w e s t </w>"는 "n e w es t </w>"로 변경됩니다. 새로운 어휘로 vocab을 교체하고 다음 반복으로 진행합니다.

마지막으로, 모든 병합이 완료되면 merges 리스트를 반환합니다. 이 리스트는 병합이 수행된 순서대로 문자 쌍을 기록하고 있으며, 나중에 새로운 텍스트를 토큰화할 때 동일한 순서로 적용됩니다.

여러분이 이 코드를 실행하면 콘솔에 병합 과정이 출력됩니다. 처음엔 개별 문자들이, 점차 의미 있는 서브워드로 조합되는 과정을 눈으로 확인할 수 있습니다.

이것이 바로 GPT와 ChatGPT가 내부적으로 수행하는 토큰화 학습 과정입니다.

실전 팁

💡 병합 횟수(num_merges)는 어휘 사전 크기를 결정합니다. 초기 문자 개수 + 병합 횟수 = 최종 어휘 크기가 됩니다. 실제로는 원하는 어휘 크기를 먼저 정하고 역산합니다.

💡 대용량 말뭉치를 처리할 때는 메모리 효율에 주의하세요. 위 코드는 교육용으로 간결하지만, 프로덕션에서는 Counter 대신 힙 자료구조를 사용하거나, 병렬 처리를 도입해야 합니다.

💡 병합 규칙을 JSON이나 텍스트 파일로 저장하세요. json.dump(merges, file) 같은 방식으로 저장하면, 나중에 토크나이저를 재사용할 때 다시 학습할 필요가 없습니다.

💡 정규화 단계를 추가하면 성능이 향상됩니다. 학습 전에 소문자 변환, 유니코드 정규화(NFD/NFC), 공백 정리 등을 수행하면 더 일관된 토큰을 학습할 수 있습니다.

💡 초기 테스트는 작은 데이터셋으로 하세요. 위 예제처럼 단어 4개, 병합 10회 정도로 시작하면 각 병합 단계를 육안으로 검증할 수 있어 버그를 빠르게 발견할 수 있습니다.


4. BPE 토크나이저 적용 - 새 텍스트를 토큰으로 변환하기

시작하며

여러분이 앞서 학습한 BPE 병합 규칙을 손에 쥐고 있습니다. 이제 이 규칙을 사용해서 새로운 문장을 토큰으로 변환할 차례입니다.

토큰화 적용 단계는 학습보다 훨씬 간단합니다. 학습된 병합 규칙을 순서대로 적용하기만 하면 됩니다.

하지만 여기에는 중요한 함정이 하나 있습니다. 규칙을 적용하는 순서를 바꾸면 완전히 다른 결과가 나온다는 것입니다.

실제 ChatGPT나 GPT-4가 여러분의 입력을 받으면 정확히 이 과정을 수행합니다. 학습된 병합 규칙을 순서대로 적용하여 텍스트를 토큰 ID 시퀀스로 변환하고, 이를 모델에 전달합니다.

개요

간단히 말해서, BPE 토크나이저 적용은 학습된 병합 규칙을 순서대로 적용하여 텍스트를 토큰으로 변환하는 과정입니다. 왜 순서가 중요할까요?

BPE는 탐욕적(greedy) 알고리즘입니다. 먼저 적용된 병합이 나중 병합의 가능성을 차단할 수 있습니다.

예를 들어, "est"를 먼저 병합하면 "e"와 "st"를 따로 병합할 수 없게 됩니다. 그래서 학습 시 병합된 순서를 정확히 기록하고, 적용 시에도 동일한 순서를 따라야 일관된 토큰화가 보장됩니다.

전통적인 규칙 기반 토크나이저와 비교하면, BPE는 데이터에서 학습한 패턴을 사용합니다. 규칙 기반은 "명사는 이렇게, 동사는 저렇게" 같은 언어학적 규칙을 코딩해야 하지만, BPE는 통계적으로 자주 나타나는 패턴을 자동으로 찾아냅니다.

이 덕분에 다국어나 코드, 이모지 같은 다양한 데이터에도 유연하게 대응할 수 있습니다. 토큰화 적용의 핵심 단계는 세 가지입니다.

첫째, 입력 텍스트를 문자 단위로 분할합니다. 둘째, 병합 규칙을 순서대로 적용하여 문자들을 합칩니다.

셋째, 최종 토큰을 ID로 변환하여 반환합니다. 이 과정이 효율적으로 구현되어야 실시간 서비스에서 빠른 응답이 가능합니다.

코드 예제

def apply_bpe(word, merges):
    # 단어를 문자 단위로 분할
    word = list(word) + ['</w>']

    # 병합 규칙을 순서대로 적용
    for merge in merges:
        pairs = []
        i = 0
        while i < len(word) - 1:
            if (word[i], word[i+1]) == merge:
                # 병합할 쌍을 발견
                pairs.append(i)
            i += 1

        # 뒤에서부터 병합 (인덱스 변화 방지)
        for i in reversed(pairs):
            word[i:i+2] = [''.join(merge)]

    return word

# 사용 예시
merges = [('e', 's'), ('es', 't'), ('est', '</w>'),
          ('l', 'o'), ('lo', 'w')]

# "lowest"를 토큰화
tokens = apply_bpe('lowest', merges)
print(f"토큰화 결과: {tokens}")  # ['lo', 'w', 'est</w>']

# "newest"를 토큰화
tokens = apply_bpe('newest', merges)
print(f"토큰화 결과: {tokens}")  # ['n', 'e', 'w', 'est</w>']

설명

이것이 하는 일: 위 코드는 BPE 병합 규칙을 텍스트에 적용하여 실제 토큰화를 수행합니다. 첫 번째로, 입력 단어를 문자 리스트로 변환하고 끝에 </w> 토큰을 추가합니다.

예를 들어 "lowest"는 ['l', 'o', 'w', 'e', 's', 't', '</w>']가 됩니다. 리스트를 사용하는 이유는 나중에 문자들을 쉽게 병합하기 위해서입니다.

두 번째로, 각 병합 규칙을 순서대로 처리합니다. 외부 루프는 merges 리스트를 순회하고, 내부 while 루프는 현재 단어에서 병합할 쌍을 찾습니다.

예를 들어 현재 규칙이 ('e', 's')라면, 단어에서 'e' 다음에 's'가 나오는 모든 위치를 찾아 pairs 리스트에 저장합니다. 세 번째로, 찾은 모든 위치에서 실제 병합을 수행합니다.

중요한 점은 reversed(pairs)를 사용하여 뒤에서부터 병합한다는 것입니다. 왜냐하면 앞에서부터 병합하면 리스트의 인덱스가 변경되어 나중 병합 위치가 틀어지기 때문입니다.

word[i:i+2] = [''.join(merge)]는 두 개의 문자를 하나의 문자열로 교체합니다. 마지막으로, 모든 병합 규칙이 적용된 후 최종 토큰 리스트를 반환합니다.

예제에서 "lowest"는 ['lo', 'w', 'est</w>']로 토큰화됩니다. 병합 규칙에 ('l', 'o'), ('e', 's'), ('es', 't')가 있었기 때문에 이런 결과가 나온 것입니다.

여러분이 이 코드로 다양한 단어를 테스트해보면, 같은 병합 규칙을 사용해도 단어마다 토큰화 결과가 다르다는 것을 알 수 있습니다. "newest"는 ('l', 'o') 병합이 적용되지 않아 'n', 'e', 'w'가 개별 토큰으로 남습니다.

이렇게 BPE는 단어의 특성에 따라 유연하게 토큰 길이를 조정합니다.

실전 팁

💡 병합 적용 시 정규표현식을 활용하면 더 빠릅니다. re.sub()로 패턴을 한 번에 교체할 수 있지만, 순서 보장에 주의해야 합니다. 프로덕션에서는 Trie 자료구조를 사용한 최적화도 고려하세요.

💡 알 수 없는 문자(예: 희귀한 유니코드)를 처리하는 로직을 추가하세요. <UNK> 토큰으로 대체하거나, 바이트 레벨 BPE를 사용하면 모든 문자를 표현할 수 있습니다.

💡 토큰 ID 변환 단계를 빠뜨리지 마세요. 위 코드는 토큰 문자열을 반환하지만, 실제 모델은 숫자 ID를 입력받습니다. vocab = {token: idx for idx, token in enumerate(unique_tokens)}로 매핑 테이블을 만드세요.

💡 문장 단위 토큰화 시 공백 처리를 명확히 하세요. 단어 사이 공백을 특수 토큰(예: 'Ġ')으로 표시하거나, 단어 시작을 표시하는 GPT-2 방식을 사용할 수 있습니다.

💡 성능 최적화가 필요하면 Rust나 C++로 구현하세요. HuggingFace의 tokenizers 라이브러리는 Rust로 작성되어 Python보다 10배 이상 빠릅니다. Python은 프로토타이핑용으로 사용하고, 운영은 최적화된 라이브러리를 쓰세요.


5. 바이트 레벨 BPE - 모든 문자를 표현하는 비법

시작하며

여러분이 일반 BPE로 토큰화를 하다가 이모지(😀)나 희귀한 중국어 문자(𠮷)를 만났다고 상상해보세요. 어휘 사전에 없는 문자는 어떻게 처리할까요?

전통적인 BPE는 미지의 문자를 <UNK> 토큰으로 대체했습니다. 하지만 이러면 정보가 완전히 손실됩니다.

GPT-2는 이 문제를 해결하기 위해 "바이트 레벨 BPE"라는 혁신적인 방법을 도입했습니다. 바이트 레벨 BPE는 문자 대신 바이트를 기본 단위로 사용합니다.

모든 텍스트는 UTF-8 바이트로 표현할 수 있으므로, 어떤 문자도 표현 가능하고 OOV(Out-Of-Vocabulary) 문제가 완전히 사라집니다.

개요

간단히 말해서, 바이트 레벨 BPE는 텍스트를 바이트 단위로 분해하여 토큰화하는 방식입니다. 왜 이 방식이 혁명적일까요?

유니코드에는 14만 개 이상의 문자가 있지만, 바이트는 256개(0-255)만 있습니다. 바이트를 기본 단위로 사용하면 초기 어휘 크기가 256개로 고정되고, 여기에 병합으로 새 토큰을 추가하는 방식입니다.

예를 들어, "안녕"은 UTF-8로 6바이트이므로 초기에는 6개 토큰이지만, 학습을 거치면 하나의 토큰으로 합쳐질 수 있습니다. 전통적인 문자 레벨 BPE와 비교하면 차이가 명확합니다.

문자 레벨은 다국어 데이터에서 초기 어휘가 수천~수만 개로 커지고, 여전히 모든 문자를 커버할 수 없습니다. 바이트 레벨은 항상 256개로 시작하며, 이론적으로 모든 텍스트를 표현할 수 있습니다.

바이트 레벨 BPE의 핵심 특징은 네 가지입니다. 첫째, 고정된 초기 어휘(256 바이트)를 사용합니다.

둘째, OOV가 절대 발생하지 않습니다. 셋째, 다국어와 특수문자를 동일하게 처리합니다.

넷째, 바이트를 사람이 읽을 수 있게 매핑하여 가독성을 높입니다. GPT-2, GPT-3, ChatGPT가 모두 이 방식을 채택한 이유입니다.

코드 예제

# 바이트 레벨 BPE의 핵심: 바이트-문자 매핑
def bytes_to_unicode():
    # 출력 가능한 ASCII 문자들
    bs = list(range(ord("!"), ord("~")+1)) + \
         list(range(ord("¡"), ord("¬")+1)) + \
         list(range(ord("®"), ord("ÿ")+1))
    cs = bs[:]
    n = 0

    # 나머지 256개 바이트를 유니코드에 매핑
    for b in range(2**8):
        if b not in bs:
            bs.append(b)
            cs.append(2**8+n)
            n += 1

    # 바이트 -> 유니코드 문자 딕셔너리
    cs = [chr(c) for c in cs]
    return dict(zip(bs, cs))

# 텍스트를 바이트로 변환
text = "Hello 안녕 😀"
byte_encoder = bytes_to_unicode()

# UTF-8 바이트로 인코딩
utf8_bytes = text.encode('utf-8')
print(f"바이트: {list(utf8_bytes)}")

# 바이트를 매핑된 문자로 변환
tokens = ''.join([byte_encoder[b] for b in utf8_bytes])
print(f"바이트 토큰: {tokens}")

설명

이것이 하는 일: 위 코드는 GPT-2가 사용하는 바이트-유니코드 매핑 방식을 구현합니다. 첫 번째로, bytes_to_unicode() 함수는 256개의 바이트 값을 사람이 읽을 수 있는 유니코드 문자로 매핑합니다.

왜 이런 매핑이 필요할까요? 일부 바이트 값(예: 0x00-0x1F)은 제어 문자로 화면에 출력되지 않습니다.

이를 출력 가능한 유니코드 영역으로 매핑하면 디버깅과 시각화가 쉬워집니다. 두 번째로, 출력 가능한 ASCII 범위('!'부터 '~', 그리고 일부 확장 라틴 문자)를 먼저 선택합니다.

이 문자들은 바이트 값과 동일한 유니코드로 매핑됩니다. 예를 들어 바이트 0x48('H')은 유니코드 'H'로 그대로 매핑됩니다.

세 번째로, 나머지 바이트들(제어 문자 등)은 유니코드의 Private Use Area(0x100 이상)로 매핑됩니다. 이렇게 하면 256개 모든 바이트가 고유한 출력 가능 문자를 갖게 됩니다.

최종적으로 바이트 값을 키로, 유니코드 문자를 값으로 하는 딕셔너리를 반환합니다. 실제 사용 예시를 보면, "Hello 안녕 😀"를 UTF-8로 인코딩하면 18바이트가 됩니다.

ASCII 문자는 1바이트, 한글은 글자당 3바이트, 이모지는 4바이트입니다. 각 바이트를 byte_encoder로 매핑하면 18개의 유니코드 문자 시퀀스가 됩니다.

이제 이 시퀀스에 일반 BPE를 적용하면 바이트 레벨 토큰화가 완성됩니다. 여러분이 이 코드를 실행하면 바이트 표현이 생각보다 길다는 것을 알 수 있습니다.

하지만 BPE 학습을 거치면 자주 나오는 바이트 시퀀스(예: "안녕"의 6바이트)가 하나의 토큰으로 병합되어 효율성이 크게 향상됩니다. 실제 GPT-2는 50,000개 이상의 토큰을 학습하여 대부분의 일반 단어를 1-2토큰으로 표현합니다.

실전 팁

💡 바이트 레벨 BPE는 초기 시퀀스가 길어집니다. 한글 문장은 문자 레벨보다 3배 긴 바이트 시퀀스가 되므로, 충분한 병합 학습이 필수입니다. 최소 30,000회 이상 병합을 수행하세요.

💡 디코딩 시 유효성 검사를 추가하세요. 병합된 바이트 시퀀스가 유효한 UTF-8인지 확인하지 않으면, 깨진 문자가 출력될 수 있습니다. bytes.decode('utf-8', errors='replace')를 사용하세요.

💡 GPT-2의 정확한 구현을 참고하려면 HuggingFace의 tokenizers 라이브러리를 보세요. 오픈소스로 공개된 GPT-2 토크나이저가 바이트 레벨 BPE의 표준 구현입니다.

💡 바이트 레벨은 압축률이 중요합니다. 학습 데이터가 특정 언어에 편향되면 다른 언어의 토큰 효율이 떨어집니다. 다국어 서비스라면 균형잡힌 말뭉치로 학습하세요.

💡 시각화 도구를 활용하세요. OpenAI의 Tokenizer 페이지(platform.openai.com/tokenizer)에서 텍스트가 어떻게 토큰화되는지 실시간으로 볼 수 있습니다. 자신의 토크나이저 결과와 비교하면 디버깅에 도움이 됩니다.


6. 토크나이저 성능 측정 - 압축률과 효율성

시작하며

여러분이 두 개의 토크나이저를 만들었습니다. 하나는 어휘 크기 10,000개, 다른 하나는 50,000개입니다.

어느 것이 더 좋을까요? 토크나이저의 품질을 평가하는 것은 생각보다 중요합니다.

좋은 토크나이저는 같은 텍스트를 더 적은 토큰으로 표현하고, 이는 모델의 처리 속도와 문맥 길이에 직접 영향을 줍니다. 또한 API 사용료도 토큰 개수로 책정되므로 비용과도 연관됩니다.

토크나이저 성능을 측정하는 핵심 지표는 압축률입니다. 원본 텍스트 대비 토큰 개수가 얼마나 줄었는지를 나타내며, 이를 통해 토크나이저의 효율성을 정량화할 수 있습니다.

개요

간단히 말해서, 토크나이저 성능 측정은 텍스트를 얼마나 효율적으로 압축하는지 평가하는 과정입니다. 왜 압축률이 중요할까요?

토큰 개수가 적을수록 모델이 더 긴 문맥을 처리할 수 있습니다. GPT-4의 문맥 창이 8,192 토큰이라면, 압축률이 높은 토크나이저는 같은 토큰 수로 더 많은 정보를 담을 수 있습니다.

예를 들어, 한 토크나이저가 문서를 1,000 토큰으로 표현하고 다른 것이 1,500 토큰으로 표현한다면, 전자가 33% 더 효율적입니다. 전통적인 평가 방법으로는 perplexity(혼란도)를 사용했지만, 이는 모델 성능 지표입니다.

토크나이저 자체의 효율성을 보려면 압축률, 어휘 커버리지(OOV 비율), 평균 토큰 길이 같은 직접적인 지표가 필요합니다. 성능 측정의 핵심 지표는 네 가지입니다.

첫째, 압축률 = 문자 수 / 토큰 수로 토큰당 평균 문자 수를 나타냅니다. 둘째, OOV 비율은 미지의 토큰이 차지하는 비율입니다(바이트 레벨 BPE는 0%).

셋째, 어휘 활용도는 전체 어휘 중 실제로 사용된 토큰의 비율입니다. 넷째, 토큰화 속도는 초당 처리할 수 있는 토큰 개수입니다.

이 지표들을 종합적으로 보면 토크나이저의 품질을 정확히 평가할 수 있습니다.

코드 예제

import time
from collections import Counter

def measure_tokenizer(text, tokenize_func):
    # 토큰화 수행 및 시간 측정
    start = time.time()
    tokens = tokenize_func(text)
    elapsed = time.time() - start

    # 기본 통계
    num_chars = len(text)
    num_tokens = len(tokens)
    compression_ratio = num_chars / num_tokens if num_tokens > 0 else 0

    # 어휘 활용도
    unique_tokens = len(set(tokens))

    # 결과 출력
    print(f"=== 토크나이저 성능 측정 ===")
    print(f"원본 문자 수: {num_chars}")
    print(f"토큰 개수: {num_tokens}")
    print(f"압축률: {compression_ratio:.2f} 문자/토큰")
    print(f"고유 토큰 수: {unique_tokens}")
    print(f"토큰화 시간: {elapsed*1000:.2f}ms")
    print(f"처리 속도: {num_tokens/elapsed:.0f} 토큰/초")

    return {
        'compression_ratio': compression_ratio,
        'num_tokens': num_tokens,
        'unique_tokens': unique_tokens,
        'speed': num_tokens/elapsed
    }

# 사용 예시
sample_text = """
ChatGPT는 대규모 언어 모델입니다.
BPE 토크나이저를 사용하여 텍스트를 처리합니다.
""" * 100  # 반복하여 충분한 데이터 생성

# 임시 토크나이저 함수 (실제로는 앞서 구현한 BPE 사용)
def simple_tokenize(text):
    return text.split()

stats = measure_tokenizer(sample_text, simple_tokenize)

설명

이것이 하는 일: 위 코드는 토크나이저의 성능을 다각도로 측정하고 리포트를 생성합니다. 첫 번째로, 토큰화를 수행하면서 실행 시간을 측정합니다.

time.time()으로 시작과 종료 시각을 기록하여 소요 시간을 계산합니다. 이는 대용량 텍스트를 처리할 때 토크나이저의 속도가 병목이 될 수 있기 때문에 중요합니다.

두 번째로, 기본 통계를 계산합니다. 원본 텍스트의 문자 수와 토큰화 결과의 토큰 개수를 세고, 압축률을 계산합니다.

압축률이 높을수록(예: 4.5) 한 토큰이 평균 4.5문자를 표현한다는 뜻으로, 효율적입니다. 영어는 보통 3-4, 한글은 BPE 학습 정도에 따라 2-3 정도입니다.

세 번째로, 어휘 활용도를 측정합니다. set(tokens)로 고유 토큰 개수를 세어, 전체 어휘 중 몇 개가 실제로 사용되는지 파악합니다.

만약 50,000개 어휘 중 500개만 사용된다면, 어휘 크기를 줄일 수 있다는 신호입니다. 마지막으로, 처리 속도를 계산합니다.

토큰 개수를 소요 시간으로 나누면 초당 처리 토큰 수가 나옵니다. 프로덕션 환경에서는 초당 수만~수십만 토큰을 처리할 수 있어야 실시간 서비스가 가능합니다.

Python 구현은 보통 느리므로, Rust나 C++ 구현과 비교해보세요. 여러분이 이 코드로 여러 토크나이저를 비교하면, 어휘 크기와 압축률의 트레이드오프를 확인할 수 있습니다.

어휘가 크면 압축률은 높아지지만 메모리 사용량이 늘고, 어휘가 작으면 메모리는 적지만 토큰 시퀀스가 길어집니다. 최적의 균형점을 찾는 것이 토크나이저 설계의 핵심입니다.

실전 팁

💡 다양한 도메인에서 테스트하세요. 기술 문서, 소설, 대화 데이터 등 다른 텍스트 유형에서 압축률이 크게 달라질 수 있습니다. 서비스 도메인과 유사한 데이터로 평가하세요.

💡 언어별 압축률을 따로 측정하세요. 영어는 압축률이 높고 한글/중국어는 낮은 경향이 있습니다. 다국어 서비스라면 각 언어별 성능을 개별 측정하여 편향을 확인하세요.

💡 토큰 길이 분포를 시각화하세요. Counter(len(t) for t in tokens)로 히스토그램을 그리면, 대부분 토큰이 몇 문자인지 알 수 있습니다. 이상적으로는 2-6문자 범위에 집중되어야 합니다.

💡 OpenAI나 HuggingFace의 공개 토크나이저를 벤치마크로 사용하세요. 같은 텍스트를 GPT-2 토크나이저로 처리한 결과와 비교하면 자신의 구현이 얼마나 효율적인지 객관적으로 평가할 수 있습니다.

💡 A/B 테스트를 고려하세요. 실제 모델 성능(정확도, 생성 품질)도 함께 측정해야 합니다. 압축률이 높아도 모델 성능이 떨어지면 의미가 없습니다. 다운스트림 태스크에서 실험하세요.


7. 특수 토큰 처리 - 시작, 끝, 패딩 토큰

시작하며

여러분이 ChatGPT에 여러 질문을 한 번에 보내면, AI는 어떻게 각 질문의 경계를 알 수 있을까요? 또한 배치 처리 시 길이가 다른 문장들을 어떻게 동일한 크기로 맞출까요?

이런 문제를 해결하는 것이 바로 특수 토큰입니다. 특수 토큰은 텍스트의 구조와 메타 정보를 표현하는 토큰으로, 일반 단어와는 다른 역할을 합니다.

모델이 입력을 정확히 이해하려면 이런 구조 정보가 필수적입니다. GPT 시리즈는 <|endoftext|>, BERT는 [CLS], [SEP], [PAD] 같은 특수 토큰을 사용합니다.

이들은 문장의 시작과 끝을 표시하거나, 여러 문장을 구분하거나, 빈 공간을 채우는 역할을 합니다.

개요

간단히 말해서, 특수 토큰은 텍스트의 구조적 정보를 표현하는 예약된 토큰입니다. 왜 특수 토큰이 필요할까요?

첫째, 문장 경계를 명확히 해야 합니다. "질문: ...

답변: ..." 같은 구조에서 각 부분을 구분해야 모델이 역할을 이해합니다. 둘째, 배치 처리를 위해 길이를 맞춰야 합니다.

GPU는 고정 크기 텐서를 처리하므로, 짧은 문장은 패딩으로 채워야 합니다. 셋째, 특별한 의미를 전달해야 합니다.

예를 들어 <|endoftext|>는 문서의 끝을 나타내 모델이 문맥을 리셋할 수 있게 합니다. 전통적인 텍스트 처리와 비교하면, 자연어에는 명시적인 구분자가 없습니다.

마침표가 문장 끝일 수도 있고 약어일 수도 있습니다. 특수 토큰은 이런 모호함을 제거하고, 모델에게 명확한 신호를 제공합니다.

특수 토큰의 주요 종류는 다섯 가지입니다. 첫째, <PAD> (패딩): 짧은 시퀀스를 채워 길이를 맞춥니다.

둘째, <UNK> (미지 토큰): 어휘에 없는 단어를 대체합니다(바이트 레벨 BPE에서는 불필요). 셋째, <BOS>/<SOS> (시작 토큰): 시퀀스의 시작을 표시합니다.

넷째, <EOS> (종료 토큰): 시퀀스의 끝을 표시합니다. 다섯째, 도메인 특화 토큰: 예를 들어 ChatGPT는 <|im_start|>, <|im_end|> 같은 토큰으로 메시지 역할을 구분합니다.

이런 토큰들을 적절히 활용하면 모델의 이해도가 크게 향상됩니다.

코드 예제

# 특수 토큰을 포함한 토크나이저 구현
class TokenizerWithSpecial:
    def __init__(self, merges, vocab_size):
        # 특수 토큰 정의 (vocab 시작 부분에 예약)
        self.special_tokens = {
            '<PAD>': 0,
            '<UNK>': 1,
            '<BOS>': 2,
            '<EOS>': 3,
        }

        self.merges = merges
        self.vocab_size = vocab_size

        # 일반 토큰은 특수 토큰 다음부터 시작
        self.token_to_id = self.special_tokens.copy()
        self.id_to_token = {v: k for k, v in self.special_tokens.items()}

    def encode(self, text, add_special_tokens=True):
        # BPE 적용 (실제로는 앞서 구현한 apply_bpe 사용)
        tokens = text.split()  # 간단한 예시

        # 특수 토큰 추가
        if add_special_tokens:
            tokens = ['<BOS>'] + tokens + ['<EOS>']

        # 토큰을 ID로 변환
        token_ids = []
        for token in tokens:
            if token in self.special_tokens:
                token_ids.append(self.special_tokens[token])
            elif token in self.token_to_id:
                token_ids.append(self.token_to_id[token])
            else:
                token_ids.append(self.special_tokens['<UNK>'])

        return token_ids

    def pad(self, token_ids_list, max_length=None):
        # 배치의 최대 길이 찾기
        if max_length is None:
            max_length = max(len(ids) for ids in token_ids_list)

        # 패딩 추가
        padded = []
        for ids in token_ids_list:
            if len(ids) < max_length:
                ids = ids + [self.special_tokens['<PAD>']] * (max_length - len(ids))
            padded.append(ids[:max_length])  # 최대 길이 초과 시 자르기

        return padded

# 사용 예시
tokenizer = TokenizerWithSpecial(merges=[], vocab_size=10000)
texts = ["Hello world", "Hi", "How are you"]

# 각 텍스트 인코딩
encoded = [tokenizer.encode(text) for text in texts]
print(f"인코딩 결과: {encoded}")

# 패딩 적용
padded = tokenizer.pad(encoded)
print(f"패딩 결과: {padded}")

설명

이것이 하는 일: 위 코드는 특수 토큰을 관리하고 배치 처리를 위한 패딩을 구현한 토크나이저 클래스입니다. 첫 번째로, 초기화 단계에서 특수 토큰을 어휘 사전의 맨 앞에 예약합니다.

<PAD>는 ID 0, <UNK>는 1 등으로 고정합니다. 이렇게 하면 나중에 일반 토큰을 추가할 때 ID 충돌이 없고, 특수 토큰 ID를 쉽게 기억할 수 있습니다.

양방향 매핑(token_to_id, id_to_token)을 유지하여 인코딩과 디코딩을 모두 지원합니다. 두 번째로, encode 메서드는 텍스트를 토큰 ID 리스트로 변환합니다.

add_special_tokens 플래그가 True면 시작과 끝에 <BOS><EOS>를 추가합니다. 이는 모델이 시퀀스의 경계를 명확히 인식하게 합니다.

각 토큰을 ID로 변환할 때, 특수 토큰인지 먼저 확인하고, 일반 토큰 사전에 있는지 확인하고, 둘 다 아니면 <UNK>로 대체합니다. 세 번째로, pad 메서드는 여러 시퀀스를 동일한 길이로 맞춥니다.

배치 내 가장 긴 시퀀스를 기준으로 하거나, 지정된 max_length를 사용합니다. 짧은 시퀀스는 <PAD> 토큰(ID 0)으로 채우고, 긴 시퀀스는 잘라냅니다.

이렇게 하면 모든 시퀀스가 같은 길이가 되어 배치 텐서를 만들 수 있습니다. 실제 사용 예시를 보면, 세 문장의 길이가 다릅니다.

인코딩 후 <BOS>, <EOS>가 추가되고, 패딩 후 모두 같은 길이가 됩니다. 예를 들어 "Hi"는 [2, <Hi의ID>, 3, 0, 0] 형태가 됩니다.

여기서 2는 <BOS>, 3은 <EOS>, 0은 <PAD>입니다. 여러분이 실제 모델 훈련 시에는 attention mask도 함께 생성해야 합니다.

패딩 부분은 attention 계산에서 제외해야 하므로, 마스크 배열(패딩은 0, 실제 토큰은 1)을 만들어 모델에 전달합니다. 이렇게 하면 모델이 패딩을 무시하고 실제 내용만 처리합니다.

실전 팁

💡 특수 토큰 ID는 항상 어휘 사전 시작 부분에 배치하세요. ID 0부터 시작하면 패딩을 0으로 초기화하기 쉽고, 일반 토큰과 명확히 구분됩니다.

💡 모델별로 특수 토큰 규칙이 다릅니다. GPT는 <|endoftext|> 하나로 시작과 끝을 모두 표현하지만, BERT는 [CLS], [SEP]를 구분합니다. 사용할 모델 아키텍처에 맞춰 설계하세요.

💡 패딩 방향을 고려하세요. 일반적으로 오른쪽 패딩(문장 뒤에 추가)을 사용하지만, 일부 모델은 왼쪽 패딩(문장 앞에 추가)을 선호합니다. GPT-2는 왼쪽 패딩을 권장합니다.

💡 동적 패딩을 사용하면 효율적입니다. 전체 데이터셋의 최대 길이로 패딩하지 말고, 각 배치의 최대 길이로만 패딩하면 불필요한 연산을 줄일 수 있습니다.

💡 디코딩 시 특수 토큰을 제거하세요. 생성된 텍스트에 <EOS><PAD>가 포함되지 않도록 필터링 로직을 추가해야 합니다. skip_special_tokens=True 옵션을 제공하면 사용자가 선택할 수 있습니다.


8. 실전 토크나이저 저장과 로드 - 재사용 가능하게 만들기

시작하며

여러분이 며칠 동안 학습시킨 토크나이저를 완성했습니다. 하지만 프로그램을 종료하면 모든 것이 사라집니다.

어떻게 해야 나중에 다시 사용할 수 있을까요? 토크나이저를 파일로 저장하고 불러오는 것은 필수적입니다.

학습은 한 번만 하고, 저장된 토크나이저를 서비스에서 반복적으로 사용해야 효율적입니다. 또한 다른 팀원과 공유하거나, 버전을 관리하려면 파일 형태로 저장해야 합니다.

HuggingFace의 토크나이저는 JSON 파일로 모든 정보를 저장합니다. 병합 규칙, 어휘 사전, 특수 토큰 설정 등이 모두 포함되어 있어, 하나의 파일만으로 완전한 토크나이저를 복원할 수 있습니다.

개요

간단히 말해서, 토크나이저 저장은 병합 규칙과 어휘 사전을 파일로 직렬화하여 재사용 가능하게 만드는 과정입니다. 왜 적절한 저장 포맷이 중요할까요?

토크나이저는 크게 세 가지 정보를 담고 있습니다. 첫째, 병합 규칙 리스트(수만 개).

둘째, 토큰-ID 매핑 딕셔너리(수만 항목). 셋째, 특수 토큰 설정과 옵션들.

이 모든 정보를 효율적으로 저장하고 빠르게 로드할 수 있어야 합니다. JSON은 사람이 읽을 수 있고 범용적이지만, pickle은 Python 전용이지만 더 빠릅니다.

전통적인 방법과 비교하면, 예전에는 여러 파일로 분산 저장했습니다(vocab.txt, merges.txt 등). 하지만 현대적인 접근은 단일 JSON 파일에 모든 설정을 담아 관리를 단순화합니다.

HuggingFace의 tokenizer.json 형식이 사실상 표준이 되었습니다. 저장해야 할 핵심 정보는 다섯 가지입니다.

첫째, 버전 정보로 호환성을 관리합니다. 둘째, 병합 규칙 리스트를 순서대로 저장합니다.

셋째, 완전한 어휘 사전(토큰-ID 매핑)을 저장합니다. 넷째, 특수 토큰 정의와 ID를 저장합니다.

다섯째, 설정 옵션(대소문자 처리, 정규화 규칙 등)을 저장합니다. 이 모든 정보가 있어야 토크나이저를 완벽하게 복원할 수 있습니다.

코드 예제

import json
import os

class SaveableTokenizer:
    def __init__(self, merges=None, vocab=None, special_tokens=None):
        self.merges = merges or []
        self.vocab = vocab or {}  # token -> id 매핑
        self.special_tokens = special_tokens or {
            '<PAD>': 0, '<UNK>': 1, '<BOS>': 2, '<EOS>': 3
        }
        self.version = "1.0"

    def save(self, directory):
        """토크나이저를 디렉토리에 저장"""
        os.makedirs(directory, exist_ok=True)

        # 모든 정보를 하나의 딕셔너리로 구성
        tokenizer_data = {
            'version': self.version,
            'merges': self.merges,  # [('e', 's'), ('es', 't'), ...]
            'vocab': self.vocab,     # {'hello': 100, 'world': 101, ...}
            'special_tokens': self.special_tokens,
        }

        # JSON 파일로 저장
        config_path = os.path.join(directory, 'tokenizer.json')
        with open(config_path, 'w', encoding='utf-8') as f:
            json.dump(tokenizer_data, f, ensure_ascii=False, indent=2)

        print(f"토크나이저 저장 완료: {config_path}")

    @classmethod
    def load(cls, directory):
        """디렉토리에서 토크나이저 로드"""
        config_path = os.path.join(directory, 'tokenizer.json')

        with open(config_path, 'r', encoding='utf-8') as f:
            tokenizer_data = json.load(f)

        # 병합 규칙은 리스트의 튜플로 변환 (JSON은 튜플 미지원)
        merges = [tuple(pair) for pair in tokenizer_data['merges']]

        # 토크나이저 객체 생성
        tokenizer = cls(
            merges=merges,
            vocab=tokenizer_data['vocab'],
            special_tokens=tokenizer_data['special_tokens']
        )

        print(f"토크나이저 로드 완료: {config_path}")
        print(f"  - 버전: {tokenizer_data['version']}")
        print(f"  - 어휘 크기: {len(tokenizer.vocab)}")
        print(f"  - 병합 규칙: {len(tokenizer.merges)}개")

        return tokenizer

# 사용 예시
# 저장
tokenizer = SaveableTokenizer(
    merges=[('e', 's'), ('es', 't'), ('l', 'o')],
    vocab={'hello': 100, 'world': 101, 'test': 102}
)
tokenizer.save('./my_tokenizer')

# 로드
loaded_tokenizer = SaveableTokenizer.load('./my_tokenizer')

설명

이것이 하는 일: 위 코드는 토크나이저의 모든 상태를 JSON 파일로 저장하고 복원하는 완전한 구현입니다. 첫 번째로, __init__ 메서드에서 토크나이저의 세 가지 핵심 정보를 초기화합니다.

병합 규칙(merges)은 BPE 학습 결과입니다. 어휘 사전(vocab)은 모든 토큰과 그 ID의 매핑입니다.

특수 토큰(special_tokens)은 <PAD>, <UNK> 등의 정의입니다. 버전 정보도 포함하여 나중에 호환성 체크에 사용할 수 있습니다.

두 번째로, save 메서드는 모든 정보를 하나의 딕셔너리로 묶어 JSON 파일로 저장합니다. os.makedirs로 디렉토리가 없으면 생성하고, ensure_ascii=False로 한글 같은 유니코드 문자를 그대로 저장합니다.

indent=2로 가독성 좋은 포맷을 만듭니다. 이렇게 하면 파일을 텍스트 에디터로 열어 내용을 확인할 수 있습니다.

세 번째로, load 클래스 메서드는 저장된 JSON을 읽어 토크나이저 객체를 복원합니다. JSON은 튜플을 지원하지 않아 리스트로 저장되므로, 병합 규칙을 다시 튜플로 변환합니다.

로드 후 버전과 통계 정보를 출력하여 사용자가 확인할 수 있게 합니다. 실제 사용 시나리오를 보면, 먼저 학습 스크립트에서 토크나이저를 학습하고 save('./my_tokenizer')로 저장합니다.

이후 서비스 코드에서 SaveableTokenizer.load('./my_tokenizer')로 로드하여 사용합니다. 학습은 한 번만, 로드는 서버 시작 시마다 수행하므로 효율적입니다.

여러분이 이 패턴을 확장하면 모델 체크포인트처럼 버전 관리도 가능합니다. tokenizer_v1.json, tokenizer_v2.json 같은 식으로 저장하고, 실험 결과에 따라 최적의 버전을 선택할 수 있습니다.

Git으로 관리하면 팀원들과 공유도 쉽습니다.

실전 팁

💡 대용량 어휘 사전은 압축하여 저장하세요. gzip으로 압축하면 JSON 파일 크기를 70% 이상 줄일 수 있습니다. 로드 시 압축 해제 오버헤드는 미미합니다.

💡 버전 호환성 체크를 추가하세요. 로드 시 저장된 버전과 현재 코드의 버전을 비교하여, 호환되지 않으면 경고나 에러를 발생시킵니다. 예: if loaded_version != self.version: raise ValueError(...).

💡 체크섬을 포함하면 무결성을 보장할 수 있습니다. 파일 저장 시 MD5 해시를 계산하여 함께 저장하고, 로드 시 검증하면 파일 손상을 감지할 수 있습니다.

💡 HuggingFace 포맷과 호환되게 만들면 생태계를 활용할 수 있습니다. tokenizer.json 구조를 HuggingFace 표준에 맞추면, transformers 라이브러리로 직접 로드할 수 있습니다.

💡 클라우드 스토리지 통합을 고려하세요. S3나 GCS에 저장하고 로드하는 헬퍼 함수를 추가하면, 여러 서버에서 동일한 토크나이저를 공유할 수 있습니다. boto3google-cloud-storage를 사용하세요.


9. 토크나이저 디버깅 - 토큰화 결과 시각화하기

시작하며

여러분이 만든 토크나이저가 "machine learning"을 어떻게 쪼갤까요? "ma", "chine", "learning"일까요?

아니면 "machine", "learning"일까요? 토크나이저의 동작을 눈으로 확인하는 것은 디버깅과 개선에 필수적입니다.

예상과 다르게 토큰화되는 경우를 발견하면, 학습 데이터나 병합 규칙에 문제가 있다는 신호입니다. 특히 한글이나 특수문자 처리를 확인할 때 시각화가 큰 도움이 됩니다.

OpenAI는 웹 기반 토크나이저 시각화 도구를 제공합니다. 여기서 텍스트를 입력하면 각 토큰이 색상으로 구분되어 표시됩니다.

우리도 비슷한 도구를 만들어 자신의 토크나이저를 검증할 수 있습니다.

개요

간단히 말해서, 토크나이저 디버깅은 토큰화 결과를 시각적으로 표시하여 동작을 이해하고 문제를 찾는 과정입니다. 왜 시각화가 중요할까요?

토큰 ID 리스트만 보면 직관적으로 이해하기 어렵습니다. [2, 152, 3847, 102, 3] 같은 숫자 나열로는 문제를 파악할 수 없습니다.

하지만 원본 텍스트 위에 토큰 경계를 표시하거나 색상을 입히면, 어떤 부분이 어떻게 쪼개졌는지 한눈에 알 수 있습니다. 전통적인 디버깅 방법으로는 print 문으로 토큰 리스트를 출력하는 것이 전부였습니다.

하지만 현대적인 접근은 웹 UI나 터미널 컬러를 활용하여 대화형으로 탐색할 수 있게 합니다. 실시간으로 텍스트를 입력하고 토큰화 결과를 보면서 실험할 수 있습니다.

토크나이저 디버깅의 핵심 기능은 네 가지입니다. 첫째, 토큰 경계 표시로 각 토큰이 원본의 어느 부분에 해당하는지 보여줍니다.

둘째, 토큰 ID와 텍스트를 함께 표시하여 매핑을 확인합니다. 셋째, 토큰 길이 통계로 비정상적으로 짧거나 긴 토큰을 찾습니다.

넷째, 특수 토큰을 강조 표시하여 구조를 명확히 합니다. 이런 기능들이 있으면 토크나이저의 품질을 빠르게 평가하고 개선점을 찾을 수 있습니다.

코드 예제

from colorama import init, Fore, Back, Style
import random

# 터미널 색상 초기화 (Windows 지원)
init(autoreset=True)

class TokenizerDebugger:
    # 토큰별로 다른 색상 할당을 위한 색상 목록
    COLORS = [Fore.RED, Fore.GREEN, Fore.YELLOW, Fore.BLUE,
              Fore.MAGENTA, Fore.CYAN, Fore.WHITE]

    @staticmethod
    def visualize_tokens(text, tokens):
        """토큰화 결과를 색상으로 시각화"""
        print("\n" + "="*60)
        print("토큰화 결과 시각화")
        print("="*60)

        # 각 토큰에 색상 부여
        colored_tokens = []
        for i, token in enumerate(tokens):
            color = TokenizerDebugger.COLORS[i % len(TokenizerDebugger.COLORS)]
            # 특수 토큰은 배경색 강조
            if token.startswith('<') and token.endswith('>'):
                colored = Back.WHITE + Fore.BLACK + token + Style.RESET_ALL
            else:
                colored = color + token + Style.RESET_ALL
            colored_tokens.append(colored)

        print("토큰 표시: " + " | ".join(colored_tokens))

        # 통계 정보
        print(f"\n총 토큰 수: {len(tokens)}")
        print(f"평균 토큰 길이: {sum(len(t) for t in tokens) / len(tokens):.2f} 문자")

        # 토큰 상세 정보
        print("\n토큰 상세:")
        for i, token in enumerate(tokens):
            special = " (특수 토큰)" if token.startswith('<') else ""
            print(f"  [{i}] '{token}' (길이: {len(token)}){special}")

        print("="*60 + "\n")

    @staticmethod
    def compare_tokenizers(text, tokenizers_dict):
        """여러 토크나이저 결과 비교"""
        print("\n" + "="*60)
        print("토크나이저 비교")
        print("="*60)
        print(f"원본 텍스트: {text}\n")

        for name, tokenize_func in tokenizers_dict.items():
            tokens = tokenize_func(text)
            print(f"{name}:")
            print(f"  토큰 수: {len(tokens)}")
            print(f"  토큰: {tokens}")
            print(f"  압축률: {len(text) / len(tokens):.2f} 문자/토큰\n")

# 사용 예시
text = "Hello, machine learning!"

# 간단한 토크나이저 예시
def simple_tokenize(text):
    return ['<BOS>'] + text.split() + ['<EOS>']

tokens = simple_tokenize(text)
TokenizerDebugger.visualize_tokens(text, tokens)

# 여러 토크나이저 비교
TokenizerDebugger.compare_tokenizers(
    text,
    {
        '단어 단위': lambda t: t.split(),
        '문자 단위': lambda t: list(t),
        '간단한 BPE': simple_tokenize,
    }
)

설명

이것이 하는 일: 위 코드는 터미널에서 토큰화 결과를 컬러로 표시하고 상세 정보를 제공하는 디버깅 도구입니다. 첫 번째로, visualize_tokens 메서드는 각 토큰에 다른 색상을 부여합니다.

colorama 라이브러리를 사용하여 터미널에 색상을 출력합니다. 7가지 기본 색상을 순환하며 적용하므로, 인접한 토큰은 다른 색으로 표시되어 경계가 명확합니다.

예를 들어 "Hello"는 빨간색, "world"는 초록색으로 나타납니다. 두 번째로, 특수 토큰(<BOS>, <EOS> 등)은 배경색을 반전시켜 강조합니다.

흰색 배경에 검은 글씨로 표시하여 일반 토큰과 구분됩니다. 이렇게 하면 토큰 시퀀스에서 구조적 요소를 쉽게 식별할 수 있습니다.

세 번째로, 통계 정보를 계산하여 출력합니다. 총 토큰 수는 시퀀스 길이를 나타내고, 평균 토큰 길이는 압축 효율을 간접적으로 보여줍니다.

예를 들어 평균이 5.2 문자라면, 대부분 토큰이 의미 있는 서브워드 크기라는 뜻입니다. 네 번째로, 각 토큰의 상세 정보를 나열합니다.

인덱스, 내용, 길이를 보여주어 특정 위치의 토큰을 정확히 파악할 수 있습니다. 비정상적으로 긴 토큰(15문자 이상)이나 짧은 토큰(1문자)을 찾는 데 유용합니다.

다섯 번째로, compare_tokenizers 메서드는 여러 토크나이저의 결과를 나란히 비교합니다. 같은 텍스트를 단어 단위, 문자 단위, BPE 등 다양한 방식으로 토큰화하여, 각 방식의 토큰 수와 압축률을 비교할 수 있습니다.

예를 들어 문자 단위는 토큰이 많고 압축률이 낮지만, BPE는 적은 토큰으로 같은 내용을 표현합니다. 여러분이 이 도구를 사용하면 토크나이저 개발 과정에서 실시간으로 피드백을 받을 수 있습니다.

병합 규칙을 조정한 후 바로 결과를 확인하고, 예상대로 동작하는지 검증할 수 있습니다. 특히 다국어 텍스트나 코드 토큰화 시 어떤 부분이 잘못 쪼개지는지 쉽게 발견할 수 있습니다.

실전 팁

💡 웹 UI로 만들면 더 편리합니다. Flask나 Streamlit으로 간단한 웹앱을 만들어, 텍스트 입력 상자와 토큰 시각화를 제공하세요. 팀원들과 공유하기도 쉽습니다.

💡 토큰 빈도 하이라이팅을 추가하세요. 자주 나오는 토큰은 진한 색, 희귀한 토큰은 연한 색으로 표시하면 어휘 분포를 직관적으로 파악할 수 있습니다.

💡 diff 기능을 구현하면 토크나이저 비교가 강력해집니다. 두 토크나이저의 결과를 diff 형식으로 보여주면, 어느 부분이 다르게 토큰화되는지 명확합니다.

💡 로그 파일로 저장하세요. 디버깅 결과를 텍스트 파일로 저장하면, 나중에 회귀 테스트로 사용할 수 있습니다. 토크나이저 수정 후 같은 입력의 결과가 변했는지 자동 검증합니다.

💡 예외 케이스 컬렉션을 만드세요. 토큰화가 이상하게 되는 입력들을 모아 테스트 케이스로 관리하면, 개선 작업 시 회귀를 방지할 수 있습니다. "test_cases.json"에 저장하고 자동 테스트를 돌리세요.


10. HuggingFace Tokenizers 라이브러리 활용하기

시작하며

여러분이 지금까지 BPE를 직접 구현하며 원리를 배웠습니다. 하지만 실제 프로덕션에서는 어떻게 해야 할까요?

매번 처음부터 구현해야 할까요? 다행히도 HuggingFace는 tokenizers라는 초고속 토크나이저 라이브러리를 제공합니다.

Rust로 작성되어 Python 구현보다 수십 배 빠르고, BPE, WordPiece, Unigram 등 다양한 알고리즘을 지원합니다. GPT-2, BERT, RoBERTa 등 유명 모델의 토크나이저도 바로 사용할 수 있습니다.

이번 섹션에서는 HuggingFace tokenizers로 실무 수준의 BPE 토크나이저를 몇 줄의 코드로 구현하는 방법을 배웁니다. 직접 구현으로 원리를 이해했으니, 이제 검증된 도구로 효율을 높일 차례입니다.

개요

간단히 말해서, HuggingFace tokenizers는 프로덕션급 토크나이저를 쉽고 빠르게 만들 수 있는 라이브러리입니다. 왜 이 라이브러리를 사용해야 할까요?

첫째, 성능이 뛰어납니다. Rust로 작성되어 대용량 데이터를 빠르게 처리합니다.

초당 수십만 토큰을 처리할 수 있어 실시간 서비스에 적합합니다. 둘째, 검증된 구현입니다.

수천 개의 프로젝트에서 사용되며 버그가 거의 없습니다. 셋째, 사전 학습된 토크나이저를 바로 로드할 수 있습니다.

GPT-2, BERT 등의 토크나이저를 한 줄로 불러올 수 있습니다. 직접 구현과 비교하면, 우리가 만든 Python 코드는 교육용으로는 좋지만 성능이 부족합니다.

병합 과정이 O(n²) 복잡도를 가져 대용량 데이터에서는 매우 느립니다. HuggingFace는 최적화된 알고리즘과 병렬 처리로 이 문제를 해결했습니다.

HuggingFace tokenizers의 핵심 구성 요소는 네 가지입니다. 첫째, Normalizer는 입력을 정규화합니다(소문자 변환, 유니코드 정규화 등).

둘째, Pre-tokenizer는 초기 분할을 수행합니다(공백 기준, 바이트 레벨 등). 셋째, Model은 실제 토큰화 알고리즘입니다(BPE, WordPiece 등).

넷째, Post-processor는 특수 토큰을 추가합니다(<BOS>, <EOS> 등). 이 컴포넌트들을 조합하여 원하는 토크나이저를 만듭니다.

코드 예제

# 먼저 설치: pip install tokenizers
from tokenizers import Tokenizer
from tokenizers.models import BPE
from tokenizers.trainers import BpeTrainer
from tokenizers.pre_tokenizers import Whitespace
from tokenizers.normalizers import Lowercase, NFD, StripAccents
from tokenizers.processors import TemplateProcessing

# 1. 빈 BPE 토크나이저 생성
tokenizer = Tokenizer(BPE(unk_token="<UNK>"))

# 2. 정규화: 소문자 변환 + 유니코드 정규화
tokenizer.normalizer = NFD()  # 유니코드 정규화

# 3. Pre-tokenization: 공백 기준 분할
tokenizer.pre_tokenizer = Whitespace()

# 4. 학습 설정
trainer = BpeTrainer(
    vocab_size=5000,  # 어휘 크기
    special_tokens=["<UNK>", "<BOS>", "<EOS>", "<PAD>"],
    show_progress=True
)

# 5. 학습 데이터로 토크나이저 학습
files = ["./sample_data.txt"]  # 학습할 텍스트 파일
# tokenizer.train(files, trainer)  # 실제 파일이 있을 때 실행

# 6. 사전 학습된 GPT-2 토크나이저 로드 (대안)
from transformers import AutoTokenizer
gpt2_tokenizer = AutoTokenizer.from_pretrained("gpt2")

# 7. 토큰화 수행
text = "Hello, how are you?"
output = gpt2_tokenizer.encode(text)
print(f"토큰 ID: {output}")
print(f"토큰 텍스트: {gpt2_tokenizer.convert_ids_to_tokens(output)}")

# 8. 배치 처리
texts = ["Hello world", "Machine learning", "Tokenization"]
batch_output = gpt2_tokenizer(texts, padding=True, truncation=True)
print(f"배치 결과: {batch_output}")

설명

이것이 하는 일: 위 코드는 HuggingFace tokenizers로 BPE 토크나이저를 만들고 사용하는 전체 과정을 보여줍니다. 첫 번째로, 빈 BPE 토크나이저 객체를 생성합니다.

BPE(unk_token="<UNK>")는 BPE 모델을 초기화하고, 미지의 토큰을 <UNK>로 설정합니다. 이것이 토크나이저의 핵심 엔진입니다.

두 번째로, 정규화 컴포넌트를 설정합니다. NFD()는 유니코드 정규화 형식 D를 적용하여, 예를 들어 "é"를 "e + ́"(기본 문자 + 결합 악센트)로 분해합니다.

이렇게 하면 다양한 형태의 문자를 일관되게 처리할 수 있습니다. 필요에 따라 Lowercase()를 추가하여 모든 텍스트를 소문자로 변환할 수도 있습니다.

세 번째로, pre-tokenizer를 설정합니다. Whitespace()는 공백을 기준으로 초기 분할을 수행합니다.

"Hello world"는 ["Hello", "world"]로 먼저 나뉘고, 각 단어가 BPE로 처리됩니다. 바이트 레벨 BPE를 원하면 ByteLevel()을 사용할 수 있습니다.

네 번째로, 학습 트레이너를 구성합니다. vocab_size=5000은 최종 어휘 크기를 5,000개로 제한합니다.

special_tokens는 예약할 특수 토큰 리스트입니다. show_progress=True는 학습 진행 상황을 표시합니다.

실제로는 tokenizer.train(files, trainer)를 호출하여 텍스트 파일에서 BPE를 학습합니다. 다섯 번째로, 사전 학습된 토크나이저를 로드하는 방법을 보여줍니다.

transformers 라이브러리의 AutoTokenizer.from_pretrained("gpt2")는 GPT-2의 토크나이저를 다운로드하고 로드합니다. 이렇게 하면 직접 학습 없이 검증된 토크나이저를 바로 사용할 수 있습니다.

여섯 번째로, 실제 토큰화를 수행합니다. encode()는 텍스트를 토큰 ID 리스트로 변환하고, convert_ids_to_tokens()는 ID를 다시 토큰 텍스트로 변환합니다.

이를 통해 어떻게 토큰화되었는지 확인할 수 있습니다. 마지막으로, 배치 처리 기능을 보여줍니다.

여러 텍스트를 한 번에 처리하고, padding=True로 자동 패딩을, truncation=True로 최대 길이 초과 시 자르기를 수행합니다. 반환된 batch_outputinput_idsattention_mask를 포함하여 바로 모델에 입력할 수 있습니다.

여러분이 이 라이브러리를 사용하면 몇 줄의 코드로 GPT나 BERT 수준의 토크나이저를 만들 수 있습니다. 원리를 이해한 상태에서 검증된 도구를 쓰면, 개발 속도와 품질을 모두 확보할 수 있습니다.

실전 팁

💡 학습 데이터는 서비스 도메인과 유사한 텍스트를 사용하세요. 기술 문서로 서비스한다면 기술 문서로 학습해야 압축률이 높습니다. Wikipedia 같은 범용 데이터는 일반적인 용도에 적합합니다.

💡 어휘 크기는 실험으로 결정하세요. 작은 데이터셋으로 5,000, 10,000, 30,000 등을 테스트하고, 다운스트림 태스크 성능을 비교하여 최적값을 찾으세요.

💡 save_pretrained()from_pretrained()를 활용하세요. 학습된 토크나이저를 디렉토리에 저장하고, 나중에 빠르게 로드할 수 있습니다. 이는 HuggingFace Hub에 업로드하여 공유할 수도 있습니다.

💡 add_special_tokens()로 커스텀 토큰을 추가할 수 있습니다. 예를 들어 <|USER|>, <|ASSISTANT|> 같은 역할 토큰을 추가하여 대화 구조를 명확히 할 수 있습니다.

💡 tokenizers의 공식 문서를 정독하세요(huggingface.co/docs/tokenizers). Pipeline 구조, 다양한 normalizer/pre-tokenizer 옵션, 커스터마이징 방법 등이 상세히 설명되어 있습니다.


#Python#BPE#Tokenizer#NLP#ChatGPT#ai

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.