Skip to main content
Summary

이전 장에서 우리는 텍스트 분류 모델을 만들며 nn.Embedding을 사용했습니다. 문장을 단어 인덱스의 목록으로 바꾸고, 그 인덱스를 모델에 넣자 갑자기 벡터가 튀어나왔습니다.

조금 이상하지 않았나요?

영화라는 단어가 17번이고, 재미있다라는 단어가 42번이라고 해 봅시다. 그렇다면 모델은 17과 42라는 숫자를 보고 단어의 의미를 이해한 걸까요? 당연히 아닙니다. 여기서 숫자는 계산할 값이 아니라 단어를 찾기 위한 번호표에 가깝습니다.

임베딩 레이어는 이 번호표를 받아서, 각 단어에 대응하는 학습 가능한 벡터를 꺼내 줍니다. 이 장의 핵심은 바로 이것입니다.

임베딩은 단어를 숫자 하나로 다루는 것이 아니라, 의미를 담을 수 있는 벡터 공간의 한 점으로 바꾸는 방법이다.

이 감각을 얻으면 LLM을 볼 때도 훨씬 덜 낯설어집니다. LLM도 결국 텍스트를 그대로 읽는 것이 아니라, 토큰을 벡터로 바꾸고, 그 벡터들 사이의 관계를 계산하며 문맥을 이해합니다.

임베딩 레이어 이해하기

숫자 인덱스는 의미가 아니다

먼저 가장 중요한 오해부터 정리해 봅시다.

다음과 같은 단어 사전이 있다고 하겠습니다.

vocab = {
    "<pad>": 0,
    "<unk>": 1,
    "영화": 2,
    "정말": 3,
    "재미있다": 4,
    "지루하다": 5,
    "배우": 6,
    "훌륭하다": 7,
}

여기서 영화는 2번이고, 재미있다는 4번입니다. 하지만 재미있다가 영화보다 두 배 크다는 뜻은 아닙니다. 지루하다가 5번이라고 해서 재미있다보다 더 강한 단어라는 뜻도 아닙니다.

정수 인덱스는 단지 사전에서 단어를 찾기 위한 주소입니다. 마치 아파트 204호와 405호가 있을 때, 405호 사람이 204호 사람보다 두 배 중요한 것이 아닌 것과 같습니다. 호수는 위치를 찾기 위한 약속일 뿐입니다.

그런데 신경망은 숫자로 계산합니다. 그렇다면 단어를 숫자로 바꿔야 합니다. 문제는 숫자 하나로 바꾸면 그 숫자에 원하지 않는 크기 관계가 생긴다는 점입니다.

모델이 영화 = 2, 재미있다 = 4, 지루하다 = 5를 그대로 입력받으면, 이 숫자들 사이에 어떤 순서나 거리의 의미가 있는 것처럼 계산할 수 있습니다. 하지만 단어 사전의 번호는 사람이 임의로 붙인 값입니다. 모델이 배워야 할 의미와는 직접적인 관련이 없습니다.

그래서 우리는 정수 인덱스를 그대로 모델의 의미 입력으로 쓰지 않습니다. 대신 인덱스를 이용해 벡터를 꺼냅니다. 그 역할을 하는 층이 바로 임베딩 레이어입니다.

원-핫 인코딩의 아이디어와 한계

임베딩을 이해하려면 먼저 원-핫 인코딩을 떠올리면 좋습니다.

단어가 8개 있는 사전에서 영화가 2번이라면, 원-핫 벡터는 다음처럼 표현할 수 있습니다.

영화 = [0, 0, 1, 0, 0, 0, 0, 0]

재미있다가 4번이라면 이렇게 됩니다.

재미있다 = [0, 0, 0, 0, 1, 0, 0, 0]

원-핫 인코딩의 장점은 분명합니다. 단어 번호의 크기 관계를 없애 줍니다. 영화 = 2라는 숫자 하나를 쓰는 대신, 전체 단어 사전에서 해당 단어 위치만 1로 켜진 벡터를 사용합니다. 이제 2와 4의 숫자 차이는 사라지고, 단어는 서로 다른 위치로만 구분됩니다.

하지만 원-핫 인코딩에는 큰 한계가 있습니다.

  • 단어 수가 많아질수록 벡터가 너무 길어집니다.

  • 대부분의 값이 0이라 계산이 비효율적입니다.

  • 단어 사이의 의미적 유사도를 표현하지 못합니다.

