12  텍스트 표현: 토큰화와 임베딩

학습 목표
  • 텍스트가 이미지보다 다루기 까다로운 세 가지 이유를 설명할 수 있다
  • 토큰화를 정의하고, BPE 병합 절차를 직접 따라 계산할 수 있다
  • 원-핫 인코딩이 단어 표현에 실패하는 두 가지 이유를 설명할 수 있다
  • 임베딩 벡터가 사람이 정하는 값이 아니라 학습되는 파라미터임을 설명할 수 있다
  • 두 임베딩 벡터의 내적과 코사인 유사도를 직접 계산할 수 있다
  • 임베딩 공간의 벡터 연산 성질(왕 − 남자 + 여자 ≈ 여왕)을 확인할 수 있다
  • RNN의 핵심 아이디어와 두 가지 한계를 설명할 수 있다

12.1 컴퓨터는 글자를 어떻게 읽는가

Ch09 마지막에 던진 질문에서 시작하자. 우리는 이미 한 번 이 문제를 풀어 봤다. Ch05에서 이미지는 픽셀 숫자의 격자가 되었고, 그 순간부터 Ch02의 신경망이 이미지를 먹을 수 있게 되었다. 텍스트도 똑같은 관문 앞에 서 있다 — 신경망은 숫자만 먹는다. 글자를 숫자로 바꿔야 한다.

그런데 텍스트는 이미지보다 까다롭다. 세 가지 이유 때문이다.

이미지 (Ch05) 텍스트
값의 성질 픽셀은 연속값 (0~255, 밝기를 조금씩 바꿀 수 있다) 단어는 이산적 기호 (“고양이”와 “고양시” 사이의 중간 단어는 없다)
길이 크기를 224×224로 맞추면 끝 문장마다 길이가 다르다 — 3단어짜리도, 300단어짜리도 있다
순서 픽셀을 좌우 반전해도 고양이는 고양이 순서가 의미를 바꾼다

세 번째가 특히 무섭다. 다음 두 문장은 완전히 같은 단어들로 이루어져 있다:

“개가 사람을 물었다” — 흔한 일

“사람이 개를 물었다” — 뉴스에 나올 일

단어 목록만 보면 구별할 수 없다. 순서까지 표현해야 진짜 텍스트 표현이다.

이번 장의 여정은 세 단계다: 글자를 조각내고(토큰화), 조각을 좌표로 바꾸고(임베딩), 마지막으로 순서를 다루는 첫 시도(RNN)와 그 한계까지 간다. 그 한계가 다음 장의 트랜스포머를 부른다.


12.2 토큰화: 텍스트를 조각내기

12.2.1 무엇을 한 단위로 셀 것인가

숫자로 바꾸기 전에 먼저 정해야 할 것이 있다. 텍스트의 최소 단위는 무엇인가?

정의: 토큰화 (Tokenization)

텍스트를 모델이 처리할 최소 단위(토큰, token)의 나열로 쪼개는 절차. 쪼개진 각 토큰은 어휘 사전(vocabulary)의 번호 하나에 대응된다.

가장 단순한 방법은 띄어쓰기로 자르는 단어 단위 토큰화다. 그런데 두 가지 문제에 부딪힌다.

문제 1 — 어휘 폭발: 한국어에서는 조사가 단어에 붙는다.

학교에 / 학교를 / 학교가 / 학교에서 / 학교로 / 학교까지 …

의미의 핵심은 전부 “학교”인데, 띄어쓰기 기준으로는 전부 다른 단어다. 명사 하나마다 조사 조합만큼 어휘가 불어나고, 어휘 사전이 수십만 개로 폭발한다.

문제 2 — 미등록 단어(OOV, Out-of-Vocabulary): 사전에 없는 단어가 오면 처리할 방법이 없다. 신조어(“갓생”), 새 상품명, 오타 — 세상은 사전이 갱신되는 속도보다 빨리 새 단어를 만든다.

12.2.2 서브워드: 자주 나오는 조각으로 쪼갠다

해법의 아이디어는 단순하다. 단어보다 작고 글자보다 큰, “자주 나오는 조각”을 단위로 쓰자. “학교에”를 “학교” + “에”로 쪼개면, “학교”라는 조각 하나로 수십 개의 변형을 전부 커버한다. 처음 보는 단어도 아는 조각들의 조합으로 표현할 수 있으니 미등록 단어 문제도 사라진다.

