벡터 임베딩

벡터 임베딩

OpenAI의 텍스트 임베딩은 텍스트 문자열 간의 연관성을 숫자로 측정해요. 자연어를 벡터라는 숫자 배열로 바꿔 주니까, 검색·클러스터링·추천·이상 탐지 같은 작업에서 텍스트의 의미를 다루기 쉬워지죠. 이 가이드에서는 임베딩이 뭔지, 어떻게 얻는지, 그리고 실제 데이터로 어떤 일을 할 수 있는지 차례대로 살펴볼게요.

출처: 공식문서

임베딩이란 무엇인가?

OpenAI의 텍스트 임베딩은 텍스트 문자열의 연관성을 측정해요. 흔한 용도는 이렇습니다.

  • 검색(쿼리 문자열과의 관련성으로 결과 정렬)
  • 클러스터링(유사도로 텍스트 문자열 그룹화)
  • 추천(관련 텍스트를 가진 항목 추천)
  • 이상 탐지(연관성이 낮은 이상점 식별)
  • 다양성 측정(유사도 분포 분석)
  • 분류(가장 유사한 레이블로 텍스트 분류)

임베딩은 부동소수점 숫자의 벡터(리스트)예요. 두 벡터 사이의 거리가 연관성을 측정하고, 거리가 작을수록 연관성이 높고 클수록 낮다고 봐요. 임베딩 가격은 가격 페이지에서 확인할 수 있고, 요청은 입력의 토큰 수 기준으로 과금됩니다.

임베딩 얻기

임베딩을 얻으려면 텍스트 문자열을 임베딩 모델 이름(예: text-embedding-3-small)과 함께 임베딩 API 엔드포인트로 보내면 돼요.

from openai import OpenAI

client = OpenAI()

response = client.embeddings.create(
    input="Your text string goes here", model="text-embedding-3-small"
)

print(response.data[0].embedding)
import OpenAI from "openai";
const openai = new OpenAI();

const embedding = await openai.embeddings.create({
  model: "text-embedding-3-small",
  input: "Your text string goes here",
  encoding_format: "float",
});

console.log(embedding);
curl https://api.openai.com/v1/embeddings \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ***" \
  -d '{
    "input": "Your text string goes here",
    "model": "text-embedding-3-small"
  }'

응답에는 임베딩 벡터(부동소수점 숫자 리스트)와 추가 메타데이터가 들어 있어요. 임베딩 벡터를 추출해서 벡터 DB에 저장하고 다양한 사용 사례에 활용할 수 있습니다.

{
  "object": "list",
  "data": [
    {
      "object": "embedding",
      "index": 0,
      "embedding": [
        -0.006929283495992422, -0.005336422007530928, -4.547132266452536e-5,
        -0.024047505110502243
      ]
    }
  ],
  "model": "text-embedding-3-small",
  "usage": {
    "prompt_tokens": 5,
    "total_tokens": 5
  }
}

기본적으로 임베딩 벡터의 길이는 text-embedding-3-small1536, text-embedding-3-large3072예요. 개념 표현 속성을 잃지 않으면서 차원을 줄이려면 dimensions 파라미터를 넘기면 되고, 자세한 내용은 아래 사용 사례 섹션에서 다뤄요.

임베딩 모델

OpenAI는 두 개의 강력한 3세대 임베딩 모델(모델 ID의 -3이 이를 나타냄)을 제공합니다. 자세한 내용은 임베딩 v3 출시 블로그 글을 읽어 보는 걸 추천해요. 사용량은 입력 토큰당 과금되고, 아래는 페이지당 약 800토큰을 가정했을 때 달러당 처리할 수 있는 페이지 수 예시예요.

모델 ~ 달러당 페이지 MTEB 평가 성능 최대 입력
text-embedding-3-small 62,500 62.3% 8192
text-embedding-3-large 9,615 64.6% 8192
text-embedding-ada-002 12,500 61.0% 8192

사용 사례

