이 글은 누구를 위한 것인가
- RAG 시스템을 구축할 때 어떤 임베딩 모델을 써야 할지 모르는 팀
- 한국어 문서 검색 품질이 낮아서 고민인 엔지니어
- 임베딩 비용을 줄이면서 품질을 유지하고 싶은 팀
들어가며
"RAG를 구축했는데 검색 품질이 낮다." 원인의 70%는 임베딩 모델 선택이다. text-embedding-3-large가 항상 최선이 아니다. 한국어 도메인에서는 multilingual-e5-large가 더 나을 수 있다.
이 글은 bluefoxdev.kr의 RAG 시스템 최적화 를 참고하여 작성했습니다.
1. 임베딩 모델 비교
[주요 임베딩 모델 비교 (2026년 기준)]
OpenAI:
text-embedding-3-small: 1536차원, $0.02/M토큰
text-embedding-3-large: 3072차원, $0.13/M토큰
장점: API 안정성, 영어 최강
단점: 한국어 상대적 약세, 비용
Cohere:
embed-multilingual-v3.0: 1024차원, $0.1/M토큰
장점: 다국어 최강, 검색 특화
단점: 비용
오픈소스 (자체 호스팅):
multilingual-e5-large: 1024차원, 무료
ko-sbert-nli: 768차원, 한국어 특화
BGE-M3: 1024차원, MTEB 최상위
장점: 무료, 프라이버시
단점: GPU 필요, 관리 부담
[한국어 성능 순위 (KLUE-STS 기준)]
1. BGE-M3
2. multilingual-e5-large
3. Cohere multilingual-v3
4. text-embedding-3-large
5. ko-sbert-nli (경량)
[임베딩 차원 vs 성능]
높은 차원 → 정확도 ↑, 저장/계산 비용 ↑
PCA/MRL로 차원 축소 시 품질 손실 최소화
실용적: 1024차원이 비용/성능 균형
2. 임베딩 평가 및 최적화
import anthropic
import numpy as np
from dataclasses import dataclass
client = anthropic.Anthropic()
@dataclass
class EmbeddingBenchmark:
model_name: str
dimension: int
cost_per_million_tokens: float
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8)
def evaluate_retrieval_quality(
queries: list[str],
corpus: list[str],
relevant_indices: list[list[int]],
embeddings_fn,
) -> dict:
"""검색 품질 평가: Recall@K, MRR"""
corpus_embeddings = embeddings_fn(corpus)
query_embeddings = embeddings_fn(queries)
recall_at_1 = recall_at_5 = recall_at_10 = 0
mrr_total = 0
for q_idx, (q_emb, relevant) in enumerate(zip(query_embeddings, relevant_indices)):
scores = [cosine_similarity(q_emb, c_emb) for c_emb in corpus_embeddings]
ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
for rank, doc_idx in enumerate(ranked[:10]):
if doc_idx in relevant:
mrr_total += 1 / (rank + 1)
if rank < 1: recall_at_1 += 1
if rank < 5: recall_at_5 += 1
if rank < 10: recall_at_10 += 1
break
n = len(queries)
return {
"recall@1": recall_at_1 / n,
"recall@5": recall_at_5 / n,
"recall@10": recall_at_10 / n,
"mrr": mrr_total / n,
}
def reduce_dimensions(
embeddings: np.ndarray,
target_dim: int,
method: str = "pca",
) -> np.ndarray:
"""임베딩 차원 축소 (비용 절감)"""
if method == "pca":
from sklearn.decomposition import PCA
pca = PCA(n_components=target_dim)
reduced = pca.fit_transform(embeddings)
# L2 정규화
norms = np.linalg.norm(reduced, axis=1, keepdims=True)
return reduced / (norms + 1e-8)
elif method == "mrl":
# Matryoshka Representation Learning: 처음 N차원만 사용
truncated = embeddings[:, :target_dim]
norms = np.linalg.norm(truncated, axis=1, keepdims=True)
return truncated / (norms + 1e-8)
return embeddings
class EmbeddingCache:
"""임베딩 캐싱으로 비용 절감"""
def __init__(self, redis_client):
self.redis = redis_client
self.ttl = 86400 * 30 # 30일
def _hash_text(self, text: str) -> str:
import hashlib
return hashlib.sha256(text.encode()).hexdigest()[:16]
async def get_or_compute(
self,
texts: list[str],
embed_fn,
model: str,
) -> list[list[float]]:
results = [None] * len(texts)
uncached = []
for i, text in enumerate(texts):
key = f"emb:{model}:{self._hash_text(text)}"
cached = await self.redis.get(key)
if cached:
import json
results[i] = json.loads(cached)
else:
uncached.append((i, text))
if uncached:
indices, missing_texts = zip(*uncached)
new_embeddings = await embed_fn(list(missing_texts))
for idx, emb in zip(indices, new_embeddings):
results[idx] = emb
key = f"emb:{model}:{self._hash_text(texts[idx])}"
import json
await self.redis.setex(key, self.ttl, json.dumps(emb))
return results
async def compare_embedding_models(
test_queries: list[str],
test_corpus: list[str],
models: list[str],
) -> dict:
"""여러 임베딩 모델 성능 비교"""
results = {}
for model in models:
if model.startswith("text-embedding"):
import openai
oai = openai.OpenAI()
def embed_fn(texts):
resp = oai.embeddings.create(input=texts, model=model)
return [e.embedding for e in resp.data]
# 평가 실행 (relevant_indices는 사전 레이블링 필요)
# metrics = evaluate_retrieval_quality(test_queries, test_corpus, labels, embed_fn)
# results[model] = metrics
return results
마무리
임베딩 모델 선택은 벤치마크보다 도메인 테스트가 중요하다. MTEB에서 1위라도 법률 문서 한국어 검색에서는 다를 수 있다. 50개 쿼리-문서 쌍으로 Recall@5를 측정하면 2시간 안에 최적 모델을 찾을 수 있다. 캐싱으로 반복 임베딩 비용을 90% 절감하라.