포스트

2026년 4월 기준 Embedding Model 3파전: OpenAI vs Cohere vs BGE, “우리 도메인”에서 이기는 선택법

2026년 4월 기준 Embedding Model 3파전: OpenAI vs Cohere vs BGE, “우리 도메인”에서 이기는 선택법

들어가며

RAG/semantic search를 “돌아가게” 만드는 것과 “정확하게” 만드는 것의 차이는 대부분 embedding에서 시작합니다. 특히 2026년 현재는 (1) 상용 API(OpenAI, Cohere)(2) 오픈소스(BGE 계열) 가 모두 강력해져서, “그냥 유명한 거”로 고르기보다 도메인/운영 제약(비용·지연·보안·배포) 중심으로 선택해야 합니다.

  • 언제 쓰면 좋나:
    • 문서 검색(RAG), 유사 사례 추천, FAQ 라우팅, 고객문의 분류처럼 텍스트 의미를 거리로 계산해야 할 때
    • BM25 같은 lexical만으로는 recall이 부족한 동의어/패러프레이즈/다국어 환경
  • 언제 쓰면 안 되나(또는 단독 사용 금지):
    • “정답 근거”가 중요한 법률/의료처럼 오탐 비용이 큰 도메인에서 embedding만으로 top-k를 확정하는 경우(→ reranker/hybrid 필요)
    • 데이터가 짧고 라벨이 충분한 분류 문제(→ fine-tuned classifier가 더 싸고 정확할 때가 많음)

이번 글은 2026년 4월 시점의 공개 자료(모델 문서/리더보드/가격표)를 바탕으로, OpenAI 임베딩(text-embedding-3), Cohere Embed(v3/v4), BGE-M3를 “내 프로젝트 기준”으로 비교하고, 도메인별 선택 가이드를 제공합니다. (platform.openai.com)


🔧 핵심 개념

1) Embedding 비교에서 진짜 중요한 축 4가지

  1. 품질(정확도) = 도메인 분포 + 평가셋
    MTEB 같은 리더보드는 유용하지만, 내 데이터(예: 고객센터 말투, 사내 약어, 긴 PDF, 혼합 언어)에선 순위가 뒤집히기도 합니다. “MTEB 상위=내 서비스 상위”가 아닙니다. (discuss.huggingface.co)

  2. Vector 차원(dimension) = 저장비/속도/정확도의 삼각형
    • OpenAI는 text-embedding-3-large가 기본 3072d, 3-small이 1536d이며, API에서 dimensions로 축소(예: 1024, 256)할 수 있습니다. 이때 정확도와 저장비를 트레이드오프합니다. (openai.com)
    • Cohere는 Embed v3/v4 계열에서 Matryoshka Embeddings(256/512/1024/1536 등) 를 공식 지원합니다(차원 줄여도 상대적으로 의미 보존). (docs.cohere.com)
    • BGE-M3는 오픈소스라 “차원 축소”는 보통 PCA/quantization 같은 후처리로 해결합니다(모델이 matryoshka로 학습된 건 아니라서 단순 슬라이싱은 위험). (huggingface.co)
  3. 맥락 길이/입력 단위 = “긴 문서” 비용 구조를 바꿈
    Cohere 문서에는 Embed 모델의 최대 토큰(예: 128k) 과 차원 옵션이 명시되어 있습니다. 긴 PDF/규정집을 “덩어리 크게” 넣고 싶은 팀에 중요합니다. (docs.cohere.com)
    (OpenAI 임베딩도 max token이 공개 문서/가이드에 정리되어 있습니다.) (pinecone.io)

  4. 운영 제약(보안/온프레/벤더락인/지연)
    • OpenAI/Cohere: 품질·운영 단순성 강점, 대신 API 의존
    • BGE: 사내 GPU/온프레 배포 가능(데이터 거버넌스 강점), 대신 서빙·최적화·모니터링을 팀이 떠안음

2) 내부 작동 흐름(구조/흐름 관점)

실무 RAG 검색 파이프라인을 “embedding이 들어가는 지점”으로 쪼개면 다음입니다.

  1. Chunking(문서 분절): 문단/섹션/슬라이딩 윈도우로 자르기
  2. Embedding 생성: chunk → vector
  3. Indexing: HNSW/IVF 등 ANN 인덱스 구성
  4. Query embedding: 질의 → vector
  5. ANN 검색(top-k): 근접 벡터 후보 추출
  6. (권장) Rerank: cross-encoder/LLM rerank로 top-k 재정렬
  7. Answering: top 문서로 LLM 생성