예를 들어 단어 사전에 50,000개의 단어가 있다면, 단어 하나를 표현하기 위해 길이 50,000짜리 벡터가 필요합니다. 그런데 그중 실제로 1인 값은 딱 하나뿐입니다. 나머지 49,999개는 모두 0입니다.

더 큰 문제는 의미입니다. 원-핫 벡터에서 재미있다와 훌륭하다는 완전히 다른 위치에 1이 켜져 있습니다. 재미있다와 지루하다도 완전히 다른 위치에 1이 켜져 있습니다. 원-핫 표현만 보면 두 경우 모두 똑같이 "다른 단어"일 뿐입니다.

하지만 우리는 압니다. 재미있다와 훌륭하다는 긍정적인 리뷰에서 함께 등장할 가능성이 높고, 지루하다는 반대 감정과 연결될 가능성이 높습니다. 모델도 이런 관계를 배워야 합니다.

원-핫 벡터는 단어를 구분하는 데는 좋지만, 단어의 의미적 거리를 담기에는 부족합니다.

밀집 벡터라는 작은 의미 지도

임베딩은 단어를 훨씬 짧은 벡터로 표현합니다. 예를 들어 단어 하나를 4차원 벡터로 표현한다고 해 보겠습니다.

영화     = [ 0.12, -0.33,  0.48,  0.07]
재미있다 = [ 0.81,  0.25, -0.14,  0.62]
지루하다 = [-0.76,  0.11,  0.55, -0.40]

이런 벡터를 밀집 벡터라고 부릅니다. 원-핫 벡터처럼 대부분이 0인 표현이 아니라, 여러 차원의 값이 촘촘하게 의미를 나누어 담습니다.

물론 처음부터 이 숫자들이 의미를 갖는 것은 아닙니다. 임베딩 벡터는 보통 무작위 값으로 시작합니다. 학습이 진행되면서 손실을 줄이는 방향으로 조금씩 바뀝니다.

여기서 흥미로운 일이 생깁니다. 비슷한 맥락에서 자주 쓰이는 단어들은 학습 과정에서 비슷한 방향으로 업데이트되는 경향이 있습니다. 그래서 어느 순간 벡터 공간 안에서 가까운 위치에 놓일 수 있습니다.

예를 들어 영화 리뷰 분류 모델을 학습한다고 생각해 봅시다.

이 영화 정말 재미있다  → 긍정
배우 연기가 훌륭하다    → 긍정
전개가 너무 지루하다    → 부정

모델은 긍정 리뷰를 맞히기 위해 재미있다, 훌륭하다 같은 단어 벡터를 긍정 예측에 도움이 되는 방향으로 조정합니다. 반대로 지루하다 같은 단어는 부정 예측에 도움이 되는 방향으로 조정합니다.

그 결과 임베딩 공간은 단어들의 작은 의미 지도가 됩니다. 지도에서 가까이 있는 도시들이 지리적으로 비슷한 위치에 있듯, 임베딩 공간에서 가까운 단어들은 모델이 보기에 비슷한 역할을 할 가능성이 높습니다.

다만 한 가지 조심해야 합니다. 임베딩 벡터의 각 차원이 항상 사람이 이해할 수 있는 명확한 뜻을 갖는 것은 아닙니다. 예를 들어 첫 번째 차원이 "긍정성", 두 번째 차원이 "속도감"처럼 깔끔하게 나뉘어 있다고 기대하면 안 됩니다. 의미는 여러 차원에 섞여 분산되어 있습니다. 중요한 것은 개별 숫자 하나가 아니라, 벡터 전체의 방향과 위치입니다.

임베딩 레이어는 벡터를 담은 표다

임베딩 레이어를 가장 쉽게 이해하는 방법은 벡터를 담은 표로 보는 것입니다.

단어 사전 크기가 8이고, 각 단어를 4차원 벡터로 표현한다면 임베딩 레이어 안에는 다음과 같은 표가 들어 있습니다.

단어 인덱스        임베딩 벡터
0 (<pad>)     →   [ 0.00,  0.00,  0.00,  0.00]
1 (<unk>)     →   [ 0.21, -0.15,  0.08,  0.44]
2 (영화)      →   [ 0.12, -0.33,  0.48,  0.07]
3 (정말)      →   [-0.05,  0.29, -0.31,  0.18]
4 (재미있다)  →   [ 0.81,  0.25, -0.14,  0.62]
5 (지루하다)  →   [-0.76,  0.11,  0.55, -0.40]
6 (배우)      →   [ 0.19, -0.22,  0.73, -0.09]
7 (훌륭하다)  →   [ 0.77,  0.31, -0.10,  0.58]

