🤖

본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.

⚠️

본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.

이미지 로딩 중...

바닥부터 만드는 ChatGPT 4편 - 특수 토큰 정의 및 어휘 구축 - 슬라이드 1/9
A

AI Generated

2025. 11. 11. · 15 Views

바닥부터 만드는 ChatGPT 4편 - 특수 토큰 정의 및 어휘 구축

ChatGPT와 같은 대규모 언어 모델의 핵심 구성요소인 특수 토큰과 어휘 구축 과정을 실습합니다. BPE 토크나이저 구현부터 특수 토큰 정의, 어휘 구축까지 실제 코드로 배워보세요.


목차

  1. 특수 토큰의 이해
  2. BOS와 EOS 토큰
  3. PAD 토큰
  4. UNK 토큰
  5. BPE 토크나이저 기초
  6. 어휘 사전 구축
  7. 토큰 인코딩 구현
  8. 토큰 디코딩 구현

1. 특수 토큰의 이해

시작하며

여러분이 챗봇을 만들다가 이런 상황을 겪어본 적 있나요? 사용자의 입력이 끝났는지, 아니면 아직 더 입력할 내용이 있는지 모델이 헷갈려하는 상황 말이죠.

또는 여러 개의 대화를 동시에 처리할 때 각 대화의 경계를 명확히 구분하지 못해서 엉뚱한 답변을 생성하는 경우도 있습니다. 이런 문제는 실제 AI 개발 현장에서 매우 자주 발생합니다.

언어 모델은 기본적으로 연속된 텍스트만 보기 때문에, 문장의 시작과 끝, 특별한 명령, 데이터의 구조 같은 메타 정보를 이해하지 못합니다. 이로 인해 학습이 제대로 되지 않거나, 추론 시 예상치 못한 결과가 나올 수 있죠.

바로 이럴 때 필요한 것이 특수 토큰(Special Tokens)입니다. 특수 토큰은 일반 단어가 아닌, 모델에게 특별한 의미를 전달하는 제어 신호로, 문장의 구조와 경계를 명확히 해줍니다.

개요

간단히 말해서, 특수 토큰은 언어 모델이 텍스트의 구조와 특별한 상황을 이해하도록 돕는 특별한 기호입니다. 일반 텍스트 토큰과 달리 특수 토큰은 의미를 전달하는 것이 아니라 제어 정보를 제공합니다.

예를 들어, 여러 문장을 하나의 배치로 학습시킬 때 각 문장의 길이가 다르면 처리가 어려운데, 이때 PAD 토큰으로 길이를 맞춰주면 효율적으로 학습할 수 있습니다. 또한 대화형 AI에서는 사용자 입력과 모델 응답의 경계를 구분해야 하는데, 이때도 특수 토큰이 필수적입니다.

전통적인 자연어 처리에서는 구두점이나 공백으로 문장을 구분했다면, 현대 언어 모델에서는 특수 토큰을 통해 훨씬 더 정교한 제어가 가능합니다. 특수 토큰의 핵심 특징은 다음과 같습니다: 첫째, 어휘 사전에서 고정된 ID를 가집니다.

둘째, 학습 중에는 일반 토큰처럼 임베딩을 학습하지만, 그 의미는 제어 목적입니다. 셋째, 절대 일반 텍스트에서 자연스럽게 나타나지 않도록 특별한 형식(예: <BOS>, [PAD])을 사용합니다.

이러한 특징들이 모델의 안정적인 학습과 추론을 보장합니다.

코드 예제

# 특수 토큰 정의 - 언어 모델의 제어 신호
SPECIAL_TOKENS = {
    '<PAD>': 0,    # 패딩: 배치 처리 시 길이 맞추기
    '<BOS>': 1,    # 문장 시작: Begin of Sequence
    '<EOS>': 2,    # 문장 종료: End of Sequence
    '<UNK>': 3,    # 미등록 단어: Unknown token
}

# 특수 토큰을 포함한 간단한 어휘 사전
vocab = SPECIAL_TOKENS.copy()
vocab.update({'hello': 4, 'world': 5, 'ai': 6})

# 텍스트 인코딩 예시
def encode_with_special_tokens(text, vocab):
    tokens = [vocab['<BOS>']]  # 문장 시작
    for word in text.lower().split():
        tokens.append(vocab.get(word, vocab['<UNK>']))  # 단어 또는 UNK
    tokens.append(vocab['<EOS>'])  # 문장 끝
    return tokens

# 실행 예시
text = "hello world"
encoded = encode_with_special_tokens(text, vocab)
print(f"원본: {text}")
print(f"인코딩: {encoded}")  # [1, 4, 5, 2]

설명

이것이 하는 일: 특수 토큰은 언어 모델에게 일반 텍스트 이외의 중요한 메타 정보를 전달합니다. 일반 단어처럼 어휘 사전에 포함되지만, 그 목적은 문장의 구조와 처리 방법을 알려주는 것입니다.

첫 번째로, SPECIAL_TOKENS 딕셔너리는 각 특수 토큰에 고유한 정수 ID를 할당합니다. PAD는 0번, BOS는 1번처럼 낮은 번호를 사용하는 것이 관례인데, 이는 나중에 어텐션 마스크를 만들거나 특수 토큰을 빠르게 식별할 때 유리하기 때문입니다.

이렇게 명시적으로 정의하면 코드의 가독성이 높아지고, 나중에 특수 토큰을 추가하거나 수정하기도 쉽습니다. 두 번째로, encode_with_special_tokens 함수는 일반 텍스트를 토큰 ID 시퀀스로 변환하면서 자동으로 특수 토큰을 추가합니다.

문장 시작에 BOS를 붙이고, 각 단어를 어휘 사전에서 찾되 없으면 UNK로 처리하고, 마지막에 EOS를 붙입니다. 이렇게 하면 모델은 "이제 문장이 시작되는구나", "여기서 문장이 끝나는구나"를 명확히 알 수 있습니다.

세 번째로, 실제 인코딩 결과를 보면 [1, 4, 5, 2]가 나오는데, 이는 [<BOS>, hello, world, <EOS>]를 숫자로 표현한 것입니다. 이 숫자 시퀀스가 모델의 입력이 되며, 각 숫자는 임베딩 테이블에서 해당하는 벡터를 가져오는 인덱스로 사용됩니다.

특수 토큰도 일반 토큰처럼 학습 가능한 임베딩을 가지지만, 학습 데이터를 통해 "문장의 시작/끝"이라는 특별한 의미를 학습하게 됩니다. 여러분이 이 코드를 사용하면 모델 학습이 훨씬 안정적이고 효과적이 됩니다.

특히 다양한 길이의 문장을 처리할 때, 대화 시스템을 만들 때, 또는 특정 작업(번역, 요약 등)의 시작과 끝을 명시해야 할 때 필수적입니다. 또한 디버깅할 때도 인코딩된 시퀀스를 보고 특수 토큰이 제대로 들어갔는지 쉽게 확인할 수 있어 개발 효율이 높아집니다.

실전 팁

💡 특수 토큰은 절대 일반 텍스트에서 자연스럽게 나타나지 않는 형식을 사용하세요. <PAD>, [BOS], ##UNK## 같은 형식이 안전합니다. 만약 END같은 일반 단어를 특수 토큰으로 쓰면 텍스트에 "END"가 나올 때 혼란이 발생할 수 있습니다.

💡 특수 토큰의 ID는 어휘 사전에서 가장 앞부분(0~10번대)에 배치하는 것이 좋습니다. 이렇게 하면 나중에 "ID가 4보다 작으면 특수 토큰"처럼 빠른 체크가 가능하고, 디버깅할 때도 한눈에 알아보기 쉽습니다.

💡 모든 입력에 일관되게 특수 토큰을 적용하세요. 학습 데이터에는 BOS/EOS를 붙였는데 추론 시에는 안 붙이면 모델 성능이 크게 떨어집니다. 전처리 함수를 만들어서 항상 동일한 방식으로 처리하는 것이 중요합니다.

💡 특수 토큰도 임베딩을 학습하므로, 학습 초기에 특수 토큰 임베딩을 작은 값으로 초기화하거나 zero로 시작하는 것도 고려해보세요. 일부 연구에서는 이렇게 하면 초기 학습이 더 안정적이라고 보고합니다.

💡 프로젝트 초기에 필요한 모든 특수 토큰을 정의해두세요. 나중에 특수 토큰을 추가하면 기존 모델의 어휘 사전과 맞지 않아 재학습이 필요할 수 있습니다. SEP(구분자), MASK(마스킹), CLS(분류) 등 작업에 따라 필요한 토큰을 미리 계획하세요.


2. BOS와 EOS 토큰

시작하며

여러분이 언어 모델을 학습시킬 때 이런 문제를 겪어본 적 있나요? 모델이 문장을 생성하다가 언제 멈춰야 할지 몰라서 계속 무한히 단어를 생성하거나, 여러 문장을 이어 붙여서 이상한 결과를 만드는 경우 말이죠.

이런 문제는 시퀀스 투 시퀀스 모델이나 텍스트 생성 모델에서 매우 흔합니다. 모델이 입력의 시작과 끝을 명확히 인식하지 못하면, 학습 시 문맥을 제대로 파악하지 못하고, 생성 시에는 종료 시점을 판단하지 못합니다.