그럼 “자주 나오는 조각”을 누가 정하는가? 사람이 아니라 데이터가 정한다. 대표적 절차가 BPE(Byte Pair Encoding)다.

정의: BPE (Byte Pair Encoding)

글자 단위에서 시작해, 가장 자주 등장하는 인접 쌍을 하나의 새 토큰으로 병합하는 것을 원하는 어휘 크기가 될 때까지 반복하는 절차. 자주 붙어 다니는 글자들이 자동으로 한 조각이 된다.

12.2.3 손계산: BPE 병합 절차 따라가기

장난감 문자열 aaabdaaabac에서 병합 3스텝을 직접 따라가 보자. 각 스텝은 똑같다: 인접 쌍의 빈도를 세고 → 최다 빈도 쌍을 새 기호로 병합한다.

코드
import pandas as pd

pd.DataFrame([
    {'단계': 0, '토큰열': 'a a a b d a a a b a c', '토큰 수': 11,
     '최다 빈도 쌍': '(a, a) — 4회', '병합 규칙': 'aa → Z'},
    {'단계': 1, '토큰열': 'Z a b d Z a b a c', '토큰 수': 9,
     '최다 빈도 쌍': '(a, b) — 2회', '병합 규칙': 'ab → Y'},
    {'단계': 2, '토큰열': 'Z Y d Z Y a c', '토큰 수': 7,
     '최다 빈도 쌍': '(Z, Y) — 2회', '병합 규칙': 'ZY → X'},
    {'단계': 3, '토큰열': 'X d X a c', '토큰 수': 5,
     '최다 빈도 쌍': '(모든 쌍 1회 — 종료)', '병합 규칙': ''},
]).to_html(index=False, border=0)
표 12.1: BPE 손계산 — 문자열 aaabdaaabac, 병합 3스텝
단계 토큰열 토큰 수 최다 빈도 쌍 병합 규칙
0 a a a b d a a a b a c 11 (a, a) — 4회 aa → Z
1 Z a b d Z a b a c 9 (a, b) — 2회 ab → Y
2 Z Y d Z Y a c 7 (Z, Y) — 2회 ZY → X
3 X d X a c 5 (모든 쌍 1회 — 종료)
  • 단계 1에서 (Z, a)도 2회로 동률이다 — 동률이면 미리 정한 규칙(예: 먼저 나온 쌍)으로 하나를 고른다
  • 병합할 때마다 어휘 사전에 새 토큰이 추가된다: {a, b, c, d} → {a, b, c, d, Z, Y, X}
  • 실제로는 이 절차를 수십억 글자의 말뭉치 위에서 수만 번 반복한다 — 그러면 Z, Y, X 자리에 “학교”, “니다”, “ing” 같은 진짜 자주 쓰이는 조각들이 들어선다

실제 LLM의 토크나이저에 한국어 문장을 넣으면 대략 이런 식으로 쪼개진다:

“깊은 학습은 즐겁다” → ["깊", "은", " 학", "습", "은", " 즐", "겁", "다"]

자주 쓰이는 조각은 통째로, 드문 조각은 잘게 — 데이터의 빈도가 그대로 칼자국이 된 모습이다.


12.3 숫자로 바꾸기 1차 시도: 원-핫의 실패

토큰을 얻었으니 이제 숫자로 바꾸자. 우리에게는 이미 도구가 하나 있다. Ch04에서 브랜드 {삼성, 애플, LG}를 원-핫 인코딩으로 바꿨었다 — 범주 3개, 0/1 열 3개, 깔끔하게 끝났다.

같은 방법을 단어에 쓰면 어떻게 될까? 어휘 사전이 50,000개라면?

실패 1 — 차원의 낭비: 단어 하나가 50,000차원 벡터가 된다. 그중 49,999개는 0이고 딱 하나만 1인 희소(sparse) 벡터다. 20단어짜리 문장 하나가 숫자 100만 개 — 거의 전부가 0인 데이터를 쌓는 셈이다. Ch04에서 경고했던 “범주가 수백 개인 변수는 파라미터를 수백 배로 늘린다”가 범주 5만 개에서 폭발한 것이다.