대표적인 사용 사례를 Amazon 미식품 리뷰 데이터셋으로 살펴볼게요. 이 데이터셋은 2012년 10월까지 Amazon 사용자가 남긴 총 568,454개의 음식 리뷰로 이루어져 있고, 여기서는 최근 리뷰 1,000개 하위 집합을 씁니다. 리뷰는 영어이며 긍정적이거나 부정적인 경향이 있어요. 각 리뷰에는 ProductId, UserId, Score, 리뷰 제목(Summary), 리뷰 본문(Text)이 있습니다.

아래에서는 리뷰 요약과 본문을 하나의 결합 텍스트로 합치고, 모델이 이 결합 텍스트를 인코딩해 단일 벡터 임베딩을 출력하도록 해요.

from openai import OpenAI

client = OpenAI()


def get_embedding(text, model="text-embedding-3-small"):
    text = text.replace("\n", " ")
    return client.embeddings.create(input=[text], model=model).data[0].embedding


df["ada_embedding"] = df.combined.apply(
    lambda x: get_embedding(x, model="text-embedding-3-small")
)
df.to_csv("output/embedded_1k_reviews.csv", index=False)

저장된 파일에서 데이터를 불러오려면 이렇게 실행하면 됩니다.

import pandas as pd

df = pd.read_csv("output/embedded_1k_reviews.csv")
df["ada_embedding"] = df.ada_embedding.apply(eval).apply(np.array)

임베딩 차원 줄이기

큰 임베딩(예: 벡터 스토어에 저장해 검색할 때)은 작은 임베딩보다 비용이 더 들고 더 많은 컴퓨팅·메모리·저장 공간을 소모해요. 두 새 임베딩 모델은 개발자가 성능과 비용을 맞바꿀 수 있게 하는 기법으로 학습됐어요. 구체적으로 dimensions API 파라미터를 넘기면 임베딩의 개념 표현 속성을 잃지 않으면서 벡터를 줄일 수 있어요(시퀀스 끝에서 일부 숫자 제거). 예를 들어 MTEB 벤치마크에서 text-embedding-3-large 임베딩을 256 크기로 줄여도, 줄이지 않은 1536 크기의 text-embedding-ada-002 임베딩보다 더 나은 성능을 냅니다.

일반적으로 임베딩을 만들 때 dimensions 파라미터를 사용하는 게 권장돼요. 하지만 임베딩을 생성한 뒤 차원을 바꿔야 하는 경우도 있는데, 그럴 때는 아래처럼 임베딩 차원을 정규화해야 합니다.

from openai import OpenAI
import numpy as np

client = OpenAI()


def normalize_l2(x):
    x = np.array(x)
    if x.ndim == 1:
        norm = np.linalg.norm(x)
        if norm == 0:
            return x
        return x / norm
    else:
        norm = np.linalg.norm(x, 2, axis=1, keepdims=True)
        return np.where(norm == 0, x, x / norm)


response = client.embeddings.create(
    model="text-embedding-3-small", input="Testing 123", encoding_format="float"
)

cut_dim = response.data[0].embedding[:256]
norm_dim = normalize_l2(cut_dim)

print(norm_dim)

차원을 동적으로 바꾸면 매우 유연하게 쓰일 수 있어요. 예를 들어 임베딩을 최대 1024차원까지만 지원하는 벡터 데이터 스토어를 쓴다면, 최고 성능 모델인 text-embedding-3-large를 그대로 쓰면서 dimensions를 1024로 지정해 3072차원에서 줄일 수 있습니다. 약간의 정확도를 희생하는 대신 더 작은 벡터 크기를 얻는 방식이죠.

임베딩 기반 검색으로 질의응답

모델이 훈련 데이터에 없는 핵심 사실을 사용자 질의에 대한 답변에서 접근 가능하게 만들고 싶을 때가 많죠. 한 가지 방법은 아래처럼 모델의 컨텍스트 창에 추가 정보를 넣는 거예요. 이 방식은 많은 사용 사례에서 효과적이지만 토큰 비용이 늘어나요. 여기서는 이 방식과 임베딩 기반 검색 사이의 절충을 살펴봅니다.

query = f"""Use the below article on the 2022 Winter Olympics to answer the subsequent question. If the answer cannot be found, write "I don't know."

Article:
\"\"\"
{wikipedia_article_on_curling}
\"\"\"

Question: Which athletes won the gold medal in curling at the 2022 Winter Olympics?"""

