LLM·트랜스포머

LLM / 4번째 글

위치 인코딩: 사인·코사인에서 RoPE까지

Self-Attention이 순서를 모르는 이유부터 사인·코사인 절대 인코딩, 학습형 임베딩, 상대 위치 바이어스, 그리고 쿼리·키를 회전시키는 RoPE와 PI·YaRN 확장까지 한 흐름으로 정리한다.

PALDYN Team35 MIN READ

지난 글에서 Multi-Head Attention이 서로 다른 관점으로 어텐션을 병렬 계산하는 방식을 봤다. 헤드를 여덟 개로 늘려도 고쳐지지 않는 구멍이 하나 남아 있다. 어텐션은 "나는 AI를 공부한다"와 "AI를 나는 공부한다"를 완전히 같은 입력으로 본다. 단어 집합이 같기 때문이다.

이 구멍을 메우는 장치가 위치 인코딩이고, 이 글은 그 계보를 한자리에서 훑는다. 원논문의 사인·코사인 절대 인코딩에서 출발해 학습형 임베딩과 상대 위치 바이어스를 거쳐, 현대 LLM 대부분이 쓰는 RoPE(Rotary Position Embedding)와 그 위에 얹힌 컨텍스트 확장 기법까지가 한 줄기다. 네 방식은 서로 다른 발명처럼 보이지만, 실은 위치 정보를 어텐션 계산의 어느 지점에 끼워 넣는가라는 하나의 질문에 대한 서로 다른 답이다.

어텐션과 위치 정보

순열 불변성

순열 불변(permutation invariance)은 입력의 순서를 섞어도 결과가 본질적으로 달라지지 않는 성질이다. Self-Attention이 정확히 그렇다.

이유는 계산식에 그대로 드러나 있다. 어텐션 점수는 쿼리와 키의 내적 QK⊤Q K^{\top} 으로 만들어지는데, 이 곱은 두 벡터의 값만 볼 뿐 그 벡터가 몇 번째 자리에서 왔는지는 알지 못한다. 토큰 세 개를 늘어놓는 방법은 여섯 가지지만, 여섯 경우 모두 같은 어텐션 행렬이 나온다 — 행과 열의 배치만 따라 바뀔 뿐 값 자체는 하나도 달라지지 않는다.

영어로 옮기면 문제가 더 선명하다. "dog bites man"과 "man bites dog"는 토큰 집합이 완전히 같고 뜻은 정반대다. 위치 정보가 없는 어텐션에게 이 둘은 구별되지 않는 입력이다. 한국어는 조사가 격을 표시해 주니 덜 치명적으로 보이지만, "먼저"·"그다음"처럼 순서 자체가 의미인 문장에서는 똑같이 무너진다.

RNN에는 이 문제가 없었다. 앞 스텝의 은닉 상태를 이어받는 구조라서 순서가 계산 경로에 새겨져 있기 때문이다. 트랜스포머는 그 순차성을 버리고 모든 위치를 동시에 처리해 병렬성을 얻었고, 그 대가로 순서를 잃었다. 위치 인코딩은 잃어버린 것을 돌려주기 위해 따로 지불하는 비용이다.

위치 주입 지점

위치 정보를 되돌려 놓을 자리는 셋뿐이다. 어텐션 한 층의 계산 흐름이 「입력 임베딩 → 쿼리·키 만들기 → 내적으로 점수 계산 → 소프트맥스」이므로, 손댈 수 있는 지점도 그 흐름 위에 셋이 있다.

첫째, 입력 임베딩에 더한다. 토큰 벡터에 위치 벡터를 그냥 합쳐 버리는 방식이고, 원논문의 사인·코사인 인코딩과 BERT·GPT-2의 학습형 임베딩이 여기 속한다. 위치를 한 번만 주입하면 나머지 층은 손댈 필요가 없다는 것이 장점이다.

둘째, 어텐션 점수에 더한다. 내적으로 점수를 낸 다음, 두 토큰 사이의 거리에 따른 값을 그 점수에 직접 얹는다. 상대 위치 바이어스와 ALiBi가 이 자리를 쓴다.