실패 2 — 유사성의 실종: 더 근본적인 문제다. 서로 다른 두 원-핫 벡터의 내적은 항상 0이다. 1이 서 있는 자리가 다르니 곱해서 더하면 반드시 0이 나온다.

\[\text{고양이} \cdot \text{강아지} = 0, \qquad \text{고양이} \cdot \text{미분방정식} = 0\]

원-핫의 세계에서 “고양이”와 “강아지”는 “고양이”와 “미분방정식”만큼 멀다. 모든 단어가 모든 단어와 똑같이 무관하다. 하지만 우리는 안다 — 고양이와 강아지는 분명히 비슷한 무언가를 공유한다. 유사성을 표현할 수 없는 표현은 단어의 표현으로 실격이다.

원-핫은 “서로 다르다”만 말할 수 있고, “얼마나 비슷한가”는 말할 수 없다. 우리에게 필요한 것은 후자다.


12.4 임베딩: 단어를 좌표로

정의: 임베딩 (Embedding)

단어(토큰)를 낮은 차원의 밀집(dense) 벡터 — 즉 연속 공간의 좌표 — 로 대응시키는 것. 50,000차원 희소 벡터 대신 수백 차원의 실수 벡터를 쓰며, 비슷한 의미의 단어는 가까운 좌표에 놓이도록 한다.

핵심 질문: 그 좌표는 누가 정하는가? 사람이 “고양이는 (3, 4)로 하자”라고 정하는 것이 아니다. 임베딩 값은 학습되는 파라미터다 — Ch02에서 만난 가중치 \(w\)와 완전히 같은 지위다. 처음에는 무작위 값으로 시작하고, 손실을 줄이는 방향으로 경사하강법이 조금씩 옮겨 놓는다. “고양이”와 “강아지”가 비슷한 문맥에 자꾸 등장하면, 학습이 두 좌표를 점점 가까이 끌어다 놓는 것이다.

구현은 의외로 소박하다. 룩업 테이블(lookup table) 하나면 된다:

  • 어휘 50,000개 × 임베딩 차원 256 크기의 행렬을 만든다 (파라미터 1,280만 개)
  • 토큰 번호 \(i\)가 들어오면 → 행렬의 \(i\)번째 행을 꺼내 준다. 그게 전부다
토큰 번호 단어 임베딩 벡터 (행렬의 해당 행)
0 고양이 \((0.31, -1.20, 0.88, \ldots)\)
1 강아지 \((0.29, -1.15, 0.91, \ldots)\)
2 자동차 \((-2.01, 0.44, -0.37, \ldots)\)

원-핫 벡터에 행렬을 곱하면 정확히 그 행이 나온다 — 룩업 테이블은 “원-핫 × 행렬”을 낭비 없이 구현한 것뿐이다. 1차 시도가 완전히 버려진 게 아니라 학습 가능한 행렬 하나를 사이에 끼운 것이 임베딩이다.


12.5 유사도 손계산: 내적과 코사인

“비슷한 단어는 가까운 좌표”라고 했다. 그럼 가깝다를 숫자로 재는 도구가 필요하다. 두 가지를 쓴다.

정의: 내적과 코사인 유사도 (Dot Product & Cosine Similarity)

두 벡터 \(\mathbf{a} = (a_1, a_2)\), \(\mathbf{b} = (b_1, b_2)\)에 대해

\[\mathbf{a} \cdot \mathbf{b} = a_1 b_1 + a_2 b_2, \qquad \cos\theta = \frac{\mathbf{a} \cdot \mathbf{b}}{\|\mathbf{a}\| \, \|\mathbf{b}\|}, \qquad \|\mathbf{a}\| = \sqrt{a_1^2 + a_2^2}\]

  • 코사인 유사도는 두 벡터 사이 각도만 본다: 같은 방향 +1, 수직 0, 반대 방향 −1
  • 내적은 방향뿐 아니라 벡터의 크기에도 영향을 받는다

12.5.1 손계산

실제 임베딩은 수백 차원이지만, 계산 원리는 2차원과 똑같다. 장난감 임베딩 4개로 직접 계산해 보자:

\[\text{고양이} = (3, 4), \quad \text{강아지} = (4, 3), \quad \text{호랑이} = (6, 8), \quad \text{자동차} = (4, -3)\]

예를 들어 고양이·강아지 쌍은:

\[\mathbf{a} \cdot \mathbf{b} = 3 \times 4 + 4 \times 3 = 24, \qquad \|\text{고양이}\| = \sqrt{9+16} = 5, \quad \|\text{강아지}\| = 5\]

\[\cos\theta = \frac{24}{5 \times 5} = 0.96\]

코드
import pandas as pd
import numpy as np

vecs = {
    '고양이': np.array([3, 4]),
    '강아지': np.array([4, 3]),
    '호랑이': np.array([6, 8]),
    '자동차': np.array([4, -3]),
}

pairs = [('고양이', '강아지'), ('고양이', '호랑이'), ('강아지', '호랑이'),
         ('고양이', '자동차'), ('강아지', '자동차')]

fmt = lambda v: f'({v})' if v < 0 else f'{v}'

rows = []
for w1, w2 in pairs:
    a, b = vecs[w1], vecs[w2]
    dot = int(a @ b)
    na, nb = np.linalg.norm(a), np.linalg.norm(b)
    rows.append({
        '쌍': f'{w1} · {w2}',
        '내적 계산': f'${a[0]}\\times{fmt(b[0])} + {a[1]}\\times{fmt(b[1])} = {dot}$',
        '$\\|\\mathbf{a}\\|$': f'{na:.0f}', '$\\|\\mathbf{b}\\|$': f'{nb:.0f}',
        '코사인': f'**{dot / (na * nb):.2f}**',
    })

pd.DataFrame(rows).to_html(index=False, border=0, escape=False)
표 12.2: 유사도 손계산 — 장난감 임베딩 4개의 내적·크기·코사인
내적 계산 \(\\|\\mathbf{a}\\|\) \(\\|\\mathbf{b}\\|\) 코사인
고양이 · 강아지 \(3\\times4 + 4\\times3 = 24\) 5 5 0.96
고양이 · 호랑이 \(3\\times6 + 4\\times8 = 50\) 5 10 1.00
강아지 · 호랑이 \(4\\times6 + 3\\times8 = 48\) 5 10 0.96
고양이 · 자동차 \(3\\times4 + 4\\times(-3) = 0\) 5 5 0.00
강아지 · 자동차 \(4\\times4 + 3\\times(-3) = 7\) 5 5 0.28

숫자가 우리의 직관을 그대로 재현한다:

  • 고양이·강아지 = 0.96 — 거의 같은 방향
  • 고양이·자동차 = 0.00 — 정확히 수직, 무관한 사이
  • 고양이·강아지(0.96) > 고양이·자동차(0.00) — 원-핫이 못 하던 일이 좌표 하나 바꿨더니 된다

12.5.2 왜 내적이 아니라 코사인을 쓰는 경우가 많은가

표에서 이상한 점을 하나 발견했는가? 강아지·호랑이의 내적은 48로 고양이·강아지(24)의 두 배인데, 코사인은 0.96으로 똑같다. 호랑이 벡터가 \((6,8)\)로 크기가 10 — 다른 벡터들의 두 배이기 때문이다. 내적은 방향이 같아도 벡터가 길면 커진다. 실제 임베딩에서 벡터의 크기는 단어의 등장 빈도 같은 부수적 요인에 흔들리기 쉬워서, “의미가 얼마나 같은 방향인가”만 보고 싶을 때는 크기로 나눠 정규화한 코사인을 쓴다. 반대로 크기 정보까지 활용하고 싶은 계산(다음 장의 어텐션이 그렇다)에서는 내적을 그대로 쓴다.


인터랙티브 — 임베딩 지도에서 단어 사이의 각도를 재 보세요

14개 단어가 배치된 2차원 임베딩 지도입니다. 단어 두 개를 고르면 원점에서 두 벡터가 화살표로 그려지고 코사인 유사도가 계산됩니다. 같은 무리(동물, 탈것, 과일) 안에서 고를 때와 무리를 건너 고를 때 값이 어떻게 달라지는지 확인해 보세요.

시도해 보세요: ① 고양이-강아지(0.96) → 고양이-자동차(0.00) → 고양이-미분방정식(−0.99) 순서로 골라 보세요. 유사도가 +1에서 −1까지 미끄러져 내려갑니다. ② 고양이-호랑이를 고르면 코사인이 정확히 1.000 — 크기는 두 배 달라도 방향이 같으면 유사도는 만점입니다.


