본문 바로가기
TYLER SONGBlog
← Blog index
LLM Research8 min read

Subword · BPE 직접 구현 · Word2Vec 맥락: 자주 붙는 문자 쌍이 LLM의 토큰이 되는 과정

언어 모델은 문장을 그대로 읽지 않고 토큰(token, 모델이 처리하는 텍스트 조각)으로 나눈다. 오늘은 문자와 단어 사이 크기의 서브워드(subword)를 만들기 위해 BPE가 자주 등장하는 쌍을 반복해서 합치는 과정을 직접 구현한다. 한국어 문장을 여러 방식으로 나누고 토큰 길이와 경계를 비교한다.

By 송민성

오늘의 목표

현대 LLM이 문장을 어떤 단위로 나누는지 이해한다. 순수 파이썬으로 BPE 학습·인코딩·디코딩을 구현하고, 한국어에서 문자·단어·BPE 토큰화가 만드는 차이를 관찰한다.

개념과 직관

1. 왜 문자나 단어만 사용하지 않을까

토큰화(tokenization, 문장을 모델이 처리할 작은 단위로 나누는 과정)의 가장 단순한 방법은 문자 또는 단어 단위다.

문장:

> 나는 인공지능을 공부한다

문자 단위:

text
나 | 는 | (공백) | 인 | 공 | 지 | 능 | 을 | (공백) | 공 | 부 | 한 | 다

장점은 처음 보는 단어도 문자로 표현할 수 있다는 점이다. 하지만 문장이 너무 길어진다.

단어 단위:

text
나는 | 인공지능을 | 공부한다

문장은 짧아지지만 인공지능은, 인공지능이, 공부했다처럼 형태가 조금만 달라져도 별도 단어가 된다. 가능한 모든 단어를 사전에 넣기도 어렵다.

서브워드(subword, 문자보다 크고 단어보다 작을 수 있는 토큰 단위)는 두 방식의 중간 지점이다.

text
나는 | 인공지능 | 을 | 공부 | 한다

자주 나오는 덩어리는 크게 묶고, 드문 단어는 작은 조각으로 나눈다.

2. BPE는 자주 붙는 쌍을 합친다

BPE(Byte Pair Encoding, 가장 자주 인접하는 토큰 쌍을 반복해서 합치는 알고리즘)는 처음에 단어를 작은 기호로 쪼갠다.

text
▁공부한다 → ▁ | 공 | 부 | 한 | 다
▁공부했다 → ▁ | 공 | 부 | 했 | 다

여기서 ▁는 공백 또는 새 단어의 시작을 나타내는 표시다.

말뭉치(corpus, 토크나이저나 모델을 학습할 텍스트 모음)에서 인접한 쌍을 센다고 하자.

text
(▁, 공): 5회
(공, 부): 5회
(부, 한): 3회
(부, 했): 2회

가장 자주 나온 (▁, 공)을 합친다.

text
▁ | 공 | 부 | 한 | 다
→
▁공 | 부 | 한 | 다

다시 빈도를 세어 (▁공, 부)가 가장 자주 나오면 또 합친다.

text
▁공 | 부 | 한 | 다
→
▁공부 | 한 | 다

병합 횟수가 늘어날수록 토큰은 커지고 문장 길이는 짧아진다. 대신 어휘 사전(vocabulary, 사용할 수 있는 토큰 목록)이 커진다.

핵심 계산은 다음과 같다.

text
쌍의 빈도 = 모든 단어에서 해당 인접 쌍이 나타난 횟수
선택할 쌍 = 빈도가 가장 높은 인접 쌍
새 토큰 = 선택한 두 토큰을 이어 붙인 것

3. SentencePiece는 공백도 정보로 다룬다

SentencePiece(문장을 공백에 먼저 의존하지 않고 서브워드로 나누는 토크나이저 방식)는 공백을 보통 ▁ 같은 기호로 표현한다.

text
나는 인공지능을 공부한다
→
▁나는 | ▁인공지능 | 을 | ▁공부 | 한다

영어는 공백으로 단어를 나누기 쉽다. 한국어는 조사와 어미가 붙는다.

