Documents

트랜스포머(Transformer) 따라가기 2.5탄. Sinusoidal Positional Encoding 편

9 min readJun 15, 2026Jun 16, 2026

토큰을 embedding vector로 바꾸면 문장은 숫자 벡터의 나열이 된다. 여기까지는 2편에서 봤다. 그런데 이 벡터 나열에는 아직 한 가지가 빠져 있다. 순서다.

나는 밥을 먹었다밥이 나를 먹었다는 같은 토큰들을 꽤 많이 공유한다. embedding layer 입장에서 각 token ID는 table의 row를 찾아가는 주소이고, row에서 나온 vector는 그 토큰의 의미와 쓰임새를 담는다. 문제는 self-attention이 기본 형태만 놓고 보면 입력 전체를 한 번에 비교한다는 점이다. token embedding만 넣으면 모델은 각 토큰이 몇 번째 자리에 있었는지 직접 알 수 없다.

그래서 트랜스포머는 토큰 벡터에 위치 벡터를 더한다. Attention Is All You Need의 원래 트랜스포머는 이 위치 벡터를 학습하지 않고, 사인과 코사인 함수로 미리 계산했다. 이 방식이 sinusoidal positional encoding이다.

토큰 임베딩과 위치 인코딩을 더해 트랜스포머 입력을 만드는 단순 흐름도
토큰 임베딩과 위치 인코딩을 더해 트랜스포머 입력을 만드는 단순 흐름도

그림 1. token embedding과 positional encoding은 같은 hidden size를 가지며 element-wise add로 합쳐진다.

위치는 왜 embedding에 더해도 되는가

처음 보면 조금 이상하다. token embedding은 토큰의 의미를 담고, positional encoding은 토큰의 위치를 담는다. 서로 성격이 다른 정보를 그냥 더해도 되나 싶다. 실제로 더한다.

트랜스포머의 입력 벡터는 보통 다음처럼 만들어진다.

x = token_embedding[token_ids] + positional_encoding[:seq_len]

shape로 보면 더 단순하다.

token_ids              [batch, seq_len]
token_embedding        [batch, seq_len, hidden_size]
positional_encoding    [       seq_len, hidden_size]
input_to_transformer   [batch, seq_len, hidden_size]

hidden_size가 768이면 token embedding도 768차원이고 positional encoding도 768차원이다. 같은 차원의 두 벡터를 더하면 결과도 768차원이다. 모델 입장에서는 입력 벡터 안에 토큰 정보와 위치 정보가 섞여 들어온다.

이게 가능한 이유는 뒤의 layer들이 이 섞인 표현을 다시 분해하고 조합할 수 있기 때문이다. self-attention의 query, key, value projection은 입력 벡터에 행렬을 곱한다. 어떤 head는 토큰 의미에 강하게 반응하고, 어떤 head는 위치 차이에 민감하게 반응한다. 학습 과정에서 그런 방향이 만들어진다. 처음부터 사람이 앞의 384차원은 의미, 뒤의 384차원은 위치라고 나눠 주지 않는다.

더하는 방식에는 실무적인 장점도 있다. sequence 길이가 바뀌어도 입력 shape가 그대로 유지된다. token embedding과 positional encoding을 concat하면 hidden size가 커지고 이후 모든 projection matrix의 크기도 바뀐다. 더하면 모델 구조가 깔끔하게 유지된다. 이 선택은 단순하지만 꽤 강하다.

sinusoidal positional encoding 공식 읽기

원 논문에 나온 공식은 다음과 같다.

PE(pos, 2i)     = sin(pos / 10000^(2i / d_model))
PE(pos, 2i + 1) = cos(pos / 10000^(2i / d_model))

기호가 많아 보이지만 하나씩 보면 어렵지 않다.