입력으로 영화의 인덱스인 2가 들어오면, 임베딩 레이어는 2번 행의 벡터를 꺼냅니다. 입력으로 [2, 3, 4]가 들어오면, 2번, 3번, 4번 행을 차례대로 꺼냅니다.

PyTorch에서는 이 표를 nn.Embedding으로 만듭니다.

import torch
import torch.nn as nn

vocab = {
    "<pad>": 0,
    "<unk>": 1,
    "영화": 2,
    "정말": 3,
    "재미있다": 4,
    "지루하다": 5,
    "배우": 6,
    "훌륭하다": 7,
}

embedding = nn.Embedding(
    num_embeddings=len(vocab),
    embedding_dim=4,
    padding_idx=0,
)

input_ids = torch.tensor([
    [2, 3, 4],  # 영화 정말 재미있다
    [6, 7, 0],  # 배우 훌륭하다 <pad>
])

vectors = embedding(input_ids)

print(vectors.shape)

출력 형태는 다음과 같습니다.

torch.Size([2, 3, 4])

이 모양을 천천히 읽어 봅시다.

입력은 문장 2개로 이루어진 배치입니다. 각 문장은 길이 3의 토큰 인덱스 목록입니다. 그리고 각 토큰은 4차원 벡터로 바뀝니다. 그래서 최종 모양은 [배치 크기, 문장 길이, 임베딩 차원], 즉 [2, 3, 4]가 됩니다.

이전 장에서 RNN이나 텍스트 분류 모델에 입력했던 텐서 모양이 바로 이 흐름을 따릅니다.

정수 인덱스 문장
[2, 3, 4]

        ↓ 임베딩 레이어

벡터 문장
[[...], [...], [...]]

텍스트 모델은 문자 그대로의 단어를 직접 읽지 않습니다. 먼저 각 토큰을 벡터로 바꾼 뒤, 그 벡터들의 순서와 조합을 계산합니다.

임베딩은 어떻게 학습될까

임베딩 레이어도 다른 층과 마찬가지로 파라미터를 갖습니다. 정확히 말하면 임베딩 표의 숫자 하나하나가 학습 대상입니다.

우리가 앞에서 반복해 온 딥러닝의 흐름을 다시 떠올려 봅시다.

모델이 예측한다
→ 틀린 정도를 손실로 계산한다
→ 어느 파라미터가 오차에 얼마나 영향을 줬는지 본다
→ 조금씩 수정한다
→ 반복한다

임베딩도 이 흐름에서 벗어나지 않습니다.

문장이 모델에 들어오면 임베딩 레이어는 해당 단어들의 벡터를 꺼냅니다. 그 벡터들은 RNN, CNN, MLP, Transformer 같은 다음 층으로 전달됩니다. 마지막에는 모델이 예측을 만들고, 정답과 비교해 손실을 계산합니다. 역전파가 일어나면 손실에 영향을 준 임베딩 벡터들도 함께 수정됩니다.

예를 들어 영화 정말 재미있다라는 문장이 긍정인데 모델이 부정이라고 예측했다고 해 봅시다. 손실이 커집니다. 역전파는 이 예측에 사용된 여러 파라미터를 향해 "이 방향은 틀렸으니 조금 고쳐야 해"라는 신호를 보냅니다. 이때 영화, 정말, 재미있다에 해당하는 임베딩 벡터도 업데이트됩니다.

중요한 점은 입력에 등장한 단어의 행만 주로 업데이트된다는 것입니다. 재미있다가 등장한 문장을 학습할 때는 재미있다의 벡터가 손실 계산에 참여합니다. 반면 그 배치에 등장하지 않은 단어의 벡터는 직접적인 업데이트 대상이 아닙니다.

아래 코드로 임베딩 레이어의 파라미터를 확인해 보겠습니다.

print(embedding.weight.shape)

결과는 다음과 같습니다.

torch.Size([8, 4])

사전 크기가 8이고 임베딩 차원이 4이므로, 임베딩 레이어는 [8, 4] 모양의 가중치 행렬을 가지고 있습니다. 이 행렬의 각 행이 하나의 단어 벡터입니다.

사실 원-핫 벡터와 행렬 곱으로도 임베딩을 설명할 수 있습니다. 영화의 원-핫 벡터에 임베딩 행렬을 곱하면, 결국 2번 행만 선택됩니다.

원-핫 벡터 × 임베딩 행렬 = 해당 단어의 임베딩 벡터