셋째, 쿼리·키 벡터 자체를 손본다. 벡터에 무언가를 더하는 대신 위치에 해당하는 각도만큼 회전시킨다. RoPE가 이 자리에 선다.

세 자리는 각각 다른 성질을 가진다. 아래에서는 첫째 자리부터 순서대로 보면서, 앞의 방식이 남긴 문제가 어떻게 다음 방식을 불러왔는지를 따라간다.

사인·코사인 절대 인코딩

주파수의 사다리

원논문 Attention Is All You Need가 제안한 고정(fixed) 위치 인코딩은 아래 공식으로 계산된다. pos\text{pos} 는 시퀀스 안에서의 토큰 위치이고, ii 는 임베딩 차원 인덱스이며, dd 는 임베딩 크기 dmodeld_{model} 이다.

PE(pos, 2i)=sin⁡ ⁣(pos100002i/d)PE(pos, 2i+1)=cos⁡ ⁣(pos100002i/d)\begin{aligned} PE_{(\text{pos},\, 2i)} &= \sin\!\left(\frac{\text{pos}}{10000^{2i/d}}\right) \\ PE_{(\text{pos},\, 2i+1)} &= \cos\!\left(\frac{\text{pos}}{10000^{2i/d}}\right) \end{aligned}

Positional Encoding 공식과 주파수 비교

공식이 하는 일은 한 문장으로 줄일 수 있다. 차원마다 다른 속도로 도는 시곗바늘을 여러 개 세워 두고, 지금 위치에서 각 바늘이 가리키는 값을 적어 둔다. 짝수 차원에는 사인, 홀수 차원에는 코사인을 쓰는 이유도 여기 있다 — 한 바늘의 위치를 사인·코사인 두 값으로 함께 적어야 각도가 하나로 정해진다.

바늘이 도는 속도는 차원마다 기하급수적으로 느려진다. d=512d = 512 로 숫자를 넣어 보면 i=0i = 0 인 첫 쌍은 분모가 1이라 위치가 1 늘 때마다 1라디안씩 돌고, 약 6.3 토큰이면 한 바퀴를 마친다. i=128i = 128 이면 분모가 100000.5=10010000^{0.5} = 100 이라 한 바퀴에 약 628 토큰이 걸린다. 마지막 쌍에 이르면 주기가 2π×100002\pi \times 10000, 곧 6만 3천 토큰 가까이까지 늘어난다.

이 사다리 덕분에 낮은 차원은 바로 옆 토큰과의 미세한 차이를 담고, 높은 차원은 문서 규모의 거리를 담는다. 시계로 치면 초침과 시침을 함께 읽는 것과 같고, 이진수로 치면 1의 자리가 매번 뒤집히는 동안 상위 비트는 천천히 바뀌는 것과 같다. 512차원이면 그런 자릿수가 256칸 있는 셈이다.

임베딩과의 덧셈

위치 인코딩은 토큰 임베딩과 같은 모양(seq_len × d_model)으로 만들어지고, 결합 방식은 그냥 원소별 덧셈이다. 더한 뒤 드롭아웃을 한 번 걸어 첫 인코더·디코더 층으로 넘긴다.

PE 더하기 흐름 — 토큰 임베딩 + 위치 정보

왜 이어 붙이지(concatenate) 않고 더하는지를 물어볼 만하다. 이어 붙이면 위치 정보가 내용과 섞이지 않아 깔끔하지만, dmodeld_{model} 이 통째로 커지고 그 뒤에 오는 모든 가중치 행렬이 함께 부풀어 오른다. 덧셈은 차원을 하나도 늘리지 않고 위치를 실어 나른다.

대가는 섞임이다. 같은 단어라도 위치 0에 있을 때와 위치 100에 있을 때 서로 다른 벡터가 되고, 이후 어텐션이 계산하는 내적은 「내용 × 내용」·「내용 × 위치」·「위치 × 위치」 항이 한 덩이로 뭉친 값이다. 뒤에서 볼 문제의 씨앗이 여기서 뿌려진다.

