1편에서 토크나이저는 문장을 토큰 ID의 배열로 바꿨다. 예를 들어 "나는 밥을 먹었다"라는 문장이 [8172, 392, 10491, 1837] 같은 숫자 배열이 되는 식이다. 여기까지 오면 텍스트는 사라지고 정수만 남는다.
트랜스포머는 이 정수를 그대로 읽지 않는다. 8172라는 숫자 자체에는 아무 의미가 없다. 8172가 8171보다 크다고 해서 더 강한 단어도 아니고, 8173과 가깝다고 해서 비슷한 단어도 아니다. 토큰 ID는 vocabulary table에서 몇 번째 항목인지를 가리키는 주소에 가깝다.
이 주소를 모델이 계산할 수 있는 벡터로 바꾸는 층이 embedding layer다.
토큰 ID는 embedding table의 row를 가리키고, row lookup 결과가 transformer 입력 벡터가 된다.
토큰 ID는 의미가 없는 정수다
토크나이저가 만든 결과를 다시 보자.
입력 문장: 나는 밥을 먹었다
토큰: ["나는", " 밥", "을", " 먹었다"]
토큰 ID: [8172, 392, 10491, 1837]
사람은 "나는"과 "밥"과 "먹었다"를 보고 문장 구조를 느낀다. 모델 입장에서는 네 개의 정수만 들어온다. 이 정수를 바로 attention에 넣으면 이상한 일이 생긴다. 숫자의 크기 관계가 언어의 의미 관계처럼 취급되기 때문이다.
10491은 392보다 약 26배 크다. 하지만 "을"이 "밥"보다 26배 더 큰 의미를 가진다는 해석은 없다. 토큰 ID는 정렬 가능한 수치형 feature가 아니다. 단지 lookup key다.
그래서 첫 단계에서 ID를 벡터로 바꾼다.
8172 -> [ 0.12, -0.03, 0.77, ..., 0.08]
392 -> [-0.41, 0.56, 0.02, ..., -0.19]
10491 -> [ 0.09, 0.11, -0.64, ..., 0.33]
1837 -> [ 0.48, -0.22, 0.17, ..., 0.05]
각 벡터의 길이는 모델이 정한다. GPT류 모델에서는 보통 hidden_size, d_model, n_embd 같은 이름으로 부른다. d_model=4096인 모델이라면 토큰 하나가 4096개의 실수로 표현된다. 문장 길이가 128토큰이면 embedding layer를 지난 뒤 텐서 모양은 대략 [128, 4096]이 된다. batch까지 포함하면 [batch_size, sequence_length, hidden_size]다.
embedding layer는 거대한 lookup table이다
embedding layer의 모양은 단순하다.
embedding table shape = [vocab_size, hidden_size]
vocabulary가 50,000개이고 hidden size가 768이면 embedding table에는 50,000 x 768개의 파라미터가 있다. 토큰 ID 8172가 들어오면 8172번째 row를 꺼낸다. 끝이다. matrix multiplication처럼 보일 수 있지만 구현 관점에서는 row lookup에 가깝다.
아주 작은 예제로 쓰면 이렇게 된다.
import torch
import torch.nn as nn
embedding = nn.Embedding(num_embeddings=5, embedding_dim=3)
input_ids = torch.tensor([0, 2, 4])
x = embedding(input_ids)
print(x.shape)
# torch.Size([3, 3])
num_embeddings는 vocabulary 크기다. embedding_dim은 토큰 하나를 몇 차원 벡터로 표현할지다. input_ids에 [0, 2, 4]를 넣으면 embedding table의 0번, 2번, 4번 row가 나온다.
수식으로 쓰면 더 짧다.
E: embedding table, shape [V, D]
input_ids = [t0, t1, t2, ...]
output[i] = E[input_ids[i]]
여기서 V는 vocabulary size, D는 hidden size다. embedding layer 자체는 문맥을 보지 않는다. 같은 토큰 ID는 항상 같은 초기 벡터로 시작한다. "배"가 과일인지, 신체 부위인지, 선박인지는 embedding layer 혼자 판단하지 못한다. 그 뒤의 self-attention과 feed-forward layer를 지나며 주변 토큰을 섞어 문맥 표현으로 바뀐다.
이 구분이 꽤 중요하다. embedding vector는 토큰의 기본 좌표이고, transformer block을 지난 hidden state는 문맥이 반영된 좌표다.
embedding table은 vocabulary 크기만큼 row가 있고, 각 row는 hidden size 길이의 학습 가능한 벡터다.
벡터는 어떻게 의미를 갖게 되는가
처음 만든 embedding table은 거의 랜덤이다. 학습 전에는 8172번 row와 392번 row 사이에 언어적 의미가 없다. 모델이 next token prediction을 하면서 이 좌표들이 계속 조정된다.
문장 안에서 자주 비슷한 위치에 나오고, 비슷한 다음 토큰을 예측하는 데 쓰이는 토큰들은 학습 과정에서 비슷한 방향으로 움직인다. "강아지"와 "고양이"는 서로 다른 토큰이지만 둘 다 동물, 주어, 목적어, 묘사 대상 같은 패턴에서 자주 등장한다. 그래서 embedding 공간에서 어느 정도 가까운 성질을 얻는다. 이 직관은 Word2Vec 시절부터 유명해졌고, 트랜스포머에서도 입력 embedding의 학습 원리는 같은 뿌리를 가진다.
다만 LLM의 embedding을 단어장처럼 해석하면 바로 한계가 온다. 현대 토크나이저는 단어 단위로만 자르지 않는다. "unbelievable"이 un, believ, able처럼 쪼개질 수도 있고, 한국어에서는 조사나 어미가 별도 토큰이 되거나 byte-level 조각으로 갈라진다. embedding table의 한 row는 완성된 단어 하나가 아니라 토큰 하나의 좌표다.
그래도 모델은 이 조각들을 조합해 의미를 만든다. "먹", "었", "다"가 따로 들어와도 여러 layer를 지나며 과거형 서술어의 문맥 표현으로 묶인다. 토크나이저가 잘게 쪼갠 언어일수록 이 조합 비용이 커진다. 같은 문장을 더 많은 토큰으로 표현하면 attention이 처리해야 할 길이도 늘고, embedding lookup도 더 많이 발생한다. 한국어 모델에서 토크나이저 품질을 가볍게 보면 안 되는 이유가 여기에 있다.
embedding layer의 파라미터 수
embedding layer는 생각보다 크다. 작은 BERT 계열에서도 vocabulary와 hidden size가 커지면 입력 embedding만 수천만 파라미터를 먹는다.
vocab_size = 50,000
hidden_size = 4,096
embedding parameters = 50,000 x 4,096
= 204,800,000
FP16으로 저장하면 파라미터 하나가 2바이트이므로 token embedding table만 약 390MiB를 차지한다. 여기에 output projection, optimizer state, gradient까지 고려하면 학습 시 메모리 부담은 더 커진다.
이 숫자는 tokenizer 설계와 모델 크기가 직접 연결된다는 사실을 보여준다. vocabulary를 크게 잡으면 한 문장을 더 적은 토큰으로 표현할 수 있다. 대신 embedding table이 커진다. vocabulary를 작게 잡으면 embedding table은 작아지지만 문장이 더 길어진다. 어느 쪽도 공짜가 아니다.
실무에서는 이 trade-off가 꽤 자주 보인다. 한국어, 일본어, 코드, 수식, 도메인 특수 문자열이 많이 들어가는 모델은 vocabulary 설계가 비용에 바로 반영된다. 토큰 수가 늘면 context window를 더 빨리 소모하고, attention 계산량도 증가한다. 반대로 vocabulary를 무식하게 키우면 드물게 등장하는 토큰의 embedding을 충분히 학습시키기 어려워진다.
input embedding과 output embedding
언어 모델은 입력 토큰을 embedding으로 바꾼 뒤 여러 transformer block을 통과시킨다. 마지막에는 다음 토큰의 확률 분포를 만들어야 한다. 이때 hidden state를 vocabulary 크기의 logit vector로 바꾸는 projection이 필요하다.
hidden state shape = [sequence_length, hidden_size]
output logits shape = [sequence_length, vocab_size]
마지막 위치의 hidden state가 [4096]이라면, 모델은 이것을 [vocab_size] 크기의 점수로 바꾼다. 각 점수는 다음 토큰 후보 하나에 대응한다. softmax를 거치면 next token probability distribution이 된다.
여기서 input embedding table과 output projection weight를 공유하는 방식이 자주 쓰인다. 이를 weight tying이라고 부른다. Press and Wolf의 논문은 입력 embedding과 출력 embedding을 공유하면 언어 모델의 파라미터 수를 줄이면서 성능도 개선될 수 있음을 보였다.
구조만 보면 이런 모양이다.
input: token id -> embedding table row -> hidden vector
output: hidden vector -> embedding table과 같은 weight로 vocab logits 계산
이 방식은 직관적으로도 괜찮다. 입력에서 토큰을 벡터로 이해하는 좌표계와 출력에서 다음 토큰 후보를 고르는 좌표계를 맞추는 셈이다. 모든 모델이 항상 이렇게 동작한다고 보면 곤란하지만, decoder-only LLM에서는 흔히 만나는 설계다.
입력에서는 token id가 embedding vector가 되고, 출력에서는 hidden state가 vocabulary 전체의 logit으로 투영된다.
임베딩은 semantic search의 embedding과 이름이 같다
실무에서 embedding이라고 하면 두 가지가 섞인다.
하나는 지금 다루는 token embedding이다. 모델 내부의 첫 층에서 토큰 ID를 hidden vector로 바꾸는 파라미터다. 학습 대상이고, 모델 구조의 일부다.
다른 하나는 RAG나 semantic search에서 쓰는 sentence embedding, document embedding이다. 문장이나 문서를 하나의 벡터로 뽑아 검색에 사용한다. "환불 정책"이라는 쿼리와 비슷한 문서를 vector DB에서 찾는 그 embedding이다.
둘 다 벡터 표현이라는 점에서는 연결되어 있다. 하지만 쓰임새는 다르다. token embedding은 모델 내부 계산의 출발점이고, sentence embedding은 외부 검색이나 clustering을 위한 결과물이다. 이름이 같아서 처음 공부할 때 자주 헷갈린다.
간단히 나누면 이렇다.
| 구분 | token embedding | sentence/document embedding |
|---|---|---|
| 입력 | 토큰 ID | 문장, 문서, chunk |
| 출력 | 토큰별 벡터 | 텍스트 단위 벡터 |
| 위치 | LLM 내부 layer | embedding model의 출력 |
| 주 용도 | transformer 입력 표현 | 검색, 유사도, clustering |
| 문맥 반영 | embedding layer 단독으로는 약함 | 보통 전체 문맥을 반영 |
embedding vector 하나로는 순서를 모른다
embedding layer를 통과하면 각 토큰은 벡터가 된다. 문제는 이 벡터만으로는 토큰의 위치를 알 수 없다는 점이다.
[나는] [밥을] [먹었다]
[먹었다] [밥을] [나는]
두 문장은 토큰 구성이 비슷해도 의미가 다르다. self-attention은 기본적으로 토큰들 사이의 관계를 계산한다. 하지만 입력 벡터에 위치 정보가 없으면 "몇 번째 토큰인가"라는 단서가 부족하다. 그래서 트랜스포머는 token embedding에 position 정보를 더한다.
Attention Is All You Need에서는 sinusoidal positional encoding을 사용했다. 각 위치마다 고정된 sin, cos 패턴의 벡터를 만들고, 이를 token embedding에 더한다.
x = token_embedding + positional_encoding
이 한 줄 때문에 모델은 같은 토큰이라도 0번째에 나온 "나는"과 5번째에 나온 "나는"을 다르게 볼 수 있다. 최신 LLM에서는 absolute sinusoidal encoding 대신 RoPE 같은 방식이 널리 쓰이지만, 출발점은 같다. 토큰의 의미 좌표에 위치 좌표를 섞어 sequence로 만든다.
sinusoidal positional encoding은 2.5탄에서 따로 보는 편이 낫다. 식 자체는 짧지만 왜 sin과 cos를 쓰는지, 위치 차이를 attention에서 어떻게 다루는지까지 들어가면 embedding 글의 흐름을 잡아먹는다.
embedding layer는 학습 중 계속 움직인다
fine-tuning을 이야기할 때 embedding layer를 빼놓으면 설명이 이상해진다. 새로운 도메인 데이터로 학습하면 transformer block만 변하는 게 아니다. 학습 설정에 따라 token embedding도 업데이트된다. 특히 도메인 특수 토큰, 코드 조각, 의학 용어, 법률 용어처럼 특정 패턴에서 반복 등장하는 토큰은 그 주변 분포에 맞춰 좌표가 흔들린다.
이 변화는 좋은 방향일 수도 있다. 모델이 특정 형식의 출력을 더 잘 따르게 만들거나, 자주 쓰는 용어 조합을 더 안정적으로 처리하게 만들 수 있다. 반대로 일반 언어 분포를 망칠 수도 있다. embedding은 전체 네트워크의 입구라서 여기서 좌표가 크게 움직이면 뒤쪽 layer가 기대하던 입력 분포도 같이 바뀐다.
그래서 작은 데이터로 full fine-tuning을 할 때는 조심해야 한다. 모델은 새 지식을 데이터베이스처럼 저장하지 않는다. 토큰과 토큰 사이의 확률 분포를 다시 맞춘다. embedding layer 역시 그 분포를 구성하는 파라미터다. 앞선 글에서 도메인 fine-tune이 자주 실패하는 이유를 next token 분포의 훼손으로 설명했는데, embedding은 그 훼손이 시작될 수 있는 가장 앞단이다.
직접 shape를 따라가면 덜 헷갈린다
아래는 decoder-only transformer에 들어가기 직전까지의 모양이다. 실제 모델마다 변수명은 다르지만 흐름은 거의 같다.
import torch
import torch.nn as nn
batch_size = 2
seq_len = 4
vocab_size = 50000
hidden_size = 768
input_ids = torch.randint(0, vocab_size, (batch_size, seq_len))
# shape: [2, 4]
tok_embedding = nn.Embedding(vocab_size, hidden_size)
x = tok_embedding(input_ids)
# shape: [2, 4, 768]
여기서 x[:, 0, :]는 각 batch의 첫 번째 토큰 벡터다. x[0, :, :]는 첫 번째 문장의 모든 토큰 벡터다. x[0, 2, :]는 첫 번째 문장 안의 세 번째 토큰 하나를 나타내는 768차원 벡터다.
position embedding까지 learned 방식으로 둔다면 이렇게 더한다.
max_seq_len = 1024
pos_embedding = nn.Embedding(max_seq_len, hidden_size)
positions = torch.arange(seq_len).unsqueeze(0)
# shape: [1, 4]
x = tok_embedding(input_ids) + pos_embedding(positions)
# shape: [2, 4, 768]
broadcasting 덕분에 [1, 4, 768] 모양의 position vector가 batch 전체에 더해진다. 같은 위치는 batch가 달라도 같은 position embedding을 받는다. 같은 토큰이라도 위치가 다르면 최종 입력 벡터가 달라진다.
sinusoidal 방식은 nn.Embedding으로 위치 테이블을 학습하지 않고, 수식으로 위치 벡터를 만든다. learned position embedding은 학습 가능한 파라미터고, sinusoidal positional encoding은 고정된 함수다. 이 차이는 다음 편에서 코드로 직접 찍어보면 바로 보인다.
1편과 2편을 붙여서 보면
토크나이저는 문자열을 모델 vocabulary의 ID로 바꾼다. embedding layer는 그 ID를 모델이 계산할 수 있는 dense vector로 바꾼다. 그 다음 position 정보가 더해지고, transformer block이 attention으로 문맥을 섞는다.
text
-> tokenizer
-> input_ids
-> token embedding
-> position 정보 추가
-> transformer blocks
-> logits
-> next token probabilities
이 흐름을 이해하면 LLM을 훨씬 덜 신비롭게 볼 수 있다. 텍스트가 갑자기 지능이 되는 게 아니라, 문자열이 ID가 되고, ID가 벡터가 되고, 벡터들이 layer를 지나며 다음 토큰 분포로 바뀐다.
2.5탄에서는 sinusoidal positional encoding을 수식과 코드로 분해한다. pos / 10000^(2i/d_model) 같은 식이 왜 나오는지, sin과 cos를 번갈아 쓰면 위치 차이를 어떻게 표현하는지까지 직접 숫자를 찍어볼 생각이다.