하지만 실제로는 거대한 원-핫 벡터를 매번 만들 필요가 없습니다. 인덱스를 이용해 필요한 행을 바로 꺼내는 편이 훨씬 효율적입니다. 그래서 nn.Embedding은 "원-핫 벡터를 만든 뒤 행렬 곱을 하는 과정"을 더 빠르고 간단하게 구현한 것처럼 볼 수 있습니다.

비슷한 단어가 가까워진다는 말의 의미

임베딩을 설명할 때 자주 나오는 말이 있습니다.

비슷한 의미의 단어는 임베딩 공간에서 가까워진다.

이 문장은 멋지지만, 처음 들으면 조금 막연합니다. 단어가 공간에서 가까워진다는 게 무슨 뜻일까요?

벡터는 좌표입니다. 2차원 벡터 [1.0, 2.0]은 평면의 한 점으로 볼 수 있고, 3차원 벡터 [1.0, 2.0, 3.0]은 입체 공간의 한 점으로 볼 수 있습니다. 임베딩 차원이 100이라면 사람이 직접 그릴 수는 없지만, 수학적으로는 100차원 공간의 한 점입니다.

두 단어 벡터가 비슷하다는 말은, 그 두 점의 위치나 방향이 비슷하다는 뜻입니다. 모델은 이 벡터들을 이용해 예측하므로, 비슷한 벡터를 가진 단어들은 모델 내부에서 비슷한 영향을 줄 가능성이 높습니다.

예를 들어 리뷰 분류 모델에서 다음 단어들이 있다고 해 봅시다.

재미있다, 훌륭하다, 최고다, 감동적이다

이 단어들은 긍정 리뷰에서 자주 등장합니다. 학습이 충분히 진행되면 이 단어들의 임베딩은 긍정 예측에 도움이 되는 방향으로 조정될 수 있습니다. 그래서 서로 가까운 위치에 모일 가능성이 있습니다.

반대로 다음 단어들은 다른 쪽에 모일 수 있습니다.

지루하다, 별로다, 실망스럽다, 최악이다

물론 현실의 언어는 훨씬 복잡합니다. 미쳤다 같은 단어는 상황에 따라 긍정일 수도 있고 부정일 수도 있습니다. 가볍다도 영화 리뷰에서는 "가볍게 보기 좋다"일 수도 있고 "내용이 가볍다"일 수도 있습니다.

그래서 좋은 언어 모델은 단어 하나만 보지 않습니다. 주변 단어, 문장 구조, 문맥을 함께 봅니다. 이 흐름이 나중에 Attention과 Transformer로 이어집니다. 하지만 그 출발점에는 여전히 임베딩이 있습니다. 단어든 토큰이든, 모델은 먼저 그것을 벡터로 바꿔야 합니다.

임베딩 차원은 얼마나 커야 할까

임베딩 차원은 단어 하나를 몇 개의 숫자로 표현할지 정하는 값입니다.

embedding = nn.Embedding(
    num_embeddings=vocab_size,
    embedding_dim=100,
)

여기서 embedding_dim=100이면 단어 하나가 100차원 벡터로 바뀝니다. 그렇다면 차원은 클수록 좋을까요?

항상 그렇지는 않습니다. 임베딩 차원이 커지면 더 많은 정보를 담을 수 있지만, 그만큼 학습해야 할 파라미터 수도 늘어납니다. 사전 크기가 50,000이고 임베딩 차원이 300이라면 임베딩 레이어의 파라미터 수는 다음과 같습니다.

50,000 × 300 = 15,000,000

임베딩 레이어 하나에만 1,500만 개의 숫자가 들어갑니다. 꽤 큰 표입니다.

작은 데이터셋에서 너무 큰 임베딩을 쓰면 모델이 훈련 데이터에만 과하게 맞춰질 수 있습니다. 반대로 너무 작은 임베딩을 쓰면 단어의 다양한 차이를 충분히 표현하지 못할 수 있습니다.

실전에서는 데이터 크기, 어휘 수, 모델 규모, 문제 난이도에 따라 적절한 값을 고릅니다. 처음 실습할 때는 32, 64, 128 정도의 작은 차원부터 시작해도 충분합니다. 중요한 것은 처음부터 거대한 임베딩을 쓰는 것이 아니라, 임베딩 차원이 모델의 표현 능력과 학습 비용 사이의 균형이라는 점을 이해하는 것입니다.

패딩 토큰은 조금 특별하다