다만 이 설계에는 뜻밖의 미덕도 하나 숨어 있다. PE(pos+k)PE(\text{pos}+k) 는 PE(pos)PE(\text{pos}) 의 선형 변환으로 표현된다 — 그리고 그 선형 변환은 다름 아닌 회전이다. 사인·코사인으로 적어 둔 값이 결국 각도이므로, kk 만큼 위치를 옮기는 일은 각 바늘을 kθk\theta 만큼 더 돌리는 일과 같다. 절대 위치를 적어 두었는데 상대 거리가 회전으로 나타나는 이 성질이, 뒤에 나올 RoPE가 끝까지 밀고 간 실마리다.

상수 텐서 구현

계산이 수식으로 닫혀 있으므로 표를 한 번 만들어 두고 재사용하면 된다.

import torch
import math
import torch.nn as nn

class PositionalEncoding(nn.Module):
    def __init__(self, d_model: int, max_len: int = 5000, dropout: float = 0.1):
        super().__init__()
        self.dropout = nn.Dropout(p=dropout)

        pe = torch.zeros(max_len, d_model)
        pos = torch.arange(max_len).unsqueeze(1)            # (max_len, 1)
        div = torch.exp(
            torch.arange(0, d_model, 2) * -(math.log(10000.0) / d_model)
        )                                                   # (d_model/2,)
        pe[:, 0::2] = torch.sin(pos * div)
        pe[:, 1::2] = torch.cos(pos * div)
        self.register_buffer('pe', pe)                      # 학습 제외 상수

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # x: (batch, seq_len, d_model)
        x = x + self.pe[:x.size(1)]
        return self.dropout(x)

div가 지수·로그를 거치는 것은 100002i/d10000^{2i/d} 로 직접 나누면 큰 지수에서 수치가 불안정해지기 때문이고, 결과는 같다. 만들어진 pe는 max_len × d_model 짜리 표 하나 — d_model = 512, max_len = 5000이면 256만 개의 숫자다. 그런데 이 중 학습되는 것은 하나도 없다.

register_buffer로 등록하는 이유가 그것이다. 버퍼로 두면 state_dict에 실려 저장·복원되고 .to('cuda') 한 번에 함께 옮겨 가지만, 옵티마이저에는 넘어가지 않아 기울기가 붙지 않는다. 모델의 상태이면서 파라미터는 아닌 값을 놓는 자리다.

정리하면 고정 인코딩의 장점은 셋이다. 학습 파라미터가 없고, 수식이라 훈련 때 보지 못한 길이에도 값을 계산할 수 있으며, 앞 절에서 본 대로 상대 거리가 회전으로 암묵적으로 담긴다. 여기서 두 번째 항목은 조심해서 읽어야 한다. 값이 나온다는 것과 성능이 유지된다는 것은 다른 이야기다. max_len을 늘리면 어떤 위치든 숫자는 나오지만, 훈련에서 본 적 없는 각도 조합을 받은 모델이 그 값을 제대로 해석한다는 보장은 어디에도 없다. 「훈련 길이 밖의 확장」 절 전체가 이 간극에서 나온다.

학습형 임베딩과 상대 바이어스

위치별 임베딩 테이블

BERT와 GPT 계열은 다른 길을 골랐다. 수식으로 만들지 않고 위치마다 벡터를 하나씩 두고 학습시킨다. 구현은 nn.Embedding(max_len, d_model) 한 줄이고, 토큰 임베딩 테이블과 똑같은 물건을 위치용으로 하나 더 두는 셈이다. 결합 방식은 고정 인코딩과 같은 원소별 덧셈이다.

파라미터가 붙는다는 것이 유일하면서 결정적인 차이다. BERT-base는 최대 512 위치에 768차원이므로 위치 임베딩만 393,216개다. 대신 「몇 번째 자리가 어떤 성질을 갖는가」를 사람이 정한 사인 파형이 아니라 데이터가 정한다.

