토큰화 알고리즘 비교 정리

장보윤·2025년 3월 30일

API, GUI를 이용해 자연어처리를 하는 요즘 사용자 입장에서는 굳이 토큰화 방식까지 고민할 필요는 거의 없어졌습니다. 그럼에도 다시 복습하고자 하는 의미에서 토크나이저 작동 방식 세 가지를 정리해봅니다.

허깅페이스 NLP Course에 나온 내용 중 Tokenizer에 대한 내용을 정리한 적이 있는데, 그 중 아래 세 강의를 요약 정리하였습니다.
https://huggingface.co/learn/nlp-course/chapter6/5?fw=pt
https://huggingface.co/learn/nlp-course/chapter6/6?fw=pt
https://huggingface.co/learn/nlp-course/chapter6/7?fw=pt

토큰화 알고리즘이 필요한 이유

자연어 처리 모델들을 학습하고 이용할 때, 텍스트를 모델이 이해할 수 있는 벡터로 변환하는 과정이 필요합니다. 이 때 텍스트를 어떤 단위로 잘라서 벡터로 변환할지를 결정해야 합니다. 단순하게 생각할 수 있는 것은 글자 하나하나마다 벡터를 부여하는 방식입니다. 이렇게 벡터를 부여하면 문제가 발생합니다. 알파벳 'a'가 어떤 의미를 가진 것은 아니기 때문이고, 모델은 토큰의 의미를 같이 학습해야 하기 때문입니다.

그렇다면 또 다른 단순한 방법은, 띄어쓰기 단위로 구분된 단어마다 벡터를 부여하는 방식도 고려할 수 있습니다. 여기서도 문제는 발생합니다. 세상에는 참으로 많은 단어들이 있으며, 신조어도 끊임없이 생기고 있습니다. 만약 모델이 학습하지 못한 새로운 단어가 등장한다면 모델은 단어의 뜻을 이해할 수 없고 OOV(Out Of Vocabulary) 문제가 발생합니다. 그렇다고 모든 단어에 대한 벡터를 부여하면 모델이 가진 단어사전의 크기는 심각하게 커집니다.

따라서 그 중간 어딘가에서 토큰을 쪼개는 알고리즘이 필요한데, 그 방식으로 세 가지를 여기서 정리해봅니다.

Byte Pair Encoding (BPE)

BPE는 작은 단어부터 시작하여 빈도가 높은 단어들을 합치는 방식으로 토큰화하는 알고리즘입니다.

허깅페이스 문서와 동일한 예시를 바로 보여드리겠습니다. 다음과 같은 단어들이 있다고 가정합니다. (단어, 빈도) 셋으로 구성되어 있고, 여기서 빈도는 문서 속에서 그 단어가 등장한 횟수입니다.

("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)

글자의 가장 작은 단위는 알파벳입니다. 따라서 먼저 알파벳 단위로 쪼개어봅니다.

("h" "u" "g", 10), ("p" "u" "g", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "u" "g" "s", 5)

쪼갠 상태에서 두 개의 단위를 합쳐서 빈도를 세어봅니다. 먼저 "hu"를 고민해볼 수 있습니다. "hu"는 "hug"를 통해 10번, "hugs"를 통해 5번, 총 15번 등장합니다. 이런 식으로 "ug", "pu", "un" 등의 단위들도 빈도를 계산합니다. 그 중 "ug"가 총 20번으로 가장 많이 등장합니다. 따라서 먼저 "u"와 "g"를 합쳐서 단어 사전을 만듭니다.

Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "u" "n", 12), ("b" "u" "n", 4), ("h" "ug" "s", 5)

