Field Log · Entry
LLM 토큰화 입문: BPE부터 Context Window 계산까지 (2/10)
오늘의 결론
- LLM의 실제 입력은 문자열이 아니라 vocabulary에 매핑된 토큰 ID 배열입니다.
- token은 글자나 단어와 같지 않으며, tokenizer와 언어에 따라 결과가 달라집니다.
- context window는 입력과 출력에 쓸 수 있는 토큰의 총예산입니다.
- RAG의 chunk 크기, 검색 개수, 프롬프트 길이는 이 예산 안에서 함께 설계해야 합니다.
API에 “펌프 알람 원인을 알려줘”를 보내면 모델이 이 문장을 그대로 읽는 것처럼 느껴집니다. 실제로는 먼저 tokenizer가 문자열을 작은 조각으로 나누고, 각 조각을 정수 ID로 바꿉니다. Transformer가 받는 것은 결국 정수들의 행렬입니다.
그림 1. tokenizer는 문자열을 token으로 나누고 vocabulary에서 ID를 찾는다. embedding table 조회는 그다음 모델 단계다.
문자열, 토큰, 토큰 ID를 구분하자
다음 네 가지는 서로 다릅니다.
| 이름 | 예 | 담당 |
|---|---|---|
| 문자열 | “RAG를 만들자” | 애플리케이션과 사람 |
| 토큰 | “R”, “AG”, “를”, “ 만들”, “자” 같은 조각 | tokenizer |
| 토큰 ID | 51, 1923, 287… 같은 정수 | vocabulary |
| token embedding | 각 ID에 대응하는 실수 벡터 | 모델의 embedding table |
같은 문자열도 tokenizer가 다르면 토큰 경계와 ID가 달라집니다. 따라서 한 모델의 토큰 ID를 다른 모델에 그대로 넣을 수 없습니다. tokenizer는 모델과 함께 배포되는 모델 입력 규약입니다.
왜 단어 단위로 자르지 않을까
단어 tokenizer는 직관적이지만 두 문제가 있습니다.
- 세상의 모든 단어, 신조어, 코드, 오타를 vocabulary에 넣을 수 없습니다.
- “검색”, “검색기”, “검색하다”처럼 관련된 형태가 서로 완전히 다른 ID가 되면 학습 효율이 떨어집니다.
반대로 문자 하나씩 자르면 모르는 단어 문제는 줄지만 sequence가 너무 길어집니다. attention 비용과 context 사용량이 커지고, 단어 수준 패턴을 다시 조합해야 합니다.
현대 LLM은 주로 subword라는 중간 지점을 사용합니다. 자주 나오는 조각은 크게 묶고, 드문 표현은 더 작은 조각으로 나눕니다.
자주 등장: transformer → [transformer]
덜 익숙함: retrievability → [retrieve] [ability] 또는 더 작은 조각
새 코드: E17_PUMP_FAIL → [E] [17] [_PUMP] [_FAIL] 같은 조각
정확한 분할은 예시와 다를 수 있습니다. 중요한 원리는 자주 본 패턴은 적은 토큰, 낯선 패턴은 많은 토큰이 되는 경향입니다.
BPE는 무엇을 학습하는가
Byte Pair Encoding 계열의 직관은 “가장 자주 붙어 나오는 이웃 조각을 반복해서 합친다”입니다.
아주 작은 말뭉치가 있다고 가정해 봅시다.
low
lower
newest
widest
처음에는 문자 수준에서 시작하고, 빈번한 쌍을 하나의 새 기호로 합칩니다.
l o w
l o w e r
빈번한 (l, o) 합치기 → lo
빈번한 (lo, w) 합치기 → low
합치기를 정해진 vocabulary 크기까지 반복하면 자주 나오는 low는 한 조각이 되고, 드문 단어는 여러 조각으로 남습니다. 실제 LLM tokenizer는 byte-level BPE, WordPiece, unigram language model 등 세부 방식이 다르지만 “유한한 vocabulary로 열린 문자열을 표현한다”는 목적은 같습니다.
SentencePiece 논문의 중요한 특징은 미리 언어별 단어 분리를 가정하지 않고 raw sentence에서 직접 subword 모델을 학습할 수 있다는 점입니다. 띄어쓰기 규칙이 다른 여러 언어를 같은 파이프라인으로 다루기 좋습니다.
한국어에서 토큰 비용이 달라지는 이유
한국어는 조사와 어미가 붙고 형태가 다양합니다. tokenizer의 학습 데이터에 한국어가 적거나 vocabulary가 영어 중심이면 같은 의미를 표현해도 토큰 수가 더 많아질 수 있습니다.
모델 A: "검색합니다" → [검색] [합니다] # 2 tokens
모델 B: "검색합니다" → [검] [색] [합] [니다] # 4 tokens
위 숫자는 설명용입니다. 실제 결과는 반드시 사용할 모델의 tokenizer로 재야 합니다. 영어 기준으로 흔히 말하는 “한 토큰은 약 몇 글자” 같은 규칙을 한국어 문서에 그대로 적용하면 chunk와 비용 계산이 틀어질 수 있습니다.
RAG에서 특히 확인할 데이터는 다음과 같습니다.
- 한글 본문과 표
- 영문 약어가 섞인 기술 문서
- 장비 코드, 에러 코드, 파일 경로
- 숫자와 단위
- OCR 오류가 포함된 문자열
특수 토큰도 예산을 사용한다
모델은 텍스트 조각 외에 역할과 경계를 알리는 특수 토큰을 사용할 수 있습니다.
| 종류 | 역할 |
|---|---|
| BOS / EOS | sequence 시작과 끝 |
| PAD | batch 안에서 길이를 맞추기 |
| 역할 token | system, user, assistant 메시지 경계 |
| 구분 token | 문장이나 두 입력의 경계 |
채팅 API가 내부 chat template을 적용하면 화면에 보이지 않는 역할·구분 토큰도 들어갑니다. 따라서 단순히 본문 글자 수만 세면 실제 요청 token 수와 차이가 생깁니다.
Context window는 공동 예산이다
context window가 8,192 tokens라고 해서 검색 문서 8,192개를 넣는다는 뜻이 아닙니다. 보통 다음 항목이 같은 예산을 나눠 씁니다.
system instruction
+ 대화 기록
+ 사용자 질문
+ 검색된 문서와 metadata
+ tool 호출/결과
+ 모델이 생성할 답변
≤ context window
예를 들어 최대 8,192 tokens인 모델에 1,000 token의 system·질문, 1,500 token의 답변 공간을 예약하면 검색 context에 쓸 수 있는 이론상 상한은 5,692 tokens입니다. 여기에 안전 여유와 chat template 비용도 남겨야 합니다.
retrieval_budget
= context_limit
- fixed_prompt_tokens
- conversation_tokens
- reserved_output_tokens
- safety_margin
이 식을 애플리케이션 설정으로 명시하면 긴 대화에서 갑자기 context가 잘리는 문제를 줄일 수 있습니다.
RAG chunk 크기와 token의 관계
chunk가 너무 작으면 한 절차가 여러 조각으로 찢어집니다. 너무 크면 관련 없는 문장이 함께 검색되고, top-k 몇 개만으로 context 예산을 다 써 버립니다.
예를 들어 검색 예산이 4,000 tokens일 때:
| chunk 크기 | top-k 5개의 이론상 최대 본문 | 관찰할 위험 |
|---|---|---|
| 200 tokens | 1,000 tokens | 필요한 문맥이 인접 chunk에 흩어짐 |
| 600 tokens | 3,000 tokens | 균형점 후보 |
| 1,200 tokens | 6,000 tokens | 예산 초과, 잘림 또는 후보 감소 |
실제 사용량에는 제목, 출처, 구분자도 포함됩니다. 중요한 건 “정답 chunk 크기”를 외우는 것이 아니라 문서 구조와 질문 유형을 기준으로 여러 값을 평가하는 것입니다.
10분 실습: 내 데이터의 token 분포 재기
사용 중인 모델의 tokenizer로 문서 길이를 직접 재 보세요. Hugging Face 계열 모델이라면 다음 형태로 시작할 수 있습니다.
from pathlib import Path
from statistics import median
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("YOUR_MODEL_ID")
texts = [p.read_text(encoding="utf-8") for p in Path("docs").glob("*.md")]
lengths = [len(tokenizer.encode(text, add_special_tokens=False)) for text in texts]
lengths.sort()
print("documents:", len(lengths))
print("min / median / max:", lengths[0], median(lengths), lengths[-1])
print("p95:", lengths[int(len(lengths) * 0.95)])
그다음 실제 chunker의 결과에도 같은 측정을 적용합니다.
chunk_lengths = [
len(tokenizer.encode(chunk, add_special_tokens=False))
for chunk in chunks
]
평균만 보지 말고 p95와 최대값을 봅니다. 표 하나나 코드 블록 하나 때문에 유난히 긴 chunk가 생길 수 있기 때문입니다.
자주 하는 오해
“토큰은 단어다.”
아닙니다. 단어 하나가 여러 token이 될 수도 있고, 공백과 문장 부호가 이웃 문자와 묶일 수도 있습니다.
“tokenizer는 전처리 도구일 뿐 모델 품질과 무관하다.”
tokenizer는 sequence 길이, 다국어 효율, 코드·숫자 표현, 학습 단위를 바꿉니다. 모델 입력 설계의 일부입니다.
“context limit 안이면 많이 넣을수록 좋다.”
무관하거나 중복된 문서는 비용을 늘리고 중요한 근거를 묻히게 할 수 있습니다. context 용량과 context 품질은 다릅니다.
스스로 확인하기
- 문자열, token, token ID, token embedding의 차이를 설명할 수 있는가?
- 같은 한국어 문장이 모델마다 다른 token 수가 되는 이유는 무엇인가?
- context limit에서 검색 문서에 쓸 수 있는 예산을 어떤 항목을 빼서 계산해야 하는가?
- 현재 RAG의 chunk 크기는 문자 기준인가 token 기준인가?
다음 글에서는 token ID가 어떻게 의미를 비교할 수 있는 좌표가 되는지 벡터와 임베딩으로 이어갑니다.