지난 글에서 토크나이저가 텍스트를 정수 ID 시퀀스로 바꾸는 과정을 봤다. 이 글은 그 변환을 실제로 수행하는 두 알고리즘을 한자리에서 다룬다. BPE(Byte Pair Encoding)는 GPT 시리즈와 LLaMA·Mistral이 쓰고, WordPiece는 BERT 계열이 쓴다. 현대 LLM이 쓰는 토크나이저는 사실상 둘 중 하나이거나 둘의 변형이다.
둘을 한 편에 묶는 이유가 있다. 뼈대가 같기 때문이다. 문자에서 출발해 인접한 두 심볼을 반복해서 하나로 합친다는 절차가 똑같고, 갈리는 자리는 넷뿐이다 — 어떤 쌍을 합칠지 재는 점수, 단어 경계를 적는 마커, 학습 결과를 새 텍스트에 적용하는 인코딩 방식, 그리고 모르는 글자를 만났을 때의 처리다. 따로 배우면 두 개의 알고리즘이지만, 이 네 자리를 짚어 두면 하나의 알고리즘에 손잡이가 넷 달린 셈이 된다. 이 글의 절 순서도 그 넷을 따라간다.
어휘 단위
단어 어휘
토큰 하나를 단어 하나로 두는 것이 가장 자연스러운 출발점이다. 「고양이가 잠을 잔다」를 「고양이가」·「잠을」·「잔다」 셋으로 자르면 사람이 읽는 단위와 그대로 맞는다. 그런데 이 방식은 두 군데에서 막힌다.
첫째는 어휘의 크기다. 말뭉치를 키울수록 서로 다른 단어형이 계속 새로 나온다. 사람 이름, 지명, 오타, 신조어, 숫자, 코드 조각까지 전부 별개의 단어다. 어휘를 무한정 키울 수는 없으므로 어딘가에서 자르는데, 자르는 순간 두 번째 문제가 나타난다.
둘째가 OOV(Out-Of-Vocabulary)다. 어휘에 없는 단어를 만나면 모델은 그 자리에 <UNK>라는 하나의 특수 토큰을 넣는 수밖에 없다. 어휘를 5만으로 자르면 그 밖의 모든 단어가 같은 <UNK> 하나로 뭉개진다. 「tokenization」과 「antidisestablishmentarianism」이 모델 입장에서 완전히 같은 입력이 되는 것이다. 정보가 사라질 뿐 아니라 되돌릴 수도 없다 — 모델이 <UNK>를 출력하면 원래 무슨 단어였는지 알 방법이 없다.
한국어는 이 문제가 더 심하다. 한국어는 어간에 조사와 어미가 계속 붙는 교착어(어근에 형태소를 차례로 붙여 문법 정보를 표현하는 언어)라서, 「먹다」 하나에서 「먹고」·「먹으니」·「먹었습니다」·「먹었었는데」가 끝없이 파생된다. 이것을 전부 다른 단어로 세면 어휘가 영어보다 훨씬 빨리 터진다. 정작 모델이 배워야 하는 것은 「먹-」이 같다는 사실인데, 단어 어휘는 그 공통점을 아예 볼 수 없게 만든다.
문자 어휘
반대쪽 끝으로 가면 어떨까. 토큰 하나를 문자 하나로 두면 어휘가 아주 작아진다. 영어는 알파벳 대소문자와 숫자, 문장부호를 합쳐 100개 남짓이면 끝난다. 한글도 완성형 음절이 11,172자이니 그 정도 어휘면 모든 한국어 문장을 표현할 수 있다. OOV는 원리적으로 사라진다.
대신 시퀀스가 길어진다. 「토크나이저」 다섯 글자가 다섯 토큰이 되고, 문장 하나가 수백 토큰이 된다. 트랜스포머의 어텐션 비용은 시퀀스 길이의 제곱으로 늘기 때문에 이 차이는 그대로 계산량이 된다. 모델이 한 번에 볼 수 있는 컨텍스트 윈도우도 같은 속도로 빨리 찬다.
더 근본적인 손실은 의미 단위가 사라진다는 점이다. 「c」·「a」·「t」 세 토큰에서 「고양이」라는 뜻을 다시 조립하는 일을 모델이 처음부터 배워야 한다. 단어 어휘가 공짜로 주던 정보를 버리고 그 자리를 파라미터와 학습량으로 메우는 셈이다.
서브워드
서브워드(subword)는 단어보다 작고 문자보다 큰 단위다. 두 끝의 장점만 취하자는 발상이고, 방법은 단순하다 — 자주 쓰이는 단어는 통째로 어휘에 넣고, 드물게 쓰이는 단어만 조각으로 나눈다. 「the」·「is」는 한 토큰이 되고 「antidisestablishmentarianism」은 여러 조각이 된다. 어휘 크기를 우리가 정할 수 있고, 그 크기 안에서 자주 나오는 것부터 채워 넣는 방식이다.
만드는 방법은 아래에서 위로 쌓아 올리는 것이다. 문자 단위로 흩어 놓고 시작해서, 붙어 다니는 쌍을 하나씩 합쳐 큰 조각을 만들어 나간다. 목표한 어휘 크기에 닿으면 멈춘다. BPE와 WordPiece가 공유하는 뼈대가 정확히 이것이다.
여기서 두 단계를 구분해 두는 것이 중요하다. 학습은 말뭉치를 훑어 병합 결과를 만드는 일이고, 모델 하나에 대해 딱 한 번 한다. 인코딩은 그 결과를 새 텍스트에 적용해 토큰 시퀀스를 뽑는 일이고, 사용자 요청마다 매번 한다. 그리고 두 알고리즘은 학습이 남기는 산출물부터 다르다 — BPE는 순서 있는 병합 규칙 목록을 남기고, WordPiece는 어휘 집합만 남긴다. 뒤에서 볼 인코딩 방식의 차이가 전부 이 산출물 차이에서 나온다.
BPE
초기 심볼
BPE는 원래 NLP 알고리즘이 아니라 데이터 압축 알고리즘이다. 파일에서 가장 자주 나오는 바이트 쌍을 쓰이지 않는 바이트 하나로 바꿔 넣기를 반복해 크기를 줄이는 방법이었다. 2016년 Sennrich 등이 이 아이디어를 기계 번역의 어휘 문제에 가져다 쓰면서 서브워드 토크나이저의 기준이 됐다.
말뭉치에 「low」가 5회, 「lower」가 2회, 「newest」가 6회, 「widest」가 3회 있다고 하자. 첫 손질은 모든 단어를 문자 단위로 흩어 놓고 끝에 단어 경계 마커 </w>를 붙이는 것이다.
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
마커가 왜 필요한지는 마커를 빼 보면 바로 드러난다. 마커가 없으면 「low」라는 단어의 low와 「lowest」 안에 들어 있는 low가 완전히 같은 심볼이 된다. 단어 끝에서만 나타나는 조각과 단어 안쪽에서만 나타나는 조각을 구분할 방법이 사라지고, 나중에 토큰을 다시 이어 붙일 때 어디에 공백을 넣어야 할지도 알 수 없다. </w>는 그 정보를 심볼 하나로 적어 두는 장치다.
이 글의 예시는 </w>를 병합 후보에서 빼는 규약을 쓴다. 경계는 끝까지 독립 심볼로 남고 아래에서 세는 쌍에도 들어가지 않는다. 반대 규약도 흔한데, 그 차이는 잠시 뒤에 다시 본다.
최빈 쌍 병합
이제 인접한 심볼 쌍의 빈도를 전부 센다. 단어의 등장 횟수를 가중치로 쓰므로 「low」에서 나온 (l, o)는 5를 보태고 「lower」에서 나온 (l, o)는 2를 보탠다. 합해서 (l, o) = 7이다. 같은 식으로 세면 (o, w) = 7, (w, e) = 8, (n, e) = 6, (e, s) = 9, (s, t) = 9가 나온다.
가장 큰 값은 9다. (e, s)가 「newest」에서 6번, 「widest」에서 3번 나왔기 때문이다. 이 쌍을 하나의 새 심볼 es로 합치고 어휘에 추가한다. 말뭉치의 모든 자리에서 e s가 es로 바뀐다.
두 번째 라운드에서는 빈도를 다시 센다. 방금 만든 es 덕분에 (es, t)라는 새 쌍이 생겼고 값은 역시 9다. 이 쌍을 합쳐 est를 만든다. 세 번째 라운드에서는 9짜리가 더 없고 (l, o)와 (o, w)가 7로 가장 높아 lo가 만들어진다.
값이 같은 쌍이 여럿이라는 점을 지나치면 안 된다. 첫 라운드에서 9인 쌍은 (e, s)만이 아니라 (s, t)도 있었다. 어느 쪽을 고르느냐에 따라 이후의 어휘가 통째로 달라지므로, 구현은 이 동점을 결정적으로 깨야 한다. 파이썬 Counter에 max를 쓰면 값이 같을 때 먼저 들어간 쪽이 이기고, 쌍은 말뭉치를 훑는 순서대로 들어가므로 결과가 항상 같다. 여기가 흔들리면 같은 말뭉치로 두 번 학습했을 때 다른 토크나이저가 나온다.
앞서 정해 둔 마커 규약이 여기서 값을 바꾼다. </w>를 후보에서 뺐기 때문에 첫 라운드의 9는 (e, s)와 (s, t) 둘뿐이었지만, </w>를 보통 심볼로 취급하면 (t, </w>)도 9로 나란히 서고 세 번째 라운드에서는 (est, </w>)가 9로 남아 「단어 끝의 est」가 est</w>라는 토큰 하나로 굳는다. 어느 쪽이든 동작하지만 나오는 어휘가 달라지므로, 남의 토크나이저 어휘를 읽을 때는 이 규약부터 확인해야 한다.
학습 루프 자체는 세 줄로 요약된다. 쌍의 빈도를 센다, 가장 많은 쌍을 고른다, 말뭉치 전체에서 그 쌍을 합친다. 이것을 num_merges번 반복하면 끝이다. 어휘의 각 단어는 'l o w </w>'처럼 공백으로 구분된 심볼 시퀀스로 들고 있으므로, 쌍을 셀 때는 word.split()으로 심볼 목록부터 얻고 그 목록에서 인접한 둘씩 센다.
실제 병합을 수행하는 부분은 이렇게 생겼다. 같은 표현을 쓰는 덕분에 병합은 문자열 치환 한 번으로 끝난다.
import re
def merge_vocab(vocab, pair):
"""어휘 내 모든 단어에서 선택된 쌍을 하나의 심볼로 합친다."""
pattern = re.escape(' '.join(pair)) # ('e', 's') → 'e\\ s'
replacement = ''.join(pair) # ('e', 's') → 'es'
return {re.sub(pattern, replacement, word): freq
for word, freq in vocab.items()}
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,
}
merges = bpe_train(vocab, num_merges=10)
# merges: [('e','s'), ('es','t'), ('l','o'), ('lo','w'), ...]
병합 한 번이 어휘에 심볼 하나를 더하므로 최종 어휘 크기는 「초기 문자 어휘 + 병합 횟수」다. 어휘 5만을 목표로 하고 초기 문자가 300개라면 병합을 대략 4만 9,700번 돌리면 된다. num_merges가 하이퍼파라미터처럼 보이지만 실제로는 원하는 어휘 크기에서 역산하는 값이다.
규칙 순차 적용
학습이 끝나면 남는 것은 [('e','s'), ('es','t'), ('l','o'), ('lo','w'), ...] 같은 목록이다. 새 텍스트를 만나면 다시 문자로 흩어 놓고 이 규칙을 학습된 순서 그대로 적용한다.
def encode(text, merges):
"""학습된 병합 규칙으로 텍스트를 토큰 시퀀스로 바꾼다."""
words = [list(w) + ['</w>'] for w in text.split()]
for pair in merges: # 순서가 곧 우선순위다
words = [apply_merge(w, pair) for w in words]
return [tok for word in words for tok in word]
def apply_merge(symbols, pair):
out, i = [], 0
while i < len(symbols):
if (i < len(symbols) - 1
and symbols[i] == pair[0] and symbols[i + 1] == pair[1]):
out.append(pair[0] + pair[1])
i += 2
else:
out.append(symbols[i])
i += 1
return out
순서를 지켜야 하는 이유를 「lowest」로 따라가 보자. 규칙이 위 목록 순서라면 l o w e s t </w>는 (e, s)로 l o w es t </w>가 되고, (es, t)로 l o w est </w>가 되고, (l, o)로 lo w est </w>가 되고, (lo, w)로 low est </w>가 된다. 최종 결과는 ["low", "est", "</w>"] 세 심볼이고, 마지막 </w>가 여기서 단어가 끝났음을 알린다.
만약 (lo, w)를 먼저 시도했다면 어떻게 될까. 그 시점에는 lo라는 심볼이 아직 존재하지 않으므로 규칙이 아무 일도 하지 못하고 지나간다. 뒤쪽 규칙은 앞쪽 규칙이 만들어 놓은 심볼을 재료로 쓴다. 그래서 BPE의 학습 산출물은 집합이 아니라 순서 있는 목록이어야 하고, 규칙 목록을 정렬하거나 섞으면 토크나이저가 통째로 망가진다.
이 방식의 대가는 속도다. 규칙을 처음부터 끝까지 훑으므로 규칙이 5만 개면 짧은 단어 하나를 자르는 데도 5만 번의 시도가 필요하다. 그래서 실무 구현은 규칙마다 순위를 매겨 두고 단어 안에 실제로 존재하는 쌍 중 순위가 가장 높은 것만 골라 합치는 방식으로 뒤집고, 단어별 결과를 캐시에 담아 둔다. 같은 단어가 반복해서 나오는 자연어에서는 캐시가 대부분의 일을 없애 준다.
WordPiece
빈도의 한계
WordPiece는 Google이 2012년 일본어·한국어 음성 인식에서 처음 제안한 방식이다. 2018년 BERT에 쓰이면서 NLP 주류가 됐고, DistilBERT·ELECTRA·mBERT 같은 BERT 계열 모델이 모두 이것을 쓴다.
출발점의 문제의식은 이렇다. 빈도만으로 쌍을 고르면 각자 흔하기만 한 조각들끼리 먼저 붙는다. 영어에서 e와 s는 둘 다 매우 흔한 문자다. 둘이 특별히 붙어 다니는 사이가 아니어도, 각자 자주 나오기만 하면 우연히 인접할 기회가 그만큼 많아진다. 빈도 9는 그 우연의 결과일 수도 있고 진짜 결속의 결과일 수도 있는데, BPE의 점수는 둘을 구별하지 못한다.
우리가 원하는 조각은 그런 것이 아니다. un과 known처럼 각자보다 함께일 때 훨씬 의미가 뚜렷해지는 쌍, 즉 형태소 경계에 가까운 쌍을 골라야 언어학적으로 쓸모 있는 어휘가 만들어진다.
우도 기반 점수
WordPiece의 점수는 그 「우연 대비」를 그대로 수식으로 옮긴다.
분모가 핵심이다. 는 두 심볼이 아무 관계 없이 각자의 빈도대로만 흩어져 있을 때 기대되는 동시 등장 비율이다. 분자를 그것으로 나누면 「우연보다 몇 배 자주 붙어 있는가」가 남는다. 이 값에 로그를 씌운 것이 상호 정보량(PMI, Pointwise Mutual Information)이고, 병합으로 말뭉치의 언어 모델 로그 우도가 오르는 폭이 여기에 비례한다. WordPiece를 「우도 기반 병합」이라고 부르는 이유가 여기 있다.
숫자를 하나 넣어 보면 두 점수가 정반대 방향으로 움직이는 것이 보인다. 심볼 자리가 100만 개인 말뭉치를 가정하고, e가 6만 회, s가 3만 회 나오며 둘이 인접한 경우가 9,000회라고 하자. 한편 un은 4,000회, known은 2,000회, unknown은 400회다.
| 쌍 | 각각의 확률 | 함께 나올 확률 | BPE 점수 | WordPiece 점수 |
|---|---|---|---|---|
| e + s | 0.06, 0.03 | 0.009 | 9,000 | 5.0 |
| un + known | 0.004, 0.002 | 0.0004 | 400 | 50.0 |
BPE는 9,000과 400을 비교해 위쪽을 고른다. WordPiece는 5와 50을 비교해 아래쪽을 고른다. e와 s는 우연히 붙을 기회가 워낙 많아 9,000회도 기대치의 다섯 배밖에 안 되지만, un과 known은 400회가 기대치의 쉰 배다. 같은 말뭉치를 놓고도 두 알고리즘이 서로 다른 어휘를 만들어 내는 지점이 정확히 여기다.
대가는 학습 비용이다. BPE는 쌍의 개수만 세면 되지만 WordPiece는 후보마다 확률을 계산하고 병합이 우도를 얼마나 올리는지를 재야 한다. 병합 한 번마다 이 계산을 다시 하므로 같은 어휘 크기를 만드는 데 시간이 더 든다. 학습은 한 번뿐이라 치명적이지는 않지만, 어휘 크기를 바꿔 가며 여러 번 실험할 때는 이 차이가 체감된다.
최장 일치
앞에서 두 알고리즘의 학습 산출물이 다르다고 했다. WordPiece가 남기는 것은 어휘 집합이고 병합 순서는 남지 않는다. 그러니 BPE처럼 규칙을 차례로 적용하는 인코딩은 애초에 불가능하다. 대신 쓰는 것이 최장 일치(Longest Match First)다 — 단어의 왼쪽 끝에서 시작해 어휘에 들어 있는 가장 긴 조각을 떼어 내고, 잘린 자리에서 같은 일을 반복한다.
def wordpiece_encode(word, vocab):
"""왼쪽부터 어휘에 있는 가장 긴 조각을 떼어 낸다."""
tokens, start = [], 0
while start < len(word):
end = len(word)
piece = None
while start < end:
candidate = word[start:end]
if start > 0:
candidate = '##' + candidate # 단어 안쪽 조각
if candidate in vocab:
piece = candidate
break
end -= 1
if piece is None:
return ['[UNK]'] # 조각 하나가 없으면 단어 전체를 버린다
tokens.append(piece)
start = end
return tokens
「unaffable」을 넣어 보자. 처음에는 unaffable 전체를 어휘에서 찾고, 없으면 한 글자씩 줄여 unaffabl, unaffab, … 순으로 내려간다. un에서 걸리면 그것을 떼고 위치를 2로 옮긴다. 이제 남은 부분은 단어 안쪽이므로 ##affable부터 찾기 시작해 ##aff에서 걸리고, 마지막으로 ##able이 걸린다. 결과는 ["un", "##aff", "##able"] 셋이다.
이 방식은 탐욕적(greedy, 매 단계에서 지금 최선인 선택만 하고 되돌아가지 않는 방식)이라 전역 최적을 보장하지 않는다. 왼쪽에서 긴 조각을 먼저 떼는 바람에 오른쪽이 잘게 부서지는 분해가 나올 수 있고, 전체 조각 수를 최소로 만드는 다른 분해가 있어도 찾지 못한다. 대신 되돌아가지 않으므로 빠르고, 규칙 목록을 훑을 필요가 없어 BPE의 순차 적용보다 인코딩이 단순하다.
경계 표기와 OOV
경계 마커
두 알고리즘의 결과물을 눈으로 봤을 때 가장 먼저 눈에 띄는 차이가 마커다. BPE는 단어 끝에 </w>를 붙이고, WordPiece는 단어 안쪽 조각의 앞에 ##를 붙인다. 같은 정보를 반대 방향에서 적는 셈이다.
| BPE | WordPiece | |
|---|---|---|
| 마커 | </w> |
## |
| 붙는 자리 | 단어의 마지막 조각 뒤 | 첫 조각을 뺀 나머지 앞 |
| 「unaffable」 | un, aff, able, </w> |
un, ##aff, ##able |
| 알려 주는 것 | 여기서 단어가 끝났다 | 이 조각은 단어 안쪽이다 |
##의 실익은 같은 글자열을 두 개의 다른 토큰으로 둘 수 있다는 점이다. ing는 독립 단어이고 ##ing는 접미사다. 어휘에 둘을 따로 넣어 두면 임베딩도 따로 학습되고, 모델은 「단어로서의 ing」와 「단어 끝에 붙는 ing」를 다른 것으로 본다. 마커가 없다면 이 둘이 한 벡터를 공유했을 것이다.
디코딩 쪽에서도 마커가 일한다. WordPiece는 ##이 붙은 조각을 앞 토큰에 그대로 이어 붙이고 나머지 앞에는 공백을 넣으면 원문이 복원된다. </w> 쪽은 마커를 공백으로 바꾸면 된다. 어느 방식이든 요구사항은 하나다 — 토큰을 다시 이어 붙여 원문이 그대로 나와야 한다. 이 요구가 완전히 만족되지 않는 자리가 남아 있고, 그것이 이 글 마지막의 전처리 문제다.
바이트 레벨 BPE
GPT-2가 도입한 Byte-Level BPE는 OOV 문제를 다른 층위에서 없앤다. 기본 단위를 유니코드 문자가 아니라 바이트로 내리는 것이다. 유니코드 문자는 십만 개가 넘고 표준이 개정될 때마다 늘어나지만, 바이트는 언제나 정확히 256개다.
# 256개 바이트를 기본 어휘로 깔고 그 위에 병합을 쌓는다
base_vocab = {bytes([i]).decode('latin-1'): i for i in range(256)}
이 위에 병합을 쌓으면 최종 어휘 크기가 깔끔한 식으로 정해진다.
GPT-2의 어휘 50,257개가 정확히 이 합이다 — 바이트 256개, 병합 5만 번, 그리고 문서 끝을 표시하는 <|endoftext|> 하나. 어휘 크기가 어중간한 숫자로 보였다면 그 이유가 이것이다.
이 구조에서는 OOV가 원리적으로 불가능하다. 어떤 유니코드 문자든 UTF-8로 쓰면 바이트 시퀀스이고, 모든 바이트가 어휘에 있기 때문이다. 처음 보는 이모지도, 학습 말뭉치에 없던 문자 체계도 토큰으로 표현된다. GPT-4의 cl100k_base가 10만 남짓, LLaMA 3가 12만 8천 규모의 어휘를 쓰는데 모두 같은 방식이다.
다만 OOV가 없다는 것과 효율적이라는 것은 다르다. 학습 말뭉치에서 거의 못 본 문자는 병합이 쌓이지 않아 바이트 낱개로 쪼개진다. 한글 한 글자는 UTF-8에서 3바이트이므로, 한국어를 별로 안 본 토크나이저에서는 「글」 한 글자가 토큰 셋이 된다. 같은 문장이 영어보다 두세 배 많은 토큰을 먹는다는 뜻이고, 이것이 그대로 API 비용과 컨텍스트 소모로 나타난다. 최근 모델들이 어휘를 10만 이상으로 키운 이유의 상당 부분이 이 다국어 효율이다.
UNK 처리
WordPiece에는 이 바닥이 없다. 최장 일치가 한 글자까지 줄여도 어휘에서 못 찾으면 [UNK]를 낸다. 그리고 위 코드가 보여 주듯 버려지는 것은 못 찾은 조각 하나가 아니라 단어 전체다. 「café2024」에서 é 하나가 어휘에 없으면 단어 통째로 [UNK] 하나가 된다.
실제로는 잘 터지지 않는다. BERT의 어휘에는 말뭉치에 등장한 개별 문자와 그 ## 버전이 함께 들어 있어서, 최악의 경우에도 한 글자씩 쪼개져 나오기 때문이다. 문제가 되는 것은 학습 때 아예 본 적 없는 문자다 — 다루지 않은 문자 체계, 드문 기호, 이모지가 그렇다.
BERT의 기본 전처리에 손실이 한 겹 더 있다는 것도 알아 둘 만하다. bert-base-uncased처럼 이름에 uncased가 붙은 모델은 토크나이즈 전에 소문자화와 악센트 제거를 거친다. 「Apple」과 「apple」이 같은 토큰이 되고 「café」는 「cafe」가 된다. 어휘를 아끼는 대신 대소문자 정보를 버리는 선택이고, 되돌릴 수 없다. 개체명 인식처럼 대문자가 단서인 작업에서는 cased 모델을 써야 하는 이유다.
BERT 계열
특수 토큰
BERT의 어휘에는 WordPiece가 만든 조각 외에 모델 구조가 요구하는 특수 토큰이 다섯 개 들어 있다. 이것들은 병합으로 생긴 것이 아니라 손으로 넣은 자리다.
| 토큰 | ID | 역할 |
|---|---|---|
[PAD] |
0 | 배치 안에서 길이를 맞추는 채움 |
[UNK] |
100 | 어휘에서 못 찾은 단어 |
[CLS] |
101 | 문장 시작 · 분류용 자리 |
[SEP] |
102 | 문장 구분 · 종료 |
[MASK] |
103 | 마스크 언어 모델 학습용 빈칸 |
[CLS] 토큰의 최종 은닉 상태가 문장 전체의 표현으로 쓰인다. 분류 작업에서는 여기에 선형 층 하나를 얹어 라벨을 낸다. [SEP]은 두 문장을 이어 넣을 때 경계를 표시하거나 입력의 끝을 알린다.
ID 값이 0, 100, 101, 102, 103으로 띄엄띄엄한 것도 이유가 있다. ID는 어휘 파일에서 그 토큰이 몇 번째 줄인지를 뜻하는데, 1번부터 99번까지는 [unused0]부터 시작하는 빈 자리로 채워져 있다. 나중에 도메인 전용 토큰을 어휘 크기를 바꾸지 않고 끼워 넣을 수 있도록 남겨 둔 공간이다.
한국어 음절 분해
한국어 BERT로 실제 문장을 넣어 보면 서브워드 경계가 어떻게 잡히는지 바로 보인다.
from transformers import BertTokenizer
tokenizer = BertTokenizer.from_pretrained("klue/bert-base")
text = "토크나이저는 텍스트를 서브워드로 분해한다."
print(tokenizer.tokenize(text))
# ['토크', '##나이', '##저', '##는', '텍스트', '##를', '서브', '##워드', '##로',
# '분해', '##한다', '.']
「토크나이저」 한 낱말이 셋으로 쪼개졌고 그 경계가 의미와 아무 관련이 없다. 「토크」·「나이」·「저」 어느 것도 이 단어의 뜻과 상관없는 조각이다. 조사 「는」·「를」·「로」가 ##을 달고 떨어져 나온 것은 오히려 자연스럽다 — 교착어의 형태소 경계와 얼추 맞는 자리다. 이쪽은 우연이 아니다. KLUE BERT는 어휘를 만들 때 형태소 분석기로 먼저 끊어 놓고 그 조각 위에 WordPiece를 올렸기 때문에, 조사가 떨어지는 자리는 설계로 들어간 것이다.
한국어가 이렇게 잘게 쪼개지는 데는 두 가지가 겹친다. 첫째, 한글 완성형 음절이 11,172자라 어휘에 한국어 조각을 넉넉히 담으려면 자리를 많이 써야 한다. 어휘를 영어 중심으로 학습하면 한국어 몫으로는 음절 낱개 정도만 남는다. 둘째, 앞서 본 교착어 특성 탓에 같은 어간이 수십 가지 표면형으로 나타나 각 표면형의 빈도가 흩어진다. 병합 점수는 빈도에 기대므로 흩어진 것은 병합될 기회를 얻지 못한다.
KLUE BERT나 KoBERT는 한국어 말뭉치로 어휘를 따로 학습해 이 문제를 완화한 모델들이다. 다만 둘의 방식은 다르다 — KLUE BERT가 WordPiece를 그대로 쓴 반면 KoBERT는 어휘 자체를 SentencePiece로 만들었다. 한국어 서비스에서는 모델의 성능 지표만 볼 것이 아니라 자기 도메인 문장을 실제로 넣어 토큰 수를 세어 보는 편이 낫다. 국내 모델들의 토크나이저 이야기는 한국 LLM 완전 해부에서 따로 다뤘다.
어휘 크기와 선택 기준
어휘 크기의 대가
어휘 크기는 두 알고리즘 모두에서 가장 중요한 하이퍼파라미터다. 값 하나가 세 군데를 동시에 움직인다.
| 어휘 크기 | 시퀀스 길이 | 임베딩 행렬 | 성격 |
|---|---|---|---|
| 1만 안팎 | 길다 | 작다 | 드문 단어가 잘게 쪼개진다 |
| 5만 안팎 | 보통 | 보통 | GPT-2 수준, 영어에 맞춘 크기 |
| 10만 이상 | 짧다 | 크다 | 다국어 조각을 담을 자리가 생긴다 |
임베딩 행렬 쪽 숫자를 한 번 세어 보면 규모가 잡힌다. 은닉 차원이 4,096인 모델에서 어휘가 5만이면 임베딩 행렬은 , 약 2억 개다. 어휘를 12만 8천으로 키우면 약 5억 2천만 개로 뛴다. 출력 층이 입력 임베딩과 가중치를 공유하지 않는 구조라면 이 크기가 한 벌 더 붙는다. 어휘를 키우는 결정은 파라미터 3억 개를 더 쓰는 결정과 같다.
반대편에서 얻는 것은 시퀀스 길이다. 같은 문장이 더 적은 토큰이 되면 어텐션 비용이 줄고, 같은 컨텍스트 창에 더 많은 내용이 들어가며, 토큰 단위로 매기는 API 비용도 내려간다. 조용한 손실도 하나 있다 — 어휘를 키울수록 드물게만 쓰이는 토큰이 늘고, 그런 토큰의 임베딩은 학습 신호를 적게 받아 잘 익지 않는다. 어휘를 키운다고 무한정 나아지지 않는 이유다.
한국어 비중이 높은 서비스라면 이 결정이 특히 중요하다. 앞에서 본 바이트 낱개 분해가 여기서 그대로 비용이 되므로, 한국어 텍스트를 충분히 포함한 말뭉치로 토크나이저를 학습하거나 이미 한국어 어휘가 넉넉한 모델을 고르는 쪽이다.
BPE와 WordPiece
지금까지 본 네 자리를 한 표에 모으면 이렇게 된다.
| BPE | WordPiece | |
|---|---|---|
| 병합 점수 | ||
| 학습 산출물 | 순서 있는 병합 규칙 | 어휘 집합 |
| 인코딩 | 규칙을 순서대로 적용 | 왼쪽부터 최장 일치 |
| 경계 마커 | 단어 끝 </w> |
조각 앞 ## |
| OOV | 바이트 레벨이면 없음 | 단어 통째로 [UNK] |
| 학습 비용 | 낮다 | 높다 (우도 계산) |
| 쓰는 곳 | GPT, LLaMA, Mistral | BERT, DistilBERT, ELECTRA, mBERT |
생성 모델이 BPE로 몰린 이유는 표의 다섯째 줄에 있다. 생성 모델은 무엇이든 입력으로 받고 무엇이든 출력해야 하는데, 코드와 이모지와 여러 언어가 섞인 입력에서 [UNK]가 하나라도 뜨면 그 자리는 복원이 불가능하다. 바이트 레벨 BPE는 그 요구를 정확히 만족한다. 게다가 학습이 싸서 어휘 크기를 바꿔 가며 실험하기도 쉽다.
WordPiece가 사라지지 않은 이유는 성능이 아니라 관성이다. 이미 사전학습을 마친 BERT 계열 체크포인트가 전부 그 어휘에 묶여 있다. 토크나이저를 바꾸면 임베딩 행렬의 의미가 통째로 달라지므로 사전학습을 처음부터 다시 해야 한다. 그래서 BERT 계열을 파인튜닝해 쓰는 한 WordPiece는 계속 남는다.
둘 중 하나를 직접 고르는 상황은 생각보다 드물다. 모델을 정하면 토크나이저가 따라오기 때문이다. 정말로 고르는 자리는 처음부터 자기 말뭉치로 사전학습할 때이고, 그때는 바이트 레벨 BPE가 무난한 기본값이다.
공백 분리 전제
두 알고리즘이 공유하는 전제가 하나 있다. 앞의 인코딩 코드에서 아무렇지 않게 지나간 text.split()이 그것이다 — 텍스트가 이미 단어로 나뉘어 있다는 가정이고, 더 정확히는 공백이 단어를 나눈다는 가정이다.
이 전제는 언어를 가린다. 중국어·일본어·태국어는 단어 사이에 공백을 쓰지 않으므로 애초에 나눌 기준이 없고, 언어마다 별도의 단어 분리기를 앞에 붙여야 한다. 한국어는 공백을 쓰지만 띄어쓰기가 흔들려서 같은 문장이 여러 방식으로 나뉜다.
복원 쪽에도 구멍이 남는다. 공백으로 자르고 나면 원래 공백이 하나였는지 둘이었는지, 줄바꿈이었는지 탭이었는지가 사라진다. 토큰을 다시 이어 붙여도 원문과 정확히 같은 문자열이 나오지 않는다는 뜻이고, 코드나 서식 있는 텍스트를 다루는 모델에서는 그냥 넘길 수 없는 손실이다.
다음 글에서는 이 전제 자체를 없애는 접근을 본다. 공백을 미리 자르지 않고 원시 문자열을 통째로 먹되, 공백조차 하나의 기호로 어휘에 넣어 두는 방식이다. 언어마다 다른 전처리가 필요 없어지고 복원도 정확해진다. 그 위에서 실제 서비스가 쓰는 토크나이저 구현과 토큰 수를 직접 세어 보는 방법까지 함께 다룬다.
읽어주셔서 감사합니다. 😊