12.6 임베딩 공간의 성질: 의미가 기하학이 된다

좋은 임베딩 공간에서는 놀라운 일이 벌어진다. 단어 사이의 의미 관계벡터의 방향으로 나타나는 것이다. 가장 유명한 예:

\[\vec{\text{왕}} - \vec{\text{남자}} + \vec{\text{여자}} \approx \vec{\text{여왕}}\]

“왕에서 남자를 빼고 여자를 더하면 여왕이 된다” — 성별이라는 의미의 차이가 공간에서 일정한 방향의 이동으로 표현되기 때문이다. 장난감 2D 좌표로 산술을 직접 확인해 보자. 가로축이 성별, 세로축이 신분을 나타내도록 배치된 임베딩이라면:

\[\text{왕} = (4, 7), \quad \text{여왕} = (1, 7), \quad \text{남자} = (4, 2), \quad \text{여자} = (1, 2)\]

\[\vec{\text{왕}} - \vec{\text{남자}} + \vec{\text{여자}} = (4-4+1,\; 7-2+2) = (1, 7) = \vec{\text{여왕}}\]

“남자→여자” 이동은 어느 신분에서 출발하든 똑같이 \((-3, 0)\)이다. 같은 원리로 실제 학습된 임베딩에서는 “서울 − 한국 + 일본 ≈ 도쿄”, “walked − walk + swim ≈ swam” 같은 관계가 발견된다.

아무도 임베딩에게 “성별”이나 “수도”라는 개념을 가르치지 않았다. 손실을 줄이는 학습만 시켰을 뿐인데, 의미의 구조가 공간의 구조로 떠오른 것이다. 단어를 좌표로 만든 순간, 의미를 다루는 일이 기하학이 되었다.


12.7 순서라는 마지막 문제

여기까지 정리하자. 토큰화가 글자를 조각냈고, 임베딩이 조각을 좌표로 바꿨다. 문장은 이제 벡터의 나열이다. 하지만 첫 절의 세 번째 난관이 그대로 남아 있다 — “개가 사람을 물었다”와 “사람이 개를 물었다”는 같은 벡터들의 다른 나열이다. 벡터를 그냥 합치거나 평균 내면 순서 정보가 증발한다. 순서를 읽는 장치가 필요하다.

12.7.1 RNN: 요약 메모를 옆으로 넘기며 읽는다

RNN(Recurrent Neural Network, 순환 신경망)의 아이디어는 사람이 글을 읽는 방식과 닮았다. 왼쪽에서 오른쪽으로 한 토큰씩 읽되, “지금까지 읽은 내용의 요약”을 상태(state) \(h\)에 담아 다음 칸으로 넘긴다. 매 시점의 입력은 두 개다: 지금 읽는 토큰, 그리고 직전까지의 요약. 출력도 두 개다: 필요하면 예측, 그리고 갱신된 요약.

RNN RNN RNN RNN h₁ h₂ h₃ 나는 어제 영화를 봤다 h₄ = 문장 전체의 요약 → 예측 “나는”까지 기억 “나는 어제”까지 기억 세 단어까지 기억

토큰이 들어오는 순서가 요약 \(h\)에 새겨지므로, “개가 사람을”과 “사람이 개를”은 다른 \(h\)를 만든다. 순서 문제가 드디어 풀린 것처럼 보인다. 게다가 매 칸이 같은 신경망을 재사용하므로 문장이 3단어든 300단어든 같은 구조로 처리한다 — 가변 길이 문제도 함께 풀린다.

12.7.2 그러나 두 가지 한계

한계 1 — 먼 기억이 흐려진다 (장기 의존성 문제). \(h\)는 크기가 고정된 요약 메모다. 새 토큰이 들어올 때마다 메모를 덮어쓰며 갱신하는데, 문장이 길어지면 앞쪽 단어의 흔적은 갱신을 거듭하며 점점 희미해진다. 전언 게임에서 첫 사람의 말이 열 번 건너면 뭉개지는 것과 같다.

어릴 때 프랑스에서 자란 그 학생은, 대학에서 통계학을 전공하고 회사에서 삼 년을 일한 뒤, 유창한 ___를 살려 해외 지사로 발령받았다.”