pos는 토큰의 위치다. 첫 번째 토큰이면 0, 두 번째 토큰이면 1이다. 구현에 따라 1부터 시작할 수도 있지만 원리는 같다. i는 hidden dimension의 index 쌍을 가리킨다. d_model은 hidden size다. GPT류 모델 문맥에서는 보통 hidden_size라고 부르는 그 값이다.

짝수 차원에는 sin을 넣고, 홀수 차원에는 cos를 넣는다. 0번 차원과 1번 차원은 같은 주파수를 쓰되 하나는 sin, 하나는 cos다. 2번과 3번 차원은 조금 더 느린 주파수를 쓴다. 뒤쪽 차원으로 갈수록 파장이 길어진다.

그래서 한 위치의 positional encoding은 여러 주파수의 파동을 한 줄로 잘라낸 벡터가 된다.

사인 코사인 positional encoding이 위치와 차원에 따라 다른 주파수를 담는 구조
사인 코사인 positional encoding이 위치와 차원에 따라 다른 주파수를 담는 구조

그림 2. 각 position은 여러 주파수의 sin, cos 값을 hidden dimension 방향으로 가진다.

예를 들어 hidden size가 8이면 각 position은 이런 식의 값을 가진다. 실제 모델에서는 512, 768, 4096 같은 훨씬 큰 차원을 쓴다.

position 0 -> [sin(0/a0), cos(0/a0), sin(0/a1), cos(0/a1), ...]
position 1 -> [sin(1/a0), cos(1/a0), sin(1/a1), cos(1/a1), ...]
position 2 -> [sin(2/a0), cos(2/a0), sin(2/a1), cos(2/a1), ...]

position 0의 값이 전부 0은 아니다. sin 계열은 0이지만 cos 계열은 1이다. 이 덕분에 첫 번째 위치도 고유한 벡터를 가진다.

파장이 짧은 차원은 가까운 위치 변화에 민감하다. position이 1만 바뀌어도 값이 꽤 달라진다. 파장이 긴 차원은 멀리 떨어진 위치를 부드럽게 구분한다. 여러 주파수를 섞으면 가까운 순서와 먼 순서를 한 벡터 안에 같이 담는다.

왜 하필 sin과 cos인가

사인과 코사인은 연속적이고 주기적이다. position이 조금 바뀌면 positional vector도 조금 바뀐다. position 10과 position 11은 완전히 다른 ID처럼 튀지 않고, 가까운 위치라는 흔적을 남긴다. 이 성질이 순서를 다루는 데 유리하다.

또 하나의 장점은 상대 위치를 선형적으로 다루기 좋다는 점이다. sin과 cos에는 다음 관계가 있다.

sin(a + b) = sin(a)cos(b) + cos(a)sin(b)
cos(a + b) = cos(a)cos(b) - sin(a)sin(b)

이 말은 position pos + k의 encoding을 position pos의 sin, cos 값과 offset k의 sin, cos 값으로 표현할 수 있다는 뜻이다. 트랜스포머의 내부 연산은 대부분 선형 변환과 attention 조합으로 이루어진다. sinusoidal encoding은 이런 연산이 상대적인 거리 패턴을 잡아내기 좋은 모양을 제공한다.

문장에서 중요한 건 절대 위치만이 아니다. 바로 뒤에 이 오는지, 동사가 문장 끝 근처에 있는지, 괄호가 열린 뒤 몇 토큰 뒤에 닫히는지 같은 상대 위치가 훨씬 자주 등장한다. sinusoidal positional encoding은 모델이 이런 상대 위치 규칙을 배우기 쉬운 좌표계를 깔아 준다.

토큰이 앞에 추가되어 절대 위치가 밀려도 주변 토큰의 상대 관계가 유지되는 예시
토큰이 앞에 추가되어 절대 위치가 밀려도 주변 토큰의 상대 관계가 유지되는 예시

그림 3. 문장 앞에 토큰이 끼어들면 절대 position은 밀리지만, 주변 토큰과의 상대 관계는 유지된다.