text
학교
학교에
학교에서
학교에서는

공백만 기준으로 단어 사전을 만들면 모두 다른 항목이 된다. 서브워드는 다음처럼 공통 부분을 재사용할 수 있다.

text
▁학교 | 에
▁학교 | 에서
▁학교 | 에서는

SentencePiece는 BPE 자체와 같은 말이 아니다. SentencePiece는 원문에서 토큰화를 학습하고 적용하는 틀이며, 내부 알고리즘으로 BPE 등을 사용할 수 있다.

4. Word2Vec이 남긴 핵심 아이디어

분포 가설(distributional hypothesis, 비슷한 문맥에 등장하는 단어는 비슷한 의미를 가진다는 생각)은 단어의 의미를 주변 단어로 학습할 수 있다는 관점이다.

text
고양이가 우유를 마신다
강아지가 물을 마신다

고양이와 강아지는 비슷한 위치와 문맥에 자주 등장한다. 모델은 두 단어를 가까운 벡터로 배치할 수 있다.

임베딩(embedding, 토큰을 여러 숫자로 바꾼 벡터)은 다음처럼 생각할 수 있다.

text
고양이 → [0.2, -0.7, 0.4]
강아지 → [0.3, -0.6, 0.5]
자동차 → [-0.8, 0.1, 0.2]

Word2Vec(주변 단어 관계를 이용해 단어 임베딩을 학습하는 방법)은 단어를 고정된 번호가 아닌 의미를 담은 벡터로 표현하는 흐름을 널리 알렸다.

BPE와 Word2Vec의 역할은 다르다.

text
BPE: 문장을 어떤 토큰으로 나눌 것인가
Word2Vec: 각 토큰을 어떤 숫자 벡터로 표현할 것인가

현대 LLM도 토큰을 임베딩으로 바꿔 처리한다. 다만 하나의 단어에 항상 같은 최종 표현만 쓰는 Word2Vec보다 문장 전체의 영향을 더 많이 반영한다. 그 구체적인 계산법은 이후 과정에서 배운다.

코드로 직접 해보기

1. 학습용 말뭉치 준비

같은 단어가 여러 번 등장해야 BPE가 반복 패턴을 찾기 쉽다.

python
corpus = [ "나는 공부한다", "나는 인공지능을 공부한다", "우리는 공부한다", "우리는 인공지능을 공부했다", "인공지능 연구를 한다", ]

2. 단어를 문자와 경계 기호로 나누기

각 단어 앞에 ▁를 붙여 공백 위치를 보존한다.

python
from collections import Counter def make_vocab(corpus): vocab = Counter() for sentence in corpus: for word in sentence.split(): symbols = tuple(["▁"] + list(word)) vocab[symbols] += 1 return vocab vocab = make_vocab(corpus) for symbols, count in vocab.items(): print(symbols, count)

출력 예시는 다음 형태다.

text
('▁', '나', '는') 2
('▁', '공', '부', '한', '다') 3
('▁', '인', '공', '지', '능', '을') 2

튜플(tuple, 값을 순서대로 묶어 저장하는 변경 불가능한 자료형)을 사전의 키로 사용했다.

3. 인접한 토큰 쌍의 빈도 세기

python
def count_pairs(vocab): pair_counts = Counter() for symbols, word_count in vocab.items(): for i in range(len(symbols) - 1): pair = (symbols[i], symbols[i + 1]) pair_counts[pair] += word_count return pair_counts

단어가 세 번 등장했다면 그 안의 쌍도 세 번 등장한 것으로 센다.

4. 선택한 쌍 합치기

python
def merge_pair(symbols, target): merged = [] i = 0 while i < len(symbols): if i + 1 < len(symbols) and (symbols[i], symbols[i + 1]) == target: merged.append(symbols[i] + symbols[i + 1]) i += 2 else: merged.append(symbols[i]) i += 1 return tuple(merged) def apply_merge(vocab, target): new_vocab = Counter() for symbols, count in vocab.items(): new_symbols = merge_pair(symbols, target) new_vocab[new_symbols] += count return new_vocab

i += 2는 두 토큰을 한 번에 합쳤기 때문이다. 쌍이 일치하지 않으면 한 칸만 이동한다.