여기서 embedding 모델 선택은 2,4의 품질뿐 아니라 3,5의 비용(차원/정규화/metric)까지 영향을 줍니다. OpenAI는 dimensions로 벡터 길이를 줄이는 “내장형” 최적화 옵션을 제공하고, Cohere는 matryoshka 차원 세트를 공식적으로 제공합니다. (openai.com)

3) “OpenAI vs Cohere vs BGE”를 한 줄로 요약하면

  • OpenAI(text-embedding-3): API로 빠르게 고품질. dimensions로 저장/성능 튜닝 가능. 가격표가 명확해 비용 산정이 쉽다. (openai.com)
  • Cohere Embed(v3/v4): 기업 환경에서 선호되는 케이스가 많고(특히 배포 옵션/긴 컨텍스트), matryoshka 차원 지원이 강점. (docs.cohere.com)
  • BGE-M3(BAAI): 온프레/로컬 최강 후보. 다국어/다기능을 목표로 한 모델/연구 생태계가 탄탄. 다만 “내 도메인” 성능 보장은 결국 자체 평가가 답. (huggingface.co)

💻 실전 코드

아래 예제는 “toy”가 아니라, 사내 위키 + PDF 텍스트 + 고객문의 같이 섞인 코퍼스를 가정합니다.

  • 입력 문서: data/docs/*.md, data/tickets.jsonl
  • 공통: chunking → embedding → FAISS 인덱싱 → 질의 검색(top-k)
  • 모델만 교체: OpenAI / Cohere / BGE

0) 의존성 설치 & 환경변수

1
2
3
pip install -U faiss-cpu numpy tiktoken python-dotenv openai cohere sentence-transformers
export OPENAI_API_KEY="..."
export COHERE_API_KEY="..."

1) 인덱싱/검색 공통 코드 (OpenAI/Cohere/BGE 플러그인)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
import os, json, glob
import numpy as np
import faiss
from dataclasses import dataclass
from typing import List, Dict, Tuple, Protocol

# -------------------------
# Chunking (현실적인 기본형)
# -------------------------
def chunk_text(text: str, max_chars: int = 1200, overlap: int = 150) -> List[str]:
    text = text.replace("\r\n", "\n")
    chunks = []
    i = 0
    while i < len(text):
        j = min(len(text), i + max_chars)
        chunk = text[i:j].strip()
        if chunk:
            chunks.append(chunk)
        i = j - overlap
        if i < 0:
            i = 0
        if j == len(text):
            break
    return chunks

# -------------------------
# Embedding Provider 인터페이스
# -------------------------
class Embedder(Protocol):
    dim: int
    def embed(self, texts: List[str], *, is_query: bool) -> np.ndarray: ...

@dataclass
class CorpusItem:
    doc_id: str
    text: str

def load_corpus() -> List[CorpusItem]:
    items: List[CorpusItem] = []

    for path in glob.glob("data/docs/*.md"):
        doc_id = os.path.basename(path)
        with open(path, "r", encoding="utf-8") as f:
            raw = f.read()
        for k, ch in enumerate(chunk_text(raw)):
            items.append(CorpusItem(doc_id=f"{doc_id}#chunk{k}", text=ch))

    for path in glob.glob("data/tickets.jsonl"):
        with open(path, "r", encoding="utf-8") as f:
            for n, line in enumerate(f):
                obj = json.loads(line)
                # 예: {"ticket_id":"T-19381","subject":"...","body":"...","lang":"ko"}
                raw = f"[SUBJECT] {obj.get('subject','')}\n[BODY]\n{obj.get('body','')}"
                for k, ch in enumerate(chunk_text(raw)):
                    items.append(CorpusItem(doc_id=f"{obj.get('ticket_id','T?')}#chunk{k}", text=ch))

    return items

def l2_normalize(x: np.ndarray) -> np.ndarray:
    norm = np.linalg.norm(x, axis=1, keepdims=True) + 1e-12
    return x / norm

def build_faiss_cosine_index(vectors: np.ndarray) -> faiss.Index:
    # cosine ~= inner product on normalized vectors
    vectors = l2_normalize(vectors).astype("float32")
    index = faiss.IndexFlatIP(vectors.shape[1])
    index.add(vectors)
    return index

def search(index: faiss.Index, doc_vectors: np.ndarray, query_vec: np.ndarray, top_k: int = 8) -> List[Tuple[int, float]]:
    q = l2_normalize(query_vec).astype("float32")
    scores, ids = index.search(q, top_k)
    return list(zip(ids[0].tolist(), scores[0].tolist()))

# -------------------------
# OpenAI Embedder
# -------------------------
class OpenAIEmbedder:
    def __init__(self, model: str = "text-embedding-3-large", dimensions: int | None = 1024):
        from openai import OpenAI
        self.client = OpenAI()
        self.model = model
        self.dim = dimensions if dimensions else (3072 if model.endswith("3-large") else 1536)

        # OpenAI는 dimensions 파라미터로 축소 가능 (저장/속도 vs 정확도 튜닝 포인트)
        self.dimensions = dimensions

    def embed(self, texts: List[str], *, is_query: bool) -> np.ndarray:
        # OpenAI embeddings API는 input 배열 지원
        resp = self.client.embeddings.create(
            model=self.model,
            input=texts,
            dimensions=self.dimensions
        )
        vecs = np.array([d.embedding for d in resp.data], dtype="float32")
        return vecs

# -------------------------
# Cohere Embedder
# -------------------------
class CohereEmbedder:
    def __init__(self, model: str = "embed-multilingual-v3.0", input_type_query: str = "search_query",
                 input_type_doc: str = "search_document", dim: int = 1024):
        import cohere
        self.client = cohere.Client(os.environ["COHERE_API_KEY"])
        self.model = model
        self.dim = dim
        self.input_type_query = input_type_query
        self.input_type_doc = input_type_doc

    def embed(self, texts: List[str], *, is_query: bool) -> np.ndarray:
        input_type = self.input_type_query if is_query else self.input_type_doc
        # Cohere는 dimension 옵션/Matryoshka를 공식적으로 제공(모델/버전에 따라 지원 범위 상이)
        resp = self.client.embed(
            model=self.model,
            texts=texts,
            input_type=input_type,
            embedding_types=["float"],
            truncate="END"
        )
        vecs = np.array(resp.embeddings.float, dtype="float32")
        # 일부 설정에서는 반환 dim이 model/config에 의해 결정되므로, 운영에선 여기서 assert로 고정 권장
        return vecs

# -------------------------
# BGE Embedder (로컬)
# -------------------------
class BGEEmbedder:
    def __init__(self, model_name: str = "BAAI/bge-m3"):
        from sentence_transformers import SentenceTransformer
        self.model = SentenceTransformer(model_name)
        # sentence-transformers는 get_sentence_embedding_dimension 제공
        self.dim = self.model.get_sentence_embedding_dimension()

    def embed(self, texts: List[str], *, is_query: bool) -> np.ndarray:
        # BGE류는 query/doc 프롬프트를 붙이는 레시피가 있는 경우가 많음(모델 카드 권장사항 확인)
        # 여기서는 운영 단순화를 위해 동일 인코딩(대신 자체 튜닝 여지 남김)
        vecs = self.model.encode(texts, batch_size=32, normalize_embeddings=True, show_progress_bar=False)
        return np.array(vecs, dtype="float32")

# -------------------------
# 파이프라인 실행
# -------------------------
def run(embedder: Embedder, query: str):
    corpus = load_corpus()
    doc_texts = [c.text for c in corpus]

    doc_vecs = embedder.embed(doc_texts, is_query=False)
    assert doc_vecs.shape[1] == embedder.dim or True  # Cohere/BGE는 dim이 유동일 수 있어 운영에선 엄격히 맞추세요.

    index = build_faiss_cosine_index(doc_vecs)

    q_vec = embedder.embed([query], is_query=True)
    hits = search(index, doc_vecs, q_vec, top_k=8)

    print(f"\n[Query] {query}\nTop hits:")
    for rank, (idx, score) in enumerate(hits, 1):
        item = corpus[idx]
        snippet = item.text[:120].replace("\n", " ")
        print(f"{rank:02d}. score={score:.4f} id={item.doc_id} :: {snippet}...")

if __name__ == "__main__":
    q = "결제 오류가 반복될 때 고객에게 어떤 안내를 해야 하나요? (한국어/영어 혼합 문서 포함)"
    # 1) OpenAI: 3-large를 1024d로 줄여 저장비/속도 타협
    # run(OpenAIEmbedder(model="text-embedding-3-large", dimensions=1024), q)

    # 2) Cohere: multilingual v3 기반 (운영 환경에 따라 v4 사용 고려)
    # run(CohereEmbedder(model="embed-multilingual-v3.0", dim=1024), q)

    # 3) BGE: 로컬/온프레
    run(BGEEmbedder("BAAI/bge-m3"), q)

예상 출력(형태)

  • “결제 오류”, “billing failure”, “payment declined”가 섞인 문서가 상위에 뜨고, 사내 위키의 “결제 장애 대응 Runbook” 섹션 chunk가 1~3위로 뜨는 형태가 정상입니다.

⚡ 실전 팁 & 함정

Best Practice (바로 효과 나는 것 3가지)

1) 차원(dimension)을 ‘저장비’가 아니라 ‘SLA’로 결정

  • 대규모 코퍼스(수백만 chunk)에서는 3072d→1024d만 내려도 RAM/디스크/인덱스 빌드 시간이 크게 줄고, 그게 곧 latency/비용으로 직결됩니다.
  • OpenAI는 dimensions로 축소 가능하고, Cohere는 matryoshka 차원 세트가 강점입니다. (openai.com)

2) “top-k는 embedding, 최종은 rerank”로 책임 분리

  • embedding은 recall 담당(많이 가져오기), reranker는 precision 담당(정확히 고르기).
  • 법률/정책/의료 같은 도메인에서 “embedding만으로 top-3 확정”은 사고 확률이 높습니다(문장 뉘앙스·예외조항·최신성 이슈).

3) MTEB 점수보다 ‘우리 쿼리 로그’ 200개가 더 가치 있음

  • 최소한 (a) 실제 질의 200개, (b) 정답 chunk 라벨(약식으로라도)을 만들어서,
    • Recall@k (k=10/20)
    • nDCG@10
    • “다국어/오탈자/짧은 쿼리” 별 slice
      로 비교하세요. 리더보드와 다른 결과가 나오는 건 흔합니다. (discuss.huggingface.co)

흔한 함정 / 안티패턴

  • (함정) 모델을 바꾸고 과거 임베딩을 그대로 사용
    임베딩 공간이 바뀌면 유사도 의미가 깨집니다. “모델 변경 = 재임베딩”이 원칙입니다(부분 마이그레이션은 듀얼 인덱스 등 설계 필요). (reddit.com)
  • (함정) cosine/IP metric 혼용 + 정규화 누락
    Cohere/OpenAI/BGE 조합에서 특히 많이 터집니다. “cosine로 검색”이면 문서/쿼리 모두 normalize를 습관화하세요.
  • (안티패턴) chunk를 너무 작게 쪼개서 의미가 깨짐
    고객문의/규정은 문맥 의존이 커서 과도한 분절이 오히려 성능을 내립니다. “chunk 크기”는 embedding 모델보다 먼저 튜닝할 때가 많습니다.

비용/성능/안정성 트레이드오프(2026년 4월 관점)

  • OpenAI 임베딩은 공개 가격표 기준으로 text-embedding-3-large가 더 비싸고, 3-small이 비용 효율이 좋다는 분석이 많습니다. (대량 인덱싱이면 batch 할인 언급도 자주 등장) (awesomeagents.ai)
  • Cohere는 모델에 따라 차원/컨텍스트/배포 옵션이 달라 “조직 제약”에 맞출 때 강점이 생깁니다(특히 matryoshka, 멀티모달/기업 배포 옵션 언급). (docs.cohere.com)
  • BGE-M3는 GPU가 있으면 토큰당 비용을 “내부 비용”으로 바꿀 수 있지만, 서빙 장애/스케일링/업그레이드까지 포함하면 총소유비용(TCO)이 다시 올라갈 수 있습니다. (huggingface.co)

🚀 마무리

핵심만 정리하면, 2026년 4월 기준 “OpenAI vs Cohere vs BGE”는 성능만의 싸움이 아니라 운영 모델의 선택입니다.

  • OpenAI(text-embedding-3-small/large) 를 고르기 좋은 팀
    • 빠르게 제품화, 품질 상향, dimensions로 저장비 튜닝까지 하고 싶다 (openai.com)
  • Cohere Embed(v3/v4) 가 맞는 팀
    • matryoshka 차원/긴 컨텍스트/기업 배포 옵션 등 “조직 제약”이 크다 (docs.cohere.com)
  • BGE-M3 가 맞는 팀
    • 온프레/사내망/데이터 레지던시가 1순위, 그리고 서빙을 직접 운영할 역량이 있다 (huggingface.co)

다음 학습/실험 추천: 1) 내 쿼리 200개로 Recall@20 + nDCG@10 미니 벤치 만들기
2) 차원 1536 vs 1024 vs 512로 내려가며 인덱스 크기/지연/품질 같이 측정
3) embedding 단독이 아니라 hybrid(BM25 + dense) + rerank까지 포함한 end-to-end로 비교

원하시면, “당신 도메인(예: 법률/CS/개발문서/쇼핑/논문)”과 “코퍼스 규모(문서 수/평균 길이/언어)”를 알려주시면, 위 코드 베이스로 모델 3종을 동일 조건으로 벤치마크하는 체크리스트 + 지표 설계까지 더 구체화해 드릴게요.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.