텍스트 모델을 만들 때는 문장 길이를 맞추기 위해 패딩을 사용합니다. 이전 장에서도 길이가 짧은 문장 뒤에 <pad> 토큰을 붙였습니다.

영화 정말 재미있다
배우 훌륭하다 <pad>

<pad>는 실제 의미를 가진 단어가 아닙니다. 단지 배치 처리를 위해 빈칸을 채우는 용도입니다. 따라서 <pad>의 임베딩이 학습 과정에서 이상한 의미를 갖지 않도록 조심해야 합니다.

PyTorch의 nn.Embedding에는 padding_idx 옵션이 있습니다.

embedding = nn.Embedding(
    num_embeddings=len(vocab),
    embedding_dim=4,
    padding_idx=0,
)

이렇게 설정하면 0번 인덱스, 즉 <pad>에 해당하는 임베딩은 특별히 다루어집니다. 일반적으로 패딩 벡터는 0에 가깝게 유지되며, 학습 중 의미 있는 단어처럼 업데이트되지 않도록 처리됩니다.

패딩은 모델 입장에서 "여기는 실제 단어가 아닙니다"라는 표시입니다. 이 구분이 제대로 되지 않으면 모델은 문장의 빈칸까지 의미 있는 정보처럼 해석하려 할 수 있습니다.

나중에 Transformer를 배울 때도 패딩 처리는 중요하게 다시 등장합니다. Attention은 문장 안의 토큰들이 서로를 바라보는 구조인데, 이때 패딩 토큰까지 바라보면 안 되기 때문입니다. 그래서 마스크라는 장치를 사용해 패딩 위치를 가립니다. 지금은 우선 <pad>도 임베딩 테이블의 한 행이지만, 실제 의미를 가진 단어와는 다르게 다루어야 한다는 정도를 기억하면 됩니다.

작은 텍스트 분류 모델에서 임베딩 보기

이제 임베딩 레이어를 포함한 아주 작은 텍스트 분류 모델을 다시 만들어 보겠습니다. 구조는 단순하게 하겠습니다. 문장의 각 단어를 임베딩으로 바꾸고, 문장 방향으로 평균을 낸 뒤, 선형층으로 긍정과 부정을 분류합니다.

RNN을 쓰지 않아도 됩니다. 여기서는 임베딩 자체의 역할을 보는 것이 목적입니다.

import torch
import torch.nn as nn
import torch.optim as optim

sentences = [
    ["영화", "정말", "재미있다"],
    ["배우", "연기", "훌륭하다"],
    ["스토리", "정말", "감동적이다"],
    ["영화", "너무", "지루하다"],
    ["전개", "정말", "별로다"],
    ["연기", "너무", "어색하다"],
]

labels = torch.tensor([1, 1, 1, 0, 0, 0])  # 1: 긍정, 0: 부정

words = sorted({word for sent in sentences for word in sent})
vocab = {"<pad>": 0, "<unk>": 1}
for word in words:
    vocab[word] = len(vocab)

id_to_word = {idx: word for word, idx in vocab.items()}

encoded = []
for sent in sentences:
    encoded.append([vocab.get(word, vocab["<unk>"]) for word in sent])

x = torch.tensor(encoded)

class SimpleTextClassifier(nn.Module):
    def __init__(self, vocab_size, embed_dim, num_classes):
        super().__init__()
        self.embedding = nn.Embedding(vocab_size, embed_dim, padding_idx=0)
        self.fc = nn.Linear(embed_dim, num_classes)

    def forward(self, input_ids):
        embedded = self.embedding(input_ids)      # [batch, seq_len, embed_dim]
        pooled = embedded.mean(dim=1)             # [batch, embed_dim]
        logits = self.fc(pooled)                  # [batch, num_classes]
        return logits

model = SimpleTextClassifier(
    vocab_size=len(vocab),
    embed_dim=2,
    num_classes=2,
)

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.05)

for epoch in range(200):
    optimizer.zero_grad()
    logits = model(x)
    loss = criterion(logits, labels)
    loss.backward()
    optimizer.step()

    if (epoch + 1) % 50 == 0:
        pred = logits.argmax(dim=1)
        acc = (pred == labels).float().mean().item()
        print(f"epoch {epoch+1}, loss={loss.item():.4f}, acc={acc:.2f}")

여기서는 일부러 embed_dim=2로 두었습니다. 보통 실제 모델에서는 2차원 임베딩이 너무 작지만, 지금은 시각화하기 좋습니다. 2차원 벡터는 바로 평면 위에 그릴 수 있기 때문입니다.

