토크나이저 — 모델이 보는 것은 글자가 아니다
한 줄 요약
모델은 글자도 단어도 아닌 토큰 id 의 열을 본다. 그 변환 규칙을 정하는 것이 토크나이저이고, 어떤 규칙을 쓰느냐가 비용, 문맥 한도, 다국어 품질을 동시에 좌우한다. 토큰 수는 글자 수로 어림할 수 없으므로 반드시 그 모델의 토크나이저로 센다.
왜 이게 필요했나
월말에 API 청구서를 받은 팀이 있다고 하자. 같은 공지문을 영어판과 한국어판으로 요약시켰는데, 글자 수는 비슷한데 한국어판의 입력 토큰이 몇 배로 찍혀 있다. 같은 날 문맥 한도에 걸려 한국어 문서만 뒷부분이 잘려 나갔다는 신고도 들어온다. 둘 다 원인이 같다. 모델이 한국어 문장을 더 잘게 쪼개서 보고 있기 때문이다. 왜 그렇게 되는지 알려면 토크나이저가 어떻게 만들어지는지부터 봐야 한다.
첫 번째 시도는 단어 단위였다. 문제는 어휘가 무한하다는 것이다. 사전에 없는 단어는 전부 미지 토큰이 되어 정보가 사라진다. 한국어처럼 조사와 어미가 붙는 언어에서는 같은 어간의 변형만으로도 어휘가 폭발한다.
두 번째 시도는 문자 단위였다. 어휘는 작아지지만 열이 너무 길어진다. 문맥 길이가 제한된 모델에서 길이는 곧 비용이고, 멀리 떨어진 관계를 학습하기도 어려워진다.
BPE(Byte Pair Encoding) 는 그 사이를 찾은 답이다. 문자에서 시작해 자주 붙어 나오는 쌍을 하나로 합치기를 반복한다. 자주 쓰이는 단어는 통째로 하나의 토큰이 되고, 드문 단어는 조각으로 남는다. 어휘 크기를 고정하면서 미지 토큰을 사실상 없앤다.
어떻게 동작하나
학습 절차는 놀랄 만큼 단순하다.
- 코퍼스를 단어로 나누고 각 단어를 문자 리스트로 만든다. 단어 경계를 잃지 않도록 끝에 특별한 표시를 붙인다.
- 전체에서 가장 자주 인접하는 쌍을 찾는다. 빈도가 같으면 정해 둔 규칙(예: 사전순)으로 하나를 고른다.
- 그 쌍을 하나의 토큰으로 합치고, 이 병합 규칙을 순서대로 기록한다.
- 원하는 어휘 크기가 될 때까지 2~3을 반복한다. 최종 어휘 크기는 기본 문자 수에 병합 횟수를 더한 값이다.
동점 규칙이 사소해 보이지만 결정적이다. 동점을 아무렇게나 깨면 같은 코퍼스로 학습해도 실행마다 병합 순서가 달라지고, 그 뒤의 모든 규칙과 id 가 어긋난다. 토크나이저는 재현 가능해야 쓸 수 있는 부품이다.
인코딩은 학습된 병합 규칙을 기록된 순서 그대로 적용하는 것이다. 순서가 왜 중요한지는 작은 예로 보인다. 병합 규칙이 아래 순서로 학습되었다고 하자.
merges (in order): 1) e r 2) w e
word: l o w e r _
apply 1 then 2: l o w er _
apply 2 then 1: l o we r _
같은 규칙 두 개인데 적용 순서만 바꿔도 결과 토큰이 달라진다. 학습할 때 er 이 먼저 만들어졌다는 사실이 곧 "이 코퍼스에서는 er 로 묶는 편이 더 흔했다" 는 정보이므로, 인코딩도 그 순서를 따라야 학습 때와 같은 분할이 나온다. 디코딩은 반대로 쉽다. 토큰을 이어 붙이고 단어 끝 표시를 공백으로 되돌리면 된다.
실제 모델의 토크나이저는 이 뼈대에 몇 가지를 더한다.
- 바이트 수준 BPE. 유니코드 문자 전체를 기본 어휘로 두면 너무 크므로, 256개의 바이트 값을 기본 어휘로 삼는다. 어떤 문자열도 바이트로는 표현되므로 미지 토큰이 생기지 않는다. GPT-2 의 어휘 50,257개는 바이트 256개, 병합 50,000개, 문장 끝 특수 토큰 1개로 이루어진다.
- SentencePiece. 공백을
▁라는 문자로 바꿔 어휘 안에 넣는다. 공백으로 단어를 나누지 않는 언어에도 그대로 쓸 수 있고, 디코딩은 토큰을 이어 붙인 뒤▁를 공백으로 바꾸면 끝난다. - WordPiece. BERT 계열이 쓰는 방식으로, 단어 중간 조각에
##를 붙여 표시하고 병합할 쌍을 빈도가 아니라 가능도 기준으로 고른다.
여기서 한국어 비용 문제가 설명된다. UTF-8 에서 한글 음절 하나는 3바이트다(예: 가 는 EA B0 80). 바이트 수준 토크나이저가 학습 코퍼스에서 한국어를 충분히 보지 못했다면 한글 음절을 하나로 합치는 병합 규칙이 적게 만들어지고, 그 음절은 최악의 경우 바이트 세 개, 곧 토큰 세 개로 남는다. 영어 단어는 통째로 한 토큰이 되는 동안 한국어는 음절 하나가 여러 토큰이 되는 것이다. 토크나이저를 바꿀 수도 없다. 어휘가 바뀌면 임베딩 행렬이 바뀌고 모델을 다시 학습해야 하기 때문이다.
현장에서 무엇이 잘못되나
다른 토크나이저로 센다. 비용 추정이나 문맥 예산을 계산할 때 편한 라이브러리 하나로 모든 모델의 토큰을 세는 경우가 많다. 모델마다 어휘가 다르므로 숫자가 틀리고, 증상은 "예산 안이라고 계산했는데 출력이 잘린다" 로 나타난다. 토큰 수는 호출할 그 모델의 토크나이저로 세거나, API 가 응답에 돌려주는 사용량을 기록해 보정한다.
유니코드 정규화가 섞인다. 한글은 같은 글자를 두 가지로 표현할 수 있다. 완성형 음절 하나(NFC)로 적을 수도 있고, 초성·중성·종성 자모를 이어 붙여(NFD) 적을 수도 있다. 화면에서는 똑같이 보이지만 코드포인트 수가 두세 배이고, 토큰 수도 따라 늘며, 문자열 비교와 검색이 조용히 실패한다. 예전 macOS 파일 시스템을 거친 파일 이름이나 일부 문서 변환기에서 나온 텍스트가 이런 모양이다. "같은 문장인데 토큰 수가 유독 많다" 면 정규화부터 의심한다.
특수 토큰 문자열이 입력에 섞인다. 사용자가 붙여 넣은 텍스트에 문장 끝 표시 같은 특수 토큰의 문자열이 들어 있으면, 그것을 평범한 글자로 볼지 제어 토큰으로 볼지가 문제가 된다. 제어 토큰으로 해석되면 입력이 거기서 끊긴 것처럼 동작할 수 있다. 그래서 tiktoken 은 기본값으로 이런 입력을 만나면 오류를 낸다. 오류를 끄기 전에 왜 막혀 있는지부터 이해해야 한다.
모델이 글자를 못 센다고 놀란다. 토큰 경계는 문자 경계와 다르다. "이 단어의 세 번째 글자" 나 자릿수 계산에서 모델이 실수하는 것은 모델이 멍청해서가 아니라 애초에 그 단위를 보지 못하기 때문이다. 이런 일은 모델에 맡기지 말고 코드로 처리한다.
공백과 서식이 토큰을 먹는다. 들여쓰기, 연속된 줄바꿈, 반복되는 마크다운 기호가 생각보다 많은 토큰을 차지한다. 프롬프트를 줄일 때는 글자 수가 아니라 토큰 수를 보고, 어느 부분이 많이 먹는지 토큰을 직접 찍어 확인한다.
어떻게 확인하나
토크나이저를 만들거나 바꿨다면 세 가지를 반드시 본다.
import unicodedata
text = open("corpus.txt", encoding="utf-8").read()
print(unicodedata.is_normalized("NFC", text)) # False means mixed forms
print(len(text), len(text.encode("utf-8"))) # characters vs bytes
# round trip: decode(encode(x)) must equal x for every line
# compression: characters / tokens, compare across languages
첫째, 왕복 검사. 인코딩한 뒤 디코딩한 결과가 원문과 한 글자도 다르지 않아야 한다. 단어 끝 표시를 빠뜨리면 공백을 복원할 수 없어 여기서 바로 드러난다. 한 줄이라도 다르면 그 줄을 diff 로 비교해 어느 토큰에서 어긋났는지 찾는다.
둘째, 압축비. 문자 수를 토큰 수로 나눈 값이다. 값이 클수록 토큰 하나가 많은 글자를 담는다는 뜻이다. 같은 내용의 영어판과 한국어판을 나란히 재면, 한국어 비용이 왜 더 나오는지 숫자로 설명할 수 있다.
셋째, 병합 규칙의 앞부분을 읽는다. 처음 몇십 개의 병합은 코퍼스에서 가장 흔한 조각이다. 조사나 어미처럼 예상한 조각이 보이는지, 이상한 조각(공백 문자, 제어 문자)이 섞여 있지 않은지 눈으로 확인한다. 이상한 조각이 보이면 전처리가 잘못된 것이다.
이어서 읽을 것
바로 뒤의 이론에서 텍스트를 벡터로 바꾸는 임베딩을 다룬 뒤, 첫 실습에서 BPE 를 바닥부터 구현한다. 문자 빈도를 세고, 기본 어휘를 만들고, 동점 규칙까지 지켜 병합 규칙 120개를 학습하고, 그 규칙을 기록된 순서대로 적용해 문서를 인코딩한 뒤, id 열만으로 원문을 복원하는 왕복 검사와 압축비 계산까지 한다. 라이브러리 없이 파이썬만 쓴다.