본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research9 min read

텍스트·토큰·어휘집·토큰화: 문장을 모델이 읽는 숫자로 바꾸는 과정

언어 모델은 문장을 직접 읽지 못하므로 텍스트를 작은 단위인 토큰으로 나누고, 각 토큰을 정수 아이디로 바꾼다. 오늘은 문자·단어·부분 단어 방식의 차이와 미등록 단어 문제를 이해하고, 단어 단위 토크나이저와 어휘집을 직접 구현한다. 어휘 크기에 따라 커버리지와 입력 길이가 어떻게 달라지는지도 실험한다.

By 송민성

오늘의 목표

텍스트가 토큰, 토큰 아이디, 임베딩 순서로 변환되어 모델에 들어가는 과정을 이해한다. 단어 단위 토크나이저와 어휘집을 직접 만들고 문장과 아이디 사이를 양방향으로 변환한다.

개념과 직관

1. 텍스트에서 숫자까지

모델은 "고양이가 잔다"라는 문자열을 그대로 계산하지 못한다. 다음 변환이 필요하다.

text
"고양이가 잔다"
      ↓ 토큰화
["고양이가", "잔다"]
      ↓ 어휘집 조회
[17, 42]
      ↓ 임베딩 조회
[[0.2, -0.7, 0.1], [0.8, 0.3, -0.4]]
  • 텍스트(text): 사람이 읽는 문자열
  • 토큰(token): 모델 입력을 위해 나눈 텍스트 조각
  • 토큰화(tokenization, 텍스트를 토큰 목록으로 나누는 과정)
  • 어휘집(vocabulary, 모델이 알고 있는 토큰과 아이디의 대응표)
  • 토큰 아이디(token ID, 각 토큰에 붙인 정수 번호)
  • 임베딩(embedding, 토큰 아이디를 학습 가능한 실수 벡터로 바꾼 표현)

토큰 아이디의 숫자 자체에는 크기나 의미가 없다. 아이디 42가 아이디 17보다 더 크거나 중요한 토큰이라는 뜻이 아니다. 아이디는 임베딩 표에서 어느 행을 찾을지 알려주는 주소다.

2. 무엇을 토큰 하나로 볼 것인가

문자 단위

문자 단위 토큰화(character-level tokenization, 글자 하나를 토큰 하나로 보는 방식)는 문장을 다음처럼 나눈다.

text
"고양이가 잔다" → ["고", "양", "이", "가", " ", "잔다"의 "잔다"도 글자별 분리]
              → ["고", "양", "이", "가", " ", "잔다"[0], "잔다"[1]]
              → ["고", "양", "이", "가", " ", "잤", "다"]  # 실제 입력 문장에 따라 달라짐

예를 들어 "고양이"는 ["고", "양", "이"]가 된다.

장점은 필요한 어휘집이 작고 처음 보는 단어도 문자로 쪼갤 수 있다는 점이다. 단점은 문장이 길어지고, "고양이"라는 단어 단위 의미를 여러 토큰에서 조합해야 한다는 점이다.

단어 단위

단어 단위 토큰화(word-level tokenization, 공백이나 규칙을 기준으로 단어를 나누는 방식)는 다음처럼 동작한다.

text
"고양이가 잔다" → ["고양이가", "잔다"]

사람이 보는 단어와 가까워 이해하기 쉽고 입력 길이도 짧다. 그러나 "고양이", "고양이가", "고양이는"을 서로 다른 토큰으로 처리한다. 학습 데이터에 없던 단어가 나오면 변환할 방법이 없다.

부분 단어 단위

부분 단어 토큰화(subword tokenization, 자주 등장하는 문자열은 크게 유지하고 드문 단어는 작은 조각으로 나누는 방식)는 문자와 단어 방식의 중간에 있다.

text
"고양이가" → ["고양이", "가"]
"재학습했다" → ["재", "학습", "했다"]

실제 분할 결과는 토크나이저가 학습한 어휘집에 따라 달라진다. 같은 문장도 토크나이저가 다르면 다른 토큰으로 나뉜다.

부분 단어 방식은 자주 쓰는 표현을 짧게 나타내면서도 처음 보는 단어를 더 작은 조각으로 처리할 수 있다. 현대 언어 모델에서 널리 쓰이지만, 오늘은 원리를 분명히 보기 위해 단어 단위 토크나이저부터 구현한다.