나는 오늘 밥을 먹었다에서 먹었다는 4번 위치에 있을 수 있다. 나는 정말 오늘 밥을 먹었다에서는 5번 위치로 밀린다. 절대 위치는 달라졌지만 밥을 다음에 동사가 온다는 구조는 유지된다. 언어 모델이 일반화해야 하는 것은 이런 쪽이다.

학습되는 position embedding과의 차이

positional encoding을 꼭 sin, cos로 만들 필요는 없다. BERT 계열에서는 position embedding table을 학습하는 방식이 널리 쓰였다. token embedding table처럼 position 0, position 1, position 2에 해당하는 row를 두고 학습으로 값을 업데이트한다.

학습형 position embedding은 단순하고 성능도 좋다. 모델이 데이터에서 자주 보는 위치 패턴에 맞춰 position vector를 직접 조정한다. max sequence length가 정해져 있는 encoder 모델에서는 이 방식이 자연스럽다. BERT의 입력 길이가 512 토큰으로 제한되는 식이다.

sinusoidal 방식은 parameter가 없다. max length만 정하면 미리 계산해 둘 수 있고, 이론적으로는 학습 때 보지 않은 더 긴 position에도 값을 만들 수 있다. 이 장점은 매력적이지만, 실제 긴 문맥 일반화가 자동으로 해결되지는 않는다. attention layer, 학습 데이터의 길이 분포, normalization, 후처리 학습까지 같이 영향을 준다. positional encoding만 길게 만든다고 2K context 모델이 갑자기 128K context 모델처럼 동작하지 않는다.

나는 이 지점이 초보자가 가장 자주 헷갈리는 부분이라고 본다. sinusoidal positional encoding은 임의 길이의 위치 벡터를 계산하는 방법이다. 긴 문맥을 제대로 이해하는 능력은 모델 전체가 긴 문맥에서 훈련되고 정렬되어야 생긴다. 위치 벡터를 만들 수 있다는 말과 긴 문맥 추론을 잘한다는 말은 다르다.

작은 PyTorch 구현

sinusoidal positional encoding은 구현이 짧다. 아래 코드는 [max_len, d_model] matrix를 만든다.

import math
import torch


def sinusoidal_positional_encoding(max_len: int, d_model: int) -> torch.Tensor:
    pe = torch.zeros(max_len, d_model)

    position = torch.arange(0, max_len, dtype=torch.float32).unsqueeze(1)
    div_term = torch.exp(
        torch.arange(0, d_model, 2, dtype=torch.float32)
        * (-math.log(10000.0) / d_model)
    )

    pe[:, 0::2] = torch.sin(position * div_term)
    pe[:, 1::2] = torch.cos(position * div_term)
    return pe


pe = sinusoidal_positional_encoding(max_len=8, d_model=6)
print(pe.round(decimals=4))

대략 이런 형태가 나온다.

tensor([[ 0.0000,  1.0000,  0.0000,  1.0000,  0.0000,  1.0000],
        [ 0.8415,  0.5403,  0.0464,  0.9989,  0.0022,  1.0000],
        [ 0.9093, -0.4161,  0.0927,  0.9957,  0.0043,  1.0000],
        [ 0.1411, -0.9900,  0.1388,  0.9903,  0.0065,  1.0000],
        [-0.7568, -0.6536,  0.1846,  0.9828,  0.0086,  1.0000],
        [-0.9589,  0.2837,  0.2300,  0.9732,  0.0108,  0.9999],
        [-0.2794,  0.9602,  0.2749,  0.9615,  0.0129,  0.9999],
        [ 0.6570,  0.7539,  0.3192,  0.9477,  0.0151,  0.9999]])

0번, 1번 차원은 빠르게 흔들린다. 4번, 5번 차원은 max_len=8 범위에서는 거의 변하지 않는다. d_model이 커지고 sequence가 길어지면 느린 파장의 차원들이 멀리 떨어진 위치를 구분하는 데 쓰인다.