학습이 끝나면 임베딩 가중치를 꺼내 봅시다.

embeddings = model.embedding.weight.detach()

for idx in range(len(vocab)):
    word = id_to_word[idx]
    vector = embeddings[idx]
    print(word, vector.tolist())

출력되는 숫자는 실행할 때마다 달라질 수 있습니다. 임베딩은 처음에 무작위로 시작하고, 학습 과정도 초기값의 영향을 받기 때문입니다. 하지만 충분히 학습되면 긍정 문맥의 단어들과 부정 문맥의 단어들이 어느 정도 구분되는 모습을 볼 수 있습니다.

임베딩을 2차원으로 시각화하기

이번에는 학습된 임베딩을 그림으로 그려 보겠습니다. 이미 임베딩 차원을 2로 만들었으므로 별도의 차원 축소 없이 바로 산점도를 그릴 수 있습니다.

import matplotlib.pyplot as plt

embeddings = model.embedding.weight.detach().numpy()

plt.figure(figsize=(8, 6))

for idx in range(2, len(vocab)):  # <pad>, <unk>는 제외
    word = id_to_word[idx]
    x_pos = embeddings[idx, 0]
    y_pos = embeddings[idx, 1]

    plt.scatter(x_pos, y_pos)
    plt.text(x_pos + 0.02, y_pos + 0.02, word, fontsize=12)

plt.axhline(0, color="gray", linewidth=0.5)
plt.axvline(0, color="gray", linewidth=0.5)
plt.title("Learned Word Embeddings")
plt.xlabel("dim 1")
plt.ylabel("dim 2")
plt.show()

그래프를 보면 단어들이 평면 위의 점으로 나타납니다. 운이 좋으면 재미있다, 훌륭하다, 감동적이다 같은 단어들이 한쪽에 모이고, 지루하다, 별로다, 어색하다 같은 단어들이 다른 쪽에 모이는 모습을 볼 수 있습니다.

물론 이 예제는 매우 작습니다. 문장도 적고, 단어도 적고, 모델도 단순합니다. 따라서 항상 아름다운 의미 지도가 나오는 것은 아닙니다. 하지만 원리는 분명합니다. 모델은 분류 문제를 풀기 위해 단어 벡터를 조정하고, 그 과정에서 예측에 비슷하게 기여하는 단어들이 비슷한 위치를 갖게 될 수 있습니다.

실제 임베딩은 보통 2차원보다 훨씬 큽니다. 50차원, 100차원, 768차원처럼 사람이 직접 그릴 수 없는 공간에 있습니다. 이럴 때는 PCA나 t-SNE 같은 차원 축소 방법을 사용해 2차원으로 줄여 시각화할 수 있습니다. 차원 축소는 고차원 벡터의 대략적인 관계를 낮은 차원에 펼쳐 보는 방법입니다.

예를 들어 임베딩 차원이 16인 모델을 학습한 뒤 PCA로 2차원 시각화를 할 수 있습니다.

from sklearn.decomposition import PCA
import matplotlib.pyplot as plt

# embed_dim=16인 모델을 학습했다고 가정합니다.
embeddings = model.embedding.weight.detach().numpy()

pca = PCA(n_components=2)
reduced = pca.fit_transform(embeddings)

plt.figure(figsize=(8, 6))

for idx in range(2, len(vocab)):
    word = id_to_word[idx]
    x_pos = reduced[idx, 0]
    y_pos = reduced[idx, 1]

    plt.scatter(x_pos, y_pos)
    plt.text(x_pos + 0.02, y_pos + 0.02, word, fontsize=12)

plt.title("Word Embeddings with PCA")
plt.xlabel("PC 1")
plt.ylabel("PC 2")
plt.show()

여기서 중요한 것은 그림을 너무 절대적으로 해석하지 않는 것입니다. 차원 축소는 고차원의 정보를 2차원으로 눌러 담는 과정이므로, 일부 관계는 왜곡될 수 있습니다. 그래도 임베딩이 단어를 단순한 번호가 아니라 벡터 공간의 위치로 다룬다는 사실을 눈으로 확인하기에는 아주 좋은 방법입니다.

임베딩은 텍스트에만 쓰이지 않는다

지금까지는 단어 임베딩을 중심으로 설명했습니다. 하지만 임베딩은 텍스트 전용 기술이 아닙니다. 핵심은 "범주를 벡터로 바꾼다"는 데 있습니다.