3. 어휘집과 미등록 단어

어휘집은 토큰과 정수 아이디를 연결하는 사전이다.

python
{ "<PAD>": 0, "<UNK>": 1, "<BOS>": 2, "<EOS>": 3, "고양이가": 4, "잔다": 5 }

미등록 단어(OOV, out-of-vocabulary, 어휘집에 없는 토큰)가 들어오면 보통 <UNK>로 바꾼다.

text
어휘집: ["고양이가", "잔다"]
입력:   "강아지가 잔다"
결과:   ["<UNK>", "잔다"]

어휘집이 작으면 메모리는 적게 들지만 <UNK>가 많아진다. 어휘집이 크면 더 많은 단어를 구분할 수 있지만 임베딩 표가 커지고 드문 단어에 학습 데이터가 충분히 모이지 않을 수 있다.

단어 단위 토크나이저의 토큰 등장 기준 커버리지는 다음처럼 계산할 수 있다.

text
커버리지 = 어휘집에서 찾은 토큰 수 / 전체 토큰 수
미등록 단어 비율 = 미등록 토큰 수 / 전체 토큰 수
커버리지 + 미등록 단어 비율 = 1

4. 특수 토큰

특수 토큰(special token, 일반 단어가 아니라 입력 구조와 상태를 표시하는 토큰)은 모델마다 이름과 사용법이 다르다.

  • <BOS>: 문장 시작(beginning of sequence)
  • <EOS>: 문장 끝(end of sequence)
  • <PAD>: 길이가 다른 문장을 같은 길이로 맞출 때 채우는 값
  • <UNK>: 어휘집에 없는 토큰

두 문장을 한 묶음으로 계산한다고 가정하자.

text
문장 A: [<BOS>, 고양이가, 잔다, <EOS>]
문장 B: [<BOS>, 새가, 난다, 오늘, 아침에, <EOS>]

길이가 짧은 문장 A에 <PAD>를 추가한다.

text
문장 A: [<BOS>, 고양이가, 잔다, <EOS>, <PAD>, <PAD>]
문장 B: [<BOS>, 새가, 난다, 오늘, 아침에, <EOS>]

<PAD>는 실제 문장 내용이 아니다. 이후 모델이 패딩 위치를 계산에서 제외하도록 별도 표시가 필요하다.

5. 토큰 아이디와 임베딩

토큰 아이디를 바로 숫자 크기로 계산하면 안 된다. 아이디는 임베딩 표의 행 번호로 사용한다.

text
토큰 아이디 4 → 임베딩 표의 4번 행 → [0.2, -0.7, 0.1]
토큰 아이디 5 → 임베딩 표의 5번 행 → [0.8, 0.3, -0.4]

임베딩 표의 모양은 다음과 같다.

text
행 개수 = 어휘 크기
열 개수 = 임베딩 차원

어휘 크기가 20,000이고 임베딩 차원이 128이면 임베딩 파라미터 수는 다음과 같다.

text
20,000 × 128 = 2,560,000개

어휘집을 키우면 미등록 단어는 줄지만 임베딩 파라미터 수는 비례해 늘어난다.

코드로 직접 해보기

1. 텍스트를 단어로 나누기

먼저 공백과 간단한 문장 부호만 처리한다.

python
import re def tokenize(text): text = text.lower() return re.findall(r"[가-힣a-z0-9]+|[^\s가-힣a-z0-9]", text) sentence = "고양이가 잔다." print(tokenize(sentence)) # ['고양이가', '잔다', '.']

이 코드는 교육용 최소 구현이다. 이메일, 이모지, 소수점, 줄임말처럼 복잡한 텍스트에는 별도 규칙이 필요하다.

2. 학습 문장에서 어휘집 만들기

자주 등장한 단어부터 아이디를 배정한다.

python
from collections import Counter SPECIAL_TOKENS = ["<PAD>", "<UNK>", "<BOS>", "<EOS>"] def build_vocab(texts, max_size): counts = Counter() for text in texts: counts.update(tokenize(text)) token_to_id = { token: index for index, token in enumerate(SPECIAL_TOKENS) } normal_token_limit = max(0, max_size - len(SPECIAL_TOKENS)) for token, _ in counts.most_common(normal_token_limit): if token not in token_to_id: token_to_id[token] = len(token_to_id) return token_to_id train_texts = [ "고양이가 잔다.", "강아지가 달린다.", "고양이가 밥을 먹는다.", ] vocab = build_vocab(train_texts, max_size=10) print(vocab)