실제 transformer block에 넣을 때는 batch dimension에 맞춰 broadcast하면 된다.

batch_size = 2
seq_len = 8
d_model = 6

token_embeddings = torch.randn(batch_size, seq_len, d_model)
pe = sinusoidal_positional_encoding(max_len=seq_len, d_model=d_model)

x = token_embeddings + pe.unsqueeze(0)
print(x.shape)  # torch.Size([2, 8, 6])

이 코드에서 positional encoding은 gradient로 업데이트되지 않는다. 학습형 position embedding을 쓰면 nn.Embedding(max_len, d_model)을 만들고 optimizer가 그 weight를 같이 업데이트한다.

position 정보가 attention 안에서 쓰이는 방식

self-attention은 각 토큰에서 query, key, value를 만든다.

q_i = x_i W_Q
k_j = x_j W_K
score(i, j) = q_i · k_j

여기서 x_i는 token embedding과 positional encoding이 더해진 벡터다. 그래서 attention score는 토큰의 의미뿐 아니라 위치 정보에도 영향을 받는다. i번째 토큰이 j번째 토큰을 얼마나 볼지 계산할 때, 둘의 내용과 위치가 같이 들어간다.

예를 들어 어떤 head는 바로 앞 토큰을 강하게 보도록 학습될 수 있다. 다른 head는 문장 시작 부분이나 구분자 token 근처를 보는 식으로 움직인다. attention map을 시각화하면 대각선 근처를 따라가는 head가 종종 보이는데, 이런 head는 인접 토큰 관계를 다룬다.

이 설명을 너무 신비롭게 받아들이면 안 된다. positional encoding이 들어간다고 모델이 문법을 자동으로 이해하는 건 아니다. 위치 정보가 없는 입력보다 학습하기 좋은 재료가 들어갈 뿐이다. 나머지는 대규모 next token prediction 과정에서 만들어진다.

요즘 모델은 RoPE를 더 많이 쓴다

원래 트랜스포머의 sinusoidal positional encoding은 지금 봐도 아름다운 설계다. 다만 최신 decoder-only LLM에서는 입력 embedding에 위치 벡터를 더하는 방식보다 RoPE를 더 자주 쓴다. RoPE는 query와 key 벡터를 position에 따라 회전시킨다. 이 방식은 상대 위치 정보를 attention score 안에 더 직접적으로 넣는다.

Qwen, Llama 계열 모델을 만지다 보면 RoPE 관련 설정을 자주 보게 된다. rope_theta, rope_scaling, context extension 같은 값들이 여기에 걸려 있다. 긴 context를 늘릴 때 positional encoding이 왜 문제가 되는지도 이 지점에서 드러난다. 모델은 단순히 토큰을 더 많이 받는 기계가 아니다. 특정 길이와 위치 분포에서 학습된 attention 패턴을 가진다.

그래도 sinusoidal positional encoding을 먼저 보는 편이 좋다. token embedding에 순서 정보를 주입한다는 아이디어가 가장 선명하게 보인다. RoPE, ALiBi, learned absolute position embedding은 이 아이디어를 서로 다른 방식으로 구현한 변형으로 이해하면 된다.

2편과 3편 사이에 이 내용이 필요한 이유

embedding layer는 token ID를 vector로 바꾼다. positional encoding은 그 vector에 순서 좌표를 준다. 이 둘이 합쳐진 뒤에야 transformer block이 문장을 처리할 입력을 받는다.

토크나이저가 문장을 token ID로 자르고, embedding layer가 token ID를 dense vector로 바꾸고, positional encoding이 각 vector에 위치를 새긴다. 다음에 볼 self-attention은 이 입력을 가지고 모든 토큰 쌍의 관계를 계산한다. 여기서부터 트랜스포머가 트랜스포머처럼 보이기 시작한다.

Tags
LLMNLPTransformerEmbedding임베딩Positional EncodingPyTorchRoPE시리즈