실무에서 가장 먼저 부딪히는 차이는 길이 쪽이다. 고정 인코딩은 훈련 길이를 넘겨도 최소한 숫자는 나오지만, 학습형은 그 위치의 벡터가 아예 존재하지 않는다. 성능이 나빠지는 것이 아니라 인덱스 범위를 벗어나 코드가 멈춘다. 컨텍스트를 늘리려면 테이블을 키워 다시 학습시키는 수밖에 없다.

상대 위치 바이어스

두 방식 모두 위치를 입력 임베딩에 실었다. Shaw 등이 제안한 상대 위치 바이어스는 자리를 옮겨, 어텐션 점수를 낸 다음 거기에 거리에 따른 값을 더한다.

score(m,n)=qm⋅knd+b n−m\text{score}(m, n) = \frac{q_m \cdot k_n}{\sqrt{d}} + b_{\,n-m}

b n−mb_{\,n-m} 은 두 토큰의 거리마다 하나씩 학습되는 숫자다. 절대 위치가 계산에서 완전히 사라진다는 것이 핵심이다 — 같은 문장이 문서 앞머리에 있든 3천 토큰 뒤에 있든, 토큰 사이의 거리가 같으면 어텐션 점수도 같다.

대신 비용을 낸다. 거리별로 학습한 표를 매 층·매 헤드에서 어텐션 행렬 크기로 펼쳐야 하므로 구현이 번거롭고 메모리도 더 쓴다. T5가 거리를 몇 개의 구간으로 묶어 표를 줄이는 방식을 쓴 것도 이 비용 때문이다.

ALiBi(Attention with Linear Biases)는 그 표마저 없앤다. 학습되는 값 대신 거리에 비례하는 음수 페널티 b n−m=−s⋅∣n−m∣b_{\,n-m} = -s \cdot |n - m| 를 그냥 얹는데, 기울기 ss 는 헤드마다 다르게 고정해 둔다. 어떤 헤드는 가파른 기울기로 코앞만 보고, 어떤 헤드는 완만한 기울기로 멀리까지 본다. 파라미터가 하나도 없고 거리가 멀어질수록 점수가 단조롭게 깎이는 규칙이라 훈련 길이 밖으로 나가도 동작이 예측 가능하다. 176B 규모의 오픈 모델 BLOOM이 학습형·회전형 대신 이 방식을 골랐다.

앞선 방식의 한계

지금까지의 셋을 나란히 놓으면 남은 문제가 보인다.

절대 방식 둘(고정·학습형)은 위치를 임베딩에 더해 넣었다. 그래서 내적 안에서 위치와 내용이 뒤엉키고, 「이 두 토큰이 얼마나 떨어져 있는가」를 어텐션이 직접 표현하기 어렵다. 게다가 앞에서 본 대로 둘 다 훈련 길이 밖에서 무너진다.

상대 바이어스와 ALiBi는 위치를 내적 밖에서 다뤄 그 뒤엉킴을 피했다. 대신 어텐션 점수 행렬에 손대는 별도 경로를 만들어야 했고, 그만큼 구현과 메모리 비용이 늘었다.

그래서 남는 질문은 이렇게 정리된다. 위치를 쿼리·키 안에 넣되, 내적을 마쳤을 때 절대 위치는 사라지고 상대 거리만 남게 할 수 있는가. 더하기로는 안 되던 이 일을 회전으로 해낸 것이 RoPE다.

RoPE

회전과 상대 거리

RoPE는 2021년 Su 등이 제안한 방식으로, 쿼리·키 벡터를 그 토큰의 위치에 비례하는 각도만큼 회전시키는 위치 인코딩이다. 벡터에 무언가를 더하지 않고 방향만 돌린다.

2차원 벡터를 각도 θ\theta 만큼 반시계로 돌리는 행렬은 이렇게 생겼다.

R(θ)=[cos⁡θ−sin⁡θsin⁡θcos⁡θ]R(\theta) = \begin{bmatrix} \cos\theta & -\sin\theta \\ \sin\theta & \cos\theta \end{bmatrix}

위치 mm 의 쿼리 qq 에 R(mθ)R(m\theta) 를, 위치 nn 의 키 kk 에 R(nθ)R(n\theta) 를 걸고 내적을 계산하면 다음이 나온다.