빈칸에 “프랑스어”를 넣으려면 문장 맨 앞의 기억이 필요하다. RNN은 수십 토큰을 거쳐 온 그 기억을 자주 잃어버린다.

한계 2 — 병렬화가 불가능하다. \(h_2\)를 계산하려면 \(h_1\)이 먼저 나와야 하고, \(h_3\)\(h_2\)를 기다려야 한다. 본질적으로 한 단어씩 순서대로만 처리할 수 있다. 그런데 Ch01과 Ch07에서 봤듯 딥러닝의 엔진은 GPU이고, GPU의 힘은 수천 개의 계산을 동시에 해치우는 데서 나온다. 인터넷 전체 규모의 텍스트로 모델을 키우려는 시대에, “한 칸씩 차례로”는 치명적인 병목이다.

기억을 오래 유지하려는 개선안(LSTM이라는 구조가 대표적이다)이 나와 첫 번째 한계를 상당히 완화했지만, 근본 해결은 아니었다 — 특히 두 번째 한계, 순차 처리라는 태생은 그대로였다.

깊이 계약 확인

이 장에서 문제집과 시험이 다루는 것은 토큰화 절차(BPE 병합 따라가기)임베딩 유사도 계산(내적·코사인)까지다. BPE의 전체 알고리즘 구현, RNN의 내부 수식, LSTM의 게이트 구조는 범위 밖이다 — RNN은 “요약을 옆으로 넘긴다”는 아이디어와 두 한계만 설명할 수 있으면 된다.


12.8 다음 장으로 가는 질문

RNN의 두 한계를 나란히 놓고 보면, 해법의 조건이 저절로 읽힌다.

  • 먼 기억이 흐려지는 이유 → 모든 것을 하나의 요약에 눌러 담아 릴레이하기 때문
  • 병렬화가 안 되는 이유 → 앞 칸이 끝나야 뒷 칸을 시작하기 때문

둘 다 “순서대로 한 칸씩”이라는 설계 자체에서 나온 문제다. 그렇다면 —

문장의 모든 단어를 동시에 보되, 지금 필요한 단어에 집중할 수는 없을까? 빈칸을 채울 때 “프랑스에서”를 릴레이 없이 곧바로 돌아볼 수는 없을까?

이 질문에 대한 답이 어텐션(Attention)이고, 어텐션으로 지은 건축물이 트랜스포머(Transformer) — 오늘날 모든 LLM의 뼈대다. 다음 장에서 만난다. 그리고 그 어텐션의 핵심 계산이 바로 오늘 손으로 연습한 벡터의 내적이다.


12.9 핵심 요약

개념 핵심 내용
텍스트의 어려움 이산적 기호, 가변 길이, 순서가 의미를 바꿈 — 이미지(연속·고정·순서 무관)보다 까다롭다
토큰화 텍스트를 최소 단위(토큰)로 쪼개기 — 단어 단위는 어휘 폭발·미등록 단어로 실패
BPE 최다 빈도 인접 쌍을 반복 병합 — “자주 나오는 조각”을 데이터가 스스로 정한다
원-핫의 실패 5만 차원 희소 벡터의 낭비 + 모든 단어 쌍의 내적이 0 (유사성 표현 불가)
임베딩 단어 → 저차원 밀집 벡터(좌표). 값은 학습되는 파라미터, 구현은 룩업 테이블
내적·코사인 \(\mathbf{a}\cdot\mathbf{b} = \sum a_i b_i\), \(\cos\theta = \mathbf{a}\cdot\mathbf{b}/(\|\mathbf{a}\|\|\mathbf{b}\|)\) — 코사인은 크기를 정규화해 방향만 본다
임베딩 공간의 성질 왕 − 남자 + 여자 ≈ 여왕 — 의미 관계가 벡터 방향으로 나타난다
RNN 요약 상태 \(h\)를 앞에서 뒤로 넘기며 읽기 — 순서·가변 길이를 처리
RNN의 두 한계 먼 기억이 흐려진다(장기 의존성) + 순차 처리라 병렬화 불가(GPU 시대에 치명적)

다음 장 예고: 모든 단어를 동시에 보고, 필요한 단어에 집중한다 — 어텐션과 트랜스포머. 오늘 배운 내적이 “얼마나 집중할지”를 계산하는 자가 된다.