특히 여러 문장을 연속으로 학습시킬 때 문장 간 경계가 모호해지면 학습 품질이 크게 떨어집니다. 바로 이럴 때 필요한 것이 BOS(Begin of Sequence)와 EOS(End of Sequence) 토큰입니다.

이 두 토큰은 모델에게 "여기서 시작해"와 "여기서 끝내"라는 명확한 신호를 보내서, 학습과 생성 모두를 안정적으로 만들어줍니다.

개요

간단히 말해서, BOS는 시퀀스의 시작을 알리는 토큰이고, EOS는 시퀀스의 종료를 알리는 토큰입니다. 이 두 토큰은 특히 자동 회귀(autoregressive) 방식의 텍스트 생성에서 핵심적입니다.

예를 들어, 기계 번역 모델에서 번역문 생성을 시작할 때 BOS 토큰을 입력으로 주고, 모델이 EOS를 생성하면 번역을 종료합니다. 이렇게 명확한 시작과 종료 신호가 없으면, 모델은 언제 생성을 시작하고 멈춰야 할지 알 수 없습니다.

기존의 규칙 기반 시스템에서는 마침표나 줄바꿈으로 문장을 구분했다면, 딥러닝 모델에서는 BOS/EOS 토큰으로 더 명확하고 학습 가능한 경계를 만듭니다. 핵심 특징은: 첫째, BOS는 디코더의 첫 입력으로 사용되어 생성을 시작합니다.

둘째, EOS는 생성 종료의 신호이자, 손실 계산에도 포함되어 모델이 적절한 시점에 종료하도록 학습됩니다. 셋째, 학습 시 모든 시퀀스에 일관되게 적용되어야 추론 시에도 올바르게 작동합니다.

이러한 특징들이 생성 모델의 품질과 안정성을 보장합니다.

코드 예제

# BOS/EOS 토큰을 사용한 시퀀스 생성 시뮬레이션
class SimpleGenerator:
    def __init__(self, vocab):
        self.vocab = vocab
        self.id_to_token = {v: k for k, v in vocab.items()}

    def generate(self, max_length=10):
        # BOS 토큰으로 시작
        sequence = [self.vocab['<BOS>']]

        # 실제로는 모델이 다음 토큰을 예측하지만, 여기서는 시뮬레이션
        # 예시: "hello ai world" 생성
        generated_ids = [self.vocab['hello'], self.vocab['ai'],
                        self.vocab['world'], self.vocab['<EOS>']]

        for token_id in generated_ids:
            sequence.append(token_id)
            # EOS를 만나면 생성 중단
            if token_id == self.vocab['<EOS>']:
                break
            # 최대 길이 체크
            if len(sequence) >= max_length:
                sequence.append(self.vocab['<EOS>'])  # 강제 종료
                break

        return sequence

    def decode(self, sequence):
        return [self.id_to_token[id] for id in sequence]

# 실행
generator = SimpleGenerator(vocab)
generated = generator.generate()
decoded = generator.decode(generated)
print(f"생성된 시퀀스: {decoded}")
# 출력: ['<BOS>', 'hello', 'ai', 'world', '<EOS>']

설명

이것이 하는 일: BOS와 EOS 토큰은 시퀀스의 명확한 경계를 정의하여, 모델이 생성 작업의 시작과 종료 시점을 학습하고 판단할 수 있게 합니다. 첫 번째로, SimpleGenerator 클래스의 generate 메서드는 항상 BOS 토큰으로 시퀀스를 시작합니다.

실제 GPT나 T5 같은 모델에서는 BOS가 디코더의 첫 입력이 되고, 모델은 이 토큰을 보고 "지금부터 텍스트를 생성해야 하는구나"를 인식합니다. 디코더는 BOS의 임베딩을 읽고, 첫 번째 실제 단어(여기서는 'hello')를 예측하는 것으로 생성을 시작하죠.

두 번째로, 생성 루프 안에서 EOS 토큰을 만나면 즉시 break로 생성을 중단합니다. 이것이 핵심입니다.

학습 시 모델은 문장이 끝나는 위치에서 EOS를 예측하도록 학습되고, 추론 시에는 모델이 EOS를 생성하는 순간 "이제 할 말이 끝났다"는 의미로 해석하여 생성을 멈춥니다. 만약 EOS가 없다면 max_length에 도달할 때까지 계속 생성하게 되는데, 이는 비효율적이고 의미 없는 반복을 만들 수 있습니다.

세 번째로, max_length 체크는 안전장치입니다. 만약 모델이 EOS를 생성하지 못하거나 학습이 덜 된 상태라면, 무한 루프를 방지하기 위해 최대 길이에서 강제로 EOS를 붙이고 종료합니다.

실제 프로덕션 시스템에서는 이런 안전장치가 필수적이며, 보통 최대 길이는 작업에 따라 512, 1024, 2048 같은 값을 설정합니다. 네 번째로, 디코딩된 결과를 보면 ['<BOS>', 'hello', 'ai', 'world', '<EOS>']처럼 특수 토큰이 그대로 보입니다.

실제 사용자에게 보여줄 때는 BOS와 EOS를 제거하고 일반 텍스트만 반환하는 후처리가 필요합니다. 하지만 디버깅이나 로깅 시에는 이렇게 특수 토큰을 포함한 전체 시퀀스를 보는 것이 모델의 동작을 이해하는 데 도움이 됩니다.

여러분이 이 패턴을 사용하면 텍스트 생성 모델을 훨씬 안정적으로 만들 수 있습니다. 특히 대화 시스템, 기계 번역, 텍스트 요약 같은 시퀀스 투 시퀀스 작업에서 필수적입니다.

또한 빔 서치나 샘플링 같은 고급 디코딩 전략을 사용할 때도 EOS 토큰을 기준으로 후보를 평가하고 선택하므로, 올바른 BOS/EOS 처리가 생성 품질을 크게 좌우합니다.

실전 팁

💡 학습 데이터에 BOS/EOS를 추가할 때는 반드시 일관성을 유지하세요. 예를 들어 인코더-디코더 모델에서 인코더 입력에는 BOS 없이 EOS만, 디코더 입력/출력에는 BOS와 EOS 모두 사용하는 식으로 명확한 규칙을 정하고 지키세요.

💡 배치 생성 시 각 시퀀스가 서로 다른 시점에 EOS를 생성할 수 있습니다. 이때 EOS 이후의 토큰은 무시하도록 마스크를 적용해야 손실 계산이 정확합니다. 보통 attention_maskloss_mask를 사용해서 EOS 이후는 손실에 포함하지 않습니다.

💡 생성 품질을 높이려면 EOS 생성 확률에 페널티나 보너스를 줄 수 있습니다. 예를 들어 너무 짧은 문장을 방지하려면 일정 길이 전까지는 EOS 확률을 낮추고, 반대로 너무 긴 문장을 방지하려면 일정 길이 후에는 EOS 확률을 높이는 전략을 쓸 수 있습니다.

💡 BOS 토큰의 임베딩은 생성의 첫 컨텍스트가 되므로, 작업별로 다른 BOS를 사용하는 것도 효과적입니다. 예를 들어 번역용 <BOS_TRANS>, 요약용 <BOS_SUM>처럼 구분하면 모델이 작업을 더 잘 인식할 수 있습니다.

💡 실전에서는 EOS를 여러 개 연속으로 생성하는 경우를 대비해야 합니다. 첫 번째 EOS에서 종료하는 것이 일반적이지만, 특정 작업(예: 코드 생성)에서는 여러 EOS가 나올 수 있으므로 종료 조건을 명확히 정의하세요.


3. PAD 토큰

시작하며

여러분이 딥러닝 모델을 학습시킬 때 이런 고민을 해본 적 있나요? 배치로 여러 문장을 동시에 처리하고 싶은데, 문장마다 길이가 달라서 텐서로 만들 수가 없는 상황 말이죠.

GPU를 효율적으로 사용하려면 배치 처리가 필수인데, 가변 길이 시퀀스는 그대로는 배치로 묶을 수 없습니다. 이런 문제는 모든 시퀀스 모델 개발에서 반드시 마주치는 도전입니다.

문장 "Hello"는 1개 토큰, "Hello world"는 2개 토큰인데, 이 둘을 하나의 텐서로 만들 수는 없죠. 그렇다고 모든 문장을 하나씩 처리하면 학습 속도가 너무 느려서 실용적이지 않습니다.

특히 BERT나 GPT처럼 대규모 모델을 학습시킬 때는 배치 사이즈가 클수록 학습이 안정적이고 빠릅니다. 바로 이럴 때 필요한 것이 PAD(Padding) 토큰입니다.

PAD 토큰으로 짧은 시퀀스를 채워서 모든 시퀀스를 같은 길이로 맞추면, 효율적인 배치 처리가 가능해집니다.

개요

간단히 말해서, PAD 토큰은 짧은 시퀀스를 긴 시퀀스와 같은 길이로 맞추기 위해 추가하는 더미(dummy) 토큰입니다. 배치 처리는 딥러닝의 핵심 효율화 기법입니다.

예를 들어, 길이가 5인 문장 3개와 길이가 10인 문장 1개를 함께 처리하려면, 모든 문장을 길이 10으로 맞춰야 합니다. 이때 짧은 문장들의 끝에 PAD 토큰 5개를 추가하는 거죠.

