1장 AI는 데이터를 어떻게 이해할까: 특징량화의 원리
서브워드 분할(actual subword split)방법으로 지금까지 BPE와 서브워드 유니그램 언어 모델을 봤고, 이번에는 이 두가지를 오픈소스(OSS)로 구현해 둔 SentencePiece를 설명합니다.
아래 원 논문과 실제 구현 일부를 같이 읽어보며 정리해 봅시다.
SentnecePiece에서는 필요한 기능을 크게 Normalizer, Trainer, Encoder, Decoder 4가지로 정의하고, 각각은 다음 역할을 가집니다.
Normalizer: Unicode문자를 정규형(canonical representation)으로 정규화하는 역할
Trainer: 정규화된 텍스트로부터 서브워드 분할 방법을 학습하는 역할이며 BPE의 경우, 자주 등장하는 토큰으로 어휘(vocabulary)를 구성하는 역할이고 유니그램 언어 모델의 경우, 이를 더해 각 서브워드 토큰의 출현 확률까지 추정함
Encoder: 내부적으로 Normalizer를 먼저 실행해 입력 텍스트를 정규화한 뒤, Trainer가 학습한 서브워드 모델을 사용해 토크나이징을 수행합니다. SentencePiece는 단순히 토큰 문자열만 내는게 아니라, 어휘와 ID매핑까지 들고 있어서 텍스트를 ID시퀀스로 바꾸기 때문에 Encoder라는 이름을 씁니다.
Decoder: 토큰 시퀀스(또는 거기서 파생된 ID시퀀스)를 입력받아 다시 정규화된 텍스트로 복원하는 역할
알고리즘적인 핵심은 이미 이전 내용들에서 다뤘기 때문에, 여기서는 구현을 직접 보지 않으면 이해가 안되는 디테일 위주로 확인해 봅시다. 예를 들어 이전 장에서 설명한 유니그램 언어 모델에서 "히유리스틱하게 주어야 하는 초기 어휘"를 SentencePiece 구현에서는 어떻게 구성하는지를 보여주는 부분을 볼 수 있습니다.
일부만 발췌했기 때문에 정의되지 않은 변수도 있지만, 아래 코드에서는 모든 문자(입력 텍스트에 등장하는 모든 문자)를 어휘에 추가하고, 두번째 코드에서는 출현빈도 x 문자열 길이를 스코어로 삼아 스코어가 큰 순서대로 서브워드 토큰을 추가합니다. (기본 어휘 크기는 다른 곳에서 100만개로 설정되어 있습니다)
접미사 배열(suffix array)이나 우선순위 큐(priority queue)에 대한 지식이 없으면 코드를 곧바로 읽기엔 조금 빡세지만, 실전에서 성능 나오는 구현을 만들려면 이런 효율적인 자료구조는 거의 필수입니다.
이처럼 효율적인 구현 덕분에, 서브워드 후보를 학습 데이터 전체를 대상으로 구축하는 것이 가능해집니다.
이전에 설명한 BPE 방식은
"단어가 공백으로 구분될 수 있다"는 꽤 강한 제약이 있었는데, 이건 영어라서 어떻게든 버티는 것이고,
나이브하게 구현하면 입력 길이 N에 대해 계산량이
까지 올라가서 긴 입력에 쓰기 어렵다는 문제도 있었습니다.
SentencePiece는 알고리즘 설계로 계산량을
까지 낮춰서, 어휘 구축을 학습 데이터 전체에 대해 수행할 수 있고, 그 덕분에 "언어 의존성"도 제거할 수 있습니다.
즉, 언어별 전처리(형태소 분석, 띄어쓰기 규칙 등)를 전혀 쓰지 않고, 순수하게 학습 데이터만으로 서브워드 모델을 학습해서 사용할 수 있습니다.
학습 데이터가 충분히 크고, 어휘 크기를 넉넉히 가져가면, 사실상 어떤 언어에도 적용가능한 범용 토크나이저가 됩니다.
연습문제11: 어휘 구축 과정에서, 나이브한 BPE 알고리즘의 계산량이 왜
이 되는지, 그리고 SentencePiece 알고리즘에서는 왜
이 되는지 직접 조사하고 이해해 봅시다.
연습문제12: SentencePiece 구현에서, BPE 방식의 어휘 구축(merge) 과정에서 토큰 빈도 카운트 정보를 어떻게 갱신하는지 확인해 봅시다.
많은 튜토리얼이나 블로그에서는 "병합한 뒤 다시 전체 토큰의 출현 횟수를 처음부터 세는 방식"으로 설명하지만, 이건 비효율적이고 실제 대규모 데이터에는 쓸 수 없는 방식입니다.
SentencePiece 토크나이저의 중요한 특징은, 토큰화가 "되돌릴 수 있다(reversible)"는 점입니다. 원 논문에서 문제의식을 이해하려면 다음 질문을 한번 생각해 보면 좋겠습니다.
연습문제13: 3개의 토큰이 주어졌다고 생각해 봅시다.
Hello
world
.
이 세 토큰을 디토크나이즈(detokenize)해서 원래 텍스트를 복원해 봅시다.
우리는 보통 아무 생각 없이 "정답: Hello world."라고 적어버리기 쉽습니다. 하지만 일반적으로 이건 틀린 답일 수 있습니다. 예를 들어, 원래 텍스트가
Helloworld.
였을 가능성도 완전히 배제할 수 없습니다. (조금 이상하긴 하지만 "불가능"은 아닙니다)
즉, 이건 ill-defined한 문제입니다.
토큰만 가지고는 "어디에 공백이 있었는지" 정보를 복원할 수 없기 때문
그래서 현실적으로는 이 문제를 각 언어별 규칙으로 때웁니다. 예를 들면,
영어에서는 단어 사이에는 공백을 넣고,
마침표 앞에는 공백을 넣지 않는 식으로
언어 의존적인 규칙을 왕창 만듭니다. 이건 당연히 언어마다 구현이 달라질 수밖에 없고, 다국어 지원을 하려면 언어별 규칙을 전부 관리해야 하는 부담이 생깁니다.
이 문제를 피하기 위해, SentencePiece는 토큰화할 때 공백을 Unicode 문자 _ (U+2581)로 바꿔서 같이 토큰으로 다룬 뒤, 디토크나이즈가 끝난 다음에 _를 다시 공백으로 치환하는 방식을 사용합니다.
예를 들어,
토큰: Hello, _world, . → 원문: Hello world.
토큰: Hello, world, _. → 원문: Helloworld .
처럼 토큰 시퀀스가 주어지면 원문을 '유일하게' 복원할 수 있습니다. 이 방식은 명백히 언어 비의존적입니다.
SentencePiece는 이를 다음과 같이 표현하며 Encode과정에서 정보가 손실되지 않는다는 의미로 "가역 토크나이징(reversible tokenization)"이라고 부릅니다.
이 아이디어는 구조 자체는 굉장히 단순하지만, 언어 의존성을 제거하는데 매우 강력한 트릭입니다. 그 결과, SentencePiece는 여러 언어를 하나의 모델로 다루는 생성형 AI 시대에 가장 널리 사용되는 토크나이저 중 하나가 되었습니다.
물론 이 방식에도 단점은 있습니다. 바로 공백을 문자로 취급하기 때문에 서브워드 단위에도 공백이 포함된 토큰들이 섞인다는 점입니다. 예를 들어, SentencePiece로 어휘를 만들면 time, _time과 같은 토큰 둘 다 사전에 들어갈 수 있습니다. 이 둘은 서로 다른 토큰이므로, 일반적으로 서로 다른 ID와 서로 다른 임베딩을 갖습니다. 그런데 실제 의미 관점에서는
앞에 공백이 있든 없든
"time" 자체의 의미는 크게 다르지 않기 때문에
비슷한 의미를 가진 벡터를 서로 다른 ID에 대해 중복 학습하게 될 수 있어, 조금 비효율적일수 있고, 어휘 크기가 불필요하게 커질 수 있습니다. 실무적으로 이게 치명적인 문제로 드러난 사례는 필자가 아는 한 거의 없지만, 토크나이저를 정확히 이해하려면, 이런 구조적 특성 정도는 알고 있는 것이 좋습니다.
또한, SentencePiece뿐만 아니라, Unicode 문자조합으로 서브워드를 구성하는 모든 토크나이저는 어휘에 없는 Unicode 문자가 등장하면 UNK토큰(unknown token)이 발생할 수 있다는 점도 알아두어야 합니다. Unicode 전체를 어휘에 넣어버리면 이 문제는 사라지지만,
Unicode 문자는 약 15만자 정도 되고
그중 상당수는 거의 쓰이지 않는 문자(예: ℧ (U+2127)같은 것들)
이기 때문에, 보통은 학습 데이터에 실제로 등장한 문자만 어휘에 포함하는 식으로 처리합니다.
연습문제14: SentencePiece처럼 공백도 하나의 문자로 다루는 토크나이저를 사용해,
time
_time
같은 토큰들의 임베딩을 학습한 모델을 준비한 뒤, 이 두 토큰의 임베딩 벡터가 서로 얼마나 비슷한지/다른지를 직접 조사해 봅시다.
©2024-2025 MDRULES.dev, Hand-crafted & made with Jaewoo Kim.
AI 에이전트 개발, 컨텍스트 엔지니어링 교육 컨설팅, 바이브코딩 강의 문의: https://bit.ly/4kjk5OB