TL;DR: 임베딩은 텍스트에 의미 좌표를 부여하는 기술이다. 문서를 통째로 넣지 않고 청킹으로 잘라야 검색 품질이 올라가며, 차원은 높다고 무조건 좋은 것이 아니다. 768~1,024차원에서도 최신 모델은 충분히 강력하고, MRL로 차원을 유연하게 조절할 수 있다
벡터 DB에 텍스트를 넣으려면
사내 위키를 기반으로 RAG(Retrieval-Augmented Generation) 시스템을 처음 만들 때, 첫 번째 관문은 임베딩이었다. "텍스트를 벡터로 바꾼다"는 설명은 이해했지만, 실제로 어떤 모델을 써야 하는지, 문서를 어떻게 자르는지, 차원은 얼마로 설정해야 하는지 물음표가 줄줄이 이어졌다.
RAG 파이프라인의 구조는 단순하다. 문서를 적당한 크기로 자르고(청킹), 각 청크를 벡터로 변환해(임베딩) 벡터 DB에 저장한다. 사용자가 질문을 던지면 질문도 같은 방식으로 임베딩(Embedding)한 뒤, 벡터 DB에서 가장 가까운 청크들을 꺼내 LLM (Large Language Model)에 전달한다.
이 글에서는 임베딩이 어떤 원리로 작동하는지, 청킹 전략은 어떻게 선택하는지, 차원 선택에 어떤 함정이 있는지, 그리고 2026년 기준 주요 모델을 어떻게 비교할 수 있는지 정리한다.
코사인 유사도나 벡터 같은 개념은 머리 아프니까 다음 게시물에서 좀 더 자세히 이야기하고, 이번 글에선 임베딩에 대해 이야기한다.
텍스트를 숫자로 바꾸는 기술
컴퓨터는 텍스트를 모른다
컴퓨터는 텍스트 자체를 이해하지 못한다. 텍스트를 숫자로 변환해야만 연산이 가능하다. 가장 단순한 방법은 원-핫 인코딩 (One-Hot Encoding)이다. 어휘 사전에 있는 단어 수만큼 차원을 만들고, 그 단어의 번호 위치에만 1을 채운다.
원-핫 인코딩을 풀어서 설명하면 이렇다. 어휘 사전에 10만 개의 단어가 있을 때, "고양이"가 사전의 7,302번째 단어라면 10만 개짜리 배열에서 7,302번 위치만 1이고 나머지는 전부 0이다. 단어 하나를 표현하는 데 10만 개 숫자를 쓰는 셈이다.
이 방식의 첫 번째 문제는 공간 낭비다. 단어 하나에 10만 차원 벡터가 필요하다. 두 번째, 더 큰 문제는 의미를 담지 못한다는 것이다. "고양이"와 "강아지"의 벡터 거리가 "고양이"와 "냉장고"의 거리와 같다. 어느 자리에 1이 있느냐만 다를 뿐, 비슷한 의미라는 정보가 숫자에 전혀 반영되지 않는다.
임베딩이란 의미 좌표다
임베딩은 이 한계를 극복하기 위해 등장했다. 고차원 희소 벡터 대신, 수백~수천 차원의 밀집 벡터(Dense Vector)로 텍스트를 표현한다. 핵심은 의미가 비슷한 텍스트를 벡터 공간에서 가깝게 배치한다는 것이다.
GPS 좌표로 비유하면 이해하기 쉽다. 서울(37.56, 126.97)과 인천(37.45, 126.70)은 좌표가 가깝기 때문에 지리적으로도 가깝다는 것을 알 수 있다. 임베딩도 마찬가지다. "배포 롤백"과 "서비스 되돌리기"는 임베딩 공간에서 가까이 위치한다. 좌표만 봐도 의미의 근접성을 계산할 수 있다.
두 벡터가 얼마나 가까운지는 코사인 유사도(Cosine Similarity)로 측정한다. -1부터 1까지 값을 가지며, 1에 가까울수록 의미가 비슷하고 -1에 가까울수록 반대 의미다. 0은 연관성이 없음을 뜻한다.
원-핫에서 문장 임베딩까지
임베딩 기술은 빠르게 진화했다. 2013년 Word2Vec이 등장하면서 단어 수준의 의미 임베딩이 가능해졌다. "왕 - 남성", "여성 = 여왕"이라는 벡터 연산이 실제로 작동하는 것을 보여줬다.
2018년 BERT가 등장하면서 문맥을 고려한 임베딩이 본격화됐다. 같은 "배"라는 단어도 "배가 아프다"와 "배를 탄다"에서 다른 벡터를 가지게 됐다. 현대 임베딩 모델들은 단어 수준을 넘어 문장 전체, 혹은 여러 문장으로 이루어진 청크를 하나의 벡터로 표현한다.
API로 임베딩 생성하기
OpenAI의 text-embedding-3-small 모델로 임베딩을 생성하는 방법은 간단하다.
아래 코드는 두 텍스트를 임베딩하고 코사인 유사도를 계산하는 예시다. 결과를 보면 의미가 비슷한 텍스트끼리는 높은 유사도 점수가 나오고, 무관한 텍스트와는 낮은 점수가 나오는 것을 확인할 수 있다.
from openai import OpenAI
import numpy as np
client = OpenAI()
def get_embedding(text: str, model: str = "text-embedding-3-small") -> list[float]:
response = client.embeddings.create(input=text, model=model)
return response.data[0].embedding
def cosine_similarity(a: list[float], b: list[float]) -> float:
a, b = np.array(a), np.array(b)
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
emb1 = get_embedding("배포 롤백")
emb2 = get_embedding("서비스를 이전 버전으로 되돌리기")
emb3 = get_embedding("오늘 점심 메뉴")
print(cosine_similarity(emb1, emb2)) # 예: 0.87 (유사)
print(cosine_similarity(emb1, emb3)) # 예: 0.12 (무관)
반환된 벡터 길이는 1,536개의 부동소수점 숫자다. 이 숫자들이 텍스트의 의미 좌표가 된다.
중요한 제약이 하나 있다. 문서를 저장할 때와 검색 쿼리를 임베딩할 때 반드시 같은 모델을 써야 한다. 모델마다 벡터 공간이 다르기 때문에, 다른 모델로 만든 벡터는 비교가 불가능하다. 모델을 바꾸면 저장된 모든 벡터를 재임베딩해야 한다.