이런 식으로 두 개의 토큰을 또 합쳐봅니다. 이번에는 "un"이 가장 많이 등장했기 때문에 "un"을 단어 사전에 추가합니다.

Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un"]
Corpus: ("h" "ug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("h" "ug" "s", 5)

그 다음으로 많은 것은 "hug"이므로, "hug"를 단어 사전에 넣습니다.

Vocabulary: ["b", "g", "h", "n", "p", "s", "u", "ug", "un", "hug"]
Corpus: ("hug", 10), ("p" "ug", 5), ("p" "un", 12), ("b" "un", 4), ("hug" "s", 5)

이렇게 만들어진 토크나이저를 이용한다면 "bug"는 "b"와 "ug"로, "mug"는 "[UNK]"와 "ug"로 토크나이징될 것입니다. ("m"은 사전에 없기 때문에 "[UNK]"로 인식될 것입니다.

WordPiece

WordPiece는 BPE와 비슷하게 작은 단위에서 시작해서 단어들을 합쳐나가는 알고리즘입니다. 하지만 약간의 차이가 있습니다.

BPE와 동일한 예시를 봅시다.

("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)

역시나 단어를 쪼갭니다. 하지만 차이가 있습니다. 처음 시작하는 단위를 제외하고는 앞에 "##"을 붙입니다. 즉 단어의 시작이 되는 단위와 중간에 들어가는 단어들을 구분합니다. 위의 예시를 쪼갠다면 다음과 같을 것입니다.

("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##g" "##s", 5)

즉 처음 단어사전은 ["b", "h", "p", "##g", "##n", "##s", "##u"]로 구성되어있을 것입니다. 이제 BPE와 마찬가지로 합칠 단위를 결정합니다. BPE에서는 빈도를 이용했는데, WordPiece에서는 점수를 계산합니다.

score=(freq_of_pair)/(freq_of_first_element×freq_of_second_element)

빈도를 사용하지 않는 이유는, 굳이 병합할 필요가 없는 토큰들을 병합하지 않기 위해서입니다. 예를 들어 ("un", "##able")이 빈도 상으로 합칠 만하다고 가정해 봅시다. 그러나 un##able은 문서 내에서 너무 자주 등장하기 때문에 굳이 합쳐야 할 이유는 없습니다. 즉 각각의 토큰들 자체가 빈도가 높다면 굳이 합치지 않는 것입니다. 반대로 ("hu", "##gging")과 같은 쌍은 hu##gging 자체가 문서 내에서 떨어져서 등장하는 일이 없기 때문에 병합해서 토큰을 만들 만하다고 보는 것입니다.

위의 예시로 다시 돌아가봅시다. BPE와 동일하게 두 개의 쌍을 묶어보고 점수를 계산합니다. 빈도 상으로는 ("##u", "##g")가 가장 많이 등장합니다. 그러나 점수를 계산했을 때는 1/36이 나오며, ("##g", "##s")가 1/20으로 가장 점수가 높습니다. 따라서 ##g##s를 합쳐 ##gs를 단어사전에 추가하고 병합합니다.

Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs"]
Corpus: ("h" "##u" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("h" "##u" "##gs", 5)

동일한 과정을 반복해봅니다. 이번에는 ("h", "##u")의 점수가 가장 높으니 hu를 추가합니다.

Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu"]
Corpus: ("hu" "##g", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("hu" "##gs", 5)

그 다음으로는 ("hu", "##g")를 합친 hug를 추가합니다.

Vocabulary: ["b", "h", "p", "##g", "##n", "##s", "##u", "##gs", "hu", "hug"]
Corpus: ("hug", 10), ("p" "##u" "##g", 5), ("p" "##u" "##n", 12), ("b" "##u" "##n", 4), ("hu" "##gs", 5)

Unigram

Unigram은 SentencePiece에서 자주 사용되는 토큰화 알고리즘입니다. 앞선 BPE, WordPiece와는 다르게 Unigram은 큰 어휘에서 시작해 불필요한 토큰들을 제외합니다.

다시 동일한 예시를 사용해봅니다.

("hug", 10), ("pug", 5), ("pun", 12), ("bun", 4), ("hugs", 5)

단어 사전을 구축하는데, 모든 가능한 substring으로 구성된 단어사전을 먼저 만듭니다.

["h", "u", "g", "hu", "ug", "p", "pu", "n", "un", "b", "bu", "s", "hug", "gs", "ugs"]

각각의 단어들의 출현 빈도를 구합니다.

("h", 15) ("u", 36) ("g", 20) ("hu", 15) ("ug", 20) ("p", 17) ("pu", 17) ("n", 16)
("un", 16) ("b", 4) ("bu", 4) ("s", 5) ("hug", 15) ("gs", 5) ("ugs", 5)

여기서 우리는 각 토큰들의 출현 확률을 계산할 수 있습니다. 전체 빈도는 210이므로, "ug"의 출현 확률은 20/210이 될 것입니다.

그 다음, 토큰화를 위해 단어 내에서 가능한 모든 분절들의 확률을 계산합니다. 예를 들어, "pug"가 ["p", "u", "g"]로 쪼개진다면 확률은 다음과 같습니다.

P([‘‘p",‘‘u",‘‘g"]) = P(‘‘p") × P(‘‘u") × P(‘‘g") = 5/210 x 36/210 x 20/210 = 0.000389

만일 ["pu", "g"]로 쪼개진다면 확률은 다음과 같습니다.

P([‘‘pu",‘‘g"]) = P(‘‘pu") × P(‘‘g") = 5/210 x 20/210 = 0.0022676

이렇게 가능한 분절들의 확률을 계산하면 다음과 같습니다.

["p", "u", "g"] : 0.000389
["p", "ug"] : 0.0022676
["pu", "g"] : 0.0022676

따라서 "pug"는 ["p", "ug"] 또는 ["pu", "g"]로 쪼개질 것임을 알 수 있습니다.

가능한 모든 분절들을 찾는 것이 위의 예시에서는 쉬웠지만, 현실적으로는 어렵습니다. 여기에 Viterbi algorithm이라는 것이 적용됩니다. 간단하게 말하자면 단어를 처음부터 확인하면서, 그 안에서 제일 높은 점수를 갖는 분절을 찾아가면서 최종적으로 높은 점수를 찾는 방식입니다.

Character 0 (u): "u" (score 0.171429)
Character 1 (n): "un" (score 0.076191)
Character 2 (h): "un" "h" (score 0.005442)
Character 3 (u): "un" "hu" (score 0.005442)
Character 4 (g): "un" "hug" (score 0.005442)

이렇게 토큰화가 완료되면 loss값이 어느 정도 발생하는지에 따라서 토큰을 제거해갑니다. 예를 들어 아래와 같이 토큰화가 진행되고 점수가 계산된다고 가정합시다.

"hug": ["hug"] (score 0.071428)
"pug": ["pu", "g"] (score 0.007710)
"pun": ["pu", "n"] (score 0.006168)
"bun": ["bu", "n"] (score 0.001451)
"hugs": ["hug", "s"] (score 0.001701)

loss 값은 빈도에 -log(P(word))를 곱한 값으로 계산합니다. 따라서 총 loss 값은 다음과 같습니다.

10 * (-log(0.071428)) + 5 * (-log(0.007710)) + 12 * (-log(0.006168)) + 4 * (-log(0.001451)) + 5 * (-log(0.001701)) = 169.8

여기서 각 토큰을 사전에서 제외했을 때 loss값이 어떻게 되는지를 봅니다. 예를 들어, "pug"는 동일한 점수로 ["p", "ug"]로도 토큰화될 수 있습니다. 따라서 사전에서 "pu"를 제거할 수도 있습니다. 그러나 만일 "hug"를 제거할 경우, 토큰화된 점수는 다음과 같으므로

"hug": ["hu", "g"] (score 0.006802)
"hugs": ["hu", "gs"] (score 0.001701)

loss 값은 올라가게 됩니다.

- 10 * (-log(0.071428)) + 10 * (-log(0.006802)) = 23.5

따라서 "pu"는 사전에서 제거되더라도 "hug"는 남아있게 됩니다. 즉 가장 큰 사전을 구축한 다음, 제거되어야 할 토큰들을 점수를 계산해서 구하는 방식이 Unigram입니다.

최근 OpenAI의 GPT-4o와 같은 모델들은 BPE를 기반으로 한 tiktoken 토크나이저를, 구글의 Gemini는 Unigram 기반의 SentencePiece를 사용하고 있다고 합니다. 대부분의 LLM 모델들이 WordPiece보다는 BPE나 SentencePiece를 많이 사용한다고 합니다. 어떤 토큰화 알고리즘을 쓰는지에 따라 모델의 성능 차이도 발생해서인 것 같은데, 새로운 토큰화 알고리즘이 나온다면 모델의 성능이 더더욱 발전하는 날이 오지 않을까 싶습니다.

profile
머신러닝 엔지니어, 취미사진가

0개의 댓글