예를 들어 추천 시스템을 생각해 봅시다. 사용자 ID와 상품 ID도 숫자 인덱스로 표현할 수 있습니다. 하지만 사용자 1024번이 사용자 512번보다 두 배 특별한 것은 아닙니다. 상품 300번이 상품 100번보다 세 배 비싼 것도 아닙니다. 이 번호들도 단어 인덱스처럼 주소일 뿐입니다.

이때 사용자 ID를 사용자 임베딩으로, 상품 ID를 상품 임베딩으로 바꿀 수 있습니다. 학습이 진행되면 비슷한 취향을 가진 사용자는 가까운 벡터를 갖고, 비슷한 상품도 가까운 벡터를 가질 수 있습니다.

임베딩은 다음과 같은 곳에서 널리 쓰입니다.

  • 단어, 서브워드, 문장 같은 텍스트 표현

  • 사용자, 상품, 카테고리 같은 추천 시스템의 범주형 데이터

  • 노드, 관계, 아이템 같은 그래프 데이터

즉 임베딩은 "이산적인 기호를 연속적인 벡터 공간으로 옮기는 다리"입니다. 딥러닝 모델은 연속적인 숫자 벡터를 다루는 데 강하므로, 임베딩은 기호 세계와 신경망 세계를 연결해 줍니다.

LLM에서 임베딩은 어디에 있을까

이 책의 최종 목적지는 Transformer와 LLM입니다. 그렇다면 지금 배운 임베딩은 LLM에서 어떤 역할을 할까요?

LLM은 문장을 먼저 토큰으로 나눕니다. 토큰은 단어일 수도 있고, 단어보다 작은 조각일 수도 있습니다. 예를 들어 딥러닝이라는 단어가 하나의 토큰일 수도 있고, 상황에 따라 더 작은 조각으로 나뉠 수도 있습니다.

토큰화가 끝나면 각 토큰은 정수 인덱스로 바뀝니다.

"딥러닝은 재미있다"
→ [1284, 302, 9176, ...]

하지만 이 숫자들도 의미 그 자체는 아닙니다. 단어 사전에서 토큰을 찾기 위한 번호입니다. LLM은 이 번호를 임베딩 레이어에 넣어 토큰 벡터로 바꿉니다.

토큰 인덱스
→ 토큰 임베딩
→ Transformer 블록
→ 다음 토큰 예측

여기서 토큰 임베딩은 LLM의 첫 번째 관문입니다. 모델은 텍스트를 문자 그대로 보는 것이 아니라, 토큰 임베딩들의 흐름으로 봅니다.

다만 Transformer에서는 단어 임베딩만으로는 부족합니다. 문장 안에서 토큰의 순서도 알아야 합니다. 고양이가 개를 쫓았다와 개가 고양이를 쫓았다는 같은 단어를 포함하지만 뜻이 다릅니다. 그래서 Transformer는 토큰 임베딩에 위치 정보를 더합니다. 이것을 위치 임베딩 또는 위치 인코딩이라고 부릅니다.

나중에 Transformer 블록을 배울 때 이 흐름을 다시 만나게 됩니다.

토큰 임베딩 + 위치 정보
→ Self-Attention
→ Feed Forward Network
→ 다음 층으로 전달

지금은 먼저 단단히 기억해 둡시다. LLM도 시작은 임베딩입니다. 거대한 언어 모델의 첫걸음은 토큰 번호를 의미를 담을 수 있는 벡터로 바꾸는 일입니다.

임베딩을 볼 때 자주 하는 질문

임베딩을 처음 배우면 몇 가지 질문이 자연스럽게 떠오릅니다. 하나씩 정리해 보겠습니다.

임베딩 값은 사람이 직접 정하나요?

대부분의 경우 직접 정하지 않습니다. 임베딩은 무작위 값으로 시작해 학습 과정에서 자동으로 조정됩니다. 우리가 정하는 것은 주로 사전 크기, 임베딩 차원, 패딩 인덱스 같은 구조적 설정입니다.

학습된 임베딩을 다른 모델에서도 쓸 수 있나요?

쓸 수 있습니다. 큰 데이터로 미리 학습한 임베딩을 가져와 다른 작업에 사용하는 방식도 있습니다. 다만 이 책에서는 먼저 "내 모델 안에서 임베딩이 손실을 통해 학습된다"는 기본 원리를 이해하는 데 집중합니다.

임베딩이 가까우면 항상 의미가 비슷한가요?

