Table of contents
Open Table of contents
들어가며
토크나이저 문제를 처음 의식한 건 3년 전이었다.
LLaMA 유출 이후 로컬 AI에 관심이 생겼고, 나도 LLaMA 기반 한국어 모델을 만들어 보려고 했다. 그때 막힌 지점 중 하나가 토크나이저였다. 학습 코드는 LLaMA 계열을 따라가고 싶은데, 기본 토크나이저는 한국어를 충분히 고려한 설계가 아니었다. 결국 직접 모은 데이터로 SentencePiece를 다시 학습해 썼다. 예전 글에도 “이게 최선인지는 잘 모르겠다”고 적어 두었다. 말 그대로 그 정도의 이해였다.
몇 년이 지나 다시 언어 모델 개발로 돌아왔을 때, 나는 이 문제가 꽤 많이 나아졌을 거라고 생각했다. 모델도 많아졌고, 한국어 LLM도 늘었고, 토크나이저도 당연히 더 좋아졌을 것 같았다. 그런데 막상 직접 작은 모델을 학습하고 여러 토크나이저를 비교해 보니, 언어별 토큰 소모량 차이는 여전히 크게 남아 있었다.
그게 이 실험의 시작이다.
토큰 수는 단순한 숫자가 아니다. 학습 비용이고, 추론 시간이고, 컨텍스트 창 안에 실제로 담을 수 있는 정보량이다. 특히 로컬 AI에서는 이 차이가 더 크게 느껴진다. 가진 하드웨어 안에서 모델을 만들어야 하니까, 입력을 몇 토큰으로 표현하는지도 모델 구조만큼 중요하다.
이번 글은 그 첫 번째 기록이다. 한국어 토크나이저를 직접 만들면서, 형태소 경계를 반영하면 좋아질 거라는 가설에서 출발해 결국 pre-tokenizer라는 더 낮은 단계로 시선이 옮겨가는 과정이다.
성공담이라기보다는 시행착오에 가깝다. 나는 여러 번 틀렸고, 몇 번은 꽤 그럴듯하게 틀렸다. 이 글의 목적은 최종 정답만 깔끔하게 보여주는 데 있지 않다. 어떤 생각으로 실험을 시작했고, 왜 그 생각을 버리게 되었는지 남기는 데 있다.
왜 한국어 토크나이저를 직접 만들려고 했나
이번에는 2023년처럼 “일단 한국어 데이터로 다시 학습해서 끼우자”에서 멈추고 싶지 않았다. 한국어를 더 적은 토큰으로 표현하려면 무엇을 바꿔야 하는지 보고 싶었다. 단순히 vocab을 키우면 되는지, 한국어 데이터를 더 넣으면 되는지, pre-tokenizer부터 바꿔야 하는지 하나씩 확인해 보기로 했다.
처음 생각은 단순했다. 토큰 수를 줄이려면 한국어의 특성을 더 반영해야 할 것 같았다. 한국어는 조사와 어미가 붙는 언어다. 그러니 형태소 경계를 알면 BPE가 더 나은 조각을 배울 수 있지 않을까 생각했다. 가장 쉬운 방법은 토크나이저 앞에 형태소 분석기를 붙이는 방식이다.
하지만 런타임에 형태소 분석기를 붙이는 방식은 제외했다. 언어 모델은 토크나이저와 사실상 한 몸으로 움직인다. 한 번 형태소 분석기를 토크나이저 앞에 넣으면, 그 모델은 학습할 때도 추론할 때도 같은 분석기를 거쳐야 한다. 모바일, 로컬 PC, 서버, 브라우저 어디에 배포하든 한국어 형태소 분석기와 같은 버전, 같은 설정이 함께 따라다녀야 한다. 연구용 스크립트라면 괜찮을 수 있지만, 배포할 모델로는 부담이 너무 크다.
그래서 제약을 하나 걸었다. 토큰 수는 줄이되, 최종 결과는 Hugging Face의 tokenizer.json 하나로 배포할 수 있어야 한다. llama.cpp나 transformers에 바로 얹을 수 있고, 표준 BPE 토크나이저처럼 쓸 수 있어야 했다.
목표는 이랬다.
- 한국어에서 토큰을 더 아끼는 토크나이저를 만든다.
- 최종 결과는 표준 BPE 토크나이저처럼 배포할 수 있어야 한다.
- 성능 평가는 감이 아니라 숫자로 한다.
여기서 자연스럽게 첫 가설이 나왔다.
학습할 때만 형태소 정보를 쓰고, 추론할 때는 일반 BPE처럼 쓰자.
모양은 표준 BPE와 같게 유지하되, 학습 과정에서만 형태소 경계를 살짝 알려주는 방식이다. 이러면 배포는 쉬우면서도 한국어 구조를 어느 정도 반영할 수 있을 것 같았다.
먼저 기준부터 정하자
토크나이저를 평가할 때는 주로 다음 지표를 본다. 네 지표 모두 낮을수록 좋다.
| 지표 | 뜻 | 주의할 점 |
|---|---|---|
| TPW | Tokens Per Word. 단어 또는 어절 하나를 표현하는 평균 토큰 수 | 언어마다 word 정의가 다를 수 있다 |
| Fertility | 입력 단위당 평균 토큰 수. 문서에 따라 tokens/word, tokens/char 등으로 쓴다 | 공백 토큰까지 포함한다 |
| CFert | Content Fertility. 공백성 토큰을 걷어내고 내용 압축을 보는 지표 | 총 토큰 수와 순위가 달라질 수 있다 |
| BPB | Bits Per Byte. LM이 바이트당 얼마나 잘 예측하는지 보는 지표 | 토큰 수와 별개로 LM 학습 결과가 필요하다 |
이번 글에서는 TPW, Fertility, CFert를 중심으로 본다. 세 지표는 언어 모델을 따로 학습하지 않아도 바로 계산할 수 있고, 같은 텍스트를 얼마나 적은 토큰으로 표현하는지 보여준다. BPB는 실제 언어 모델을 학습해야 의미가 있다. 그래서 이번 편에서는 토큰 수가 줄어드는 흐름에 집중하고, 이 차이가 언어 모델 성능으로 이어지는지는 뒤에서 따로 다룬다.
지금은 일단 한 질문에 집중하자.
한국어 텍스트를 더 적은 토큰으로 표현하려면 무엇을 바꿔야 할까?
내 첫 대답은 형태소였다.
첫 가설: 형태소를 조금 아는 BPE
BPE는 단순하다. 자주 붙어 나오는 쌍을 합친다. 바이트나 문자처럼 작은 단위에서 출발해, 가장 자주 등장하는 인접 쌍을 하나씩 merge한다. 원하는 vocab size에 도달할 때까지 이 과정을 반복한다. 이 방식은 단순하지만 강력하다.
다만 이 과정만으로는 한국어 특성을 충분히 반영하기 어렵다.
예를 들어 먹었습니다라는 표현을 생각해 보자. 사람은 대략 먹-, 었, 습니다 같은 단위로 볼 수 있다. 형태소 분석기는 더 촘촘하게 나눌 수도 있다. BPE는 그런 사실을 모른다. 단지 말뭉치에서 어떤 바이트 쌍이 자주 붙어 나오는지 본다.
여기서 첫 아이디어가 나왔다.
BPE가 형태소 경계를 따르게 제약하면 어떨까?
처음 설계는 세 층으로 나뉘었다.
첫째는 script-aware pre-tokenizer다. BPE가 merge를 배우기 전에 텍스트를 한 번 잘라 주는 단계다. 이때의 문제의식은 문자권 차이였다. byte-level BPE에서 영어 알파벳은 보통 1바이트지만, 한글 음절은 UTF-8에서 3바이트로 표현한다. 같은 merge 과정을 거쳐도 한국어는 먼저 글자 자체를 복원하는 데 더 많은 결합을 써야 한다. 영어와 한글이 섞인 말뭉치에서는 그 과정이 서로 경쟁할 수 있다고 봤다. 당시에는 한글, 영문, 숫자, 기호를 어느 정도 나눠 주면 한국어가 결합 과정에서 덜 불리해지고, 더 깔끔한 vocab이 만들어질 거라 생각했다.
둘째는 MorphBPE1다. 형태소 경계를 BPE 학습에 반영하려는 기존 논문의 방향을 빌려 왔다. 형태소 분석기로 경계를 표시해 두고, BPE가 그 경계를 넘는 merge를 고르려 할 때 점수를 조금 깎는다. 표준 BPE의 구조는 유지하되, 학습 중에만 “여기는 형태소 경계야”라는 힌트를 주는 방식이다.
셋째는 SuperBPE2다. 이것도 기존 논문의 아이디어를 차용했다. 일반 BPE가 만든 조각 위에 자주 함께 나오는 조합을 한 번 더 묶어 보는 방식이다. BPE가 놓친 긴 조합을 후처리로 보강하면 토큰 수를 더 줄일 수 있지 않을까 기대했다.
이 셋은 서로 다른 위치를 건드린다. pre-tokenizer는 BPE가 보기 전 입력 공간을 바꾼다. MorphBPE는 BPE가 merge를 고르는 순간의 점수를 바꾼다. SuperBPE는 BPE가 끝난 뒤 결과를 한 번 더 손본다.
한국어는 조사와 어미가 풍부하다. BPE가 아무렇게나 경계를 넘나들면 했다, 하고, 하면, 하는 같은 변형을 제각각 외울 수 있다. 형태소 경계를 조금 보존하면 더 재사용 가능한 조각이 생기고, 드문 활용형도 더 잘 표현할 수 있지 않을까.
이 가설은 내게 꽤 설득력 있게 들렸다.
첫 실험: 어? 생각보다 잘 나오는데?
초기 실험 결과는 꽤 좋아 보였다.
당시 실험 메모에서 내가 주로 본 숫자는 아래와 같았다. 모두 같은 12GB 혼합 코퍼스에서 학습한 내부 실험값이다. CFert는 공백성 토큰을 제외한 평균 토큰 수다. 낮을수록 좋다. Byte FB는 byte fallback 비율이다. 이 값이 높으면 학습된 subword가 아니라 원시 byte 토큰으로 떨어지는 경우가 많다는 뜻이다.
| 설정 | Vocab | 핵심 차이 | CFert↓ | Byte FB↓ |
|---|---|---|---|---|
| Vanilla BPE | 36K base | 한글, 영문, 숫자를 따로 나누지 않음 | 9.992 | 50.6% |
| MorphBPE + SuperBPE | 41K | 36K base + 형태소 penalty + SuperBPE | 2.317 | 0.37% |
| LLaMA 3.2 | 128K | 대형 vocab의 범용 baseline | 3.333 | 1.83% |
표만 보면 MorphBPE + SuperBPE가 훨씬 좋아 보인다. Vanilla BPE는 한국어를 거의 byte 단위로 흘려보냈고, MorphBPE + SuperBPE는 CFert를 9.992에서 2.317까지 낮췄다. LLaMA 3.2처럼 vocab이 훨씬 큰 범용 토크나이저와 비교해도 더 짧았다.
다만 이 표가 말해 주는 건 “이 조합이 짧다”까지다. 원인까지 말해 주지는 않는다. MorphBPE + SuperBPE 한 줄 안에는 script-aware pre-tokenizer, 형태소 경계 penalty, 후처리 merge가 함께 들어가 있었다.
그래서 변수를 쪼갰다
실험에서 여러 변수를 동시에 바꾸면 결과 해석은 쉽게 미끄러진다. 특히 토크나이저는 더 그렇다. 같은 vocab size라도 학습 데이터 순서, pre-tokenizer regex, ByteLevel 설정, special token 개수, initial alphabet, newline 처리 하나가 결과를 바꿀 수 있다. 겉으로는 모두 “BPE tokenizer”지만 내부 조건이 다르면 서로 다른 실험이다.
그래서 질문을 잘게 쪼갰다.
α만 바꾸면 좋아지는가. script-aware regex만 바꾸면 좋아지는가. SuperBPE만 붙였을 때도 같은 방향이 나오는가. 한국어 중심 코퍼스와 혼합 코퍼스는 어떤 차이를 만드는가. 평가셋은 실제 사용 비율을 제대로 반영하는가.
먼저 α부터 봤다.
첫 번째 이상 신호: α가 거의 움직이지 않는다
가장 먼저 확인한 변수는 MorphBPE의 α였다. 여기서 α는 형태소 경계를 넘는 merge에 얼마나 큰 패널티를 줄지 정하는 값이다. α=0이면 일반 BPE와 같다. 형태소 경계를 신경 쓰지 않는다. α를 키우면 경계를 넘는 merge 점수가 낮아진다. 완전히 금지하지는 않지만 덜 선택하도록 만든다.
α가 정말 중요하다면 값을 바꿀 때 결과도 달라져야 한다. 형태소 경계 패널티를 조금 줄 때와 강하게 줄 때, 토큰 수나 vocab 구성이 눈에 띄게 달라져야 한다. 적어도 방향은 보여야 한다.
| 설정 | 바꾼 것 | Fertility 변화 |
|---|---|---|
α=0.0 | 형태소 경계 패널티 없음 | 기준 |
α=0.3 | 약한 패널티 | +0.00% |
α=0.7 | 강한 패널티 | +0.02% |
α=1.0 | 가장 강한 패널티 | +0.38% |
| Hard constraint | 경계 넘는 merge 차단 | +3.10% |
여기서 +는 좋아졌다는 뜻이 아니다. Fertility가 늘었다는 뜻이다. 낮을수록 좋은 지표이므로, α를 강하게 줄수록 오히려 조금씩 나빠졌다. soft α는 거의 듣지 않았고, hard constraint는 확실히 나빴다.
이 결과는 직관에 어긋났다. 형태소 경계를 지키면 더 좋아질 줄 알았는데, 강제로 지키게 하면 오히려 악화했다.
왜 그럴까.
BPE는 텍스트를 압축하는 쪽으로 움직인다. byte-level BPE는 자주 나오는 바이트 쌍을 합치며 긴 토큰을 만든다. 그런데 한국어의 형태소 경계는 바이트 쌍 관점에서 꽤 촘촘하게 등장한다. 특히 교착어는 조사와 어미가 붙으면서 형태소 경계가 자주 나오고, 그 경계 근처의 byte pair도 자주 등장한다.
이 경계를 너무 세게 막으면 BPE가 필요한 merge를 못 한다. BPE는 다른 우회로를 찾지만, 그 우회로가 더 좋은 경로는 아니다. 형태소 분석기 관점에선 깔끔해 보여도, BPE 관점에선 손해가 날 수 있다.
여기서 첫 번째 가설이 흔들렸다.
형태소 경계를 merge 점수에서 조금 더 보존하면 TPW가 좋아지지 않을까?
적어도 이 방식으로는 맞지 않았다. 형태소 경계 자체에 가치가 없다는 뜻은 아니다. 우리가 확인한 것은 BPE merge 단계에 형태소 경계를 넣어도 효과를 거의 확인하지 못했다는 점이다.
SuperBPE도 답은 아니었다
α가 약하다는 사실을 확인한 뒤에는 SuperBPE 쪽을 봤다.
처음 기대는 이랬다. 일반 BPE가 1차로 만든 조각 위에, 자주 붙는 조합을 한 번 더 보강하면 토큰 수가 더 줄지 않을까. 특히 한국어처럼 어절 안에 여러 형태소가 붙는 언어는 추가 merge가 도움이 될 것 같았다.
코퍼스와 pre-tokenizer는 그대로 두고, SuperBPE로 추가로 묶을 토큰 수만 바꿨다. 만약 SuperBPE 자체가 강한 변수라면 추가 토큰 비율이 늘 때 Fertility가 꾸준히 내려가야 했다. 적어도 어느 구간까지는 일관된 개선이 보여야 했다.
먼저 SuperBPE를 켜고 끄는 가장 단순한 비교부터 봤다. 조건은 12GB 코퍼스, script-aware pre-tokenizer, α=0.0으로 맞췄다.
| 설정 | SuperBPE | CFert↓ | Morph F1↑ | 변화 |
|---|---|---|---|---|
α=0.0, SuperBPE 없음 | 0 | 2.321 | 0.6469 | 기준 |
α=0.0 + SuperBPE | 약 5K | 2.319 | 0.6468 | CFert −0.002 |
숫자로 보면 좋아지기는 했다. 하지만 개선 폭은 0.002, 비율로는 0.1%도 되지 않았다. Morph F1은 사실상 그대로였다. 추가된 5천 개 토큰 중에는 따로 보면 쓸모 있는 조합이 있었겠지만, 전체 평가셋의 평균 토큰 수를 크게 바꿀 정도는 아니었다.
이쯤 되면 처음 설계의 두 주역이 모두 힘을 잃는다.
- MorphBPE
α는 거의 듣지 않는다. - hard constraint는 오히려 악화한다.
- SuperBPE는 효과가 뚜렷하지 않다.
그렇다고 실험 전체가 실패였다고 보긴 어려웠다. 토큰 수를 줄이는 신호는 분명 있었다. 단지 내가 생각한 원인이 아니었다.
BPE는 생각보다 자기 갈 길을 간다
α가 듣지 않는 현상을 이해하려면 BPE의 성질을 조금 더 봐야 한다.
BPE는 매 step에서 가장 좋아 보이는 pair를 고른다. 여기서 pair 점수를 살짝 바꾸면 merge 순서가 달라진다. 당연히 최종 vocab도 크게 달라질 것처럼 보인다.
그런데 실제로는 그렇지 않은 경우가 많았다.
초기 merge 순서가 조금 흔들려도, 결국 자주 필요한 조각은 다시 선택된다. 어떤 pair가 한 step 늦게 merge되면, 다음 step이나 몇 step 뒤에 비슷한 경로로 합쳐진다. BPE가 완전히 같은 길을 걷지는 않지만, 비슷한 목적지로 돌아오는 일이 많았다.
나는 이 현상을 BPE의 자기 보정 성질로 이해했다.
간단한 예를 들어 보자.
대한민국이라는대한민국은대한민국에서이런 문자열이 자주 나온다면 BPE는 어떤 식으로든 대한민국에 가까운 조각을 만들고 싶어 한다. 중간 merge 순서를 조금 흔들어도 압력이 사라지지 않는다. 말뭉치 안에서 그 조각이 계속 필요하기 때문이다.
이 성질은 α 실험에서도 보였다. 형태소 경계를 넘는 pair 점수를 조금 낮춰도 BPE는 다른 경로로 비슷한 조각을 만든다. 경계를 완전히 막으면 달라지지만, 그때는 성능이 좋아지기보다 나빠졌다.
빈도를 일부러 흔드는 실험도 비슷한 힌트를 줬다. 말뭉치 안에서 특정 pair의 빈도를 조정해 merge 순서를 크게 흔들어도 최종 성능 변화는 생각보다 작았다. 세부 수치는 감사 과정에서 일부 수정했지만, 큰 방향은 남았다.
BPE merge는 보기보다 강건했다. merge 점수를 조금 만지는 정도로는 경로가 살짝 바뀌어도 결국 비슷한 조각으로 돌아왔다. 변화를 만들려면 훨씬 강한 개입이 필요했다.
좋은 토큰만 골라 담을 수 있을까
그래서 한 걸음 더 나아가, BPE가 이미 만든 결과물을 사후에 직접 고쳐 보기로 했다.
BPE가 만든 후보 토큰 중에서 좋은 토큰만 골라 다시 vocab을 만들 수 있을까?
계획은 이랬다. 먼저 평소보다 큰 후보 vocab을 만든다. 그다음 각 토큰의 빈도, 등장 문맥, 평가셋 사용률 같은 통계를 본다. 마지막으로 점수가 낮은 토큰을 버리고, 점수가 높은 토큰만 남긴다. 실험 노트에서는 이 접근을 QAVS라고 불렀지만, 이름보다 중요한 건 발상이다. BPE가 만든 vocab을 사후에 더 똑똑하게 고르자는 시도였다.
아이디어는 꽤 그럴듯했다. 실제로 좋은 토큰의 신호는 어느 정도 보였다. 빈도가 높고 여러 문맥에서 안정되게 쓰이는 토큰은 대체로 좋았다. 평가셋에서 한 번도 쓰이지 않는 토큰도 많았다. 그러면 안 쓰는 토큰을 걷어내고 좋은 토큰을 더 넣으면 될 것 같았다.
하지만 여기서 BPE의 구조가 발목을 잡았다.
BPE vocab은 독립된 단어장 목록이 아니다. merge chain으로 만들어진 의존성 그래프다. 어떤 긴 토큰은 그보다 짧은 부모 토큰이 있어야 도달할 수 있다. 중간 토큰을 빼면 뒤쪽 토큰도 깨진다.
예를 들어 vocab 안에 대한민국에서라는 토큰이 있다고 하자. 이 토큰은 그냥 하늘에서 떨어진 단어가 아니다. 먼저 대와 한이 합쳐져 대한이 되고, 민과 국이 합쳐져 민국이 되고, 다시 대한과 민국이 합쳐져 대한민국이 된다. 그다음에 대한민국과 에서가 합쳐져 대한민국에서가 된다.
이때 대한이나 민국 같은 중간 토큰을 “평가셋에서 별로 안 쓰였네”라고 지워 버리면 어떻게 될까. 대한민국도, 대한민국에서도 더 이상 같은 방식으로 만들 수 없다. 긴 토큰 하나가 좋아 보여도, 그 토큰에 도달하는 계단이 같이 살아 있어야 한다.
실제로 후보 vocab에서 토큰을 무작위로 많이 제거하면 대부분의 긴 토큰이 도달 불가능해졌다. 하위 빈도 토큰만 제거해도 chain이 생각보다 자주 끊겼다. BPE는 단어장처럼 보이지만, 실제로는 순서 있는 merge 목록이다. 토큰 하나를 빼는 일은 표에서 행 하나를 지우는 일이 아니라, 그래프의 간선을 끊는 일에 가까웠다.
간단히 말하면 이렇다.
좋은 토큰이 무엇인지는 어느 정도 알 수 있었다. 하지만 BPE 구조 안에서는 그 토큰만 골라 담기 어려웠다.
이 실험도 같은 방향을 가리켰다.
BPE 내부에서 merge 점수를 조금 만지는 방식은 힘이 약했다. 이미 만들어진 vocab을 사후에 고쳐 쓰는 방식도 어려웠다. 그렇다면 더 위쪽이 아니라 더 아래쪽을 봐야 했다.
BPE가 pair를 고르기 전에, 애초에 어떤 pair가 후보가 될 수 있는지 정하는 단계.
바로 pre-tokenizer다.
프리토크나이저가 핵심 변수로 떠오르다
한동안 pre-tokenizer를 BPE 앞단의 전처리 정도로 봤다. 한글은 한글끼리, 영어는 영어끼리, 숫자는 숫자끼리 적당히 나눠 주는 장치라고 여겼다. 중요하긴 하지만 결과를 결정하는 핵심은 결국 BPE merge라고 생각했다.
실험을 분리해 보니 초점이 달라졌다. 크게 움직인 변수는 merge 점수도 데이터 크기도 아니었다. BPE가 텍스트를 보기 전에 적용되는 regex였다.
조건은 최대한 단순하게 맞췄다. HF Rust BpeTrainer, vocab size 36,000, 평가셋은 그대로 두고 regex만 바꿨다.
비교한 설정은 두 가지였다. 하나는 SA(script-aware), 즉 한글과 비한글 경계를 강하게 나누는 방식이다. 다른 하나는 \S+, 공백이 나오기 전까지를 하나의 pre-token으로 보는 방식이다.
결과는 α나 데이터 크기보다 훨씬 크게 움직였다. SA의 Fertility는 2.7393, \S+의 Fertility는 2.6146이었다. \S+를 기준으로 보면 SA가 4.77% 더 나빴다. 토크나이저 실험에서 0.1%나 0.3%를 두고 한참 고민하던 입장에선 꽤 큰 차이였다.
이 차이는 pre-token 경계를 보면 이해된다. BPE는 pre-tokenizer가 만든 조각 안에서만 merge할 수 있다. 서로 다른 pre-token으로 갈라진 두 문자열은 아무리 자주 붙어 나와도 하나의 pair가 되지 않는다.
pre-token 경계만 단순화해서 보자.
GPT-4를 사용한다.SA는 문자열 GPT-4를을 대략 다음처럼 나눈다.
GPT | - | 4 | 를 | 사용한다 | .여기서 중요한 건 -, 4, 를이 서로 이어지지 못했다는 점이다. SA는 한글, 숫자, 기호 경계를 강하게 나누기 때문에 BPE가 그 경계 너머를 합칠 수 없다. 말뭉치에 GPT-4를이 수만 번 나와도 T-나 4를은 후보가 되지 않는다.
반대로 \S+는 공백 전까지를 한 덩어리로 보여준다.
GPT-4를 | 사용한다.이제 BPE는 GPT-4를 내부의 인접 pair를 볼 수 있다. GPT-4를이 반드시 최종 토큰 하나가 된다는 뜻은 아니다. 중요한 건 T-, -4, 4를 같은 pair가 후보로 올라올 수 있느냐다. 후보가 되어야 merge도 생기고, 더 긴 표현으로 이어지는 chain도 만들어진다.
이 차이는 빈도 조정으로 복제하기 어렵다. 빈도는 이미 보이는 pair의 중요도를 바꾸지만, pre-tokenizer는 처음부터 어떤 pair가 보일지를 바꾼다.
이때부터 질문이 바뀌었다.
처음 질문은 이랬다.
BPE가 형태소 경계를 더 잘 존중하게 만들 수 있을까?
이제 질문은 이렇게 바뀌었다.
BPE가 merge할 수 있는 공간을 어떻게 설정해야 할까?
이 변화가 연구의 방향을 바꿨다. 더 이상 α나 SuperBPE가 주인공이 아니었다. regex가 주인공으로 올라왔다.
이전 결과도 다르게 보이기 시작했다. script-aware pre-tokenizer가 좋거나 나쁘다는 식으로 단순하게 말할 수 없었다. 어떤 분리는 도움을 주지만, 어떤 분리는 BPE가 배울 수 있는 유용한 pair를 막는다.
예를 들어 2024년을 보자. 어떤 regex는 2024와 년을 가른다. 더 심한 경우 숫자를 2, 0, 2, 4처럼 쪼갠다. 사람에게는 큰 차이가 없어 보이지만 BPE에는 큰 차이다. 2024년 전체나 4년 같은 pair가 후보로 올라오지 못하기 때문이다.
영어 축약어를 정교하게 처리하는 GPT 계열 regex도 한국어에선 큰 도움이 되지 않았다. 's, 're, 'll 같은 패턴은 영어에는 의미가 있지만 한국어 TPW를 줄이는 핵심은 아니었다. 한국어는 숫자, 기호, 한글 조사가 만나는 경계가 더 중요했다.
그래서 pre-tokenizer regex를 “얼마나 영리하게 나눌까”의 문제로 보지 않게 됐다. 더 중요한 질문은 허용 범위였다. 어떤 문자를 나눌지보다, 어떤 문자가 서로 만날 기회를 줄지. 어느 경계를 지킬지보다, 어느 경계를 풀어줄지가 더 중요했다.
왜 형태소보다 pre-tokenizer가 더 컸나
이 지점에서 차이를 한 문장으로 정리할 수 있었다.
α는 후보의 순서를 바꾸고, pre-tokenizer는 후보의 범위를 바꾼다.
형태소 패널티는 BPE가 이미 보고 있는 pair에 개입한다. 경계를 넘는 merge의 점수를 낮춰서 덜 고르게 만든다. 그래서 효과가 있더라도 조정의 성격이 강하다. BPE가 다른 경로를 찾으면 결과는 크게 흔들리지 않는다.
반면 pre-tokenizer는 BPE 앞에서 문장을 자른다. 이 경계 밖의 pair는 아예 후보가 되지 않는다. 4와 를을 갈라놓으면 4를은 점수가 낮은 후보가 아니라, 존재하지 않는 후보가 된다. 이 차이가 컸다.
그렇다고 형태소 정보가 쓸모없다는 결론은 아니다. 한국어 구조를 이해하는 데 형태소는 여전히 중요하다. 다만 이번 실험에서 확인한 것은 위치의 문제였다. BPE merge 점수를 살짝 만지는 방식보다, BPE가 보기 전에 어떤 문자열을 한 덩어리로 남겨 둘지가 더 크게 작동했다.
처음에는 이 결론이 조금 싱겁게 느껴졌다. 형태소 분석기를 붙이고, 경계 패널티를 만들고, 후처리 merge까지 설계했는데 결국 regex가 더 중요했다니. 하지만 토크나이저 실험은 자주 이런 식이었다. 복잡한 장치를 만들고 나서야 더 낮은 단계의 설정 하나가 훨씬 큰 힘을 낸다는 사실이 보였다.
문제는 regex가 단순한 만큼 다루기 어렵다는 데 있었다. 공백 처리, 기호 결합, 숫자 처리, 영어와 한국어의 균형이 한꺼번에 움직인다. 이 복잡한 부분은 다음 글에서 자세히 다룬다.
1편 정리
이번 글의 출발점은 형태소였다. 한국어는 교착어이고, 조사와 어미가 붙으니 BPE에도 그 경계를 알려주면 좋아질 거라 생각했다.
하지만 첫 실험 묶음에서 강하게 남은 것은 형태소 경계가 아니었다. α는 거의 듣지 않았고, hard constraint는 오히려 나빠졌고, SuperBPE도 독립 효과가 뚜렷하지 않았다. vocab을 나중에 골라내는 접근도 merge chain 때문에 쉽게 깨졌다.
반대로 pre-tokenizer는 BPE가 볼 수 있는 후보 자체를 바꿨다. 여기서 첫 가설이 바뀌었다.
형태소를 더 잘 알려주면 BPE가 좋아질 거라 믿었다.
이제 질문은 이렇게 바뀌었다.
BPE에게 어떤 문자열을 한 덩어리로 보여줘야 할까?
다음 글에서는 이 질문을 들고 regex를 더 깊게 판다. 한글, 숫자, 기호를 어디까지 묶을지, 공백을 앞 단어에 붙일지 말지 하나씩 바꿔 봤다.
그 과정에서 예상보다 큰 변수를 만났다. 공백이다. 토크나이저에서 공백은 그냥 빈칸이 아니었다. 공백 하나가 TPW 순위를 뒤집었고, 이후 LM 평가까지 복잡하게 만들었다.
Footnotes
-
Ehsaneddin Asgari, Yassine El Kheir, Mohammad Ali Sadraei Javaheri. MorphBPE: A Morpho-Aware Tokenizer Bridging Linguistic Complexity for Efficient LLM Training Across Morphologies. arXiv:2502.00894, 2025. https://arxiv.org/abs/2502.00894 ↩
-
Alisa Liu, Jonathan Hayase, Valentin Hofmann, Sewoong Oh, Noah A. Smith, Yejin Choi. SuperBPE: Space Travel for Language Models. arXiv:2503.13423, 2025. https://arxiv.org/abs/2503.13423 ↩