토큰과 임베딩
신경망은 숫자만 먹는다. 그런데 우리가 모델에게 주는 것은 "고양이가 귀엽다" 같은 글자다. 이 장은 글자를 숫자로 바꾸는 두 단계, 즉 텍스트를 조각내는 토큰화와 조각마다 벡터를 붙이는 임베딩을 다룬다. 그리고 이 책에서 가장 손이 많이 가는 실험실을 연다. 임베딩 공간을 3D로 돌려 보고, 벡터를 더하고 빼서 유추를 풀고, 단어를 손가락으로 끌어 옮겨 그 단어의 "의미"를 직접 바꿔 본다. 마지막에는 브라우저 안에서 word2vec을 처음부터 학습시켜, 아무 의미도 없던 점들이 스스로 뭉치는 과정을 지켜본다.
- 문자·단어·서브워드 단위 토큰화의 장단점과 어휘 크기의 트레이드오프를 설명할 수 있다.
- BPE가 가장 빈번한 쌍을 반복 병합해 어휘를 만드는 과정을 손으로 따라가고, 한국어가 토큰을 더 많이 쓰는 이유를 안다.
- 원-핫 벡터와 임베딩 테이블 \(E \in \mathbb{R}^{V\times d}\)의 관계(원-핫 × E = 행 선택)와 실제 모델의 임베딩 파라미터 수를 계산할 수 있다.
- 분포 가설에서 출발해 동시출현 행렬·PPMI·SVD로 단어 벡터를 만드는 원리를 이해한다.
- 코사인 유사도, PCA 투영, 벡터 산술(유추)로 임베딩 공간을 읽고, 그 한계를 정직하게 평가한다.
- word2vec(skip-gram + negative sampling)의 목적 함수와 학습 과정을 직접 돌려 보고, 문맥 임베딩·문장 임베딩으로 이어지는 흐름을 안다.
텍스트를 숫자로: 무엇을 한 단위로 볼까
2장의 신경망은 입력으로 실수 벡터를 받는다. 이미지라면 픽셀 밝기가 이미 숫자이니 문제가 없다. 하지만 텍스트는 기호의 나열이다. "고양이"라는 글자 세 개에는 크기도, 방향도, 거리도 없다. 그래서 언어 모델의 맨 앞에는 항상 두 단계가 붙는다.
- 토큰화(tokenization) — 문자열을 미리 정해 둔 어휘(vocabulary)의 조각, 즉 토큰들로 자르고 각 토큰을 정수 ID로 바꾼다.
- 임베딩(embedding) — 정수 ID마다 학습 가능한 \(d\)차원 실수 벡터를 하나씩 붙인다. 이 벡터들이 신경망이 실제로 보는 입력이다.
첫 번째 질문은 "무엇을 한 단위로 볼 것인가"다. 가장 단순한 선택지는 세 가지다.
| 단위 | "고양이가 귀엽다" | 어휘 크기 | 장점 | 단점 |
|---|---|---|---|---|
| 문자 | 고양이가 귀엽다 | 작다 (영어 ~100, 한글 완성형 11,172) | 모르는 단어가 없다 | 시퀀스가 길다. 한 토큰이 담는 의미가 적어 모델이 조합을 처음부터 배워야 한다 |
| 단어 | 고양이가귀엽다 | 매우 크다 (수십만~수백만) | 토큰 하나가 의미 단위 | "고양이가/고양이를/고양이는"이 모두 별개. 처음 보는 단어는 [UNK]. 한국어처럼 활용이 많은 언어에서 폭발 |
| 서브워드 | ▁고양이가▁귀엽다 | 적당 (3만~26만) | 자주 쓰는 단어는 통째로, 드문 단어는 조각으로 | 분할이 언어학적 형태소와 꼭 일치하지는 않는다 |
어휘 크기 \(V\)는 두 비용 사이의 줄다리기다. \(V\)를 키우면 같은 문장이 더 적은 토큰으로 표현되어 시퀀스가 짧아진다. 5장에서 볼 어텐션의 비용은 시퀀스 길이 \(n\)의 제곱에 비례하므로, 그리고 8장의 생성은 토큰 하나씩 진행되므로 짧은 시퀀스는 곧 빠르고 싼 추론이다. 반대로 \(V\)가 커지면 임베딩 테이블과 출력층이 \(V\)에 비례해 커지고, 드문 토큰은 학습 데이터에서 몇 번 나오지 않아 벡터가 제대로 학습되지 않는다. 현대 LLM은 대부분 서브워드를 택하고, \(V\)는 3만(Llama 2)에서 12.8만(Llama 3), 25만 이상(다국어 모델)까지 다양하다.
토크나이저는 일종의 압축기다. 같은 텍스트를 몇 개의 토큰으로 표현하느냐(토큰당 평균 글자 수, 또는 바이트 수)가 그 토크나이저의 "효율"이다. 영어 텍스트에서 GPT 계열 토크나이저는 대략 토큰 하나에 영어 4글자, 단어 0.75개 정도를 담는다. 이 비율은 언어마다 크게 다르고, 이것이 3절에서 볼 "한국어 세금"의 정체다.
토큰화와 BPE: 가장 흔한 쌍부터 붙인다
서브워드 어휘를 어떻게 정할까? 가장 널리 쓰이는 방법은 BPE(Byte Pair Encoding)다. 원래 1994년의 데이터 압축 기법이었고, 2016년 Sennrich 등이 신경망 기계 번역에 도입한 뒤 GPT-2, GPT-4, Llama 3 같은 모델의 기본 토크나이저가 되었다. 아이디어는 놀랄 만큼 단순하다.
- 코퍼스의 모든 단어를 문자 단위로 쪼갠다. 단어 시작에는 경계 표시 ▁를 붙인다. 처음 어휘 = 등장한 모든 문자.
- 코퍼스 전체에서 인접한 두 토큰 쌍의 빈도를 센다(단어 빈도로 가중).
- 가장 빈번한 쌍 (a, b)를 새 토큰 ab로 병합하고, 코퍼스의 모든 a b를 ab로 바꾼다. 이 병합 규칙을 기록한다.
- 원하는 어휘 크기(= 기본 문자 수 + 병합 횟수)가 될 때까지 2~3을 반복한다.
새 텍스트를 토큰화할 때는 같은 문자 분해에서 출발해 학습할 때 기록한 순서대로 병합 규칙을 적용한다. 그래서 학습 코퍼스에 자주 나온 문자열일수록 큰 토큰 하나로 묶이고, 드문 문자열은 작은 조각 여러 개로 남는다. 아래 시뮬레이터는 이 알고리즘을 그대로 구현했다. 코퍼스를 고쳐 쓰면 처음부터 다시 학습한다.
BPE 단계별 학습
위 시뮬레이터는 이해를 돕기 위해 음절(유니코드 문자) 단위에서 출발했다. 그러면 처음 보는 글자가 나올 때 [UNK]를 피할 수 없다. 한글 완성형 음절만 11,172개, 유니코드 전체는 15만 자가 넘는다. GPT-2 이후 대부분의 LLM은 텍스트를 UTF-8 바이트로 바꾼 뒤 BPE를 돌린다. 기본 어휘가 256개 바이트이므로 어떤 문자열이든 표현할 수 있고 [UNK]가 원천적으로 사라진다. GPT-2의 어휘 50,257 = 바이트 256 + 병합 50,000 + 특수 토큰 1이다. 대가도 있다. 한글 음절 하나는 UTF-8로 3바이트(예: "고" = EA B3 A0)이므로, 그 음절 조합이 병합 규칙으로 학습되지 않았다면 한 글자가 토큰 2~3개로 쪼개진다. SentencePiece(Llama 2 등)는 문자 단위 BPE를 쓰되 모르는 문자만 바이트로 떨어뜨리는 byte fallback으로 같은 문제를 푼다.
BPE만 있는 것은 아니다. BERT는 빈도 대신 "병합했을 때 코퍼스 우도가 얼마나 오르나"로 쌍을 고르는 WordPiece(어휘 30,522)를, T5와 많은 다국어 모델은 큰 후보 어휘에서 출발해 덜 중요한 토큰을 깎아 내는 Unigram LM(SentencePiece)을 쓴다. 원리는 다르지만 "자주 나오는 문자열을 한 토큰으로"라는 결과는 비슷하다.
실제 토크나이저의 디테일
교과서용 BPE와 실제 LLM 토크나이저 사이에는 몇 가지 중요한 공학적 결정이 더 있다. 이 결정들이 모델의 이상한 행동(철자 세기 실패, 산수 실수 등)의 원인이 되기도 한다.
공백, 대소문자, 숫자
- 공백은 토큰의 일부다. GPT-2 계열은 공백을 다음 단어에 붙여 " hello"와 "hello"를 다른 토큰으로 만든다(어휘 파일에서는 공백이 Ġ로 보인다). SentencePiece는 공백을 ▁로 바꿔 같은 일을 한다. 그래서 문장 맨 앞 단어와 중간 단어의 토큰이 다를 수 있다.
- 대소문자는 다른 토큰이다. "Apple", "apple", "APPLE"은 서로 다른 ID이고, 모델은 이들이 관련 있다는 사실을 데이터로부터 따로 배워야 한다.
- 숫자 분할은 산수 능력에 영향을 준다. GPT-2는 숫자를 빈도대로 병합해 "1234"가 "12"+"34"가 되기도 하고 "123"+"4"가 되기도 한다. 자릿수 정렬이 깨지니 덧셈이 어렵다. Llama 1·2는 숫자를 한 자리씩 쪼개고, GPT-4(cl100k)와 Llama 3는 사전 분할 정규식으로 숫자를 최대 3자리 묶음으로 자른다.
- 사전 분할(pre-tokenization). BPE를 돌리기 전에 정규식으로 텍스트를 단어·숫자·구두점 덩어리로 먼저 나눈다. 병합은 덩어리 경계를 넘지 않으므로 "dog."과 "dog!"가 각각 별개 토큰으로 학습되는 낭비를 막는다.
한국어가 토큰을 더 많이 쓰는 이유
원인은 두 가지가 겹친다. 첫째, 출발점이 다르다. 그림 4-2처럼 같은 "세 글자"라도 한국어는 바이트가 세 배다. 둘째, 병합 예산의 배분이 다르다. BPE는 코퍼스에서 빈도가 높은 쌍에 병합을 쓰는데, 토크나이저 학습 코퍼스의 대부분이 영어와 코드라면 한국어 바이트 열에 돌아갈 병합이 적다. 결과적으로 같은 내용을 표현하는 데 한국어가 영어보다 토큰을 대략 1.5~3배 쓰는 일이 흔하다(토크나이저와 문체에 따라 크게 다르다). 토큰 수는 곧 API 비용, 컨텍스트 창 소모, 생성 시간이다. 어휘를 128K로 늘리고 다국어 데이터를 더 넣은 Llama 3 토크나이저가 Llama 2(32K)보다 한국어를 더 짧게 표현하는 것도, 국내 기업들이 한국어 비중을 높인 자체 토크나이저를 만드는 것도 이 때문이다.
어휘 크기와 특수 토큰
| 모델 | 방식 | 어휘 크기 | 비고 |
|---|---|---|---|
| BERT-base | WordPiece | 30,522 | [CLS] [SEP] [MASK] [PAD] [UNK] |
| GPT-2 | 바이트 수준 BPE | 50,257 | 256 바이트 + 50,000 병합 + <|endoftext|> |
| Llama 2 | SentencePiece BPE + byte fallback | 32,000 | 숫자 한 자리씩 |
| GPT-4 (cl100k) | 바이트 수준 BPE | ~100,000 | 숫자 최대 3자리 묶음 |
| Llama 3 | 바이트 수준 BPE (tiktoken 계열) | 128,256 | 일반 128,000 + 특수 256 |
특수 토큰(special tokens)은 텍스트에서 나오지 않고 사람이 정해 넣는 토큰이다. 문서의 시작과 끝(<|begin_of_text|>, <|endoftext|>), 대화의 역할 구분(<|start_header_id|>user<|end_header_id|>, <|eot_id|>), 패딩, 마스크 등이 여기에 속한다. 7장의 채팅 템플릿과 8장의 "생성 멈춤" 조건이 모두 이 특수 토큰으로 구현된다. 사용자 입력에 특수 토큰 문자열이 그대로 들어오면 역할 구분이 깨질 수 있어서, 실제 서비스는 일반 텍스트 안의 특수 토큰 문자열을 따로 이스케이프한다.
"strawberry에 r이 몇 개인가?" 같은 질문에 LLM이 자주 틀리는 이유 중 하나는 모델이 글자를 보지 못하기 때문이다. 모델 입력은 "str", "aw", "berry" 같은 토큰 ID일 뿐이고, 각 토큰 안에 어떤 글자가 들어 있는지는 학습으로 간접적으로만 알 수 있다. 숫자 계산, 운율, 철자 뒤집기도 같은 이유로 어렵다.
원-핫의 한계와 임베딩 룩업 테이블
토큰 ID는 정수지만, 정수 그대로 신경망에 넣으면 곤란하다. ID 118("가")과 ID 119가 수치상 가깝다고 의미가 가까운 것은 아니기 때문이다. 순서 없는 범주를 숫자로 바꾸는 고전적 방법은 원-핫 벡터(one-hot)다. 어휘 크기 \(V\)짜리 벡터에서 자기 자리만 1, 나머지는 0.
원-핫에는 두 가지 치명적 한계가 있다. 첫째, 모든 단어가 서로 똑같이 멀다. "고양이"와 "강아지"의 거리가 "고양이"와 "민주주의"의 거리와 같다. 둘째, 너무 크고 희소하다. \(V = 128{,}256\)이면 벡터 하나가 12만 차원인데 그중 1이 하나뿐이다. 해결책은 각 토큰에 작은 \(d\)차원 밀집 벡터를 하나씩 배정하고, 그 값을 학습으로 정하는 것이다. 이 벡터들을 쌓은 행렬이 임베딩 테이블 \(E\)다.
E[i]로 행을 꺼내는 룩업(lookup)이다. PyTorch의 nn.Embedding이 바로 이것이다.임베딩 룩업: 원-핫 × E = 행 선택
시뮬레이터 오른쪽의 가중치 공유(weight tying)는 모델 맨 끝의 출력층과 관련이 있다. 7장에서 보겠지만 LLM의 마지막 층은 \(d\)차원 은닉 벡터를 \(V\)개 토큰의 점수(로짓)로 바꾸는 \(d\times V\) 행렬이다. 이것은 임베딩 테이블의 전치와 모양이 같다. 그래서 GPT-2처럼 두 행렬을 같은 파라미터로 쓰면 \(V\times d\)만큼 절약된다. "입력에서 토큰을 벡터로 바꾸는 사전"과 "출력에서 벡터를 토큰 점수로 바꾸는 사전"을 하나로 쓰는 셈이다. 모델이 커지면 임베딩 비중이 작아지므로 Llama 3 8B처럼 공유하지 않는 경우도 많다(작은 Llama 3.2 1B·3B는 공유한다).
LLM에서는 임베딩 차원이 곧 모델 전체의 은닉 차원 \(d_{\text{model}}\)이다. GPT-2 small 768, GPT-2 XL 1,600, Llama 3 8B 4,096, Llama 3 70B 8,192. 어휘가 12만 개인데 4,096차원이면 충분할까? 고차원에서는 서로 "거의 직교"인 방향을 차원 수보다 훨씬 많이 만들 수 있다(1장의 고차원 직관). 그래서 수만 개 토큰이 수천 차원 안에 겹치지 않고 들어간다.
분포 가설: 단어는 이웃으로 알 수 있다
임베딩 테이블 \(E\)의 값은 처음엔 무작위다. 그렇다면 무엇을 기준으로 "고양이"와 "강아지"의 벡터를 가깝게 만들어야 할까? 사람이 의미를 일일이 적어 줄 수는 없다. 1950년대 언어학자들이 답을 남겨 두었다.
"You shall know a word by the company it keeps." — J. R. Firth (1957). 비슷한 문맥에 나타나는 단어는 비슷한 의미를 가진다(Z. Harris, 1954). "차가운 ___ 를 마신다"의 빈칸에 들어갈 수 있는 단어(우유, 주스, 물)는 서로 비슷하고, "___ 를 키운다"에 들어갈 단어(고양이, 강아지)는 또 서로 비슷하다.
이 가설을 숫자로 옮기는 가장 직접적인 방법은 동시출현 행렬(co-occurrence matrix)이다. 코퍼스를 훑으며 단어 \(w\)의 앞뒤 \(k\)칸(윈도우) 안에 단어 \(c\)가 몇 번 나왔는지 \(\#(w,c)\)를 센다. 그러면 각 단어의 행이 곧 그 단어의 "문맥 프로필" 벡터가 된다. 그런데 원시 횟수에는 문제가 있다. "를", "에" 같은 조사는 어떤 단어 옆에나 나오므로 모든 행에서 큰 값을 차지해, 의미와 상관없이 모든 단어를 비슷하게 만든다. 그래서 "우연히 기대되는 것보다 얼마나 자주 함께 나오나"를 재는 PMI(Pointwise Mutual Information)를 쓴다.
PPMI 행렬의 행은 \(V\)차원으로 여전히 크고 희소하다. 이것을 특이값 분해(SVD)로 상위 몇 개 방향만 남기면 짧고 밀집된 단어 벡터가 된다. 1990년대의 LSA(잠재 의미 분석)가 이 방법이고, 놀랍게도 9절의 word2vec이 학습하는 것도 본질적으로는 (이동된) PMI 행렬의 분해다. 아래 시뮬레이터는 교육용으로 만든 24문장짜리 코퍼스로 이 과정을 실제로 계산한다. 각 단어의 PPMI 행을 길이 1로 정규화하고, 중심화한 뒤 SVD로 상위 2개 방향(= PCA 평면)에 투영했다.
동시출현 행렬 → PPMI → 2D 단어 벡터
여기까지가 세는 방법(count-based)이다. 행렬을 만들고 분해한다. 2013년의 word2vec은 같은 목표를 예측하는 방법(prediction-based)으로 바꿨다. 행렬을 통째로 만들지 않고, 문장을 하나씩 읽으며 "이 단어 주변에 저 단어가 나올까?"를 맞히도록 벡터를 조금씩 고친다. 수십억 단어 코퍼스에서도 메모리 걱정 없이 학습할 수 있다는 것이 결정적 장점이었다. 이 방법은 9절에서 직접 돌려 보고, 먼저 "잘 학습된 임베딩 공간"이 어떻게 생겼는지 탐험해 보자.
임베딩 공간 탐험: 의미의 지도를 3D로 돌려 보기
실제 word2vec이나 LLM의 임베딩은 수백~수천 차원이고, 각 축은 사람이 해석할 수 있는 의미를 갖지 않는다. 그래서 여기서는 원리를 눈으로 보기 위해 교육용으로 손수 설계한 작은 임베딩을 쓴다. 단어 76개(사람, 동물, 음식, 나라·도시, 동작, 색, 수)에 대해 12개의 해석 가능한 축 — 왕족, 성별, 나이, 생물, 크기, 음식, 장소, 동서(지역), 동작, 과거, 색, 수 — 의 값을 직접 정하고, 표준편차 0.05의 작은 잡음을 더했다. 예를 들어 "왕"은 왕족 1, 성별 +1, 나이 0.6, 생물 1이고 나머지는 거의 0이다. 실제 모델의 공간도 이런 "의미 방향"을 어느 정도 품고 있지만, 그 방향들이 좌표축과 나란하지 않고 서로 비스듬히 섞여 있다는 점이 다르다.
두 단어가 얼마나 비슷한지는 보통 코사인 유사도로 잰다. 벡터의 길이는 빈도 같은 부수적 요인의 영향을 많이 받으므로 방향만 비교한다.
12차원은 볼 수 없으므로 3차원으로 줄여야 한다. 가장 정보를 많이 남기는 선형 투영은 주성분 분석(PCA)이다. 데이터의 공분산 행렬을 고유분해해서 분산이 가장 큰 방향 세 개를 고른다.
임베딩 공간 탐색기
무리 짓기보다 더 흥미로운 것은 무리 사이의 방향이다. 시뮬레이터에서 남자 → 여자로 가는 화살표와 왕 → 여왕, 소년 → 소녀로 가는 화살표가 거의 같은 방향, 같은 길이라는 점에 주목하자. 임베딩 공간에서 "성별"은 하나의 방향이다. 이 관찰이 다음 절의 벡터 산술로 이어진다.
논문과 블로그의 임베딩 그림은 대부분 PCA나 t-SNE, UMAP으로 수백 차원을 2차원으로 누른 것이다. PCA는 전체 구조(큰 분산)를 보존하는 대신 국소 이웃을 뭉개고, t-SNE·UMAP은 국소 이웃을 보존하는 대신 무리 사이의 거리와 크기를 왜곡한다. 실제 판단(최근접 이웃, 검색, 유추)은 언제나 원래 차원에서 계산해야 한다. 위 시뮬레이터의 이웃 목록이 3D 좌표가 아니라 12차원 코사인으로 계산되는 이유다.
벡터 산술과 유추: 왕 − 남자 + 여자 = ?
2013년 Mikolov 등이 word2vec을 발표하며 세상을 놀라게 한 장면이 이것이다. 학습된 벡터로 \(\mathbf{v}_{\text{king}} - \mathbf{v}_{\text{man}} + \mathbf{v}_{\text{woman}}\)을 계산하고 가장 가까운 단어를 찾으면 queen이 나온다. 아무도 "성별"이나 "왕족"을 가르쳐 주지 않았는데 말이다. 원리는 그림 4-4와 같다. "남자 → 왕"의 차이 벡터가 "여자 → 여왕"의 차이 벡터와 같다면, 네 점은 평행사변형을 이룬다.
정식으로 쓰면 "a가 b에 대한 관계는 ?가 c에 대한 관계와 같다"(b : a = c : ?)를 다음처럼 푼다. 입력 단어 세 개를 후보에서 빼는 것이 중요하다. 빼지 않으면 대개 a 자신(예: 왕)이 1등이 된다.
벡터 산술 & 유추
위 시뮬레이터는 의미 방향이 깨끗하도록 설계한 벡터라서 잘 된다. 실제 word2vec(Google News, 300차원)에서 "king − man + woman = queen"은 정말로 성립하지만, 유추 벤치마크 전체의 정답률은 70% 안팎이고 관계 종류에 따라 편차가 크다. 문법적 관계(시제, 복수형)는 잘 되고 백과사전적 관계는 덜 된다. 또 입력 단어를 제외하는 규칙이 결과를 상당 부분 "만들어 낸다"는 지적이 있다(제외하지 않으면 많은 문제에서 b나 a가 1위다). 더 나아가 현대 LLM의 입력 임베딩은 여러 층을 거치며 문맥과 섞이기 전의 출발점일 뿐이어서, 단어 산술이 LLM의 추론 방식을 설명하는 것도 아니다. 유추는 "임베딩 공간에 선형 구조가 생긴다"는 사실을 보여 주는 멋진 시연이지, 만능의 추론 도구가 아니다.
임베딩을 손으로 바꾸기: 의미는 상대적 위치다
지금까지는 주어진 벡터를 관찰만 했다. 이번에는 직접 바꿔 보자. 임베딩은 결국 숫자 목록이고, 학습이란 이 숫자들을 조금씩 옮기는 일이다. 아래 평면에서 단어를 끌어 옮기면 그 단어의 12차원 벡터가 실제로 수정된다. 평면이 PCA 평면(선택한 8개 단어의 주성분)이면 두 주성분 방향 \(\mathbf{u}_1, \mathbf{u}_2\)로만, 의미 축 평면이면 선택한 두 축으로만 바뀐다.
임베딩을 손으로 바꾸기
이 실험에서 체감해야 할 핵심은 두 가지다. 첫째, 단어 하나의 "의미"는 그 벡터 자체보다 다른 벡터들과의 관계로 정해진다. 여왕을 남자 옆으로 옮기면, 숫자 12개가 바뀌었을 뿐인데 모델 입장에서 여왕은 "남자 같은 것"이 된다. 둘째, 공간 전체를 회전시키면 모든 내적과 코사인이 그대로이므로 모델의 행동도 그대로다. 그래서 실제 학습된 임베딩의 좌표축 하나하나에는 의미가 없다. 의미 있는 것은 방향과 거리, 그리고 그것들이 이루는 기하 구조다. 학습은 손가락 대신 기울기가 이 끌어 옮기기를 수십억 번 하는 과정이다. 정확히 어떤 힘으로 끄는지 다음 절에서 보자.
임의의 직교 행렬 \(Q\)에 대해 \(E' = EQ\)로 모든 임베딩을 돌리고 다음 층 가중치를 \(Q^{\top}\)로 보정하면 모델 출력은 완전히 같다. 이 대칭성 때문에 학습된 임베딩의 개별 차원은 해석이 어렵다. 그럼에도 성별·시제·국가 같은 개념은 공간 안의 특정 방향으로 나타나는 경향이 있는데, 이를 "선형 표현 가설"이라 부른다. 희소 오토인코더(SAE) 같은 해석 가능성 연구는 LLM 내부에서 이런 방향을 대규모로 찾아내는 작업이다.
word2vec: 주변 단어를 맞히며 배우는 임베딩
word2vec의 skip-gram 모델은 단순한 예측 문제를 푼다. 문장의 각 위치에서 중심 단어 \(c\)를 보고, 윈도우 안의 주변 단어 \(o\)가 무엇인지 맞힌다. 단어마다 벡터가 두 개 있다. 중심 단어로 쓰일 때의 \(\mathbf{v}_c\)(입력 임베딩)와 주변 단어로 쓰일 때의 \(\mathbf{u}_o\)(출력 임베딩)다. 원래 정의는 \(V\)개 단어 전체에 소프트맥스를 하는 것이지만 \(V\)가 크면 너무 비싸다. 그래서 실제로는 음성 샘플링(negative sampling, SGNS)으로 바꾼다. "진짜 주변 단어 쌍인가?"를 맞히는 이진 분류다.
기울기도 간단하다. \(s = \sigma(\mathbf{u}\cdot\mathbf{v}_c)\), 정답 레이블 \(y \in \{0,1\}\)이라 하면 \(\partial\mathcal{L}/\partial\mathbf{v}_c = \sum (s-y)\,\mathbf{u}\), \(\partial\mathcal{L}/\partial\mathbf{u} = (s-y)\,\mathbf{v}_c\)이다. 3장의 로지스틱 회귀와 똑같은 형태로, 예측이 틀린 만큼 상대 벡터 방향으로 끌거나 민다. 8절에서 손으로 하던 일을 이 기울기가 대신하는 것이다. 아래 시뮬레이터는 5절의 코퍼스로 이 SGD를 브라우저에서 그대로 돌린다(연산은 모두 실제 계산이고, 난수 시드를 고정했다).
브라우저 word2vec (SGNS)
Levy와 Goldberg(2014)는 SGNS가 수렴하면 \(\mathbf{u}_c\cdot\mathbf{v}_w \approx \mathrm{PMI}(w,c) - \log k\)가 됨을 보였다. 즉 word2vec은 5절의 PMI 행렬을 \(\log k\)만큼 이동시킨 것을 암묵적으로 분해한다. "세는 방법"과 "예측하는 방법"이 같은 대상을 다른 경로로 근사하는 셈이다. 같은 해(2014)의 GloVe는 아예 동시출현 횟수의 로그를 직접 맞히는 목적 함수로 두 관점을 합쳤다. 실제 word2vec 구현에는 이 밖에도 흔한 단어를 확률적으로 건너뛰는 subsampling, 학습률 선형 감소 등의 요령이 들어간다. 원 논문은 Google News 약 1천억 단어로 300차원 벡터 300만 개를 학습했다.
문맥 임베딩과 문장 임베딩
word2vec 벡터는 정적(static)이다. 단어 하나에 벡터 하나. 그런데 "배"는 "배가 아프다"에서는 신체, "배를 타고"에서는 탈것, "배를 깎아 먹었다"에서는 과일이다. 정적 임베딩은 세 의미의 평균 어딘가, 어느 무리에도 속하지 않는 애매한 곳에 놓일 수밖에 없다.
현대 LLM의 입력 임베딩 테이블도 사실 정적이다. 차이는 그 뒤에 있다. 테이블에서 꺼낸 벡터가 5장의 어텐션을 거치며 같은 문장의 다른 토큰 벡터들과 가중합으로 섞이고, 6장의 트랜스포머 블록 수십 개를 지나며 점점 문맥을 반영한 문맥 임베딩(contextual embedding)이 된다. ELMo(2018)와 BERT(2018)가 이 방식의 위력을 보여 준 뒤로 "단어 하나에 벡터 하나"라는 생각은 출발점으로만 남았다.
문장 임베딩과 의미 검색
단어가 아니라 문장이나 문서 전체를 벡터 하나로 만들면, 코사인 유사도로 "의미가 비슷한 문서"를 찾을 수 있다. 가장 단순한 방법은 토큰 벡터들의 평균 풀링(mean pooling)이다.
평균 풀링 문장 임베딩과 코사인 검색
단어 벡터는 6~8절과 같은 교육용 12차원 벡터(76단어)다. 조사처럼 어휘에 없는 토큰은 무시한다.
이 검색 방식이 10장 RAG의 핵심 부품이다. 문서들을 미리 임베딩해 벡터 데이터베이스에 넣어 두고, 질문이 오면 질문을 임베딩해 코사인이 높은 문서를 찾아 LLM에게 함께 준다. 실제 임베딩 모델의 차원은 다음과 같다(2024년 기준 대표 예).
| 모델 | 차원 | 비고 |
|---|---|---|
| all-MiniLM-L6-v2 (Sentence-Transformers) | 384 | 가볍고 빠른 영어 문장 임베딩 |
| BERT-base / multilingual-e5-base | 768 | 다국어 지원 e5 계열 |
| bge-m3, multilingual-e5-large | 1,024 | 한국어 포함 다국어 검색에 널리 쓰임 |
| OpenAI text-embedding-3-small / -large | 1,536 / 3,072 | 차원 축소 옵션 제공 |
| LLM 기반 임베딩 (e5-mistral 등) | 4,096 | 7B급 LLM의 은닉 차원 그대로 |
분포 가설은 양날의 검이다. 임베딩은 코퍼스에 나타난 그대로의 연관을 배우므로 사회적 편향도 함께 배운다. Bolukbasi 등(2016)은 Google News word2vec에서 "man : computer programmer = woman : ?"의 답으로 homemaker가 나온다는 것을 보였다. 방법은 이 장의 도구 그대로다. 성별 방향 \(\mathbf{g} = \mathbf{v}_{he} - \mathbf{v}_{she}\)(여러 쌍의 평균)를 구하고 직업 단어를 \(\mathbf{g}\)에 투영하면, 성별과 무관해야 할 직업들이 한쪽으로 치우쳐 있다. 이 방향을 투영해서 빼 버리는 "편향 제거"도 제안되었지만, 편향이 다른 방향에 남아 여전히 복원된다는 반론(Gonen & Goldberg, 2019)도 있다. 8절에서 손으로 했던 "벡터 고치기"가 실제로는 얼마나 어려운 문제인지 보여 주는 사례다.
핵심 정리
- 언어 모델의 입력은 토큰화(텍스트 → 토큰 ID)와 임베딩(ID → \(d\)차원 벡터) 두 단계를 거친다. 현대 LLM은 서브워드 단위를 쓰고, 어휘 크기 \(V\)는 시퀀스 길이와 임베딩 크기의 트레이드오프다.
- BPE는 문자(또는 바이트)에서 출발해 가장 빈번한 인접 쌍을 반복 병합한다. 바이트 수준 BPE는 [UNK]가 없지만, 한글 음절은 3바이트라 학습 데이터 비중이 작으면 한국어가 토큰을 더 많이 쓴다.
- 원-핫 × \(E\) = \(E\)의 한 행. 임베딩은 곱셈 없는 룩업이지만 역전파로 학습되는 가중치다. GPT-2 small 50,257 × 768 ≈ 38.6M, Llama 3 8B 128,256 × 4,096 ≈ 525M. 가중치 공유는 출력층과 테이블을 같이 쓴다.
- 분포 가설: 비슷한 문맥의 단어는 비슷한 의미다. 동시출현 → PPMI(흔한 단어 보정) → SVD가 세는 방법이고, word2vec(SGNS)은 같은 대상(이동된 PMI)을 예측으로 근사한다.
- 임베딩 공간에서 유사도는 코사인, 시각화는 PCA 같은 투영으로 한다. 투영은 정보를 버리므로 판단은 원래 차원에서 해야 한다.
- 의미 관계는 공간의 방향으로 나타나 a − b + c 유추가 가능하다. 하지만 입력 제외 규칙에 기대고, 실제 모델에서는 관계에 따라 잘 안 되는 경우가 많다.
- 임베딩은 숫자 목록이고 의미는 상대적 위치다. 공간 전체 회전에 불변이라 개별 축은 의미가 없고, 학습은 기울기가 벡터를 끌고 미는 과정이다.
- 정적 임베딩은 다의어를 구분하지 못한다. 트랜스포머는 어텐션으로 문맥 임베딩을 만들고, 풀링한 문장 임베딩은 코사인 검색(RAG)의 기반이 된다. 임베딩은 데이터의 편향도 함께 배운다.
확인 퀴즈
Q1. BPE 학습의 각 단계에서 새 토큰으로 병합하는 것은?
Q2. 바이트 수준 BPE 토크나이저에서 같은 내용의 한국어 문장이 영어보다 토큰을 더 많이 쓰는 주된 이유로 가장 알맞은 것은?
Q3. 어휘 128,256, 은닉 차원 4,096인 Llama 3 8B의 입력 임베딩 테이블 파라미터 수와, 가중치 공유를 하지 않을 때 출력층까지 합한 값은?
Q4. 동시출현 행렬에서 원시 횟수 대신 PPMI를 쓰는 이유는?
Q5. 유추 a − b + c에서 결과 후보에서 a, b, c를 제외하는 관례에 대해 옳은 설명은?
Q6. "배가 아프다"와 "배를 타고"의 "배"에 대해 옳은 것은?