max_size에는 특수 토큰도 포함된다. max_size=10이면 일반 토큰은 최대 6개만 들어간다.

3. 문장을 토큰 아이디로 바꾸기

python
def encode(text, token_to_id): unknown_id = token_to_id["<UNK>"] ids = [token_to_id["<BOS>"]] for token in tokenize(text): ids.append(token_to_id.get(token, unknown_id)) ids.append(token_to_id["<EOS>"]) return ids ids = encode("고양이가 달린다.", vocab) print(ids)

dict.get(token, unknown_id)는 토큰이 사전에 있으면 해당 아이디를 반환하고, 없으면 <UNK> 아이디를 반환한다.

4. 아이디를 문장으로 되돌리기

python
def make_id_to_token(token_to_id): return { token_id: token for token, token_id in token_to_id.items() } def decode(ids, id_to_token): ignored = {"<PAD>", "<BOS>", "<EOS>"} tokens = [] for token_id in ids: token = id_to_token.get(token_id, "<UNK>") if token not in ignored: tokens.append(token) return " ".join(tokens) id_to_token = make_id_to_token(vocab) restored = decode(ids, id_to_token) print(restored)

원문과 복원 문장이 완전히 같지 않을 수 있다. 토큰화 과정에서 공백 정보와 대소문자 정보가 사라졌기 때문이다. 토큰 아이디를 되돌릴 수 있다는 말은 원본 문자열을 완벽히 복구한다는 뜻이 아니라, 아이디에 대응하는 토큰을 다시 찾을 수 있다는 뜻이다.

5. 아이디로 임베딩 찾기

외부 라이브러리 없이 작은 임베딩 표를 흉내 낼 수 있다.

python
import random random.seed(0) embedding_dim = 3 embedding_table = [ [random.uniform(-0.1, 0.1) for _ in range(embedding_dim)] for _ in range(len(vocab)) ] vectors = [embedding_table[token_id] for token_id in ids] print("토큰 아이디:", ids) print("임베딩:") for vector in vectors: print(vector)

현재 벡터는 무작위 값이라 언어적 의미가 없다. 실제 모델은 학습 과정에서 임베딩 값을 조정해 각 토큰에 유용한 표현을 만든다.

오늘의 실험

훈련용 텍스트로 어휘집을 만들고, 별도로 분리한 평가용 텍스트에서 미등록 단어 비율을 측정한다. 같은 데이터로 어휘 크기만 1,000개, 5,000개, 20,000개로 바꾼다.

python
def oov_stats(texts, token_to_id): total = 0 unknown = 0 for text in texts: for token in tokenize(text): total += 1 if token not in token_to_id: unknown += 1 oov_rate = unknown / total if total else 0.0 coverage = 1.0 - oov_rate return { "전체 토큰 수": total, "미등록 토큰 수": unknown, "미등록 단어 비율": oov_rate, "커버리지": coverage, } train_texts = [ "고양이가 창문 앞에서 잔다.", "강아지가 공원에서 달린다.", "학생이 책을 읽는다.", "연구자가 언어 모델을 학습한다.", ] * 1000 test_texts = [ "고양이가 공원에서 달린다.", "연구자가 새로운 데이터로 모델을 평가한다.", "로봇이 낯선 문장을 이해한다.", ] for size in [1000, 5000, 20000]: vocab = build_vocab(train_texts, max_size=size) stats = oov_stats(test_texts, vocab) print(f"\n요청한 어휘 크기: {size}") print(f"실제 어휘 크기: {len(vocab)}") print(f"전체 토큰 수: {stats['전체 토큰 수']}") print(f"미등록 토큰 수: {stats['미등록 토큰 수']}") print(f"미등록 단어 비율: {stats['미등록 단어 비율']:.2%}") print(f"커버리지: {stats['커버리지']:.2%}")

예제 데이터에는 고유 토큰이 1,000개보다 적으므로 세 설정의 실제 어휘 크기와 결과가 같을 수 있다. 제대로 비교하려면 고유 단어가 20,000개 이상인 충분히 큰 말뭉치(corpus, 학습이나 평가에 사용하는 대규모 텍스트 모음)를 사용한다.