GPU는 이렇게 통일된 크기의 텐서를 병렬로 매우 빠르게 처리할 수 있습니다. 전통적인 RNN에서는 가변 길이를 어느 정도 처리할 수 있었지만 배치 효율이 낮았고, 현대의 Transformer 모델은 고정 크기 입력을 요구하므로 PAD 토큰이 필수적입니다.

PAD 토큰의 핵심 특징은: 첫째, 의미가 없는 토큰이므로 모델이 이를 무시하도록 어텐션 마스크를 사용합니다. 둘째, 보통 어휘 사전의 0번 인덱스에 배치하여 쉽게 식별 가능하게 합니다.

셋째, 손실 계산 시 PAD 위치는 제외하여 학습에 영향을 주지 않도록 합니다. 이러한 메커니즘들이 PAD 토큰을 효과적인 배치 처리 도구로 만듭니다.

코드 예제

# PAD 토큰을 사용한 배치 생성
import numpy as np

def create_padded_batch(texts, vocab, max_length=None):
    """텍스트 리스트를 PAD 토큰으로 채워진 배치로 변환"""
    # 각 텍스트를 토큰 ID로 변환
    encoded = []
    for text in texts:
        tokens = [vocab['<BOS>']]
        tokens.extend([vocab.get(word, vocab['<UNK>'])
                      for word in text.lower().split()])
        tokens.append(vocab['<EOS>'])
        encoded.append(tokens)

    # 최대 길이 결정 (배치 내 가장 긴 시퀀스)
    if max_length is None:
        max_length = max(len(seq) for seq in encoded)

    # PAD 토큰으로 패딩하여 동일 길이로 만들기
    padded_batch = []
    attention_masks = []
    for seq in encoded:
        # 어텐션 마스크: 실제 토큰은 1, PAD는 0
        mask = [1] * len(seq) + [0] * (max_length - len(seq))
        # PAD 토큰으로 채우기
        padded = seq + [vocab['<PAD>']] * (max_length - len(seq))
        padded_batch.append(padded[:max_length])  # 최대 길이로 자르기
        attention_masks.append(mask[:max_length])

    return np.array(padded_batch), np.array(attention_masks)

# 실행 예시
texts = ["hello world", "ai", "hello ai world"]
batch, masks = create_padded_batch(texts, vocab)
print("배치 텐서:\n", batch)
print("\n어텐션 마스크:\n", masks)

설명

이것이 하는 일: PAD 토큰은 서로 다른 길이의 시퀀스를 동일한 크기의 텐서로 만들어서, GPU에서 효율적인 병렬 처리를 가능하게 합니다. 첫 번째로, create_padded_batch 함수는 먼저 각 텍스트를 토큰 ID로 인코딩합니다.

이 과정에서 각 시퀀스는 BOS로 시작하고 EOS로 끝나지만, 길이는 제각각입니다. 예를 들어 "ai"는 3개 토큰(BOS + ai + EOS), "hello world"는 4개 토큰이 됩니다.

이 상태로는 numpy 배열이나 PyTorch 텐서로 만들 수 없죠. 두 번째로, 배치 내에서 가장 긴 시퀀스의 길이를 찾아서 max_length로 설정합니다.

실전에서는 고정된 max_length(예: 512)를 설정하는 경우도 많은데, 이는 메모리 사용량을 예측 가능하게 만들고, 추론 시 일관된 입력 크기를 보장합니다. 너무 긴 시퀀스는 잘라내고(truncation), 짧은 시퀀스는 패딩합니다.

세 번째로, 각 시퀀스에 대해 어텐션 마스크를 생성합니다. 이것이 매우 중요한데, 마스크는 실제 토큰 위치는 1, PAD 위치는 0으로 표시합니다.

나중에 모델의 어텐션 메커니즘에서 이 마스크를 사용하여 PAD 위치는 어텐션 계산에서 제외합니다. 만약 마스크 없이 PAD를 그냥 처리하면, 모델이 의미 없는 PAD 토큰에도 주의를 기울여서 성능이 떨어집니다.

네 번째로, 실제 패딩 작업은 seq + [vocab['<PAD>']] * (max_length - len(seq))로 이루어집니다. 부족한 길이만큼 PAD 토큰 ID(0)를 반복해서 붙입니다.

결과적으로 모든 시퀀스가 동일한 길이를 가지게 되어, np.array()로 2D 배열을 만들 수 있습니다. 이 배열의 shape은 (batch_size, max_length)가 됩니다.

여러분이 이 기법을 사용하면 학습 속도가 극적으로 빨라집니다. 예를 들어, 배치 사이즈 32로 학습하는 것이 하나씩 32번 학습하는 것보다 GPU에서 10배 이상 빠를 수 있습니다.

또한 배치 단위로 그래디언트를 누적하므로 학습이 더 안정적입니다. 다만 PAD가 많으면 계산 낭비가 발생하므로, 실전에서는 비슷한 길이의 시퀀스끼리 묶는 동적 배치(dynamic batching) 전략도 함께 사용합니다.

실전 팁

💡 PAD 토큰은 항상 어휘 사전의 0번 인덱스로 설정하는 것이 관례입니다. 많은 딥러닝 프레임워크가 기본값으로 0을 PAD로 인식하고, 마스크 생성이나 손실 계산에서 자동으로 처리해주기 때문입니다.

💡 손실 함수에서 PAD 위치를 반드시 제외하세요. PyTorch의 CrossEntropyLossignore_index=0 파라미터로 PAD를 무시할 수 있습니다. 그렇지 않으면 모델이 PAD를 예측하는 것을 학습해서 성능이 떨어집니다.

💡 너무 긴 시퀀스에 맞춰서 패딩하면 메모리와 계산이 낭비됩니다. 배치 내 시퀀스를 길이 순으로 정렬하거나, 비슷한 길이끼리 묶으면 평균 패딩량을 줄일 수 있습니다. Hugging Face의 DataCollator가 이런 기능을 제공합니다.

💡 Transformer의 어텐션 마스크는 매우 중요합니다. 셀프 어텐션에서 PAD 위치에 대한 어텐션 스코어를 -inf로 설정하면, 소프트맥스 후 확률이 0이 되어 완전히 무시됩니다. 이 패턴을 꼭 구현하세요.

💡 추론 시에는 배치 사이즈가 1일 때도 있는데, 이때도 모델의 입력 형식을 일관되게 유지하기 위해 패딩을 적용하세요. 학습과 추론의 입력 형식이 다르면 예상치 못한 버그가 발생할 수 있습니다.


4. UNK 토큰

시작하며

여러분이 텍스트 모델을 실제 서비스에 배포했을 때 이런 일을 겪어본 적 있나요? 학습 데이터에 없던 새로운 단어나 오타, 신조어가 입력으로 들어와서 모델이 에러를 내거나 이상한 결과를 반환하는 상황 말이죠.

예를 들어 "ChatGPT4.5"처럼 최신 용어나, "안뇽하세용" 같은 비표준 표현이 들어오면 어떻게 처리해야 할까요? 이런 문제는 현실 세계 배포에서 필연적으로 발생합니다.

아무리 큰 학습 데이터를 사용해도 모든 단어를 커버할 수는 없습니다. 언어는 계속 진화하고, 사용자는 창의적인 표현을 쓰고, 오타나 외국어도 섞여 들어옵니다.

모델이 처음 보는 단어를 만날 때마다 에러를 내면 서비스로서 전혀 쓸모가 없겠죠. 바로 이럴 때 필요한 것이 UNK(Unknown) 토큰입니다.

UNK 토큰은 어휘 사전에 없는 모든 단어의 대체자 역할을 하며, 모델이 미등록 단어에도 견고하게 대응할 수 있게 해줍니다.

개요

간단히 말해서, UNK 토큰은 어휘 사전에 없는 모든 단어를 대표하는 폴백(fallback) 토큰입니다. 어휘 사전의 크기는 메모리와 계산량 때문에 제한됩니다.

예를 들어 30,000개 단어로 제한하면, 임베딩 테이블 크기는 30,000 x embedding_dim이 됩니다. 이보다 작은 어휘로도 대부분의 텍스트를 커버할 수 있지만, 반드시 일부 희귀 단어나 미등록 단어가 나타납니다.

이때 UNK 토큰으로 대체하면 모델은 중단되지 않고 계속 처리할 수 있습니다. 전통적인 NLP에서는 OOV(Out-Of-Vocabulary) 문제가 심각했지만, 현대에는 BPE나 WordPiece 같은 서브워드 토크나이저 덕분에 UNK가 줄어들었습니다.

하지만 완전히 사라지지는 않으므로 여전히 UNK 처리가 필요합니다. UNK 토큰의 핵심 특징은: 첫째, 모든 미등록 단어에 대한 통합된 표현을 제공합니다.

둘째, 모델은 UNK의 임베딩을 학습하여, "알 수 없는 단어"라는 개념 자체를 이해하게 됩니다. 셋째, UNK가 너무 많으면 모델 성능이 떨어지므로, 어휘 크기와 UNK 비율 사이의 균형을 잡는 것이 중요합니다.

이러한 메커니즘이 모델의 견고성을 보장합니다.

코드 예제

