앞 문맥을 섞는 법.
지난 편
2편에서 토큰 ID마다 임베딩 표에서 벡터 하나를 꺼내는 것을 봤다.
한 줄 결론
토큰마다 앞 토큰들을 참고할 비율을 정해 섞는다. 이 일을 레이어마다 되풀이하면 마지막 토큰 자리의 벡터에 지금까지의 문맥이 담긴다.
쉽게 보기
임베딩 표에서 꺼낸 벡터는 토큰 ID만으로 정해진다. 앞에 무슨 말이 왔든 같은 토큰이면 벡터도 같다. 그런데 말의 뜻은 앞 문맥에 따라 달라진다.
민수는 사과를 샀다. 그는 그것을 먹었다.
그것을의 벡터만 봐서는 민수가 무엇을 먹었는지 알 수 없다. 사과라는 정보는 앞 토큰 사과를에 있다. 그래서 모델은 그것을의 벡터를 고칠 때 앞 토큰들의 벡터를 각각 얼마나 넣을지 정해서 섞는다. 아래는 그 비율의 예다. 토큰은 단어 단위로 단순하게 나눴고, 비율은 가상 값이다.
"그것을"이 참고하는 비율 (가상 값)
민수는 0.10 ##
사과를 0.55 ###########
샀다. 0.05 #
그는 0.10 ##
그것을 0.20 #### <- 자기 자신도 포함
-----
합계 1.00
비율은 미리 정해 둔 표에서 꺼내지 않는다. 그것을의 벡터를 앞 토큰 각각의 벡터와 비교해 점수를 낸다. 그리고 그 점수를 비율로 바꾼다. 점수가 높을수록 비율이 크고, 비율을 모두 더하면 1이다. 이 비율대로 섞으면 그것을의 새 벡터에는 사과를의 정보가 가장 많이 들어간다. 이 계산이 어텐션이다.
섞는 대상은 언제나 자기와 그 앞 토큰이다. 샀다.는 뒤에 오는 그는이나 그것을을 섞지 않는다. 1편에서 본 것처럼 모델은 앞에서부터 한 토큰씩 만들기 때문에, 어떤 토큰을 만드는 시점에는 그 뒤 토큰이 아직 없다. 계산 자체가 뒤 토큰을 섞지 못하게 짜여 있어서, 이 예문처럼 뒤 토큰까지 입력에 이미 들어 있어도 앞만 섞는다(더 깊이 참고).
어텐션은 한 번으로 끝나지 않는다. 어텐션으로 섞은 벡터를 위치마다 한 번 더 가공하는 계산까지 묶은 단위를 레이어라고 한다. 모델은 레이어를 여러 개 쌓아 두고 벡터를 첫 레이어부터 차례로 통과시킨다. 공개 모델 Qwen2.5-7B는 레이어가 28개다(config.json의 num_hidden_layers).
2편의 예시 입력으로 보면 이렇다. 위치 15 <assistant>는 응답이 시작되는 자리이고, 모델은 마지막 레이어를 지난 이 자리의 벡터를 보고 다음 토큰을 고른다.
위치 15 <assistant>(토큰 ID 9)의 벡터 (숫자는 모두 가상 값)
임베딩 [ 0.40, -0.11, 0.27, ...] 토큰 ID 9의 값. 질문이 무엇이든 같다
|
| 레이어 1: 위치 0~15의 벡터를 비율대로 섞고, 한 번 더 가공한다
v
레이어 1 뒤 [ 0.18, 0.35, -0.06, ...] 시스템 지침과 질문 같은 앞 내용이 섞였다
|
| 레이어 2: 레이어 1을 지난 위치 0~15의 벡터로 같은 일을 한다
v
레이어 2 뒤 [-0.22, 0.41, 0.13, ...]
|
... (Qwen2.5-7B라면 28번)
v
마지막 레이어 뒤 [ 0.57, -0.30, 0.09, ...] -> 4편: 이 벡터로 다음 토큰을 고른다
레이어 2에서 위치 15가 가져오는 벡터는, 각 위치가 레이어 1에서 이미 자기 앞 문맥을 반영해 둔 것이다. 예를 들어 위치 10 과일의 벡터에는 레이어 1에서 섞은 토마토는의 정보가 들어 있다. 위치 15는 이렇게 앞 위치들끼리 섞인 결과를 레이어마다 다시 섞는다. 그래서 마지막 레이어를 지난 위치 15의 벡터에는 시스템 지침과 질문의 문맥이 함께 담긴다.
이 구조에서 두 가지를 알 수 있다.
- 입력이 길수록 계산이 빠르게 늘어난다. 위치마다 자기 앞 토큰 전부와 비교하므로, 입력 토큰이 두 배가 되면 어텐션의 비교 횟수는 약 네 배가 된다.
- 앞 토큰의 벡터는 뒤에 무엇이 붙어도 바뀌지 않는다. 위치 9
토마토는은 레이어를 몇 번 지나도 위치 0~9만 섞기 때문이다. 5편에서 다룰 계산 재사용은 이 성질 덕분에 가능하다.
새 용어
- 어텐션: 토큰마다 자기와 앞 토큰들을 얼마나 참고할지 비율을 정하고, 그 비율대로 벡터들을 섞어 자기 벡터를 고치는 계산. 비율은 벡터끼리 비교한 점수로 정하고, 다 더하면 1이다.
- 레이어: 어텐션과, 그 결과를 위치마다 한 번 더 가공하는 계산을 묶은 단위. 모델은 같은 구조의 레이어를 여러 개 쌓아 차례로 통과시킨다. 레이어마다 학습된 숫자가 달라서 참고하는 방식도 다르다.
더 깊이
Q, K, V: 비율을 정하는 벡터와 넘겨줄 벡터를 따로 만든다.
쉽게 보기에서는 벡터끼리 비교한다고만 했다. 실제로는 레이어마다 각 위치의 벡터에 학습된 숫자 표(행렬)를 곱해 용도가 다른 벡터 세 개를 만든다.
- Q(query): 이 위치가 앞에서 어떤 정보를 찾는지 나타낸다.
- K(key): 다른 위치가 이 위치를 찾을 때 비교 대상이 된다.
- V(value): 이 위치가 참고될 때 실제로 넘겨주는 내용이다.
위치 i의 어텐션은 i의 Q를 위치 0~i의 K와 하나씩 내적(같은 칸끼리 곱해서 모두 더하기)해 점수를 낸다. 점수를 K의 칸 수의 제곱근으로 나누고 softmax(점수를 0 이상이고 합이 1인 비율로 바꾸는 함수)를 거친 뒤, 그 비율대로 V를 더한다. Attention Is All You Need 3.2.1절의 Scaled Dot-Product Attention이 이 계산이다. 설명용으로 Q, K, V의 칸 수를 모두 d로 두고 의사 코드로 쓰면 이렇다.
const scores = [];
for (let j = 0; j <= i; j++) {
scores.push(dot(q[i], k[j]) / Math.sqrt(d));
}
const weights = softmax(scores); // 모두 0 이상이고 다 더하면 1
let out = zeros(d);
for (let j = 0; j <= i; j++) {
out = add(out, scale(v[j], weights[j]));
}
실제 모델은 Q, K, V를 한 벌만 만들지 않고 여러 묶음으로 나눠 이 계산을 동시에 여러 번 한다. 이 묶음 하나를 헤드라고 한다(같은 논문 3.2.2절의 Multi-Head Attention). K와 V 헤드를 Q 헤드보다 적게 두고 여러 Q 헤드가 나눠 쓰는 모델도 있다. Qwen2.5-7B는 Q 헤드가 28개, K와 V 헤드가 4개다(config.json의 num_attention_heads, num_key_value_heads).
위치 j의 K와 V는 위치 0~j의 토큰만으로 정해지므로 뒤에 토큰이 붙어도 바뀌지 않는다. 5편에서 다룰 재사용은 이 K와 V를 저장해 두는 방식이다.
causal mask와 padding mask: 섞지 않을 위치를 지운다.
두 마스크 모두 softmax 직전에 특정 점수를 음의 무한대(구현에 따라서는 아주 작은 값)로 바꿔 그 위치의 비율을 0으로 만든다. 다른 점은 지우는 기준이다.
- causal mask는 자기보다 뒤 위치를 지운다. 위 의사 코드의
j <= i가 이 규칙이다. 입력 16개를 한꺼번에 계산할 때도 적용되므로 위치 9는 위치 10~15를 보지 않는다. Attention Is All You Need 3.2.3절도 뒤 토큰의 정보가 앞으로 흘러들지 않게 하려고 softmax 입력을 음의 무한대로 가린다고 적었다. - padding mask는 빈칸을 지운다. 길이가 다른 입력 여러 개를 같은 길이로 맞춰 묶어 계산할 때는 짧은 입력을 빈칸 토큰으로 채운다. 빈칸은 뜻이 없으니 비율을 받으면 안 된다. Hugging Face Transformers는 어느 토큰이 빈칸인지를
attention_mask로 넘긴다. 1은 참고할 토큰, 0은 빈칸이다(용어집).
causal mask는 위치 관계만으로 정해져서 어떤 입력이든 모양이 같다. padding mask는 입력마다 빈칸이 어디에 몇 개 있는지에 따라 달라진다. 빈칸이 앞쪽에 있으면 causal mask만으로는 빈칸을 가릴 수 없어서 둘을 함께 쓴다.
행: 계산하는 위치, 열: 참고하는 위치, 1 = 참고함, . = 비율 0
causal mask만 위치 0이 빈칸일 때 (padding mask 추가)
0 1 2 3 0 1 2 3
0 1 . . . 0 - - - - 빈칸 자리라 결과를 쓰지 않는다
1 1 1 . . 1 . 1 . .
2 1 1 1 . 2 . 1 1 .
3 1 1 1 1 3 . 1 1 1
RoPE: 위치 정보는 어텐션 안에서 Q와 K를 회전시켜 넣는다.
어텐션 계산에는 순서 개념이 없다. 점수는 두 벡터만 보고 정해지므로, 같은 토큰이 위치 3에 있든 위치 30에 있든 벡터가 같으면 점수도 같다. 그래서 위치 정보를 따로 넣어야 한다. Attention Is All You Need는 위치마다 정해진 위치 벡터를 만들어 입력 임베딩에 더했다(3.5절).
RoFormer (Su et al., 2021)가 제안한 RoPE(Rotary Position Embedding)는 입력에 위치 벡터를 더하지 않는다. 레이어마다 어텐션 안에서 Q와 K를 위치에 비례하는 각도만큼 회전시킨다. 벡터의 칸을 두 개씩 짝지어 평면 위의 점으로 보고, 짝마다 정해진 속도가 다르고, 그 속도로 돌린다. 위치 m의 Q는 m에 비례한 각도만큼, 위치 n의 K는 n에 비례한 각도만큼 돌아가므로 둘 사이에 남는 각도 차이는 n-m에 비례한다. 그래서 내적 점수에는 두 위치가 몇 칸 떨어져 있는지가 반영되고, 각자 몇 번째 위치인지는 남지 않는다. 논문은 기존 방식이 위치 정보를 더하는 것과 달리 RoPE는 곱한다고 설명한다. V는 회전하지 않는다.
Qwen2.5-7B도 RoPE를 쓴다. config.json에 회전 속도를 계산하는 기준값 rope_theta가 있다. RoPE를 쓰는 모델에서는 2편의 임베딩 벡터가 위치 정보 없이 그대로 첫 레이어로 들어가고, 위치는 레이어마다 어텐션 안에서 반영된다.