response = client.chat.completions.create(
    messages=[
        {
            "role": "system",
            "content": "You answer questions about the 2022 Winter Olympics.",
        },
        {"role": "user", "content": query},
    ],
    model=GPT_MODEL,
    temperature=0,
)

print(response.choices[0].message.content)

임베딩으로 텍스트 검색

가장 관련성 높은 문서를 가져오려면 쿼리와 각 문서의 임베딩 벡터 사이의 코사인 유사도를 계산하고, 점수가 높은 문서를 반환하면 돼요.

def search_reviews(df, product_description, n=3, pprint=True):
    embedding = get_embedding(product_description, model="text-embedding-3-small")
    df["similarities"] = df.ada_embedding.apply(
        lambda x: cosine_similarity(x, embedding)
    )
    res = df.sort_values("similarities", ascending=False).head(n)
    return res


res = search_reviews(df, "delicious beans", n=3)

임베딩으로 코드 검색

코드 검색은 임베딩 기반 텍스트 검색과 비슷하게 동작해요. 저장소의 모든 Python 파일에서 Python 함수를 추출한 뒤, 각 함수를 text-embedding-3-small 모델로 인덱싱합니다. 코드를 검색하려면 같은 모델로 자연어 쿼리를 임베딩하고, 결과 쿼리 임베딩과 각 함수 임베딩 사이의 코사인 유사도를 계산해요. 코사인 유사도가 높을수록 관련성이 높습니다.

df["code_embedding"] = df["code"].apply(
    lambda x: get_embedding(x, model="text-embedding-3-small")
)


def search_functions(df, code_query, n=3, pprint=True, n_lines=7):
    embedding = get_embedding(code_query, model="text-embedding-3-small")
    df["similarities"] = df.code_embedding.apply(
        lambda x: cosine_similarity(x, embedding)
    )

    res = df.sort_values("similarities", ascending=False).head(n)
    return res


res = search_functions(df, "Completions API tests", n=3)

임베딩으로 추천

임베딩 벡터 사이의 거리가 짧을수록 유사도가 크다는 점을 활용하면 추천에 쓸 수 있어요. 아래는 기본적인 추천기로, 문자열 리스트와 하나의 '소스' 문자열을 받아 임베딩을 계산한 뒤 유사한 순서대로 순위를 반환합니다. 연결된 노트북은 이 함수를 AG 뉴스 데이터셋(2,000개 기사 설명으로 축소)에 적용해 주어진 소스 기사와 가장 유사한 상위 5개 기사를 반환해요.

def recommendations_from_strings(
    strings: List[str],
    index_of_source_string: int,
    model="text-embedding-3-small",
) -> List[int]:
    """Return nearest neighbors of a given string."""

    # get embeddings for all strings
    embeddings = [embedding_from_string(string, model=model) for string in strings]

    # get the embedding of the source string
    query_embedding = embeddings[index_of_source_string]

    # get distances between the source embedding and other embeddings (function from embeddings_utils.py)
    distances = distances_from_embeddings(
        query_embedding, embeddings, distance_metric="cosine"
    )

    # get indices of nearest neighbors (function from embeddings_utils.py)
    indices_of_nearest_neighbors = indices_of_nearest_neighbors_from_distances(
        distances
    )
    return indices_of_nearest_neighbors

2D 시각화

임베딩의 크기는 기반 모델의 복잡성에 따라 달라져요. 이 고차원 데이터를 시각화하려면 t-SNE 알고리즘으로 2차원으로 변환하면 됩니다. 리뷰는 리뷰어가 준 별점으로 색을 칠해요(1성: 빨강, 2성: 진한 주황, 3성: 금색, 4성: 청록색, 5성: 진한 초록). 시각화 결과 대략 3개의 클러스터가 생기는데, 그중 하나는 주로 부정적인 리뷰로 구성돼요.

import numpy as np
import pandas as pd
from sklearn.manifold import TSNE
import matplotlib.pyplot as plt
import matplotlib

df = pd.read_csv("output/embedded_1k_reviews.csv")
matrix = np.array(df.ada_embedding.apply(eval).to_list())