# UNK 토큰을 사용한 견고한 인코딩
def robust_encode(text, vocab, bos_eos=True):
    """UNK 토큰을 활용한 안전한 텍스트 인코딩"""
    tokens = []

    # BOS 추가
    if bos_eos:
        tokens.append(vocab['<BOS>'])

    # 각 단어를 처리
    words = text.lower().split()
    unk_words = []  # 디버깅용: UNK로 처리된 단어 추적

    for word in words:
        if word in vocab:
            tokens.append(vocab[word])
        else:
            # 어휘에 없는 단어는 UNK로 처리
            tokens.append(vocab['<UNK>'])
            unk_words.append(word)

    # EOS 추가
    if bos_eos:
        tokens.append(vocab['<EOS>'])

    # 통계 정보
    unk_ratio = len(unk_words) / len(words) if words else 0

    return {
        'tokens': tokens,
        'unk_words': unk_words,
        'unk_ratio': unk_ratio
    }

# 실행 예시: 신조어와 오타 포함
text = "hello xyz123 wrld ai"  # 'xyz123'과 'wrld'는 어휘에 없음
result = robust_encode(text, vocab)

print(f"원문: {text}")
print(f"토큰 ID: {result['tokens']}")
print(f"UNK 처리된 단어: {result['unk_words']}")
print(f"UNK 비율: {result['unk_ratio']:.1%}")
# 출력 예: UNK 처리된 단어: ['xyz123', 'wrld'], UNK 비율: 50.0%

설명

이것이 하는 일: UNK 토큰은 어휘 사전에 등록되지 않은 모든 단어를 하나의 통일된 토큰으로 대체하여, 모델이 예기치 않은 입력에도 안정적으로 작동하도록 합니다. 첫 번째로, robust_encode 함수는 각 단어를 처리할 때 vocab.get(word, vocab['<UNK>'])패턴을 사용합니다.

이는 파이썬의 딕셔너리 get 메서드로, 단어가 어휘에 있으면 해당 ID를 반환하고, 없으면 기본값인 UNK 토큰 ID를 반환합니다. 이렇게 하면 KeyError 같은 예외가 발생하지 않고, 모든 입력을 안전하게 처리할 수 있습니다.

두 번째로, 코드는 어떤 단어가 UNK로 처리되었는지 추적합니다. 이는 디버깅과 모델 개선에 매우 유용합니다.

예를 들어, 서비스 로그를 분석해서 UNK 비율이 높은 입력을 찾으면, 그 단어들을 어휘에 추가하거나, 사용자가 자주 쓰는 신조어를 파악할 수 있습니다. 실전에서는 이런 모니터링이 모델 품질 관리의 핵심입니다.

세 번째로, UNK 비율을 계산합니다. 예제에서 4개 단어 중 2개가 UNK로 처리되어 50%가 나왔는데, 이렇게 높은 비율은 문제가 있다는 신호입니다.

일반적으로 잘 구성된 어휘 사전이라면 UNK 비율은 1-5% 이하여야 합니다. 만약 UNK가 너무 많으면, 모델은 문맥의 많은 부분을 "알 수 없음"으로 보게 되어 정확한 예측을 하기 어렵습니다.

네 번째로, UNK 토큰의 임베딩은 학습을 통해 업데이트됩니다. 흥미로운 점은, 모델이 학습하면서 UNK 임베딩이 "일반적인 희귀 단어"의 평균적인 의미를 나타내게 된다는 것입니다.

예를 들어, 희귀한 명사들이 주로 UNK로 처리되었다면, UNK 임베딩은 "알 수 없는 명사" 같은 역할을 하게 됩니다. 이는 완벽하지 않지만, 전혀 처리하지 못하는 것보다는 훨씬 낫습니다.

여러분이 이 패턴을 사용하면 프로덕션 환경에서 훨씬 안정적인 시스템을 만들 수 있습니다. 특히 사용자 입력을 받는 챗봇, 검색 엔진, 번역 서비스에서는 UNK 처리가 필수입니다.

또한 A/B 테스트를 통해 어휘 크기와 모델 성능의 트레이드오프를 분석하고, 최적의 어휘 크기를 결정하는 데도 UNK 통계가 유용합니다.

실전 팁

💡 UNK 비율을 정기적으로 모니터링하세요. 프로덕션 로그를 분석해서 UNK 비율이 갑자기 증가하면, 새로운 트렌드나 사용자 패턴 변화를 나타낼 수 있습니다. 이를 기반으로 어휘를 업데이트하거나 모델을 재학습할 수 있습니다.

💡 BPE나 WordPiece 같은 서브워드 토크나이저를 사용하면 UNK를 크게 줄일 수 있습니다. 예를 들어 "ChatGPT4.5"를 ["Chat", "GPT", "4", ".", "5"]로 쪼개면, 각 부분이 어휘에 있을 가능성이 높아집니다. GPT와 BERT가 이 방식을 사용합니다.

💡 어휘 사전을 구축할 때, 빈도 기반으로 상위 N개 단어를 선택하되, 도메인 특화 단어는 빈도가 낮아도 포함하세요. 예를 들어 의료 AI라면 "헤모글로빈" 같은 전문 용어는 꼭 포함해야 UNK 비율을 낮출 수 있습니다.

💡 일부 고급 모델은 여러 개의 UNK 토큰을 사용합니다. 예를 들어 <UNK_NOUN>, <UNK_VERB> 처럼 품사별로 구분하면, 모델이 미등록 단어의 문법적 역할을 더 잘 이해할 수 있습니다. 다만 구현이 복잡해지므로 필요성을 잘 판단하세요.

💡 사용자에게 결과를 보여줄 때, UNK가 많았던 입력에 대해서는 "일부 단어를 인식하지 못했습니다"같은 경고를 주는 것이 좋습니다. 이렇게 하면 사용자가 결과의 신뢰도를 판단할 수 있고, 필요하면 입력을 수정할 수 있습니다.


5. BPE 토크나이저 기초

시작하며

여러분이 언어 모델을 만들 때 이런 딜레마에 빠진 적 있나요? 단어 단위로 토큰화하면 "running", "runs", "run"을 전부 다른 단어로 취급해서 어휘가 너무 커지고, 문자 단위로 하면 시퀀스가 너무 길어져서 학습이 비효율적이고, 또 새로운 단어는 UNK로 처리되어 버리는 문제 말이죠.

이런 문제는 전통적인 토큰화 방법의 근본적인 한계입니다. 단어 단위는 어휘 폭발 문제와 OOV 문제가 있고, 문자 단위는 시퀀스가 너무 길어서 Transformer의 계산 복잡도가 폭발합니다.

예를 들어 "unbelievable"이라는 단어를 문자 단위로 하면 12개 토큰이 되지만, 의미 단위로는 하나의 개념입니다. 이 중간 지점을 찾는 것이 핵심입니다.

바로 이럴 때 필요한 것이 BPE(Byte Pair Encoding) 토크나이저입니다. BPE는 데이터 기반으로 자주 나타나는 문자 조합을 병합하여, 단어와 문자 사이의 최적 지점인 서브워드(subword)를 찾아냅니다.

개요

간단히 말해서, BPE는 자주 함께 나타나는 문자나 문자 조합을 반복적으로 병합하여, 최적의 서브워드 단위를 학습하는 토큰화 알고리즘입니다. BPE는 원래 데이터 압축 알고리즘이었지만, 2016년 NLP에 도입되면서 혁명을 일으켰습니다.

핵심 아이디어는 간단합니다: 말뭉치에서 가장 빈번한 문자 쌍을 찾아서 하나로 합치고, 이를 반복하면서 점차 큰 단위를 만들어갑니다. 예를 들어, "low", "lowest", "lower"가 많으면 "l"+"o" → "lo", "lo"+"w" → "low"처럼 병합이 일어납니다.

결과적으로 "low"는 하나의 토큰이 되고, "lowest"는 ["low", "est"]로 분할됩니다. 전통적인 형태소 분석이나 스테밍은 언어별 규칙이 필요했다면, BPE는 데이터만 있으면 어떤 언어에도 적용 가능한 비지도 학습 방식입니다.

BPE의 핵심 특징은: 첫째, 어휘 크기를 원하는 대로 제어할 수 있습니다(병합 횟수로 조절). 둘째, 희귀 단어도 서브워드로 쪼개서 표현하므로 UNK가 거의 없습니다.

셋째, "preprocessing"이 ["pre", "process", "ing"]처럼 의미 단위로 분할되어, 모델이 형태론적 패턴을 학습하기 쉽습니다. 이러한 장점들이 GPT, BART, RoBERTa 같은 최신 모델들이 BPE를 사용하는 이유입니다.

코드 예제

# 간단한 BPE 구현 (학습 과정)
from collections import Counter
import re

def get_vocab(corpus):
    """말뭉치에서 초기 어휘(문자 단위) 추출"""
    vocab = Counter()
    for word in corpus:
        # 단어를 문자로 분리, 끝에 </w> 추가 (단어 경계 표시)
        chars = ' '.join(list(word)) + ' </w>'
        vocab[chars] += 1
    return vocab