⟨R(mθ)q,  R(nθ)k⟩=q⊤R(mθ)⊤R(nθ)k=q⊤R((n−m)θ)k\begin{aligned} \langle R(m\theta)q,\; R(n\theta)k \rangle &= q^{\top} R(m\theta)^{\top} R(n\theta) k \\ &= q^{\top} R\big((n-m)\theta\big) k \end{aligned}

회전 행렬은 직교행렬이라 전치가 곧 역행렬이고, 그래서 두 회전이 만나 각도의 차이만 남는다. 결과가 qq, kk, 그리고 상대 거리 n−mn-m 에만 의존한다 — 절대 위치 mm 과 nn 은 계산 중에 서로를 지우고 사라진다.

RoPE 회전 개념과 상대 위치 의존성

숫자를 넣어 한 번 따라가 보면 이 소거가 눈에 보인다. q=k=(1,0)q = k = (1, 0), θ=0.5\theta = 0.5 로 두고 두 경우를 계산한다. 먼저 m=2m = 2, n=5n = 5 다. 쿼리는 1.0라디안 돌아 (0.540, 0.841)(0.540,\, 0.841) 이 되고 키는 2.5라디안 돌아 (−0.801, 0.598)(-0.801,\, 0.598) 이 되며, 내적은 0.07070.0707 이다. 이번에는 위치를 통째로 뒤로 밀어 m=10m = 10, n=13n = 13 으로 둔다. 쿼리는 (0.284, −0.959)(0.284,\, -0.959), 키는 (0.977, 0.215)(0.977,\, 0.215) 로 전혀 다른 벡터가 되지만 내적은 다시 0.07070.0707 이다. 거리 3이 같으니 결과도 같고, 실제로 이 값은 cos⁡(3×0.5)=cos⁡1.5\cos(3 \times 0.5) = \cos 1.5 그 자체다.

회전이 벡터의 길이를 바꾸지 않는다는 점도 중요하다. 임베딩에 위치 벡터를 더하면 벡터의 크기까지 흔들려 내용의 세기가 왜곡되지만, 회전은 방향만 옮기고 노름을 보존한다. 위치 정보를 넣으면서 내용을 건드리지 않는 것이다. 2×2 행렬이 벡터에 하는 일을 더 보고 싶다면 회전·전단·스케일에 정리해 두었다.

차원쌍별 각속도

실제 헤드 차원 dd 는 보통 64에서 128 사이다. RoPE는 이 차원들을 둘씩 짝지어 d/2d/2 개의 독립된 2차원 회전을 적용하고, 쌍마다 다른 각속도를 준다.

θi=1/100002i/d,i=0,1,…,d/2−1\theta_i = 1 / 10000^{2i/d}, \quad i = 0, 1, \dots, d/2 - 1

공식이 앞의 사인·코사인 인코딩과 판박이인 것이 우연이 아니다. 주파수 사다리라는 설계는 그대로 물려받고, 그것을 더하는 대신 돌리는 데 쓴다. 낮은 인덱스는 빠르게 돌아 가까운 거리를 세밀하게 구별하고, 높은 인덱스는 느리게 돌아 먼 거리를 담당한다.

d=128d = 128 로 숫자를 보면 두 극단이 얼마나 벌어지는지 알 수 있다. 가장 빠른 쌍 i=0i = 0 은 위치가 하나 늘 때마다 1라디안, 약 57도씩 돌아 여섯 토큰 남짓이면 한 바퀴를 마친다. 가장 느린 쌍 i=63i = 63 은 위치당 약 1.16×10−41.16 \times 10^{-4} 라디안이라, 4,096 토큰을 통째로 지나도 겨우 27도밖에 돌지 않는다. 한 바퀴는커녕 사분면 하나를 못 채운다. 이 비대칭이 「훈련 길이 밖의 확장」에서 그대로 문제의 원인이 된다.

전체 회전은 이 2×2 블록들을 대각선에 늘어놓은 블록 대각 행렬이 된다. 크기가 d×dd \times d 라도 실제 값이 든 자리는 대각선 근처뿐이라, 곧이곧대로 행렬곱을 할 이유가 없다.