# Create a t-SNE model and transform the data
tsne = TSNE(
    n_components=2, perplexity=15, random_state=42, init="random", learning_rate=200
)
vis_dims = tsne.fit_transform(matrix)

colors = ["red", "darkorange", "gold", "turquoise", "darkgreen"]
x = [x for x, y in vis_dims]
y = [y for x, y in vis_dims]
color_indices = df.Score.values - 1

colormap = matplotlib.colors.ListedColormap(colors)
plt.scatter(x, y, c=color_indices, cmap=colormap, alpha=0.3)
plt.title("Amazon ratings visualized in language using t-SNE")

ML 알고리즘을 위한 텍스트 피처 인코더로서의 임베딩

임베딩은 머신러닝 모델 안에서 일반적인 자유 텍스트 피처 인코더로 쓸 수 있어요. 관련 입력 중 자유 텍스트가 있다면 임베딩을 포함하면 어떤 ML 모델의 성능도 개선됩니다. 카테고리 변수의 이름이 의미 있고 많은 경우(예: 직함)에는 카테고리 피처 인코더로도 쓰일 수 있죠. 이 작업에는 시맨틱 임베딩이 일반적으로 검색 임베딩보다 잘 동작합니다. 임베딩 표현은 매우 풍부하고 정보 밀도가 높아서, SVD나 PCA로 입력 차원을 10%만 줄여도 특정 작업의 다운스트림 성능이 나빠지는 걸 자주 관찰할 수 있어요.

아래 코드는 데이터를 훈련 세트와 테스트 세트로 나누는데, 이후 회귀와 분류 두 사용 사례에서 씁니다.

from sklearn.model_selection import train_test_split

X_train, X_test, y_train, y_test = train_test_split(
    list(df.ada_embedding.values), df.Score, test_size=0.2, random_state=42
)

임베딩 피처로 회귀

임베딩은 숫자 값을 예측하는 우아한 방법을 제공해요. 이 예시에서는 리뷰 텍스트를 바탕으로 리뷰어의 별점을 예측해요. 임베딩 안의 시맨틱 정보가 높기 때문에 리뷰가 아주 적어도 예측이 괜찮습니다. 점수를 1~5 사이의 연속 변수로 가정하고 알고리즘이 임의의 부동소수점 값을 예측하도록 허용하면, 이 ML 알고리즘은 예측값과 실제 점수의 거리를 최소화하며 평균 절대 오차 0.39를 달성해요. 평균적으로 예측이 별 반 개 이내로 들어맞는다는 뜻이죠.

from sklearn.ensemble import RandomForestRegressor

rfr = RandomForestRegressor(n_estimators=100)
rfr.fit(X_train, y_train)
preds = rfr.predict(X_test)

임베딩 피처로 분류

이번에는 15 사이 어딘가의 값을 예측하는 대신, 리뷰의 별점(15성)을 정확히 5개 버킷으로 분류해 볼게요. 훈련 후 모델은 좀 더 섬세한 리뷰(2~4성)보다 1성과 5성 리뷰를 훨씬 잘 예측하는데, 감정 표현이 더 극단적이라서 그럴 가능성이 커요.

from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, accuracy_score

clf = RandomForestClassifier(n_estimators=100)
clf.fit(X_train, y_train)
preds = clf.predict(X_test)

제로샷 분류

레이블이 붙은 훈련 데이터 없이도 임베딩으로 제로샷 분류를 할 수 있어요. 각 클래스에 대해 클래스 이름이나 짧은 설명을 임베딩하고, 새 텍스트를 분류할 때는 그 임베딩을 모든 클래스 임베딩과 비교한 뒤 유사도가 가장 높은 클래스로 예측합니다.

df = df[df.Score != 3]
df["sentiment"] = df.Score.replace(
    {1: "negative", 2: "negative", 4: "positive", 5: "positive"}
)

labels = ["negative", "positive"]
label_embeddings = [get_embedding(label, model=model) for label in labels]


def label_score(review_embedding, label_embeddings):
    return cosine_similarity(review_embedding, label_embeddings[1]) - cosine_similarity(
        review_embedding, label_embeddings[0]
    )