5. BPE 병합 규칙 학습하기

python
def train_bpe(corpus, num_merges): vocab = make_vocab(corpus) merge_rules = [] for _ in range(num_merges): pair_counts = count_pairs(vocab) if not pair_counts: break best_pair = min( pair_counts, key=lambda pair: (-pair_counts[pair], pair) ) merge_rules.append(best_pair) vocab = apply_merge(vocab, best_pair) return merge_rules merge_rules = train_bpe(corpus, num_merges=15) for index, pair in enumerate(merge_rules, start=1): print(index, pair, "→", "".join(pair))

빈도가 같을 때는 결과가 매번 달라지지 않도록 글자 순서가 앞선 쌍을 선택했다.

6. 새 문장 인코딩하기

인코딩(encoding, 원문을 토큰 목록이나 토큰 번호로 바꾸는 과정)은 학습한 병합 규칙을 같은 순서로 적용한다.

python
def encode(text, merge_rules): tokens = [] for word in text.split(): symbols = tuple(["▁"] + list(word)) for rule in merge_rules: symbols = merge_pair(symbols, rule) tokens.extend(symbols) return tokens text = "나는 인공지능을 공부한다" tokens = encode(text, merge_rules) print(tokens)

병합 규칙의 순서가 중요하다. 뒤에서 학습한 규칙은 앞선 병합 결과를 재료로 사용할 수 있다.

7. 토큰을 문장으로 디코딩하기

디코딩(decoding, 토큰을 다시 사람이 읽을 수 있는 문자열로 바꾸는 과정)은 토큰을 이어 붙이고 ▁를 공백으로 되돌린다.

python
def decode(tokens): text = "".join(tokens) return text.replace("▁", " ").strip() restored = decode(tokens) print("원문:", text) print("토큰:", tokens) print("복원:", restored) assert restored == text

전체 코드는 원문과 복원 결과가 같다는 assert 검사까지 통과해야 한다.

8. tiktoken과 토큰 경계 비교하기

tiktoken은 별도 설치가 필요하다.

text
pip install tiktoken
python
import tiktoken encoding = tiktoken.get_encoding("cl100k_base") token_ids = encoding.encode(text) print("토큰 개수:", len(token_ids)) for token_id in token_ids: raw = encoding.decode_single_token_bytes(token_id) print(token_id, raw, raw.decode("utf-8", errors="replace"))

한국어 한 글자의 UTF-8 바이트가 여러 토큰에 걸쳐 나뉠 수 있다. 이 경우 개별 토큰을 문자열로 바꾸면 �가 나타날 수 있다. 전체 토큰을 한꺼번에 디코딩하면 원문으로 복원된다.

python
print(encoding.decode(token_ids)) assert encoding.decode(token_ids) == text

직접 만든 BPE와 tiktoken의 결과가 달라도 오류가 아니다. 학습 데이터, 초기 단위, 병합 횟수, 어휘 크기가 다르기 때문이다.

오늘의 실험

다음 문장으로 문자·단어·BPE 토큰 길이를 비교한다.

python
sentences = [ "나는 인공지능을 공부한다", "우리는 인공지능을 공부했다", "인공지능 연구자는 모델을 평가한다", "학교에서 공부한다", "학교에서는 공부했다", ] for sentence in sentences: char_tokens = list(sentence) word_tokens = sentence.split() bpe_tokens = encode(sentence, merge_rules) print("\n문장:", sentence) print("문자:", len(char_tokens), char_tokens) print("단어:", len(word_tokens), word_tokens) print("BPE:", len(bpe_tokens), bpe_tokens)

병합 횟수도 바꿔 본다.

python
for num_merges in [0, 5, 10, 20]: rules = train_bpe(corpus, num_merges) tokens = encode("학교에서는 인공지능을 공부했다", rules) print(num_merges, "회 병합:", len(tokens), tokens)

결과는 다음 표로 기록한다.

문장방식병합 횟수토큰 수눈에 띈 경계
나는 인공지능을 공부한다문자0직접 기록한 글자씩 분리
나는 인공지능을 공부한다단어0직접 기록공백 기준
나는 인공지능을 공부한다BPE10직접 기록반복 표현이 묶임
학교에서는 인공지능을 공부했다BPE20직접 기록처음 본 표현의 분해 확인