원소별 곱 구현

블록 대각 구조 덕분에 회전을 코사인·사인 벡터와의 원소별 곱 두 번으로 끝낼 수 있다.

def rotate_half(x):
    # 마지막 차원을 반으로 나눠 교차 배치
    x1, x2 = x[..., : x.shape[-1] // 2], x[..., x.shape[-1] // 2 :]
    return torch.cat([-x2, x1], dim=-1)

def apply_rotary(x, cos, sin):
    # 행렬 곱 없이 요소별 연산으로 회전 구현
    return x * cos + rotate_half(x) * sin

rotate_half가 하는 부호 뒤집기와 자리 맞바꾸기가 회전 행렬의 비대각 성분 −sin⁡θ-\sin\theta, sin⁡θ\sin\theta 를 대신한다. 회전 공식 (xcos⁡θ−ysin⁡θ,  xsin⁡θ+ycos⁡θ)(x\cos\theta - y\sin\theta,\; x\sin\theta + y\cos\theta) 를 벡터 전체에 한 번에 적용한 형태다.

RoPE PyTorch 구현 코드

주파수 벡터 inv_freq는 미리 계산해 버퍼로 등록해 두면 GPU 이동이 자동으로 처리된다. torch.outer(t, inv_freq)는 시퀀스의 각 위치 t와 주파수 벡터의 외적으로 (seq_len, d/2) 크기의 각도 행렬을 만들고, 이를 두 번 이어 붙여 (seq_len, d) 크기로 맞춘 뒤 코사인·사인을 쿼리·키에 적용한다. 지금 transformers의 LLaMA 구현은 inv_freq만 버퍼로 들고 있다가 넘어온 위치에서 코사인·사인을 만들지만, 초기 구현은 최대 길이만큼의 코사인·사인 표를 아예 캐시로 안고 있었다 — 추론에서 토큰 하나를 만들 때마다 삼각함수를 다시 계산하는 비용을 피하려는 것이었다.

적용되는 자리가 앞의 방식들과 다르다는 점을 놓치면 안 된다. 사인·코사인 인코딩은 입력 임베딩에 딱 한 번 더해졌지만, RoPE는 모든 층에서 쿼리와 키에만 걸린다. 값(V)에는 걸지 않는다. 위치는 어텐션 점수를 정하는 데만 쓰이고 실제로 실려 나가는 정보는 건드리지 않는다는 뜻이다.

이 설계가 KV 캐시와 특히 잘 맞는다. 캐시에 저장되는 키는 이미 회전이 끝난 상태이고, 그 토큰의 절대 위치는 앞으로도 변하지 않으므로 새 토큰이 들어와도 손댈 것이 없다. 절대 위치 임베딩이라면 시퀀스가 길어질 때마다 위치를 다시 매길 여지가 생기지만 RoPE에는 그런 여지 자체가 없다. 캐시 자체를 줄이는 이야기는 MQA와 GQA에서 따로 다뤘다.

훈련 길이 밖의 확장

외삽의 한계

이론만 보면 RoPE는 길이에 자유로워야 한다. 내적에 남는 것이 상대 거리뿐이니 시퀀스가 4,096이든 40,960이든 같은 규칙이 성립하기 때문이다. 그런데 실제로는 훈련 길이의 2배쯤을 넘어서면 퍼플렉시티가 급격히 치솟는다. 퍼플렉시티(perplexity)는 모델이 다음 토큰을 고르며 얼마나 헤매는지를 재는 값으로, 낮을수록 좋다.

원인은 앞 절의 숫자에 이미 나와 있었다. d=128d = 128 에 훈련 길이 4,096이면 가장 느린 차원 쌍이 훈련 내내 겪어 본 각도는 0도에서 27도까지가 전부다. 그 모델에게 32,768번째 위치를 주면 그 쌍은 약 217도를 돌아 있다. 훈련에서 한 번도 본 적 없는 각도 영역이다. 빠른 쌍은 훈련 중에 수백 바퀴를 돌아 모든 각도를 골고루 겪었으니 문제가 없지만, 느린 쌍은 자기 주기의 십분의 일도 못 본 채 낯선 값을 받는다.

그러니 외삽 문제는 「RoPE가 상대 거리를 못 담는다」가 아니라 「모델이 그 각도를 해석하는 법을 배운 적이 없다」에 가깝다. 해결책들이 하나같이 각도를 훈련에서 본 범위 안으로 되돌려 놓는 방향인 이유가 그것이다.

PI

Position Interpolation(PI)은 가장 단순한 답이다. 위치 인덱스를 선형으로 줄여 새 길이를 훈련 길이 안에 우겨 넣는다.

m→m⋅LtrainLnewm \to m \cdot \dfrac{L_{\text{train}}}{L_{\text{new}}}

훈련 길이 4,096짜리 모델을 32,768까지 늘린다면 배율은 1/8이다. 32,767번째 토큰의 위치 인덱스가 4,096 근처로 눌리므로, 모든 차원 쌍이 훈련 중에 겪어 본 각도 범위 안에 머문다. 217도짜리 문제가 사라지는 것이다.

대가는 해상도다. 이웃한 두 토큰의 각도 차이도 함께 8분의 1로 줄어든다. 가장 빠른 쌍에서 1라디안이던 간격이 0.125라디안이 되고, 「바로 앞 단어」와 「두 칸 앞 단어」를 구별하던 신호가 그만큼 뭉개진다. 정수였던 위치가 소수가 되면서 원래 없던 중간값을 모델이 받게 되는 것도 낯선 일이라, PI는 보통 새 길이에서 약간의 추가 학습을 곁들여 쓴다.

YaRN

YaRN(Yet another RoPE extensioN)은 PI가 모든 차원을 똑같이 눌러 버린 것을 문제로 본다. 앞에서 확인했듯 외삽이 깨지는 원인은 느린 쌍에만 있고, 빠른 쌍은 이미 여러 바퀴를 돌아 아무 문제가 없다. 그런데 PI는 멀쩡한 빠른 쌍까지 8분의 1로 눌러 짧은 거리 해상도를 공짜로 내놓는다.

그래서 YaRN은 주파수 대역별로 다른 스케일 인수를 적용한다. 한 바퀴를 여러 번 돈 빠른 대역은 거의 손대지 않고 그대로 외삽하게 두고, 주기가 훈련 길이보다 긴 느린 대역만 눌러 담는다. 짧은 거리 구별력을 지키면서 긴 범위를 얻는 셈이다. Qwen2 계열이 기본 32K 밖으로 나갈 때 공식 안내가 지목하는 것이 이 방식이고, LLaMA 3.1이 8K를 128K로 늘릴 때 쓴 스케일링은 YaRN 자체는 아니지만 파장이 짧은 대역은 그대로 두고 긴 대역만 줄인다는 발상이 같다.

외삽·PI·YaRN의 대역별 각도 비교

LongRoPE는 한 걸음 더 나가, 스케일 인수를 대역별로 나누는 대신 탐색으로 찾은 비균일 보간을 써서 200만 토큰 규모까지 확장한다. 세 방법 모두 RoPE의 수식 자체는 그대로 두고 위치 인덱스나 주파수만 다시 재는 후처리라는 점이 공통점이다 — 그래서 이미 학습된 모델에 나중에 얹을 수 있다. 컨텍스트 길이가 실제로 무엇을 뜻하는지는 컨텍스트 윈도우에서 따로 정리했다.

방식 비교

선택 기준

지금까지 본 네 방식을 「어디에 넣는가」로 줄 세우면 이렇다.

방식 넣는 자리 파라미터 길이 확장 대표 모델
고정 사인·코사인 입력 임베딩에 덧셈 없음 값은 나오나 성능은 별개 원논문 트랜스포머
학습형 임베딩 입력 임베딩에 덧셈 max_len × d_model 최대 길이 밖은 불가 BERT, GPT-2
상대 바이어스 · ALiBi 어텐션 점수에 덧셈 표 또는 없음 비교적 자연스러움 T5, BLOOM
RoPE 쿼리·키 회전 없음 PI·YaRN으로 확장 LLaMA, Mistral, Qwen

새로 모델을 세운다면 기본값은 RoPE다. 이유는 표에 흩어져 있는 셋을 모으면 나온다. 학습 파라미터가 없어 길이를 늘려도 새로 학습할 테이블이 없고, 쿼리·키에만 걸려 KV 캐시와 충돌하지 않으며, 훈련 길이를 넘어설 때 쓸 확장 레시피가 이미 마련되어 있다.

그렇다고 나머지가 죽은 방식은 아니다. 시퀀스가 짧고 길이가 고정된 인코더 모델이라면 학습형 임베딩이 더 단순하고, 위치 정보를 어텐션 점수 수준에서 직접 통제하고 싶은 경우에는 상대 바이어스가 여전히 명확한 선택지다.

채택 모델과 확장 방식

RoPE를 채택한 주요 모델과, 각자가 기본 컨텍스트를 넘어설 때 쓰는 방법은 아래와 같다.

모델 기반 컨텍스트 확장 방식
LLaMA 2 4K PI
LLaMA 3.1 8K 대역별 스케일링
Mistral 7B 8K Sliding Window
Qwen2 32K YaRN
Gemma 2 8K 확장 없음

표를 읽을 때 한 줄만 조심하면 된다. Mistral의 Sliding Window는 위치 인코딩을 손보는 방법이 아니다. 각 토큰이 참조할 수 있는 범위를 창 크기로 잘라 어텐션 자체를 줄이는 기법이라, RoPE는 그대로 둔 채 다른 축에서 긴 시퀀스를 다룬다. 같은 열에 놓였다고 같은 종류의 해법으로 읽으면 안 된다. 어텐션 범위를 줄이는 계열은 효율적인 트랜스포머에, LLaMA 계열의 설계 결정은 LLaMA 패밀리에 따로 정리해 두었다.

어텐션 다음의 블록

한 줄로 줄이면 이 글은 같은 문제를 네 번 다르게 푼 기록이다. 어텐션은 순열에 불변해서 순서를 모르고, 그래서 위치를 넣어야 하는데, 넣을 자리가 입력·점수·쿼리와 키 셋이었다. 더하기로 시작해 회전으로 끝났고, 회전이 이긴 이유는 절대 위치가 내적에서 스스로 지워지기 때문이었다.

이제 벡터 하나에 「무엇인가」와 「몇 번째인가」가 함께 실렸다. 다음 글에서는 그 벡터가 실제로 지나가는 층의 안쪽을 연다. 어텐션 하나로는 블록이 되지 않는다 — 뒤에 붙는 잔차 연결과 정규화, 그리고 위치마다 따로 도는 피드포워드 층까지가 한 덩이이고, 그 덩이를 여러 번 쌓아야 비로소 문맥을 읽는 구조가 된다.


읽어주셔서 감사합니다. 😊

LATEST

LLM·트랜스포머의 최신 글

LLM·트랜스포머2026.08.14

작은 모델을 우리 일에 맞추는 법

파인튜닝이 실제로 고치는 것은 지식이 아니라 행동입니다. 데이터 몇 건이 필요한지, LoRA가 무엇을 바꾸는지, 학습 전에 무엇을 먼저 만들어야 하는지를 정리합니다.

15 MIN
LLM·트랜스포머2026.08.13

작은 모델로 내려도 되는지 판단하는 법

비용·지연·품질은 같은 방향으로 움직이지 않습니다. 폴백을 붙였을 때의 손익분기, 격차가 벌어지는 작업 유형, 그리고 내리기 전에 통과해야 할 네 관문을 정리합니다.

10 MIN
LLM·트랜스포머2026.08.13

작은 모델이 다시 쓸 만해진 이유

10억에서 100억 파라미터 사이의 모델이 다시 실무에 들어오고 있습니다. 무엇이 달라졌는지, 메모리는 어떻게 계산하는지, 무엇을 잘하고 무엇을 못하는지 정리합니다.

10 MIN