항상 그렇지는 않습니다. 어떤 작업으로 학습했는지에 따라 가까움의 의미가 달라집니다. 감정 분류를 위해 학습한 임베딩에서는 감정적으로 비슷한 단어들이 가까워질 수 있고, 상품 추천을 위해 학습한 임베딩에서는 함께 구매되는 상품들이 가까워질 수 있습니다. 임베딩 공간은 학습 목표의 영향을 강하게 받습니다.

단어 하나에 의미가 여러 개 있으면 어떻게 하나요?

단순한 임베딩 레이어에서는 한 토큰에 보통 하나의 벡터가 대응합니다. 그래서 문맥에 따라 달라지는 의미를 완벽히 표현하기 어렵습니다. 예를 들어 배는 과일일 수도 있고, 사람의 신체 부위일 수도 있고, 물 위를 다니는 탈것일 수도 있습니다. 이런 문제를 더 잘 다루기 위해 문맥을 반영하는 모델이 필요합니다. RNN도 어느 정도 문맥을 반영할 수 있고, Transformer는 Attention을 통해 문맥 속에서 토큰 표현을 강력하게 갱신합니다.

손으로 따라가는 임베딩 업데이트 직관

마지막으로 임베딩이 학습되는 모습을 아주 단순하게 상상해 봅시다.

모델이 이 영화 재미있다를 보고 부정이라고 잘못 예측했습니다. 실제 정답은 긍정입니다. 손실이 커졌습니다. 역전파가 시작됩니다.

이때 모델 내부에서는 대략 이런 일이 벌어집니다.

긍정 확률을 더 높여야 한다.
이번 예측에 사용된 단어 벡터는 "이", "영화", "재미있다"였다.
이 벡터들이 다음번에는 긍정 쪽 점수를 조금 더 올리도록 수정하자.

반대로 전개가 지루하다를 보고 긍정이라고 잘못 예측했다면 이렇게 됩니다.

부정 확률을 더 높여야 한다.
이번 예측에 사용된 단어 벡터는 "전개", "지루하다"였다.
이 벡터들이 다음번에는 부정 쪽 점수를 조금 더 올리도록 수정하자.

이런 조정이 수많은 문장에서 반복됩니다. 한 번의 업데이트는 작습니다. 하지만 반복되면 단어 벡터들은 점점 자신의 역할에 맞는 위치로 이동합니다.

딥러닝은 한 번에 깨닫는 마법이 아닙니다. 틀리고, 손실을 계산하고, 어느 숫자를 얼마나 고칠지 보고, 조금씩 수정하는 반복입니다. 임베딩도 예외가 아닙니다. 단어의 의미처럼 보이는 것도 결국 이 반복 속에서 만들어집니다.

이번 장에서 기억할 것

임베딩 레이어는 텍스트 모델과 LLM을 이해하는 데 꼭 필요한 다리입니다. 정수 인덱스는 단어의 의미가 아니라 사전에서 단어를 찾기 위한 번호입니다. 임베딩 레이어는 이 번호를 받아 학습 가능한 밀집 벡터를 꺼내 줍니다.

원-핫 인코딩은 단어 번호의 잘못된 크기 관계를 없애 주지만, 벡터가 너무 길고 단어 사이의 유사성을 표현하기 어렵습니다. 반면 임베딩은 더 짧은 벡터 안에 모델이 필요로 하는 의미 관계를 담을 수 있습니다.

학습 과정에서 임베딩 벡터는 손실을 줄이는 방향으로 업데이트됩니다. 비슷한 맥락에서 비슷한 역할을 하는 단어들은 벡터 공간에서 가까워질 수 있습니다. 이 공간을 2차원으로 시각화하면, 모델이 단어들을 어떻게 배치했는지 어렴풋이 관찰할 수 있습니다.

그리고 무엇보다 중요한 사실이 있습니다.

LLM도 토큰을 바로 이해하지 않습니다. 먼저 토큰을 임베딩 벡터로 바꿉니다. 그다음 Transformer가 이 벡터들 사이의 관계를 계산합니다.

이제 우리는 텍스트가 신경망 안으로 들어가는 첫 번째 문을 열었습니다. 다음 장부터는 좋은 모델을 만들기 위한 중요한 질문으로 넘어갑니다. 모델이 훈련 데이터는 잘 맞히는데 새로운 데이터에는 약하다면 무슨 일이 벌어진 것일까요? 그 현상을 우리는 과적합이라고 부릅니다.

책 목록

처음 만나는 딥러닝: 신경망에서 Transformer와 LLM까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기