prediction = (
    "positive" if label_score(get_embedding("Sample Review", model=model), label_embeddings) > 0 else "negative"
)

콜드 스타트 추천을 위한 사용자·상품 임베딩

사용자 임베딩은 그 사용자의 모든 리뷰를 평균 내어 얻을 수 있고, 상품 임베딩도 그 상품에 대한 모든 리뷰를 평균 내면 됩니다. 이 접근의 유용성을 보여주기 위해 사용자·상품당 더 많은 리뷰를 다루도록 5만 개 리뷰 하위 집합을 써요. 별도의 테스트 세트에서 사용자·상품 임베딩의 유사도를 평점의 함수로 그려 평가하면, 흥미롭게도 사용자가 상품을 받기 전에도 좋아할지 여부를 무작위보다 잘 예측할 수 있어요.

user_embeddings = df.groupby("UserId").ada_embedding.apply(np.mean)
prod_embeddings = df.groupby("ProductId").ada_embedding.apply(np.mean)

클러스터링

클러스터링은 대량의 텍스트 데이터를 이해하는 한 가지 방법이에요. 임베딩은 각 텍스트의 의미론적으로 유의미한 벡터 표현을 제공하기 때문에, 비지도 방식으로 데이터셋의 숨은 그룹을 발견할 수 있게 해 줍니다. 이 예시에서는 네 개의 뚜렷한 클러스터를 발견하는데, 하나는 개 사료, 하나는 부정적인 리뷰, 두 개는 긍정적인 리뷰에 집중돼요.

import numpy as np
from sklearn.cluster import KMeans

matrix = np.vstack(df.ada_embedding.values)
n_clusters = 4

kmeans = KMeans(n_clusters=n_clusters, init="k-means++", random_state=42)
kmeans.fit(matrix)
df["Cluster"] = kmeans.labels_

FAQ

임베딩하기 전에 문자열의 토큰 수를 어떻게 알 수 있나요?

Python에서는 OpenAI의 토크나이저 tiktoken으로 문자열을 토큰으로 나눌 수 있어요.

import tiktoken


def num_tokens_from_string(string: str, encoding_name: str) -> int:
    """Returns the number of tokens in a text string."""
    encoding = tiktoken.get_encoding(encoding_name)
    num_tokens = len(encoding.encode(string))
    return num_tokens


num_tokens_from_string("tiktoken is great!", "cl100k_base")

text-embedding-3-small 같은 3세대 임베딩 모델에는 cl100k_base 인코딩을 쓰세요. 더 자세한 내용과 예시 코드는 Cookbook의 tiktoken 토큰 세는 법 가이드에 있어요.

K개 최근접 임베딩 벡터를 빠르게 얻는 방법은?

많은 벡터를 빠르게 검색하려면 벡터 데이터베이스를 쓰는 걸 권장해요. 벡터 DB와 OpenAI API를 함께 쓰는 예시는 Cookbook에서 찾을 수 있습니다.

어떤 거리 함수를 써야 하나요?

코사인 유사도를 권장해요. 거리 함수의 선택은 대체로 큰 차이를 만들지 않습니다. OpenAI 임베딩은 길이 1로 정규화되어 있어서 다음이 성립해요.

  • 코사인 유사도를 dot product만으로 조금 더 빠르게 계산할 수 있다.
  • 코사인 유사도와 유클리드 거리는 동일한 순위 결과를 낸다.

임베딩을 온라인에 공유해도 되나요?

네, 고객은 임베딩을 포함한 모델의 입력·출력을 소유해요. API에 넣는 콘텐츠가 적용 법률이나 이용 약관을 위반하지 않는지 확인할 책임은 사용자에게 있습니다.

V3 임베딩 모델은 최근 사건을 알까요?

아니요. text-embedding-3-largetext-embedding-3-small 모델은 2021년 9월 이후 발생한 사건에 대한 지식이 없어요. 텍스트 생성 모델에 비해서는 일반적으로 큰 제약이 아니지만, 특정 엣지 케이스에서는 성능이 떨어질 수 있습니다.

더 알아보기 (Learn more)