결과는 다음 표로 기록한다.

요청한 어휘 크기실제 어휘 크기평가 토큰 수미등록 토큰 수미등록 단어 비율커버리지
1,000
5,000
20,000

관찰할 질문:

  1. 어휘 크기가 커질수록 미등록 단어 비율이 줄어드는가?
  2. 5,000개에서 20,000개로 늘렸을 때 개선 폭은 얼마나 되는가?
  3. 훈련 데이터와 주제가 다른 평가 문장에서 미등록 단어가 더 많이 나오는가?
  4. 미등록 단어로 바뀐 토큰은 사람 이름, 전문 용어, 숫자, 활용형 중 무엇인가?
  5. 어휘 크기 × 임베딩 차원으로 계산한 임베딩 파라미터 수는 얼마나 늘어나는가?

훈련 텍스트로 커버리지를 측정하면 지나치게 좋은 결과가 나온다. 어휘집을 만들 때 사용하지 않은 평가 텍스트에서 측정해야 새 문장 처리 능력을 볼 수 있다.

왜 이걸 배우나 (LLM / Post-training 연결)

LLM의 입력과 출력은 모두 토큰 아이디다. 토크나이저가 바뀌면 같은 데이터도 토큰 수, 학습 비용, 잘리는 위치, 특수 토큰 배치가 달라진다.

LLM 후속 학습(post-training, 사전 학습된 모델을 지시 수행이나 선호에 맞게 추가 학습하는 단계)에서는 대화 형식을 나타내는 특수 토큰, 패딩 처리, 최대 입력 길이, 토큰별 손실 계산을 정확히 맞춰야 한다. 토크나이저와 모델의 어휘집이 다르면 아이디가 잘못 연결되거나 임베딩 크기가 맞지 않아 학습이 실패한다.

초보자가 막히는 지점

  • 공백으로만 나누면 "잔다"와 "잔다."가 다른 토큰이 된다. 먼저 소문자 변환과 문장 부호 분리 규칙을 고정한다.
  • 어휘집을 평가 데이터까지 사용해 만들면 정보 누출(data leakage, 평가용 정보를 학습 과정에 미리 사용하는 문제)이 생긴다. 훈련 데이터로만 어휘집을 만든다.
  • <PAD>와 <UNK>를 같은 아이디로 두면 패딩과 미등록 단어를 구분할 수 없다. 모든 특수 토큰에 서로 다른 아이디를 배정한다.
  • 토큰 아이디를 수치형 특징으로 해석하면 안 된다. 아이디는 순서나 거리 의미가 없는 조회용 번호다.
  • decode(encode(text))가 원문과 다르다고 바로 오류는 아니다. 소문자 변환, 공백 제거, 문장 부호 분리 과정에서 표면 형태가 달라질 수 있다.
  • 요청한 어휘 크기보다 데이터의 고유 토큰 수가 적으면 실제 어휘집은 더 작다. 실험 결과에 요청 크기와 실제 크기를 함께 기록한다.

알고리즘 (병행 1시간)

문자열을 다루는 LeetCode Medium 2문제를 푼다.

  1. 3. Longest Substring Without Repeating Characters

- 중복 문자가 없는 가장 긴 부분 문자열을 찾는다. - 문자를 토큰처럼 보고, 현재 구간에 어떤 문자가 들어 있는지 추적한다. - 먼저 완전 탐색을 작성한 뒤 슬라이딩 윈도우(sliding window, 구간의 양 끝을 이동하며 답을 찾는 방법)로 개선한다.

  1. 49. Group Anagrams

- 같은 문자 구성을 가진 단어끼리 묶는다. - 정렬한 문자열이나 문자별 등장 횟수를 그룹의 키로 사용한다. - 두 방식의 시간복잡도와 추가 메모리를 비교한다.

각 문제마다 다음 세 항목을 기록한다.

text
접근:
시간복잡도:
틀린 이유:

시간복잡도(time complexity, 입력 크기가 커질 때 실행 횟수가 얼마나 증가하는지 나타낸 것)는 n을 문자열 길이로 두고 적는다. 정답 코드를 본 경우에는 복사로 끝내지 말고, 틀린 원인이 자료구조 선택인지 구간 갱신 조건인지 한 문장으로 남긴다.