문서를 임베딩하기 전에, 자른다
왜 자르는가
긴 문서를 통째로 하나의 벡터로 만들면 두 가지 문제가 생긴다.
첫째, 모델의 토큰 제한이 있다. text-embedding-3-small의 최대 입력 길이는 8,191 토큰이다. 수백 페이지짜리 문서는 물리적으로 들어가지 않는다.
둘째, 문서 전체를 하나의 벡터로 압축하면 특정 주제의 신호가 희석된다. "시스템 배포 절차"와 "장애 대응 매뉴얼"이 섞인 긴 문서를 하나의 벡터로 만들면, "배포 오류 처리" 같은 쿼리에 이 벡터가 적절히 응답하지 못한다.
청킹 (Chunking)은 문서를 검색에 적합한 크기로 분할하는 과정이다. 어떻게 자르느냐에 따라 RAG 검색 품질이 크게 달라진다.
주요 전략
내가 고민 했던 전략은 크게 3가지였다.
| 전략 | 특징 | 적합한 상황 |
| 고정 크기 | 단순하고 빠름. 문장 경계를 무시할 수 있음 | 빠른 프로토타입 |
| 재귀적 분할 | 문단, 문장, 단어 순서로 경계를 지키며 분할 | 범용적으로 쓰임 |
| 의미적 청킹 | ML 모델로 의미 경계를 탐지해 분할 | 주제 전환이 잦은 문서 |
다음 그림을 보고 좀 더 이해를 도울 수 있을 것이다.
얼마나 잘라야 하나
청크 크기를 너무 작게 잡으면 검색 정밀도는 높아지지만 맥락이 부족해 단편적인 답변이 나온다. 반대로 너무 크게 잡으면 여러 주제가 하나의 청크에 섞여 검색 정밀도가 떨어진다.
오버랩(Overlap)은 인접 청크가 일부 텍스트를 공유하도록 겹치는 설정이다. 청크 경계에서 문장이 끊겨 맥락이 손실되는 문제를 완화한다.
실무에서 권장하는 시작점은 400~512 토큰 청크 크기에 10~20% 오버랩이다. 도메인과 문서 특성에 따라 조정이 필요하므로 실제 쿼리로 검색 품질을 측정하면서 튜닝하는 것이 맞다.
재귀적 분할 코드 예시
LangChain의 RecursiveCharacterTextSplitter는 범용적으로 쓰기 좋다. 분할 우선순위는 단락(\n\n) → 줄바꿈(\n) → 문장(. ) → 단어 순서다. 각 청크가 chunk_size를 넘지 않을 때까지 더 세밀한 구분자로 재귀적으로 시도한다.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 최대 토큰 수 (문자 기준이므로 조정 필요)
chunk_overlap=64, # 인접 청크 간 겹치는 문자 수
separators=["\n\n", "\n", ". ", " ", ""],
)
docs = splitter.create_documents([long_document_text])
for i, doc in enumerate(docs[:3]):
print(f"[청크 {i+1}] 길이: {len(doc.page_content)}자")
print(doc.page_content[:100])
print("---")
위 코드를 실행하면 각 청크의 길이와 앞부분을 확인할 수 있다. 실제로 돌려보면서 문장이 어색하게 잘리지 않는지, 한 청크에 너무 많은 주제가 담기지 않는지 눈으로 확인하는 과정이 중요하다.
wiki-rag를 만들 때는 프로토타이핑 속도를 우선했다. 어떤 전략이 최적인지 비교할 여유 없이, 문서당 약 400자 단위로 분할하고 오버랩 구간을 넣는 방식으로 시작했다. 문장이 청크 경계에서 잘릴 수 있기 때문에 오버랩은 필수였다. 완벽한 전략은 아니었지만, 빠르게 동작하는 파이프라인을 먼저 만들고 이후에 검색 결과를 보면서 조정하는 접근이 실무에서는 더 현실적이었다.

