토큰 임베딩은 단어 조각을 벡터로 바꾼다. 그런데 그 벡터만으로는 문장 안에서 몇 번째 토큰인지 알 수 없다. 2.5편에서 다룬 sinusoidal positional encoding은 이 문제를 벡터 덧셈으로 풀었다. 각 위치마다 정해진 위치 벡터를 만들고, token embedding에 더해서 transformer block으로 넘긴다.
RoPE는 다른 길을 택한다. 위치 벡터를 token embedding에 더하지 않는다. attention을 계산하는 query와 key 벡터를 위치에 따라 회전시킨다. 이 한 문장이 RoPE의 거의 전부다. 단순해 보이지만, 이 방식 때문에 Llama, Qwen, Gemma 계열 같은 현대 decoder-only LLM에서 RoPE가 표준에 가깝게 쓰인다.
RoPE는 RoFormer 논문에서 제안된 Rotary Position Embedding이다. 이름에는 embedding이 들어가지만, 구현 관점에서는 embedding layer라기보다 attention 안에 들어가는 positional encoding 방식에 가깝다. 토큰 ID를 벡터로 바꾸는 embedding table과 역할이 다르다.
그림 1. RoPE는 token embedding을 대체하지 않고 self-attention 내부에서 query와 key를 회전시킨다.
위치를 더하는 방식과 회전시키는 방식
sinusoidal positional encoding은 입력 벡터 자체를 바꾼다. 예를 들어 나는 밥을 먹었다라는 문장이 있을 때, 밥을의 token embedding에 position 1의 벡터를 더하고, 먹었다의 token embedding에 position 2의 벡터를 더한다. transformer는 이 합쳐진 벡터를 보고 토큰의 의미와 위치를 동시에 추론한다.
RoPE는 attention score가 만들어지는 순간에 위치를 반영한다. self-attention에서는 각 토큰의 hidden state에서 query, key, value를 만든다.
hidden state -> query
hidden state -> key
hidden state -> value
attention score는 query와 key의 내적이다.
score(i, j) = query_i dot key_j
여기서 i는 지금 보고 있는 토큰의 위치고, j는 참고하려는 토큰의 위치다. RoPE는 query_i를 i만큼 회전시키고, key_j를 j만큼 회전시킨 뒤 내적한다.
score(i, j) = rotate(query_i, i) dot rotate(key_j, j)
이렇게 하면 attention score 안에 i와 j의 관계가 자연스럽게 들어간다. 더 정확히는 두 위치의 절대값보다 i - j라는 상대적 거리 정보가 내적 결과에 강하게 남는다. transformer가 다음 토큰을 예측할 때 실제로 많이 필요한 정보는 “이 토큰이 전체 문장의 몇 번째인가”보다 “내가 지금 보는 토큰과 저 토큰이 얼마나 떨어져 있는가”에 가깝다.
짧게 보면 덧셈 방식은 토큰 벡터에 위치표를 붙이는 방식이고, RoPE는 attention을 계산할 때 좌표계를 돌리는 방식이다.
벡터를 회전시킨다는 말
2차원 벡터 하나를 생각하면 쉽다. (x, y)라는 점이 있다. 이 점을 원점 기준으로 어떤 각도만큼 돌리면 길이는 그대로인데 방향만 바뀐다. 회전 행렬은 고등학교 수학에서 보던 그 형태다.
[x'] [cos theta -sin theta] [x]
[y'] = [sin theta cos theta] [y]
RoPE는 hidden dimension 전체를 한 번에 회전시키지 않는다. 벡터 차원을 2개씩 짝지어서 여러 개의 작은 2차원 평면으로 나눈다. 예를 들어 head dimension이 8이면 이런 식으로 묶인다.
[x0, x1] [x2, x3] [x4, x5] [x6, x7]
각 쌍은 서로 다른 속도로 회전한다. 낮은 차원 쪽은 빠르게 돌고, 높은 차원 쪽은 느리게 돈다. sinusoidal positional encoding에서 여러 주파수의 sin, cos를 섞었던 이유와 닮았다. 가까운 거리 변화에 민감한 축도 필요하고, 긴 거리 변화를 천천히 표현하는 축도 필요하기 때문이다.
위치 pos와 차원 쌍 k에 대해 각도는 보통 다음처럼 잡힌다.
theta(pos, k) = pos / base^(2k / head_dim)
여기서 base는 많은 구현에서 10000을 쓴다. 모델마다 설정은 다를 수 있다. 회전은 query와 key에만 적용하고 value에는 적용하지 않는 구현이 일반적이다. value는 실제로 섞어서 가져올 내용이고, query와 key는 어떤 토큰을 얼마나 볼지 정하는 주소 비교용 벡터에 가깝다.
그림 2. RoPE는 head dimension을 2개씩 묶고, 각 쌍을 위치에 따라 서로 다른 속도로 회전시킨다.
RoPE가 상대 위치를 잘 담는 이유
RoPE의 장점은 회전의 성질에서 나온다. 어떤 벡터를 i만큼 회전시키고, 다른 벡터를 j만큼 회전시킨 뒤 내적하면, 두 회전 사이의 차이가 결과에 반영된다. 회전 각도의 차이는 위치 차이와 연결된다.
rotate(q, i) dot rotate(k, j)
이 값은 대략 i - j에 의존하는 항을 갖는다. 수식을 깊게 파지 않아도 직관은 분명하다. 같은 물체를 같은 각도만큼 돌리면 둘 사이의 상대 방향은 유지된다. 하나는 10도 돌리고 다른 하나는 20도 돌리면 둘 사이에는 10도의 차이가 생긴다. RoPE는 이 차이를 attention score에 넣는다.
이 성질 때문에 문장 앞에 토큰이 조금 추가되어 모든 절대 위치가 밀려도, 토큰 사이의 상대적 거리는 크게 흔들리지 않는다. 밥을과 먹었다가 원래 position 1과 2에 있다가 position 5와 6으로 밀려도 둘의 거리는 여전히 1이다. 언어 모델이 구문 관계를 잡을 때 이 정보가 중요하다.
물론 RoPE가 모든 위치 문제를 해결하지는 않는다. 멀리 떨어진 토큰 사이에서는 회전 각도가 많이 누적되고, 특정 차원 쌍은 여러 바퀴를 돈다. 회전은 주기적이다. 충분히 멀리 가면 서로 다른 위치가 비슷한 회전 상태처럼 보일 수 있다. 긴 context에서 성능이 흔들리는 이유 중 하나다.
RoPE와 sequence length 확장
RoPE가 자주 언급되는 이유는 long context 확장과 붙어 있기 때문이다. 처음 4k context로 학습한 모델을 32k, 128k, 그 이상에서 쓰고 싶을 때 RoPE 설정을 건드리는 방법이 많이 등장했다.
여기서 조심해야 한다. RoPE를 쓴다고 sequence length가 공짜로 늘어나는 것은 아니다. 모델은 학습 중에 본 위치 범위 안에서 안정적으로 동작하도록 맞춰져 있다. 4k까지만 학습한 모델에게 갑자기 32k 위치를 그대로 넣으면, 회전 각도 분포가 학습 때와 달라진다. attention score의 통계가 바뀌고, 모델은 낯선 좌표계에서 추론하게 된다.
그래서 RoPE 기반 context 확장은 보통 위치 인덱스를 압축하거나 회전 주파수를 조정한다.
가장 이해하기 쉬운 방식은 position interpolation이다. 32k 입력을 4k 좌표계 안으로 눌러 넣는다고 보면 된다. 실제 위치 pos를 그대로 쓰지 않고 pos / scale 같은 값으로 바꿔 RoPE에 넣는다. Position Interpolation은 이런 계열의 대표적인 방법이다.
original: pos = 0, 1, 2, ..., 32767
scaled: pos = 0, 0.125, 0.25, ..., 4095.875 # scale = 8인 예시
이러면 모델이 학습 때 보던 위치 범위를 크게 벗어나지 않는다. 32k 토큰을 넣어도 RoPE 입장에서는 4k 근처의 회전 범위 안에서 움직인다. 겉보기에는 sequence length가 8배 늘어난다.
문제는 해상도다.
position을 압축하면 가까운 위치끼리의 간격도 같이 줄어든다. 원래 1칸 차이였던 토큰들이 RoPE 좌표계에서는 0.125칸 차이처럼 보인다. 긴 문서를 넣을 수는 있지만, 가까운 토큰 사이의 위치 구분이 둔해진다. 특히 짧은 문장, 코드, 수식, 표처럼 인접 토큰의 순서가 민감한 데이터에서는 손실이 생길 수 있다.
그래서 실제 long context 튜닝은 단순히 scale 하나만 바꾸고 끝내기 어렵다. 일부 차원은 짧은 거리 해상도를 보존하고, 일부 차원은 긴 거리까지 버티게 만드는 식의 보정이 들어간다. YaRN은 RoPE scaling에 fine-tuning을 결합해 긴 context 적응 비용을 줄이려는 방법이고, LongRoPE는 위치 보간을 더 세밀하게 나누어 매우 긴 context로 확장하려는 계열이다.
그림 3. RoPE scaling은 긴 context를 가능하게 하지만 위치 해상도와 추론 비용의 trade-off를 만든다.
“길게 들어간다”와 “잘 이해한다”는 다르다
context length를 늘렸다는 말은 보통 모델이 더 긴 입력을 받을 수 있다는 뜻이다. 그 긴 입력 안의 모든 정보를 균등하게 잘 쓰는지는 별개의 문제다.
long context 모델을 써보면 가운데 정보가 약해지는 현상을 자주 만난다. 앞부분과 끝부분은 비교적 잘 보는데, 중간에 있는 세부 조건을 놓친다. “Lost in the Middle”로 알려진 이 문제는 긴 context를 지원하는 모델에서도 나타난다. 관련 실험은 Lost in the Middle 논문에서 잘 정리되어 있다.
RoPE scaling은 위치 표현의 범위를 늘린다. attention 계산량은 그대로 sequence length의 제곱에 가깝게 증가한다. KV cache도 길이에 비례해서 커진다. 8k에서 잘 돌던 모델을 128k로 늘리면 메모리, latency, throughput이 모두 다른 문제가 된다. FlashAttention 같은 구현 최적화가 있어도, 긴 입력을 매번 다 읽는 비용은 사라지지 않는다.
긴 context는 RAG를 대체하지도 않는다. 문서를 통째로 넣는 방식은 간단하고 강력하지만, 필요한 문서를 고르는 retrieval 품질이 낮으면 긴 context 안에 노이즈만 늘어난다. 긴 context 모델일수록 “무엇을 넣지 않을지”가 더 중요해진다. 모델은 입력된 모든 토큰을 같은 비용 구조 안에서 처리한다.
RoPE scaling의 trade-off
RoPE로 sequence length를 늘릴 때 생기는 trade-off는 꽤 명확하다.
- 긴 문서를 넣을 수 있지만 위치 해상도가 낮아진다.
- 학습 때 보지 못한 길이로 extrapolation하면 attention score 분포가 흔들린다.
- scaling을 강하게 걸수록 짧은 context 성능이 떨어질 수 있다.
- 긴 context fine-tuning은 데이터 품질과 packing 방식에 민감하다.
- inference 비용은 context 길이에 따라 크게 증가한다.
- benchmark에서 긴 context 점수가 좋아도 실제 업무 문서 검색, 코드 수정, 멀티턴 에이전트 메모리에서 같은 품질이 나온다는 보장은 없다.
내 기준에서는 RoPE scaling만으로 long context 문제를 해결했다고 말하는 건 과장이다. RoPE scaling은 위치 좌표계를 늘리는 기술이고, long context 품질은 학습 데이터, attention 구현, cache 정책, retrieval, instruction tuning이 같이 만든다. 특히 이미 post-train이 잘 된 모델에 긴 context 적응을 추가로 넣을 때는 짧은 입력에서의 응답 품질이 흔들리는지 반드시 봐야 한다.
코드로 보는 RoPE의 모양
아래 코드는 RoPE가 어떤 shape로 적용되는지 보여주는 최소 예시다. 실제 모델 구현은 cache, partial rotary dimension, dtype, fused kernel 때문에 더 복잡하지만 흐름은 같다.
import torch
def rotate_half(x):
x1 = x[..., ::2]
x2 = x[..., 1::2]
return torch.stack((-x2, x1), dim=-1).flatten(-2)
def apply_rope(x, cos, sin):
# x: [batch, heads, seq_len, head_dim]
# cos: [1, 1, seq_len, head_dim]
# sin: [1, 1, seq_len, head_dim]
return (x * cos) + (rotate_half(x) * sin)
batch = 1
heads = 4
seq_len = 8
head_dim = 16
q = torch.randn(batch, heads, seq_len, head_dim)
k = torch.randn(batch, heads, seq_len, head_dim)
positions = torch.arange(seq_len, dtype=torch.float32)
inv_freq = 1.0 / (10000 ** (torch.arange(0, head_dim, 2).float() / head_dim))
freqs = torch.einsum("i,j->ij", positions, inv_freq)
emb = torch.cat((freqs, freqs), dim=-1)
cos = emb.cos()[None, None, :, :]
sin = emb.sin()[None, None, :, :]
q_rope = apply_rope(q, cos, sin)
k_rope = apply_rope(k, cos, sin)
scores = torch.matmul(q_rope, k_rope.transpose(-1, -2))
print(scores.shape) # [1, 4, 8, 8]
여기서 눈여겨볼 부분은 value가 없다는 점이다. RoPE는 attention score를 만들기 전 query와 key에 들어간다. 입력 embedding table을 바꾸는 작업도 아니고, vocab을 바꾸는 작업도 아니다.
RoPE를 처음 공부할 때 헷갈리는 지점
RoPE는 token embedding을 대체하지 않는다. 토큰 ID는 여전히 embedding table을 거쳐 hidden vector가 된다. RoPE는 그 뒤 attention layer에서 query와 key를 회전시킨다.
RoPE는 긴 context를 자동으로 보장하지 않는다. 학습 때 4k만 본 모델을 설정만 바꿔 128k로 열면, 입력은 들어가도 품질은 무너질 수 있다. context window는 API 스펙이고, long context reasoning은 모델 능력이다.
RoPE scaling은 fine-tuning 없이도 어느 정도 효과를 낼 수 있지만, 안정적인 확장은 보통 추가 학습이나 적응 절차를 요구한다. 특히 긴 문서 QA, 코드베이스 이해, 에이전트 로그처럼 실제 long context 사용처는 단순히 먼 위치의 토큰을 기억하는 문제보다 훨씬 지저분하다.
다음 편에서 self-attention으로 넘어가면 RoPE가 왜 query와 key에 들어가는지 더 분명해진다. attention은 모든 토큰 쌍의 관계 점수를 만들고, RoPE는 그 관계 점수에 위치 차이를 새긴다.