임베딩 모델 선택과 평가: RAG·검색 시스템을 위한 최적 선택

AI 기술

임베딩 모델벡터 검색RAGMTEB한국어 임베딩

이 글은 누구를 위한 것인가

  • 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% 절감하라.