def get_most_frequent_pair(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.most_common(1)[0][0] if pairs else None

def merge_vocab(pair, vocab):
    """선택된 쌍을 병합하여 어휘 업데이트"""
    new_vocab = {}
    bigram = ' '.join(pair)
    replacement = ''.join(pair)
    for word in vocab:
        new_word = word.replace(bigram, replacement)
        new_vocab[new_word] = vocab[word]
    return new_vocab

# 실행: BPE 학습
corpus = ['low', 'low', 'low', 'lowest', 'lowest', 'lower']
vocab = get_vocab(corpus)
print("초기 어휘:", vocab)

# 5번 병합 수행
num_merges = 5
for i in range(num_merges):
    pair = get_most_frequent_pair(vocab)
    if pair is None:
        break
    print(f"\n병합 {i+1}: {pair}")
    vocab = merge_vocab(pair, vocab)
    print("업데이트된 어휘:", vocab)

설명

이것이 하는 일: BPE는 말뭉치의 통계를 기반으로 최적의 서브워드 단위를 자동으로 찾아내며, 단어 단위와 문자 단위의 장점을 결합합니다. 첫 번째로, get_vocab 함수는 각 단어를 문자 단위로 쪼개고 끝에 </w> 마커를 붙입니다.

이 마커는 단어의 경계를 표시하는데, "low"와 "low"가 더 큰 단어의 일부인지 독립적인 단어인지 구분하는 데 중요합니다. 예를 들어 "lowly"에서 "low"는 단어 끝이 아니므로 </w>가 없지만, 독립된 "low"는 </w>를 가집니다.

이렇게 하면 BPE가 단어 경계를 고려한 더 정확한 서브워드를 학습합니다. 두 번째로, get_most_frequent_pair 함수는 현재 어휘에서 가장 자주 인접한 문자 쌍을 찾습니다.

예를 들어 초기에는 "l o w </w>"가 3번 나타나므로, ("l", "o")쌍의 빈도가 높을 것입니다. 이 빈도는 단순히 쌍이 나타나는 횟수가 아니라, 그 단어의 빈도로 가중치가 부여됩니다.

"low"가 3번, "lowest"가 2번 나타나면 ("l", "o")의 빈도는 5가 됩니다. 이렇게 하면 실제 사용 빈도를 반영한 병합이 이루어집니다.

세 번째로, merge_vocab 함수는 선택된 쌍을 하나로 합칩니다. 예를 들어 ("l", "o")가 선택되면, "l o"를 "lo"로 교체합니다.

이 과정을 거치면서 "l o w </w>"는 "lo w </w>"가 되고, 다음 반복에서는 ("lo", "w")가 병합되어 "low </w>"가 될 수 있습니다. 이렇게 반복하면서 점차 큰 단위의 서브워드가 형성됩니다.

네 번째로, num_merges 파라미터로 최종 어휘 크기를 제어합니다. 병합을 5번 하면 원래 문자 개수 + 5개의 서브워드가 어휘에 들어갑니다.

실제로는 수천~수만 번의 병합을 수행하여 원하는 어휘 크기(예: 32,000)에 도달합니다. GPT-2는 50,257개 어휘를 사용하는데, 이는 BPE 병합 결과입니다.

여러분이 BPE를 사용하면 모델이 미등록 단어도 잘 처리할 수 있게 됩니다. 예를 들어 "unbelievable"이 학습 데이터에 없어도, "un", "believe", "able" 같은 서브워드로 쪼개서 표현할 수 있습니다.

또한 형태론적 패턴(접두사, 접미사)을 자동으로 학습하므로, 모델이 언어의 구조를 더 잘 이해하게 됩니다. 이는 특히 번역이나 텍스트 생성 작업에서 품질 향상으로 이어집니다.

실전 팁

💡 실전에서는 Hugging Face의 tokenizers 라이브러리를 사용하세요. 위 코드는 교육용이고, 실제 BPE 구현은 성능 최적화와 엣지 케이스 처리가 필요합니다. tokenizers는 Rust로 작성되어 매우 빠르고, GPT/BERT와 호환됩니다.

💡 BPE 어휘를 학습할 때는 충분히 큰 말뭉치(최소 수백만 문장)를 사용하세요. 작은 데이터로 학습하면 편향된 서브워드가 만들어져서, 새로운 도메인에서 성능이 떨어질 수 있습니다. 도메인이 특수하다면 도메인 데이터와 일반 데이터를 혼합하세요.

💡 어휘 크기는 작업과 데이터에 따라 조정하세요. 일반적으로 30,000~50,000이 많이 쓰이는데, 작으면 시퀀스가 길어지고 크면 메모리가 늘어납니다. A/B 테스트로 최적값을 찾는 것이 좋습니다.

💡 BPE의 변형인 WordPiece(BERT)나 Unigram(T5)도 고려해보세요. WordPiece는 likelihood 기반으로 병합하고, Unigram은 확률 모델을 사용합니다. 각각 장단점이 있으며, 일반적으로 성능 차이는 크지 않습니다.

💡 서브워드 경계를 표시하는 방법은 라이브러리마다 다릅니다. GPT는 띄어쓰기로 시작하는 토큰에 "Ġ"(유니코드 공백)를 붙이고, BERT는 "##"를 붙입니다. 일관성을 위해 프로젝트 전체에서 하나의 방식을 선택하세요.


6. 어휘 사전 구축

시작하며

여러분이 토크나이저를 만들고 나서 이런 고민을 한 적 있나요? BPE로 서브워드를 다 학습했는데, 이제 이걸 어떻게 정수 ID로 변환하고, 특수 토큰은 어떻게 관리하고, 나중에 이 사전을 저장하고 불러오려면 어떻게 해야 할지 막막한 상황 말이죠.

이런 문제는 토큰화의 마지막 단계에서 반드시 해결해야 합니다. 토큰은 결국 모델에게 숫자로 전달되어야 하고, 각 토큰에 고유한 ID를 할당하는 일관된 매핑이 필요합니다.

또한 특수 토큰은 고정된 ID를 가져야 하고, 학습과 추론에서 동일한 사전을 사용해야 하므로, 사전을 파일로 저장하고 불러오는 기능도 필수적입니다. 바로 이럴 때 필요한 것이 체계적인 어휘 사전(Vocabulary) 구축입니다.

어휘 사전은 토큰과 ID를 양방향으로 매핑하며, 인코딩과 디코딩의 핵심 인프라입니다.

개요

간단히 말해서, 어휘 사전은 모든 토큰(특수 토큰, 단어, 서브워드)에 고유한 정수 ID를 할당하고, 토큰↔ID 간 변환을 제공하는 자료구조입니다. 어휘 사전의 구조는 매우 중요합니다.

일반적으로 두 개의 딕셔너리를 유지합니다: token_to_id는 토큰을 ID로 변환하고, id_to_token은 ID를 토큰으로 변환합니다. 예를 들어, 특수 토큰을 0~3번에 배치하고, 그 뒤에 일반 토큰들을 빈도순으로 배치하는 것이 표준 패턴입니다.

이렇게 하면 자주 쓰는 토큰이 낮은 ID를 가져서, 임베딩 테이블 접근이 약간 더 효율적입니다(캐시 친화적). 전통적인 단어 사전은 단순히 알파벳 순서로 정렬했다면, 현대 어휘 사전은 빈도, 특수 토큰 우선순위, 서브워드 병합 순서 등을 고려한 최적화된 구조를 가집니다.

어휘 사전의 핵심 특징은: 첫째, 특수 토큰이 가장 앞에 위치하여 쉽게 식별 가능합니다. 둘째, 양방향 매핑을 유지하여 인코딩과 디코딩 모두 O(1) 시간에 가능합니다.

셋째, JSON이나 pickle 형식으로 직렬화하여 재사용할 수 있습니다. 넷째, 어휘 크기가 모델 아키텍처(임베딩 레이어 크기)와 정확히 일치해야 합니다.

이러한 설계가 효율적이고 안정적인 토큰화 시스템을 만듭니다.

코드 예제

# 어휘 사전 클래스 구현
import json

class Vocabulary:
    def __init__(self, special_tokens=None):
        """어휘 사전 초기화"""
        # 특수 토큰 먼저 추가
        self.special_tokens = special_tokens or ['<PAD>', '<BOS>', '<EOS>', '<UNK>']
        self.token_to_id = {}
        self.id_to_token = {}

        # 특수 토큰을 0번부터 할당
        for idx, token in enumerate(self.special_tokens):
            self.token_to_id[token] = idx
            self.id_to_token[idx] = token

    def add_tokens(self, tokens):
        """토큰 리스트를 어휘에 추가 (중복 제거)"""
        for token in tokens:
            if token not in self.token_to_id:
                idx = len(self.token_to_id)
                self.token_to_id[token] = idx
                self.id_to_token[idx] = token

    def encode(self, token):
        """토큰을 ID로 변환"""
        return self.token_to_id.get(token, self.token_to_id['<UNK>'])

    def decode(self, idx):
        """ID를 토큰으로 변환"""
        return self.id_to_token.get(idx, '<UNK>')

    def save(self, path):
        """어휘 사전을 JSON 파일로 저장"""
        with open(path, 'w') as f:
            json.dump(self.token_to_id, f, ensure_ascii=False, indent=2)

    @classmethod
    def load(cls, path):
        """JSON 파일에서 어휘 사전 불러오기"""
        with open(path, 'r') as f:
            token_to_id = json.load(f)
        # 역매핑 생성
        vocab = cls(special_tokens=[])
        vocab.token_to_id = token_to_id
        vocab.id_to_token = {int(v): k for k, v in token_to_id.items()}
        return vocab

    def __len__(self):
        return len(self.token_to_id)

# 실행: 어휘 사전 구축
vocab = Vocabulary()
vocab.add_tokens(['hello', 'world', 'ai', 'the', 'is'])
print(f"어휘 크기: {len(vocab)}")
print(f"'hello' ID: {vocab.encode('hello')}")
print(f"ID 5 토큰: {vocab.decode(5)}")

# 저장 및 불러오기
vocab.save('/tmp/vocab.json')
loaded_vocab = Vocabulary.load('/tmp/vocab.json')
print(f"불러온 어휘 크기: {len(loaded_vocab)}")

설명

이것이 하는 일: 어휘 사전 클래스는 토큰화의 핵심 인프라로, 토큰↔ID 변환, 어휘 관리, 직렬화 기능을 제공합니다. 첫 번째로, __init__ 메서드는 특수 토큰을 0번부터 순서대로 할당합니다.

이렇게 하면 나중에 "ID < 4이면 특수 토큰"처럼 간단한 체크가 가능합니다. 또한 PAD를 0번에 배치하는 것은 많은 프레임워크의 기본 동작과 일치하여, 외부 라이브러리와의 호환성이 좋아집니다.

token_to_idid_to_token 두 개의 딕셔너리를 유지하는 것은 메모리를 조금 더 쓰지만, 인코딩과 디코딩 모두 O(1) 시간복잡도를 보장합니다. 두 번째로, add_tokens 메서드는 중복 체크를 하면서 새 토큰을 추가합니다.

if token not in self.token_to_id 조건으로 이미 존재하는 토큰은 건너뛰고, 새 토큰만 현재 어휘 크기를 ID로 할당받습니다. 이 방식은 토큰 추가 순서가 중요한데, 보통 빈도순으로 추가하여 자주 쓰는 단어가 낮은 ID를 갖도록 합니다.

실전에서는 BPE 병합 순서대로 추가하는 경우가 많습니다. 세 번째로, encodedecode 메서드는 실제 변환을 수행합니다.

encodeget 메서드로 안전하게 조회하여, 없는 토큰은 자동으로 UNK ID를 반환합니다. 이것이 UNK 토큰의 실제 활용입니다.

decode도 마찬가지로 없는 ID에 대해 UNK를 반환하지만, 정상적인 경우라면 모든 ID가 어휘에 존재해야 합니다. ID가 어휘 범위를 벗어나는 것은 버그의 신호입니다.

네 번째로, saveload 메서드는 어휘 사전의 영속성을 제공합니다. JSON 형식은 사람이 읽을 수 있어서 디버깅에 좋고, 다른 언어/프레임워크와도 호환이 잘 됩니다.

ensure_ascii=False로 한글이나 특수문자도 제대로 저장하고, indent=2로 가독성을 높입니다. 불러올 때는 JSON 키가 문자열이므로, ID를 정수로 변환하는 int(v) 처리가 필요합니다.

실전에서는 어휘 사전을 모델 체크포인트와 함께 저장하여, 나중에 정확히 같은 설정으로 모델을 불러올 수 있게 합니다. 여러분이 이 클래스를 사용하면 토큰화 시스템을 체계적으로 관리할 수 있습니다.

학습 시 한 번 어휘를 구축하고 저장한 뒤, 추론 시 같은 어휘를 불러와서 일관성을 보장합니다. 또한 어휘 크기를 쉽게 확인할 수 있어서, 모델의 임베딩 레이어 크기를 len(vocab)으로 설정하면 정확히 맞습니다.

이는 특히 여러 실험을 돌릴 때, 각 실험의 어휘 설정을 명확히 추적하는 데 매우 유용합니다.

실전 팁

💡 어휘 사전 파일에는 버전 정보와 메타데이터를 함께 저장하세요. 예를 들어 {"version": "1.0", "vocab_size": 30000, "special_tokens": [...], "vocab": {...}} 형식으로 저장하면, 나중에 어떤 설정으로 만들어진 어휘인지 명확히 알 수 있습니다.

💡 대규모 어휘(10만 개 이상)는 JSON 대신 pickle이나 msgpack을 사용하는 것이 더 빠릅니다. 하지만 호환성과 디버깅을 고려하면 JSON이 여전히 좋은 선택입니다. 속도가 중요하다면 로딩 후 캐싱하세요.

💡 어휘 사전을 불러올 때 반드시 검증하세요. 특수 토큰이 올바른 ID에 있는지, 어휘 크기가 예상과 일치하는지 체크합니다. 버전이 맞지 않는 어휘 파일을 실수로 사용하면 모델 출력이 완전히 엉망이 될 수 있습니다.

💡 여러 작업이나 언어를 지원한다면, 공통 어휘를 사용하거나 작업별 어휘를 별도로 관리할 수 있습니다. 예를 들어 다국어 모델은 언어별 서브워드를 모두 포함한 하나의 큰 어휘를 사용하는 것이 일반적입니다(mBERT, XLM-R).

💡 Hugging Face Transformers를 사용한다면, vocab.json과 merges.txt (BPE 병합 규칙) 두 파일을 함께 저장하세요. 이렇게 하면 AutoTokenizer.from pretrained로 쉽게 불러올 수 있고, 커뮤니티와 모델을 공유할 때도 표준 형식이라 편리합니다.


7. 토큰 인코딩 구현

시작하며

여러분이 어휘 사전까지 만들고 나서 이런 문제를 겪은 적 있나요? 실제 텍스트를 모델에 넣으려고 하는데, BPE 분할, 특수 토큰 추가, ID 변환, 패딩, 마스크 생성까지 모든 단계를 어떻게 통합해야 할지 막막한 상황 말이죠.

이런 문제는 토큰화 파이프라인을 실제로 구축할 때 반드시 해결해야 합니다. 각 단계를 개별적으로 이해하는 것과, 이를 end-to-end로 연결하여 견고하고 효율적인 인코더를 만드는 것은 다른 문제입니다.

특히 배치 처리, 에러 핸들링, 최대 길이 제한 같은 실전 요구사항을 모두 고려해야 하죠. 바로 이럴 때 필요한 것이 완전한 토큰 인코딩 파이프라인입니다.

이 파이프라인은 원시 텍스트를 받아서, 전처리, 토큰화, 인코딩, 패딩, 마스크 생성까지 한 번에 수행합니다.

개요

간단히 말해서, 토큰 인코딩은 원시 텍스트를 모델이 처리할 수 있는 숫자 텐서로 변환하는 전체 과정입니다. 인코딩 파이프라인은 여러 단계로 구성됩니다: 첫째, 텍스트 정규화(소문자 변환, 공백 처리 등).

둘째, 토큰화(BPE나 WordPiece로 서브워드 분할). 셋째, 특수 토큰 추가(BOS, EOS).

넷째, 어휘 사전을 사용한 ID 변환. 다섯째, 배치의 경우 패딩과 어텐션 마스크 생성.

각 단계가 견고하게 작동해야 전체 시스템이 안정적입니다. 전통적인 NLP에서는 이런 전처리를 수동으로 하나씩 작성했다면, 현대에는 Hugging Face Tokenizers 같은 라이브러리가 이 모든 것을 자동화합니다.

하지만 원리를 이해하는 것은 커스텀 토큰화가 필요하거나 디버깅할 때 필수적입니다. 토큰 인코딩의 핵심 특징은: 첫째, 모든 입력에 일관된 형식을 적용하여 학습-추론 불일치를 방지합니다.

둘째, 최대 길이를 초과하는 입력은 자르고(truncation), 짧은 입력은 패딩합니다. 셋째, 어텐션 마스크를 함께 반환하여 모델이 PAD를 무시하도록 합니다.

넷째, 배치 처리를 지원하여 효율성을 극대화합니다. 이러한 기능들이 프로덕션급 토큰화 시스템을 만듭니다.

코드 예제

# 완전한 토큰 인코딩 파이프라인
class TextEncoder:
    def __init__(self, vocab, max_length=128):
        self.vocab = vocab
        self.max_length = max_length

    def encode_single(self, text, add_special_tokens=True):
        """단일 텍스트를 인코딩"""
        # 1. 정규화: 소문자 변환, 공백 정리
        text = text.lower().strip()

        # 2. 토큰화 (여기서는 간단히 공백 분리, 실제로는 BPE)
        tokens = text.split()

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

        # 4. ID 변환
        token_ids = [self.vocab.encode(token) for token in tokens]

        # 5. 길이 제한 (truncation)
        if len(token_ids) > self.max_length:
            token_ids = token_ids[:self.max_length-1] + [self.vocab.encode('<EOS>')]

        # 6. 어텐션 마스크 (PAD 전에 실제 길이 저장)
        attention_mask = [1] * len(token_ids)

        # 7. 패딩
        padding_length = self.max_length - len(token_ids)
        token_ids += [self.vocab.encode('<PAD>')] * padding_length
        attention_mask += [0] * padding_length

        return {
            'input_ids': token_ids,
            'attention_mask': attention_mask,
            'length': len(tokens)  # 원본 길이 (디버깅용)
        }

    def encode_batch(self, texts):
        """배치 텍스트를 인코딩"""
        encoded = [self.encode_single(text) for text in texts]

        # 리스트를 2D 배열로 변환
        batch = {
            'input_ids': [e['input_ids'] for e in encoded],
            'attention_mask': [e['attention_mask'] for e in encoded]
        }
        return batch

# 실행: 인코딩 테스트
encoder = TextEncoder(vocab, max_length=10)
text = "hello world ai"
encoded = encoder.encode_single(text)
print(f"원문: {text}")
print(f"Input IDs: {encoded['input_ids']}")
print(f"Attention Mask: {encoded['attention_mask']}")
print(f"원본 길이: {encoded['length']}")

# 배치 인코딩
batch_encoded = encoder.encode_batch(["hello", "hello world ai"])
print(f"\n배치 Input IDs:\n{batch_encoded['input_ids']}")

설명

이것이 하는 일: 토큰 인코딩 파이프라인은 원시 텍스트를 받아서, 모델이 직접 사용할 수 있는 정수 텐서와 어텐션 마스크를 생성합니다. 첫 번째로, encode_single 메서드는 텍스트 정규화로 시작합니다.

lower()로 대소문자를 통일하고 strip()으로 앞뒤 공백을 제거합니다. 이는 "Hello"와 "hello"가 같은 토큰으로 처리되도록 하여 어휘 크기를 줄이고, 학습 데이터의 불필요한 변동을 제거합니다.

실전에서는 유니코드 정규화(NFD/NFC), 특수문자 처리, 숫자 처리 등 더 복잡한 정규화를 수행할 수 있습니다. 두 번째로, 토큰화 단계입니다.

여기서는 단순히 공백으로 분리했지만, 실전에서는 앞서 배운 BPE 알고리즘을 적용합니다. BPE는 "hello"["he", "llo"] 처럼 서브워드로 쪼개는데, 이를 위해 학습된 병합 규칙을 순서대로 적용합니다.

Hugging Face의 tokenizers.ByteLevelBPETokenizer가 이를 효율적으로 구현합니다. 세 번째로, 특수 토큰 추가입니다.

add_special_tokens 플래그로 제어할 수 있게 만들었는데, 이는 중요합니다. 예를 들어 이미 토큰화된 데이터를 재처리할 때는 특수 토큰을 중복 추가하지 않아야 합니다.

BOS와 EOS를 앞뒤에 추가하는 것은 BERT 스타일([CLS], [SEP])과 약간 다른데, 작업에 따라 적절한 스타일을 선택하세요. 네 번째로, ID 변환은 vocab.encode를 사용하여 각 토큰을 숫자로 바꿉니다.

리스트 컴프리헨션으로 간결하게 처리하는데, 어휘에 없는 토큰은 자동으로 UNK ID를 받습니다. 이 단계 후에는 ['<BOS>', 'hello', 'world', '<EOS>'] 같은 토큰 리스트가 [1, 4, 5, 2] 같은 ID 리스트가 됩니다.

다섯 번째로, truncation 처리입니다. 길이가 max_length를 초과하면 앞부분을 자르고, 마지막에 EOS를 다시 붙입니다.

이렇게 하면 잘린 시퀀스도 문법적으로 올바른 형식(BOS...EOS)을 유지합니다. 실전에서는 "앞에서 자르기", "뒤에서 자르기", "중간 자르기" 같은 전략을 선택할 수 있습니다.

여섯 번째로, 어텐션 마스크 생성입니다. PAD를 추가하기 전에 실제 토큰 위치는 1로 표시합니다.

그 후 패딩한 위치는 0으로 추가합니다. 이 마스크는 나중에 모델의 어텐션 메커니즘에서 사용되어, PAD 위치의 어텐션 스코어를 -inf로 만들어 완전히 무시하게 합니다.

일곱 번째로, 실제 패딩 작업입니다. max_length - len(token_ids) 만큼 PAD 토큰을 추가하여 모든 입력이 동일한 길이를 갖도록 합니다.

encode_batch는 여러 텍스트를 각각 인코딩한 뒤 리스트로 묶는데, 모든 시퀀스가 같은 길이이므로 numpy 배열이나 PyTorch 텐서로 쉽게 변환할 수 있습니다. 여러분이 이 파이프라인을 사용하면 데이터 전처리를 표준화하고 자동화할 수 있습니다.

특히 학습 코드에서 데이터로더와 통합할 때, encode_batch를 collate 함수로 사용하면 매우 편리합니다. 또한 추론 시에도 동일한 인코더를 사용하여, 학습-추론 불일치를 완전히 방지할 수 있습니다.

이는 프로덕션 배포에서 미묘한 버그를 줄이는 핵심 패턴입니다.

실전 팁

💡 인코더를 클래스로 만들어서 설정(max_length, padding 전략 등)을 캡슐화하세요. 이렇게 하면 여러 실험에서 다른 설정을 쉽게 시도하고, 나중에 어떤 설정을 사용했는지 명확히 추적할 수 있습니다.

💡 return_tensors 파라미터를 추가하여 반환 형식을 제어하세요. 예: return_tensors='pt'는 PyTorch 텐서, 'np'는 numpy 배열, None은 리스트를 반환. 이렇게 하면 Hugging Face Tokenizers API와 일관된 인터페이스를 제공할 수 있습니다.

💡 디버깅을 위해 원본 토큰도 함께 반환하는 옵션을 만드세요. return_tokens=True일 때 {'input_ids': [...], 'tokens': [...]} 형식으로 반환하면, ID와 토큰을 동시에 보면서 인코딩이 올바른지 확인할 수 있습니다.

💡 대용량 데이터를 처리할 때는 멀티프로세싱을 활용하세요. encode_batch를 여러 프로세스로 병렬화하면 CPU 코어를 모두 활용하여 인코딩 속도를 크게 높일 수 있습니다. Hugging Face의 map 함수가 이를 자동으로 지원합니다.

💡 프로덕션 환경에서는 인코더 설정을 모델 메타데이터와 함께 저장하세요. config.json에 {"max length": 128, "padding": "max length", ...} 형식으로 저장하면, 모델을 불러올 때 올바른 인코더 설정도 함께 복원할 수 있습니다.


8. 토큰 디코딩 구현

시작하며

여러분이 모델이 생성한 숫자 시퀀스를 받았을 때 이런 문제를 겪은 적 있나요? ID 리스트를 다시 사람이 읽을 수 있는 텍스트로 변환해야 하는데, 특수 토큰은 어떻게 제거하고, 서브워드를 어떻게 합치고, 공백은 어떻게 복원할지 막막한 상황 말이죠.

이런 문제는 텍스트 생성 모델의 출력을 사용자에게 보여줄 때 반드시 해결해야 합니다. 모델은 [1, 45, 234, 67, 2, 0, 0] 같은 숫자 시퀀스를 생성하는데, 이를 "Hello world!" 같은 자연스러운 텍스트로 변환하는 것이 디코딩입니다.

단순히 ID를 토큰으로 바꾸는 것만으로는 부족하고, BPE의 서브워드 경계를 제거하고, 특수 토큰을 필터링하고, 공백을 올바르게 복원해야 합니다. 바로 이럴 때 필요한 것이 견고한 토큰 디코딩 파이프라인입니다.

디코딩은 인코딩의 정확한 역과정으로, 모델 출력을 사용자 친화적인 텍스트로 변환합니다.

개요

간단히 말해서, 토큰 디코딩은 정수 ID 시퀀스를 토큰 문자열로 변환하고, 서브워드를 병합하고, 특수 토큰을 제거하여 자연스러운 텍스트를 복원하는 과정입니다. 디코딩 파이프라인도 여러 단계로 구성됩니다: 첫째, ID를 토큰으로 변환(역어휘 사전 조회).

둘째, 특수 토큰(PAD, BOS, EOS) 필터링. 셋째, 서브워드 병합(BPE의 경우 "##"이나 "Ġ" 같은 마커 제거).

넷째, 공백과 구두점 정규화. 다섯째, 최종 텍스트 후처리.

각 단계가 올바르게 작동해야 출력 품질이 좋아집니다. 전통적인 단어 기반 디코딩은 단순히 토큰을 공백으로 이었지만, 서브워드 디코딩은 토큰 간 병합 규칙을 이해해야 합니다.

예를 들어 GPT는 토큰 시작에 공백 마커(Ġ)가 있으면 앞에 공백을 추가하고, BERT는 "##"로 시작하면 앞 토큰에 붙여야 합니다. 토큰 디코딩의 핵심 특징은: 첫째, 특수 토큰을 자동으로 필터링하여 깨끗한 텍스트를 생성합니다.

둘째, skip_special_tokens 같은 플래그로 동작을 제어할 수 있습니다(디버깅 시 특수 토큰을 보고 싶을 때). 셋째, 서브워드 병합 규칙을 올바르게 적용하여 원본 단어를 복원합니다.

넷째, 배치 디코딩을 지원하여 여러 시퀀스를 한 번에 처리합니다. 이러한 기능들이 사용자 친화적인 출력을 만듭니다.

코드 예제

# 완전한 토큰 디코딩 파이프라인
class TextDecoder:
    def __init__(self, vocab):
        self.vocab = vocab
        # 특수 토큰 ID 집합 (빠른 조회)
        self.special_token_ids = {
            vocab.encode('<PAD>'),
            vocab.encode('<BOS>'),
            vocab.encode('<EOS>'),
            vocab.encode('<UNK>')
        }

    def decode_single(self, token_ids, skip_special_tokens=True,
                     clean_up_tokenization=True):
        """단일 ID 시퀀스를 텍스트로 디코딩"""
        # 1. ID를 토큰으로 변환
        tokens = [self.vocab.decode(idx) for idx in token_ids]

        # 2. 특수 토큰 필터링
        if skip_special_tokens:
            tokens = [t for i, t in enumerate(tokens)
                     if token_ids[i] not in self.special_token_ids]

        # 3. 서브워드 병합 (여기서는 간단히 공백으로 결합)
        # 실제 BPE는 특별한 마커(Ġ, ##)를 처리
        text = ' '.join(tokens)

        # 4. 후처리: 공백 정규화
        if clean_up_tokenization:
            # 여러 공백을 하나로
            text = ' '.join(text.split())
            # 구두점 앞 공백 제거 (간단한 규칙)
            for punct in ['.', ',', '!', '?', ':', ';']:
                text = text.replace(f' {punct}', punct)

        return text

    def decode_batch(self, batch_ids, **kwargs):
        """배치 ID 시퀀스를 텍스트 리스트로 디코딩"""
        return [self.decode_single(ids, **kwargs) for ids in batch_ids]

    def decode_with_details(self, token_ids):
        """디버깅용: 토큰과 ID를 함께 반환"""
        tokens = [self.vocab.decode(idx) for idx in token_ids]
        details = []
        for idx, token in zip(token_ids, tokens):
            is_special = idx in self.special_token_ids
            details.append({
                'id': idx,
                'token': token,
                'is_special': is_special
            })
        return details

# 실행: 디코딩 테스트
decoder = TextDecoder(vocab)

# 예시 ID 시퀀스: [<BOS>, hello, world, ai, <EOS>, <PAD>, <PAD>]
token_ids = [1, 4, 5, 6, 2, 0, 0]
decoded_text = decoder.decode_single(token_ids)
print(f"Input IDs: {token_ids}")
print(f"디코딩 결과: '{decoded_text}'")

# 특수 토큰 포함 디코딩 (디버깅용)
decoded_with_special = decoder.decode_single(token_ids, skip_special_tokens=False)
print(f"특수 토큰 포함: '{decoded_with_special}'")

# 상세 정보
details = decoder.decode_with_details(token_ids)
print(f"\n디코딩 상세:")
for d in details:
    special_mark = " [특수]" if d['is_special'] else ""
    print(f"  ID {d['id']:2d} → '{d['token']}'{special_mark}")

설명

이것이 하는 일: 토큰 디코딩 파이프라인은 모델이 생성한 정수 ID를 받아서, 사람이 읽을 수 있는 자연스러운 텍스트로 변환합니다. 첫 번째로, decode_single 메서드는 ID 리스트의 각 요소를 vocab.decode로 토큰 문자열로 변환합니다.

이것이 가장 기본적인 역매핑입니다. 예를 들어 ID 4가 "hello"에 매핑되어 있으면, 4를 "hello"로 바꿉니다.

리스트 컴프리헨션으로 전체 시퀀스를 한 번에 변환하는데, 이는 O(n) 시간에 작동합니다. 두 번째로, 특수 토큰 필터링입니다.

skip_special_tokens=True일 때, ID가 special_token_ids 집합에 있으면 해당 토큰을 결과에서 제외합니다. 집합 조회는 O(1)이므로 효율적입니다.

예를 들어 [<BOS>, hello, world, <EOS>, <PAD>]에서 특수 토큰을 제거하면 [hello, world]만 남습니다. 이렇게 하면 최종 사용자는 내부적인 제어 토큰을 보지 않고 깨끗한 텍스트만 봅니다.

세 번째로, 서브워드 병합입니다. 여기서는 단순히 공백으로 결합했지만, 실제 BPE 디코딩은 더 복잡합니다.

GPT 스타일이라면 "Ġ" 마커를 공백으로 바꾸고, BERT 스타일이라면 "##"를 제거하면서 앞 토큰에 붙입니다. 예를 들어 ["un", "##believ", "##able"]은 "unbelievable"이 되어야 합니다.

Hugging Face의 convert_tokens_to_string 메서드가 이를 자동으로 처리합니다. 네 번째로, 후처리 단계입니다.

clean_up_tokenization=True일 때, 여러 개의 연속 공백을 하나로 합치고, 구두점 앞의 불필요한 공백을 제거합니다. 예를 들어 "Hello , world !"를 "Hello, world!"로 정리합니다.

이런 세심한 후처리가 출력 품질을 크게 향상시키며, 특히 사용자 대면 애플리케이션에서는 필수적입니다. 다섯 번째로, decode_batch 메서드는 여러 시퀀스를 한 번에 처리합니다.

빔 서치나 배치 생성에서 나온 여러 후보를 디코딩할 때 유용합니다. **kwargs를 사용하여 skip_special_tokens 같은 옵션을 배치 전체에 일괄 적용할 수 있습니다.

여섯 번째로, decode_with_details 메서드는 디버깅을 위한 것입니다. 각 ID가 어떤 토큰으로 변환되었는지, 특수 토큰인지 아닌지를 상세히 보여줍니다.

모델이 이상한 출력을 생성할 때, 이 메서드로 ID 시퀀스를 검사하면 문제를 빠르게 찾을 수 있습니다. 예를 들어 EOS를 생성하지 않았거나, PAD를 잘못 생성했는지 등을 확인할 수 있죠.

여러분이 이 디코더를 사용하면 모델 출력을 사용자 친화적으로 만들 수 있습니다. 특히 챗봇이나 번역 서비스처럼 최종 출력이 사용자에게 바로 보이는 경우, 깨끗하고 자연스러운 텍스트가 매우 중요합니다.

또한 디버깅 모드(skip_special_tokens=False, decode_with_details)를 활용하면 모델의 생성 과정을 깊이 이해할 수 있어, 문제 해결과 모델 개선에 큰 도움이 됩니다.

실전 팁

💡 서브워드 디코딩 규칙은 토크나이저 타입에 따라 다릅니다. GPT(BPE), BERT(WordPiece), T5(Unigram) 등 각각의 병합 규칙을 정확히 구현하세요. 잘못된 디코딩은 "un ## believ ## able" 같은 이상한 출력을 만듭니다.

💡 언어별로 후처리 규칙을 다르게 적용하세요. 예를 들어 중국어나 일본어는 단어 사이에 공백이 없으므로, 공백 제거 로직이 필요합니다. 다국어 모델을 만든다면 언어 감지 후 적절한 후처리를 선택하세요.

💡 생성 품질을 평가할 때 디코딩 옵션이 영향을 줍니다. BLEU나 ROUGE 같은 메트릭을 계산할 때, 참조 텍스트와 생성 텍스트를 동일한 디코딩 설정으로 처리해야 공정한 비교가 됩니다.

💡 대용량 배치 디코딩 시 메모리를 고려하세요. 모든 시퀀스를 한 번에 디코딩하면 메모리가 부족할 수 있으니, 필요하면 청크 단위로 나눠서 처리하세요. 제너레이터 패턴을 사용하면 메모리 효율적입니다.

💡 프로덕션에서는 디코딩된 텍스트를 로깅하세요. 사용자에게 보여지기 전에 마지막 체크포인트로, 의도하지 않은 특수 토큰이나 이상한 문자가 있는지 확인할 수 있습니다. 민감한 정보가 없는지도 체크해야 합니다.


#Python#Tokenizer#BPE#SpecialTokens#NLP#ai

댓글 (0)

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

함께 보면 좋은 카드 뉴스

Phase 1 보안 사고방식 구축 완벽 가이드

초급 개발자가 보안 전문가로 성장하기 위한 첫걸음입니다. 해커의 관점에서 시스템을 바라보는 방법부터 OWASP Top 10, 포트 스캐너 구현, 실제 침해사고 분석까지 보안의 기초 체력을 다집니다.

프로덕션 워크플로 배포 완벽 가이드

LLM 기반 애플리케이션을 실제 운영 환경에 배포하기 위한 워크플로 최적화, 캐싱 전략, 비용 관리 방법을 다룹니다. Airflow와 서버리스 아키텍처를 활용한 실습까지 포함하여 초급 개발자도 프로덕션 수준의 배포를 할 수 있도록 안내합니다.

워크플로 모니터링과 디버깅 완벽 가이드

LLM 기반 워크플로의 실행 상태를 추적하고, 문제를 진단하며, 성능을 최적화하는 방법을 다룹니다. LangSmith 통합부터 커스텀 모니터링 시스템 구축까지 실무에서 바로 적용할 수 있는 내용을 담았습니다.

LlamaIndex Workflow 완벽 가이드

LlamaIndex의 워크플로 시스템을 활용하여 복잡한 RAG 파이프라인을 구축하는 방법을 알아봅니다. 이벤트 기반 워크플로부터 멀티 인덱스 쿼리까지 단계별로 학습합니다.

LangChain LCEL 완벽 가이드

LangChain Expression Language(LCEL)를 활용하여 AI 체인을 우아하게 구성하는 방법을 배웁니다. 파이프 연산자부터 커스텀 체인 개발까지, 실무에서 바로 활용할 수 있는 핵심 개념을 다룹니다.