차원이 높으면 무조건 좋을까
차원이 높은 것만 쓰면 되겠다
내가 이 개념을 공부할 때 이런 생각을 했다. 물리적으로 시스템 리소스만 문제 없다면 계속 차원을 늘려도 되지 않을까? 차원이 높을수록 더 세밀한 표현이 가능하고, 그러면 검색 성능도 높아질 것이다. text-embedding-3-small(1,536차원)보다 text-embedding-3-large(3,072차원)가 당연히 좋을 것이다.
하지만 이 생각은 부분적으로만 맞다. 실제로 차원이 다른 모델을 비교해보면 유의미한 차이가 있긴 하다. 하지만 그 차이가 차원 자체에서 오는지, 모델 아키텍처와 훈련 데이터에서 오는지를 구분하지 않으면 잘못된 결론에 이른다.
차원을 올려도 성능은 안 따라온다
모델 성능 비교 표를 보면 자주 마주할 수 있는 단어가 있다. MTEB(Massive Text Embedding Benchmark)는 검색, 분류, 클러스터링 등 다양한 태스크를 종합 평가하는 벤치마크다. MTEB 점수를 보면 차원 증가에 따른 성능 향상이 일정하지 않다.
384차원에서 768차원으로 올라가는 구간에서는 성능이 눈에 띄게 향상된다. 하지만 1,024차원에서 3,072차원, 4,096차원으로 올라가는 구간에서는 같은 차원 증가 대비 향상이 급격히 줄어든다.
더 주목할 만한 사실이 있다. BAAI의 BGE-base 모델은 768차원이지만, OpenAI ada-002(1,536차원)보다 MTEB 점수가 높다. 차원이 아니라 훈련 품질과 아키텍처가 성능을 결정한다는 뜻이다.
차원이 두 배면 비용도 두 배다
차원이 두 배가 되면 비용도 두 배가 된다.
저장 비용부터 보면, Float32 기준 벡터 하나의 크기는 차원 수 × 4 바이트다. 100만 개 벡터를 저장할 때 차원별 메모리는 다음과 같다.
| 차원 | 100만 벡터 메모리(Float32) |
| 384 | 약 1.5G |
| 1,536 | 약 6GB |
| 3,072 | 약 12GB |
| 4,096 | 약 16GB |
검색 속도도 차원에 비례한다. 쿼리 벡터와 저장된 벡터 간 거리 계산은 O(N × D) 복잡도를 가진다. 차원(D)이 두 배면 계산량도 두 배다. 수백만 건 이상의 벡터를 다루는 환경에서는 무시할 수 없는 차이다.
모든 문서가 비슷하게 멀어진다
차원이 높아질수록 모든 점 간 거리가 비슷해지는 현상이 있다. 이를 고차원에서 거리가 무의미해지는 현상(차원의 저주, Curse of Dimensionality)이라 부른다.
저차원에서는 가장 가까운 이웃과 가장 먼 이웃 사이의 거리 차이가 뚜렷하다. 하지만 차원이 높아질수록 모든 점이 서로 비슷한 거리에 위치하게 되고, "가장 가까운 문서"를 찾는 의미가 흐려진다.
단, 임베딩 벡터는 실제로 저차원 다양체(Manifold) 위에 분포한다고 알려져 있다. 텍스트의 의미 공간 자체가 저차원이기 때문에, 고차원 벡터로 표현하더라도 실질적인 정보는 저차원 구조에 집중된다. 이론적 최악보다는 실무에서 양호하게 작동하는 이유다.
차원을 줄여도 성능을 지키는 법
마트료시카 표현 학습(MRL, Matryoshka Representation Learning)은 큰 임베딩 안에 작은 임베딩이 중첩된 구조로 모델을 훈련하는 방식이다. 러시아 전통 인형 마트료시카에서 이름을 따왔다.
앞쪽 차원일수록 더 중요한 정보를 담도록 훈련하기 때문에, 전체 벡터의 앞부분만 잘라 사용해도 품질이 크게 떨어지지 않는다. OpenAI의 text-embedding-3 시리즈가 MRL을 적용한 대표적인 모델이다.
text-embedding-3-large를 256차원으로 줄여 사용해도 이전 세대 모델 ada-002의 1,536차원보다 성능이 높다는 것이 OpenAI의 발표다. MRL 덕분에 비용과 성능 사이의 균형을 유연하게 조정할 수 있다.
아래 코드는 API에서 dimensions 파라미터로 차원을 직접 지정하는 예시다. 같은 모델을 쓰되 차원만 줄이면 저장 비용을 낮추면서 의미 품질은 상당 부분 유지할 수 있다.
from openai import OpenAI
client = OpenAI()
# 기본 1,536차원
full_emb = client.embeddings.create(
input="배포 롤백 절차",
model="text-embedding-3-small"
)
# MRL로 256차원으로 축소
small_emb = client.embeddings.create(
input="배포 롤백 절차",
model="text-embedding-3-small",
dimensions=256 # 앞쪽 256차원만 사용
)
print(f"전체 차원: {len(full_emb.data[0].embedding)}") # 1536
print(f"축소 차원: {len(small_emb.data[0].embedding)}") # 256

