embedding: token을 숫자로 바꾸는 법
오늘의 질문
앞선 글에서 문장이 token으로 쪼개지고, 각 token이 vocabulary에서 정수 id로 바뀐다는 것까지 봤다. 그런데 정수 id는 그냥 일련번호다. “고양이”가 4231번이고 “개”가 8907번이라고 해서, 이 숫자들끼리 더하거나 곱하는 게 무슨 의미가 있을 리 없다.
그러면 이런 질문이 남는다.
일련번호에 불과한 정수를 가지고 모델은 어떻게 의미를 다루는가? 단어를 숫자 벡터로 바꾸면 정확히 무엇이 가능해지는가?
답의 핵심은 번호를 좌표로 바꾸는 것이다. 이 글은 그 변환이 무엇이고, 그 덕분에 무엇이 계산 가능해지는지를 다룬다.
먼저 답하기
한 문장으로 줄이면 이렇다.
embedding은 각 token에 길이 d짜리 숫자 목록(벡터)을 하나씩 배정해서, 단어를 공간 위의 한 점으로 만드는 일이다. 점이 되는 순간 “얼마나 가까운가”를 사칙연산으로 계산할 수 있게 된다.
**벡터(vector)**란 숫자를 정해진 순서로 나열한 목록이다. (0.90, 0.25)처럼 두 개면 2차원, 4096개면 4096차원이다. **차원(dimension)**은 그 목록의 길이를 말한다.
정수 id와 벡터의 결정적인 차이가 여기 있다. 4231과 8907 사이의 거리는 아무 의미가 없지만, 두 벡터 사이의 각도나 거리는 모델이 학습을 통해 의미가 담기도록 만든 값이다. 비슷한 맥락에서 쓰이는 단어들이 비슷한 방향을 향하도록 학습되면, 그 이후로는 “의미가 비슷한가”라는 질문이 “두 벡터가 이루는 각이 좁은가”라는 계산 문제로 바뀐다.
그리고 이 변환은 개념적으로만 존재하는 게 아니다. 실제로는 V × d 크기의 커다란 표가 메모리에 놓여 있고, token id는 그 표의 행 번호로 쓰인다.
주제에 맞는 핵심 설명
좌표가 되면 무엇이 계산 가능해지는가
왼쪽 그림이 없으면 “단어가 가깝다”는 말이 문학적 비유인지 실제로 측정되는 값인지 구분되지 않는다. 그림에서 보듯 이건 비유가 아니라 각도로 잴 수 있는 양이다.
세 단어를 2차원 점으로 놓아보자. 실제 모델의 차원은 수백에서 수천이지만, 원리는 2차원에서 이미 다 드러난다.
가정 — 아래 좌표는 설명을 위해 임의로 정한 값이며, 실제 모델이 학습한 값이 아니다.
- 개 =
(0.90, 0.25) - 고양이 =
(0.80, 0.40) - 자동차 =
(0.20, 0.95)
두 벡터가 얼마나 같은 방향을 향하는지는 코사인 유사도로 잰다. 두 벡터의 대응하는 성분끼리 곱해서 더한 값(내적)을 각 벡터의 길이로 나눈다. 식으로 쓰면 cos(a, b) = (a·b) / (|a| × |b|)이고, 결과는 방향이 완전히 같으면 1, 직각이면 0이 된다.
| 단어 쌍 | 내적 a·b | 길이의 곱 |a|×|b| | 코사인 유사도 | 사이각 |
|—|—|—|—|—|
| 개 – 고양이 | 0.720 + 0.100 = 0.820 | 0.934 × 0.894 = 0.836 | 0.981 | 약 11° |
| 고양이 – 자동차 | 0.160 + 0.380 = 0.540 | 0.894 × 0.971 = 0.868 | 0.622 | 약 52° |
| 개 – 자동차 | 0.180 + 0.238 = 0.418 | 0.934 × 0.971 = 0.907 | 0.460 | 약 63° |
숫자를 읽어보면 사람의 직관과 순서가 맞는다. 개와 고양이는 0.98로 거의 같은 방향이고, 개와 자동차는 0.46으로 뚜렷하게 벌어진다. 중요한 건 이 판단이 어휘 사전을 뒤져서 나온 게 아니라 곱셈과 덧셈 몇 번으로 나왔다는 점이다. 이게 좌표로 바꿔서 얻는 것 전부다.
적용 한계: 위 좌표는 결과가 직관과 맞도록 내가 고른 값이다. 실제 학습된 임베딩이 이 정도로 깔끔하게 정렬되어 있다는 뜻이 아니며, 특정 단어 쌍의 유사도가 얼마인지는 모델마다 다르다. 또 코사인 유사도는 방향만 보고 길이는 무시하므로, 길이에 정보가 담긴 경우에는 다른 척도가 필요하다.
실제로 벌어지는 일: 표에서 한 행 뽑기
오른쪽 그림이 실제 구조다. embedding은 함수라기보다 **조회 테이블(lookup table)**에 가깝다.
vocabulary 크기가 V이고 차원이 d라면, V × d 크기의 행렬이 하나 있다. token id가 4231이면 이 행렬의 4231번째 행을 그대로 가져온다. 계산이랄 게 없고, 주소를 계산해서 메모리에서 읽어오는 gather 연산이다.
이 행렬은 고정된 상수가 아니라 학습되는 파라미터다. 트랜스포머 구조를 제시한 Vaswani 외의 논문 “Attention Is All You Need”(2017)는 입력 token과 출력 token을 d_model 차원의 벡터로 바꾸기 위해 **학습된 임베딩(learned embeddings)**을 사용한다고 기술한다. 이 논문의 기본 모델에서 d_model은 512다.
같은 논문에서 눈여겨볼 대목이 두 가지 더 있다.
첫째, 입력 임베딩 층 두 개와 소프트맥스 직전의 선형 변환이 같은 가중치 행렬을 공유한다. 즉 token을 벡터로 바꾸는 표와, 마지막에 vocabulary 전체의 점수를 만드는 행렬이 같은 물건이다. 앞선 글에서 본 출력단의 [d, V] 행렬이 사실 이 표의 전치라는 뜻이다.
둘째, 임베딩 층에서 가중치에 √d_model을 곱한다. 기본 모델 기준으로는 약 22.6배다. 논문은 이 스케일링을 임베딩 층의 처리로 명시하는데, 논문 자체가 이 배수를 택한 이유를 길게 설명하지는 않는다.
같은 크기, 정반대 접근 패턴
여기서 메모리 관점에서 짚어둘 것이 하나 있다. 임베딩 표와 출력단 행렬은 가중치를 공유하므로 크기가 같다. 그런데 디코드 스텝에서 읽히는 양이 완전히 다르다.
가정 — V = 128,000, d = 4096, bf16(2 B), 디코드 1 스텝에 token 1개.
| 항목 | 식 | 결과 |
|---|---|---|
| 표 전체 크기 | 128000 × 4096 × 2 |
1,048,576,000 B ≈ 1.049 GB |
| 한 행 크기 | 4096 × 2 |
8,192 B = 8 KiB |
| 입력 임베딩이 스텝당 읽는 양 | 한 행 | 8 KiB |
| 표 대비 비율 | 8192 ÷ 1.049e9 |
0.00078% (1 : 128,000) |
| 출력단이 스텝당 읽는 양 | 전량 GEMV | 1.049 GB (100%) |
같은 1.049 GB짜리 행렬인데, 입력 쪽으로는 8 KiB만 읽히고 출력 쪽으로는 전량이 읽힌다. 입력 임베딩은 용량은 차지하되 대역폭은 거의 안 쓰는 데이터이고, 출력단은 매 스텝 전량이 흐르는 대역폭 소비자다. 파라미터 개수만 세는 관점에서는 이 둘이 구분되지 않는다.
적용 한계: V와 d는 toy 값이며 특정 모델의 구성이 아니다. 프리필처럼 여러 token을 한꺼번에 처리하거나 배치가 큰 경우에는 읽히는 행 수가 늘어나므로 위 비율이 그대로 유지되지 않는다. 또 임베딩 공유 여부는 모델마다 다르며, 공유하지 않는 구성에서는 두 행렬이 별개의 파라미터가 된다.
이 방식의 한계
좌표로 바꾸면 유사도가 계산 가능해지지만, 그 대가로 생기는 제약도 분명하다.
첫째, 표는 token 단위로만 존재한다. 한 token에 벡터 하나가 배정되므로, 이 단계에서는 문맥이 반영되지 않는다. “밤”이 야간을 뜻하든 견과류를 뜻하든 같은 token이면 표에서 같은 행을 가져온다. 문맥에 따라 달라지는 표현은 이후 층들을 거치면서 만들어지는 것이지 임베딩 표가 해주는 일이 아니다.
둘째, 유사도가 높다는 것이 의미가 같다는 뜻은 아니다. 코사인 유사도는 “비슷한 맥락에서 쓰이는가”를 반영하는데, 반의어는 대체로 비슷한 맥락에서 등장한다. “덥다”와 “춥다”는 뜻이 반대지만 나타나는 문장 구조는 매우 비슷하다. 그래서 유사도가 높게 나오는 경우가 있다.
셋째, 축 하나하나에 이름을 붙일 수 없다. 위 toy 예시에서는 축 1과 축 2가 뭔가를 뜻하는 것처럼 그렸지만, 실제 학습된 임베딩의 각 차원은 사람이 해석할 수 있는 개념에 대응하지 않는다. 의미는 개별 축이 아니라 벡터 전체의 방향에 분산되어 담긴다.
넷째, 학습 데이터에 있던 편향이 그대로 좌표에 반영된다. 표는 데이터에서 배운 것이므로, 데이터에 담긴 치우침도 함께 배운다. 유사도가 중립적인 측정값처럼 보이는 것과 별개로, 그 값이 어디서 왔는지는 데이터에 달려 있다.
자주 묻는 질문
차원 d를 키우면 성능이 좋아지나요?
표현할 수 있는 여지는 늘어나지만 비용도 함께 늘어난다. 위 계산에서 봤듯 표 크기는 V × d에 비례하므로 d를 두 배로 하면 임베딩 표 용량도 두 배가 되고, 임베딩과 가중치를 공유하는 구성에서는 출력단의 스텝당 트래픽도 그만큼 늘어난다. 게다가 d는 이후 모든 층의 폭을 결정하는 값이라 모델 전체 크기에 영향을 준다. 그래서 d는 단독으로 조절하는 값이라기보다 모델 구성 전체와 함께 정해지는 값에 가깝다.
vocabulary를 키우는 것과 차원을 키우는 것 중 무엇이 더 비싼가요?
용량 측면에서는 둘 다 V × d에 선형으로 작용하므로 대칭이다. 그런데 스텝당 트래픽에서는 대칭이 깨진다. V를 두 배로 하면 입력 임베딩은 여전히 한 행(8 KiB)만 읽지만, 출력단은 vocabulary 전체에 대해 점수를 내야 하므로 스텝당 읽는 양이 그대로 두 배가 된다. 반면 d를 두 배로 하면 양쪽이 함께 늘어난다. 그래서 V 확대는 “용량은 늘고 출력단 대역폭이 직접 늘어나는” 형태로, d 확대는 “모델 전체가 두꺼워지는” 형태로 비용이 나타난다. 다만 이 구분은 위 toy 가정에서 유도한 것이므로, 실제 서빙에서 어느 쪽이 더 아픈지는 구성과 워크로드를 넣고 다시 계산해봐야 한다.
임베딩 표를 다른 메모리 계층에 두는 게 가능한가요? 접근 패턴만 보면 후보로 삼을 만한 조건은 갖췄다. 용량은 GB 단위인데 스텝당 읽는 양은 KiB 단위라 대역폭 요구가 극히 낮기 때문이다. 다만 세 가지를 함께 봐야 한다. 첫째, 임베딩과 출력단이 가중치를 공유하는 구성이라면 같은 행렬이 매 스텝 전량 읽히므로 낮은 대역폭 계층에 둘 수 없다. 둘째, 조회는 random gather이므로 순차 읽기 대비 지연에 민감하고, 그 지연이 임계 경로에 들어가면 이득이 상쇄된다. 셋째, 배치가 커지면 스텝당 서로 다른 행을 여러 개 읽으므로 접근 패턴이 달라진다. 이득이 있는지는 이 세 조건을 넣고 실제 구성에서 측정해야 답이 나오는 문제다.
두 문장이 비슷한지 비교할 때도 같은 방식을 쓰나요? 문장 단위 비교에도 벡터와 코사인 유사도를 쓰는 방식이 널리 사용되지만, 그 벡터는 이 글에서 다룬 임베딩 표에서 바로 나오는 것이 아니다. 표는 token 하나에 벡터 하나를 주는 데까지만 관여하고, 문장 전체를 대표하는 벡터는 모델의 여러 층을 거쳐 나온 결과를 다시 가공해서 만든다. 원리(방향의 가까움을 재는 것)는 같지만 벡터가 만들어지는 경로가 다르다는 점을 구분해두는 편이 좋다.