관찰할 질문:

  1. 병합 횟수가 늘면 토큰 수가 항상 줄어드는가?
  2. 학습 말뭉치에 자주 나온 인공지능, 공부는 크게 묶이는가?
  3. 학습 말뭉치에 없던 학교에서는은 어떤 크기로 나뉘는가?
  4. 한국어 조사 을, 는, 에서가 단독 토큰으로 남는가?
  5. 직접 만든 BPE와 tiktoken은 어디에서 다른 경계를 선택하는가?

작은 말뭉치에서는 특정 문장 전체가 하나의 토큰처럼 합쳐질 수 있다. 좋은 일반화가 아니라 학습 데이터가 너무 작아서 생긴 과적합(overfitting, 학습 데이터에만 지나치게 맞는 현상)일 수 있다.

왜 이걸 배우나 (LLM / Post-training 연결)

LLM은 문자열이 아니라 토큰 번호의 배열을 입력으로 받는다. 같은 데이터라도 토크나이저에 따라 입력 길이, 학습 비용, 한국어 표현의 분해 방식, 생성 결과의 최대 길이가 달라진다.

LLM Research Engineer는 학습 데이터의 토큰 수를 계산하고, 특수 토큰과 다국어 경계를 검사하며, 토크나이저와 모델 어휘가 맞는지 검증한다. Post-training에서는 대화 형식이나 정답 구간이 예상한 토큰 경계로 나뉘는지 확인해야 손실 계산과 평가 범위를 정확히 설정할 수 있다.

Word2Vec의 분포 가설은 텍스트의 의미를 주변 문맥에서 학습한다는 흐름을 보여준다. 이후에는 서브워드 임베딩이 문장 속 다른 토큰과 상호작용하며 표현을 바꾸는 구조를 배운다.

초보자가 막히는 지점

병합 규칙은 긴 문자열부터 적용하면 되는가?

아니다. 학습된 순서대로 적용해야 한다. 뒤의 규칙이 앞의 병합 결과를 필요로 할 수 있다.

쌍의 빈도와 단어의 빈도를 구분하지 못한다

같은 단어가 세 번 나왔다면 그 단어 안의 인접 쌍도 세 번씩 더해야 한다. word_count를 더하지 않고 항상 1만 더하면 학습 결과가 달라진다.

공백이 사라진다

split()만 사용하면 공백 정보가 없어진다. 단어 앞에 ▁를 넣고 디코딩할 때 다시 공백으로 바꾼다.

BPE가 단어의 의미를 이해한다고 생각한다

BPE는 의미를 직접 이해하지 않는다. 자주 붙어 나온 쌍을 빈도로 합친다. 의미 있는 형태소와 비슷한 경계가 생길 수 있지만 보장되지 않는다.

tiktoken의 개별 한국어 토큰이 �로 보인다

토큰 경계가 UTF-8 문자 경계와 다를 수 있다. decode_single_token_bytes()로 바이트를 확인하고, 전체 복원은 decode()로 검사한다.

알고리즘 (병행 1시간)

  1. LeetCode 49번 Group Anagrams — 해시

문자열의 글자 빈도 또는 정렬 결과를 해시 키로 사용해 같은 애너그램(anagram, 글자 순서만 다른 문자열)을 묶는다.

  1. LeetCode 54번 Spiral Matrix — 구현

위·아래·왼쪽·오른쪽 경계를 갱신하며 행렬을 나선형 순서로 순회한다.

각 문제에 다음 세 항목을 기록한다.

text
접근: 어떤 자료구조와 순회 규칙을 선택했는가
시간복잡도: 입력 크기가 커질 때 연산 횟수가 어떻게 증가하는가
틀린 이유: 경계 조건, 중복 방문, 잘못된 키 중 무엇이 원인이었는가

한 문제당 30분을 사용한다. 20분 동안 진전이 없으면 해설의 핵심 아이디어만 확인한 뒤 코드를 처음부터 다시 작성한다.