어떤 모델을 고를 것인가
MTEB 벤치마크 읽는 법
MTEB는 검색, 분류, 클러스터링, 재순위 등 다양한 태스크를 종합 평가한다. 종합 점수를 보면 전반적인 품질을 파악할 수 있지만, RAG를 만든다면 Retrieval(검색) 태스크 점수를 더 중요하게 봐야 한다.
한국어 문서를 다룬다면 MIRACL, MKQA 같은 다국어 벤치마크 점수도 확인하는 것이 좋다. 영어 기준 MTEB 점수가 높아도 한국어 성능이 낮은 모델이 있다.
상용 API 비교(2026. 03 기준)
| 모델 | 제공사 | 차원 | MTEB 점수 | 가격 ($/100만 토큰) |
| text-embedding-3-small | OpenAI | 1,536 | 62.3 | $0.02 |
| text-embedding-3-large | OpenAI | 3,072 | 64.6 | $0.13 |
| gemini-embedding-001 | 3,072 | 68.3 | 별도 과금 | |
| Cohere Embed v4 | Cohere | 1,024 | 65.2 | $0.10 |
| Voyage-3-large | Voyage AI | 1,536 | 66.8 | $0.06 |
오픈소스 모델 비교
| 모델 | 제공사 | 차원 | MTEB 점수 | 라이선스 |
| BGE-M3 | BAAI | 1,024 | 63.0 | MIT |
| Qwen3-Embedding-8B | Alibaba | 4,096 | 70.6 | Apache 2.0 |
| E5-Mistral-7B | Microsoft | 4,096 | 66.6 | MIT |
Qwen3-Embedding-8B의 70.6은 2026년 3월 기준 오픈소스 모델 중 최상위 수준이다. 다만 8B 파라미터 모델을 자체 서빙하려면 GPU 인프라 비용과 운영 부담을 함께 고려해야 한다.
text-embedding-3-small을 선택하는 이유
처음 RAG를 구현하는 상황이라면 text-embedding-3-small을 권장한다. 세 가지 이유가 있다.
첫째, $0.02/100만 토큰이라는 가격은 경쟁 모델 대비 압도적이다. text-embedding-3-large와 비교해 약 6.5배 저렴하면서 MTEB 점수 차이는 2.3점에 불과하다. 근데 회사에서 구현한다면, 회사에서 지원 해주는 모델인지 확인부터 해보자. 내 경우엔 선택지가 많지 않았다.
둘째, 한국어 성능이 이전 세대 ada-002 대비 크게 향상됐다. MIRACL 한국어 벤치마크에서 ada-002 대비 두 자릿수 이상 향상됐다고 알려져 있다.
셋째, MRL 지원으로 256차원부터 1,536차원까지 자유롭게 조절할 수 있다. 프로토타입에서는 작은 차원으로 빠르게 실험하고, 프로덕션에서 필요한 차원으로 확장하는 방식이 가능하다.
프로토타이핑 과정에서 로컬 모델을 직접 돌려보기도 하고 OpenAI 모델을 사용해보기도 했다. 로컬 모델은 리소스를 너무 많이 차지해서 부담스러웠고, 임베딩 API 비용 자체가 그렇게 비싸지 않았기 때문에 결국 OpenAI를 선택했다. MTEB 같은 정량 지표와 다국어 지원 여부를 우선 기준으로 삼았다.(로컬에서 돌릴 땐 노트북 쿨러 소리 때문에 날아가는 줄 알았다..)
다만, 모델을 고르는 것보다 중요한 것이 있었다. 아무리 좋은 모델을 써도 문서의 품질 자체가 낮으면 검색 결과도 낮다. AI가 이해하기 쉬운 구조의 문서를 작성하는 것이 모델 선택보다 더 직접적으로 검색 품질에 영향을 줬다. 결국 임베딩 모델은 문서의 의미를 추출하는 도구일 뿐, 의미 자체는 문서에 있어야 한다.
마무리
wiki-rag를 만들면서 생긴 질문들에서 혼자 공부하다가 글을 발행하게 됐다. 앞으로 순차적으로 포스팅을 할텐데 청킹, 차원, 모델 선택까지 각 단계에서 선택이 필요하다는 것을 알게 됐다.
임베딩은 텍스트에 의미 좌표를 부여하는 기술이다. 그리고 그 좌표 위에 RAG 검색의 나머지가 세워진다. 어떤 LLM을 쓰느냐보다, 문서를 어떻게 자르고 어떤 임베딩 모델을 고르느냐가 검색 품질을 더 직접적으로 결정한다. 난 요즘 한 사람이 작성하는 문서가 아니라 다양한 사람이 다양한 도구(draw.io, mermaid...)를 사용해서 작성하는 문서를 어떻게 RAG에서 검색이 잘 되게 할 지 고민하고 있다.
차원에 대한 막연한 불안이 있다면 한 가지만 기억하면 된다. 좋은 모델은 낮은 차원에서도 충분히 강력하고, MRL로 차원을 줄여도 품질이 크게 떨어지지 않는다. 768~1,024차원에서 시작해서 실제 검색 결과를 보며 조정하는 것이 가장 실용적인 접근이다.
다음 단계는 검색된 청크를 얼마나 잘 쓰느냐다. 코사인 유사도 검색 이후에 Reranking을 적용하거나, 하이브리드 검색으로 키워드 기반 검색을 결합하는 방법이 실무에서 자주 쓰인다는 점을 알아두자.
인프런 지식공유자로 활동하고 있으며 MSA 전환이 취미입니다. 개발과 관련된 다양한 정보를 몰입감있게 전달합니다.