임베딩 API의 응답을 열어 봅니다.
{"embedding": [0.0123, -0.0456, 0.0789, ...]}
float 768개입니다. 그리고 토크나이저의 응답도 열어 봅니다.
{"input_ids": [9906, 1917, 0, ...]}
이쪽도 숫자 목록입니다. 파이썬에서 둘 다 그냥 리스트고, 둘 다 np.array에 넣으면 배열이 됩니다. 그런데 앞의 것은 여러 개를 평균 내서 문서 임베딩을 만들어도 되고, 뒤의 것은 평균 내면 아무 의미도 없는 숫자가 나옵니다. 토큰 9906과 1917의 평균인 5911.5는 어떤 토큰도 아닙니다.
두 배열의 자료형은 같은데 할 수 있는 일이 다릅니다. 앞의 것만 벡터이기 때문입니다.
지난 글까지가 수식을 읽는 문법이었다면 여기서부터는 수식이 다루는 대상입니다. 임베딩을 벡터로 취급한다는 말이 정확히 무엇을 보장하는지, 그리고 그 보장 위에서 mean pooling과 어텐션 출력과 "king − man + woman"이 어떻게 전부 같은 연산 하나가 되는지를 봅니다. 임베딩을 무엇에 쓰는가는 임베딩 기초가 다루므로, 이 글은 벡터라는 수학 대상만 맡습니다.
평균을 내도 되는 배열과 안 되는 배열
문서 하나를 벡터 하나로 만드는 가장 흔한 방법이 mean pooling입니다. 토큰마다 나온 벡터를 그냥 평균 냅니다.
doc_vec = token_vecs.mean(axis=0) # (T, d) -> (d,)
한 줄인데, 이 줄이 성립하려면 두 가지가 참이어야 합니다.
- 토큰 벡터를 더할 수 있어야 한다.
- 더한 결과를 로 나눌 수 있어야 하고, 그 결과가 여전히 같은 종류의 대상이어야 한다.
즉 결과가 여전히 "임베딩 공간의 점"이어야 합니다. 그래야 그 결과를 다시 다른 임베딩과 코사인 유사도로 비교할 수 있습니다. 만약 평균이 공간 밖으로 나가 버리면 그다음 검색이 성립하지 않습니다.
토큰 ID에는 이 둘이 없습니다. ID는 어휘 사전의 몇 번째 자리인지를 가리키는 라벨이고, 라벨끼리 더하는 연산은 정의되어 있지 않습니다. 9906 + 1917이라는 계산은 할 수야 있지만 그 결과가 가리키는 것이 없습니다. 연산이 정의되어 있느냐가 아니라, 연산의 결과가 다시 같은 세계 안에 있느냐가 기준입니다.
벡터의 정의 — 두 연산에 닫혀 있다는 것
수학에서 벡터는 "화살표"나 "숫자 목록"으로 정의되지 않습니다. 덧셈과 스칼라배가 정의되어 있고, 그 결과가 다시 그 모임 안에 있는 대상을 벡터라고 부릅니다. 그 모임을 벡터공간이라 합니다.
이 두 줄이 닫힘(closure)이고, 벡터공간의 조건 중 실무에서 유일하게 자주 확인하게 되는 것입니다. 나머지 조건들 — 덧셈의 결합·교환법칙, 영벡터의 존재, 역원의 존재, 스칼라배의 분배법칙 — 은 실수 성분으로 이루어진 에서 자동으로 성립하므로 따로 확인할 일이 거의 없습니다.
| 조건 | 식 | 에서 |
|---|---|---|
| 덧셈에 닫힘 | 성립 | |
| 스칼라배에 닫힘 | 성립 | |
| 영벡터 존재 | 0으로 채운 벡터 | |
| 역원 존재 | 부호 반전 | |
| 결합·교환·분배 | — | 실수 연산에서 자동 |
임베딩이 의 원소라고 말하는 순간 이 표 전체가 따라옵니다. mean pooling이 성립하는 근거가 그것이고, 어텐션의 가중합이 성립하는 근거도 같습니다. "벡터로 다룬다"는 선언이 곧 이 보장을 쓰겠다는 선언입니다.
여기서 한 가지가 따라 나옵니다. 벡터공간의 조건 어디에도 "길이"나 "각도"는 없습니다. 그래서 벡터라는 것만으로는 두 임베딩이 얼마나 가까운지 말할 수 없습니다. 거리를 재려면 노름을, 각도를 재려면 내적을 따로 얹어야 합니다. 다음 두 글이 그 둘을 차례로 얹습니다.
선형결합 — 이 커리큘럼에서 가장 자주 나올 한 문장
벡터 여러 개에 각각 계수를 곱해 더한 것을 선형결합이라 합니다.
닫힘 조건 둘을 합치면 선형결합의 결과도 반드시 같은 공간 안에 있습니다. 스칼라배로 각 항이 공간 안에 남고, 덧셈으로 합도 공간 안에 남습니다.
이 정의가 왜 중요하냐면, 딥러닝에서 벡터를 섞는 거의 모든 자리가 계수만 다른 같은 식이기 때문입니다.
| 하는 일 | 식 | 계수 |
|---|---|---|
| mean pooling | 전부 | |
| 어텐션 출력 | softmax가 정한 값, 합이 1 | |
| 잔차 연결 | 둘 다 1 | |
| 지수이동평균 | 와 | |
| 임베딩 산술 |
전부 한 줄짜리 같은 연산입니다. 그리고 여기서 중요한 결론이 하나 나옵니다. 어텐션의 출력은 값 벡터들의 선형결합이므로, 값 벡터들이 만드는 공간을 절대 벗어나지 못합니다. 새로운 방향이 생기지 않습니다. 트랜스포머 블록에 어텐션만 있는 게 아니라 비선형 활성화가 들어간 피드포워드가 반드시 붙는 이유가 이것입니다. 선형결합만 쌓으면 아무리 깊어도 선형결합 하나로 접힙니다.
king − man + woman
임베딩 산술이 유명한 예입니다. 표에서 봤듯 계수가 인 선형결합 하나입니다.
괄호를 다르게 묶으면 왜 이것이 말이 되는지가 보입니다.
은 "man에서 king으로 가는 이동"입니다. 그 이동을 woman에 그대로 적용합니다.
그림에서 초록 화살표가 파란 화살표와 같은 벡터입니다. 위치만 다릅니다. 이것이 벡터의 정의에서 곧바로 나오는 성질입니다 — 벡터는 시작점을 갖지 않습니다. 방향과 길이만 갖습니다. 그래서 한 번 구한 차이 벡터를 어디에나 더할 수 있습니다.
숫자로 직접 해 봅니다. 2차원 장난감 임베딩을 두고 계산합니다.
import numpy as np
E = {
"man": np.array([1.0, 1.0]),
"king": np.array([3.0, 2.5]),
"woman": np.array([4.0, 0.8]),
"queen": np.array([5.8, 2.5]),
"apple": np.array([-2.0, 3.0]),
"car": np.array([0.5, -2.0]),
}
target = E["king"] - E["man"] + E["woman"]
print(target) # [6. 2.3]
for word, v in E.items():
print(f"{word:6s} {np.linalg.norm(target - v):.3f}")
man 5.166
king 3.007
woman 2.500
queen 0.283
apple 8.031
car 6.981
계산 결과 은 queen의 위치 와 정확히 같지 않습니다. 거리가 남습니다. 정확히 맞은 적이 없습니다. 임베딩 산술이 하는 일은 "queen을 계산해 내는 것"이 아니라 "queen이 가장 가까운 자리를 계산해 내는 것"이고, 실제 구현도 결과 벡터에 가장 가까운 어휘를 찾는 최근접 탐색입니다.
그리고 여기서 다음 두 글의 질문이 그대로 나옵니다. "가장 가깝다"를 무엇으로 재는가? 위 코드는 유클리드 거리를 썼지만 실제 임베딩 검색은 대개 코사인을 씁니다. 둘이 같은 순위를 주는 조건이 있고 갈리는 조건이 있는데, 그 경계가 5번과 6번 글의 주제입니다.
화살표와 좌표 — 두 관점
앞의 그림에서 벡터를 화살표로 그렸지만, 코드에서는 숫자 목록으로 다뤘습니다. 둘은 같은 것입니다.
연결 고리는 기저입니다. 축을 정해 두면 화살표 하나가 축마다 얼마씩 가는지로 분해되고, 그 분해 계수의 목록이 좌표입니다.
좌표 표기 자체가 이미 선형결합입니다. , 을 계수 3과 2로 결합한 것. 그래서 np.array([3.0, 2.0])이라고 쓰는 순간 우리는 이미 기저를 하나 고른 것입니다.
같은 화살표도 기저를 바꾸면 좌표가 달라집니다. 이 사실이 나중에 크게 쓰입니다. 주성분 축으로 다시 쓰기, 회전 위치 인코딩, 화이트닝 — 전부 "화살표는 그대로 두고 축만 바꾸기"입니다. 8번 글이 그 조작을 하나로 묶습니다.
768차원은 그릴 수 없습니다. 하지만 지금까지 쓴 것 중 그림이 필요했던 문장은 없습니다. 덧셈, 스칼라배, 선형결합, 좌표 — 전부 성분별 계산으로 정의되어 있어서 차원이 몇이든 그대로 성립합니다. 그림은 이해를 돕는 보조일 뿐 정의가 아닙니다.
다만 주의할 것이 하나 있습니다. 3차원에서 얻은 직관 중 고차원에서 깨지는 것이 있습니다. 예를 들어 고차원 정규분포에서 뽑은 두 벡터는 거의 항상 직교에 가깝고, 고차원 구의 부피는 거의 전부 껍질에 몰려 있습니다. 이 현상들이 임베딩 검색의 성능을 실제로 좌우하는데, 11단원(고차원과 검색)이 그것만 따로 다룹니다. 지금 단계에서는 "선형 연산의 규칙은 차원과 무관하게 같고, 기하적 직관은 그렇지 않다"는 구별만 기억하면 됩니다.
브로드캐스팅은 무엇을 대신하는가
코드에서 벡터 연산을 쓸 때 거의 항상 브로드캐스팅이 끼어듭니다.
X = np.arange(6.0).reshape(3, 2) # (3, 2)
b = np.array([10.0, 100.0]) # (2,)
X + b
[[ 10., 101.],
[ 12., 103.],
[ 14., 105.]]
와 는 셰이프가 다른데 더해집니다. 수식으로 쓰면 이렇습니다.
즉 벡터 덧셈을 세 번 한 것이고, 브로드캐스팅은 그 반복을 대신 써 준 것입니다. 새로운 연산이 아닙니다. 지난 글의 LayerNorm에서 keepdims=True가 필요했던 것도 같은 이유입니다 — 수식이 생략한 반복을 코드에서는 축으로 표시해야 합니다.
한 가지 구별이 있습니다. 스칼라를 벡터에 더하는 v + 3은 벡터공간의 연산이 아닙니다. 벡터공간에는 덧셈(벡터+벡터)과 스칼라배(스칼라×벡터)만 있고 스칼라+벡터는 없습니다. NumPy가 해 주는 것은 이라는 벡터를 만들어 더하는 것이고, 그 은 "모든 성분이 1인 벡터"입니다. 편향 항 에서 가 스칼라가 아니라 벡터인 이유가 여기 있습니다.
벡터가 아닌 것들
닫힘 조건을 기준으로 보면, 자주 다루는 것 중 벡터공간이 아닌 것들이 눈에 들어옵니다.
확률분포는 벡터공간이 아닙니다. 성분이 음이 아니고 합이 1이어야 하는데, 두 분포를 더하면 합이 2가 됩니다.
p = np.array([0.7, 0.2, 0.1])
q = np.array([0.1, 0.3, 0.6])
(p + q).sum() # 2.0 — 나가 버린다
(2 * p).sum() # 2.0 — 스칼라배도 나간다
(0.5*p + 0.5*q).sum() # 1.0 — 계수 합이 1이면 남는다
닫힘이 깨졌습니다. 다만 마지막 줄처럼 계수의 합이 1인 선형결합은 안에 남습니다. 그런 결합을 볼록결합이라 하고, 그 모임을 확률 단체라 부릅니다. 지난 글에서 softmax의 상이 이라고 쓴 것이 이것입니다. 앙상블에서 여러 모델의 확률을 그냥 더하지 않고 가중평균하는 이유, 그리고 확률을 다룰 때 로그 공간으로 옮기는 습관 — 로그 확률은 제약 없는 실수라 자유롭게 더할 수 있습니다 — 이 여기서 나옵니다.
정규화된 임베딩도 벡터공간이 아닙니다. 길이 1인 벡터끼리 더하면 길이가 1이 아닙니다.
a = np.array([1.0, 0.0]); b = np.array([0.0, 1.0])
np.linalg.norm(a + b) # 1.414...
코사인 유사도로 검색하는 시스템에서 임베딩을 미리 정규화해 두면, mean pooling 결과를 다시 정규화해야 하는 이유가 이것입니다. 평균을 내는 순간 단위 구를 벗어나기 때문입니다. 벡터 데이터베이스가 삽입할 때 정규화 옵션을 따로 두는 것도 같은 사정입니다.
토큰 ID는 처음부터 벡터가 아닙니다. 임베딩 층이 하는 일이 정확히 이 변환입니다 — 연산이 정의되지 않은 라벨을, 연산이 닫혀 있는 공간의 점으로 옮기는 것. 원핫 벡터를 거쳐 보면 더 분명합니다. 원핫 벡터는 의 원소지만 그중 아주 특별한 것들만 쓰고, 두 개를 더하면 원핫이 아니게 됩니다. 임베딩 행렬을 곱하는 순간 그 제약이 사라지고 전체를 쓸 수 있게 됩니다.
다시 그 API 응답으로
처음의 두 응답으로 돌아갑니다.
{"embedding": [0.0123, -0.0456, ...]}
{"input_ids": [9906, 1917, 0, ...]}
둘의 차이는 자료형이 아니라 어떤 연산이 닫혀 있는가입니다. 앞의 것은 더하고 스칼라배 해도 여전히 임베딩 공간의 점이라, 평균 내고 빼고 가중합하는 모든 조작이 정당합니다. 뒤의 것은 그런 보장이 없어서 인덱싱 말고는 할 수 있는 게 없습니다.
그리고 앞의 것에도 아직 없는 것이 있습니다. 길이와 각도입니다. 벡터라는 것만으로는 "가깝다"를 말할 수 없습니다. 위에서 np.linalg.norm을 아무렇지 않게 썼지만, 그 함수가 재는 것이 무엇이고 왜 그것이 거리로 쓸 자격이 있는지는 아직 정의하지 않았습니다. 다음 글이 크기를 재는 규칙 — 노름 — 을 정의하고, 그 선택이 그래디언트 클리핑과 가중치 감쇠와 L1 희소성에서 각각 무엇을 바꾸는지까지 갑니다.
지난 글: Σ와 첨자, 그리고 einsum: 수식을 그대로 코드로 옮기기
다음 글: 노름과 거리: L1·L2·L∞와 단위구의 모양
읽어주셔서 감사합니다. 😊

