포스트

2026년 5월, Embedding 모델 3파전(OpenAI vs Cohere vs BGE-M3): “우리 도메인”에서 이기는 선택법

2026년 5월, Embedding 모델 3파전(OpenAI vs Cohere vs BGE-M3): “우리 도메인”에서 이기는 선택법

들어가며

Embedding 모델을 고르는 일은 결국 검색 품질(Recall/Precision), 운영 비용(토큰/스토리지/인덱싱), 데이터 거버넌스(외부 API vs 온프레)를 동시에 최적화하는 문제입니다. 특히 RAG/semantic search에서 “LLM이 똑똑해도 답이 엉뚱한” 원인의 상당수는 retrieval이 틀린 것(=embedding/인덱스/쿼리 설계 실패)에서 시작합니다.

언제 쓰면 좋은가

  • 문서 검색/FAQ/티켓/코드 검색처럼 “의미 기반으로 비슷한 것을 찾는” 문제가 있고,
  • BM25 같은 lexical 검색만으로는 동의어/표현 차이를 못 잡아 Recall이 떨어질 때
  • 사용자 질의가 짧고 다양(챗봇 질문, 고객 문의)하며, 운영 중 지속적으로 평가/튜닝할 때

언제 쓰면 안 되는가(혹은 단독으로 쓰면 안 되는가)

  • SKU, 모델명, 날짜, 고유명사, 약어가 중요한 도메인(커머스/부품/의료 코드/사내 약어 등)에서 embedding-only로 1차 후보를 만들면 “조용히” 망가집니다. 이 경우 BM25(또는 sparse) + dense 하이브리드가 안전합니다. (BGE-M3는 이 지점을 모델 레벨에서 한 번에 풀 수 있게 설계됨) (llmreference.com)
  • 이미 프로덕션에 수백만 벡터가 쌓여 있는데 모델을 바꾸려는 경우: 모델이 바뀌면 벡터 공간이 바뀌어 재임베딩이 필수라 마이그레이션 비용/다운타임 설계를 먼저 해야 합니다. (reddit.com)

🔧 핵심 개념

1) “Embedding 비교”에서 진짜 봐야 할 축

많은 팀이 “벤치마크 1등”만 보고 고르는데, 실무에서는 아래 축이 더 결정적입니다.

  • Vector 공간의 압축 전략
    • OpenAI text-embedding-3-large/3-small은 기본 차원이 크지만, dimensions 파라미터로 차원 축소(=Matryoshka-style truncation)를 지원해서 벡터DB 제약(예: 1024 dim 제한)이나 비용(스토리지/인덱스)을 맞추기 쉽습니다. (openai.com)
    • Cohere Embed는 int8/binary 같은 “압축된 embedding 타입”을 네이티브로 지원해 대규모 코퍼스에서 스토리지/캐시 비용이 강점이 됩니다. (docs.cohere.com)
  • 다국어/코드스위칭 내구성
    • OpenAI text-embedding-3-large는 영어/비영어 모두에 강한 범용 모델로 안내됩니다. (developers.openai.com)
    • Cohere는 영어/멀티링구얼을 모델 라인업으로 명확히 분리하고(v3 계열), 멀티링구얼에서 실무 사례가 많이 언급됩니다. (docs.cohere.com)
    • BGE-M3는 “멀티링구얼 + 멀티 기능(dense/sparse/multi-vector)”을 한 모델이 동시에 제공하는 설계가 핵심입니다. (arxiv.org)
  • 하이브리드(lexical + semantic) 구성이 쉬운가
    • OpenAI/Cohere는 기본적으로 dense 벡터 중심이라, BM25를 별도 파이프라인으로 붙여야 합니다.
    • BGE-M3는 논문/모델 카드 기준으로 dense뿐 아니라 sparse(lexical weights) + ColBERT-style multi-vector까지 한 번에 낼 수 있어, “브랜드명/ID/숫자” 같은 lexical 신호를 같이 가져가기 유리합니다. (llmreference.com)

2) 내부 작동 흐름(실무 관점)

Embedding 기반 검색의 흐름은 단순합니다. 다만 “어디서 정보가 소실되는지”를 알아야 튜닝이 됩니다.

  1. 문서 전처리/Chunking
    • 문서를 chunk로 나누는 순간, 모델은 chunk 단위 의미만 담습니다.
    • 즉 “정답이 들어있는 chunk가 후보로 안 올라오면” LLM은 절대 맞출 수 없습니다.
  2. 임베딩 생성
    • OpenAI: 기본 text-embedding-3-small=1536, 3-large=3072 차원, 필요 시 dimensions로 줄임. (platform.openai.com)
    • Cohere: 모델별로 차원 선택(256/512/1024/1536) 및 int8/binary 같은 embedding 타입 선택 가능. (docs.cohere.com)
    • BGE-M3: dense/sparse/multi-vector를 함께 산출하는 멀티-헤드 성격(구현체에 따라 반환 형태 상이). (llmreference.com)
  3. Vector DB 인덱싱 & 검색
    • cosine/dot 같은 근접도 기반 ANN 검색.
    • 압축(int8/binary) 또는 차원 축소를 하면 비용이 줄지만, 도메인별로 손실이 다르게 나타납니다(짧은 쿼리/고유명사 위주면 손실이 커질 수 있음).
  4. Rerank(선택)
    • embedding은 1차 후보 생성용, 최종 정확도는 reranker가 책임지는 구조가 일반적.
    • 이 글의 범위는 embedding 비교지만, 실무에서는 “embedding 바꾸기 전에” chunk/top_k/rerank 유무를 먼저 고정하고 비교해야 합니다(평가 드리프트 방지).

💻 실전 코드

아래 예제는 “사내 기술문서/티켓” 코퍼스에서 OpenAI vs Cohere vs BGE-M3를 같은 조건으로 비교하고, 도메인별 선택을 위한 오프라인 평가 세트까지 굴리는 현실적인 형태를 목표로 합니다.

0) 의존성/환경

1
2
3
4
5
6
7
8
9
python -m venv .venv
source .venv/bin/activate

pip install -U openai cohere \
  sentence-transformers torch \
  numpy pandas tqdm scikit-learn

# (선택) 벡터DB 대신 로컬 FAISS를 쓰려면
pip install faiss-cpu

1) 공통 데이터 형태(문서/질문-정답 쌍)

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
# file: eval_embeddings.py
import os, json
from dataclasses import dataclass
from typing import List, Dict, Tuple
import numpy as np
from tqdm import tqdm

@dataclass
class Doc:
    doc_id: str
    text: str
    meta: Dict

@dataclass
class QA:
    qid: str
    query: str
    relevant_doc_ids: List[str]  # 최소 1개 이상

def load_corpus(path: str) -> List[Doc]:
    # 예: 사내 위키/티켓을 chunking해서 만든 JSONL
    docs = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            obj = json.loads(line)
            docs.append(Doc(obj["doc_id"], obj["text"], obj.get("meta", {})))
    return docs

def load_qas(path: str) -> List[QA]:
    qas = []
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            obj = json.loads(line)
            qas.append(QA(obj["qid"], obj["query"], obj["relevant_doc_ids"]))
    return qas

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

def recall_at_k(ranked_doc_ids: List[str], relevant: set, k: int) -> float:
    return 1.0 if len(relevant.intersection(ranked_doc_ids[:k])) > 0 else 0.0

def mrr_at_k(ranked_doc_ids: List[str], relevant: set, k: int) -> float:
    for i, did in enumerate(ranked_doc_ids[:k], start=1):
        if did in relevant:
            return 1.0 / i
    return 0.0

def brute_force_search(query_vecs: np.ndarray, doc_vecs: np.ndarray, doc_ids: List[str], top_k=20):
    # cosine 유사도: normalize 후 dot
    q = l2_normalize(query_vecs)
    d = l2_normalize(doc_vecs)
    sims = q @ d.T
    idx = np.argsort(-sims, axis=1)[:, :top_k]
    return [[doc_ids[j] for j in row] for row in idx]

2) OpenAI / Cohere / BGE-M3 임베더 구현

핵심은 “동일한 chunk, 동일한 평가셋”에 대해 임베딩만 갈아끼우는 구조입니다.

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
# file: embedders.py
import os
import numpy as np

# OpenAI
from openai import OpenAI

# Cohere
import cohere

# BGE-M3 (local)
from sentence_transformers import SentenceTransformer

class OpenAIEmbedder:
    def __init__(self, model="text-embedding-3-large", dimensions=None):
        self.client = OpenAI()
        self.model = model
        self.dimensions = dimensions

    def embed(self, texts):
        kwargs = {"model": self.model, "input": texts}
        # OpenAI는 dimensions로 벡터 길이 축소 가능 ([platform.openai.com](https://platform.openai.com/docs/guides/embeddings/embedding-models%20.class?utm_source=openai))
        if self.dimensions is not None:
            kwargs["dimensions"] = self.dimensions
        res = self.client.embeddings.create(**kwargs)
        return np.array([e.embedding for e in res.data], dtype=np.float32)

class CohereEmbedder:
    def __init__(self, model="embed-multilingual-v3.0", input_type="search_document",
                 embedding_types=("float",), truncate="END"):
        self.co = cohere.ClientV2(os.environ["COHERE_API_KEY"])
        self.model = model
        self.input_type = input_type
        self.embedding_types = list(embedding_types)
        self.truncate = truncate

    def embed(self, texts):
        # Cohere는 float/int8/binary 등을 선택 가능 ([docs.cohere.com](https://docs.cohere.com/v2/docs/semantic-search-with-cohere?utm_source=openai))
        res = self.co.embed(
            model=self.model,
            input_type=self.input_type,
            embedding_types=self.embedding_types,
            texts=texts,
            truncate=self.truncate,
        )
        # float를 기본 경로로 사용
        return np.array(res.embeddings.float, dtype=np.float32)

class BGEM3Embedder:
    def __init__(self, model_name="BAAI/bge-m3", device="cpu"):
        # bge-m3는 1024 dim, 8k 컨텍스트로 알려진 멀티링구얼 모델 ([huggingface.co](https://huggingface.co/BAAI/bge-m3?utm_source=openai))
        self.model = SentenceTransformer(model_name, device=device)

    def embed(self, texts):
        vecs = self.model.encode(texts, batch_size=32, normalize_embeddings=False)
        return np.array(vecs, dtype=np.float32)

3) 실제 평가 실행: Recall@20, MRR@20로 “도메인 적합도” 판단

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
# file: run_eval.py
import numpy as np
from tqdm import tqdm
from eval_embeddings import load_corpus, load_qas, brute_force_search, recall_at_k, mrr_at_k
from embedders import OpenAIEmbedder, CohereEmbedder, BGEM3Embedder

def eval_embedder(name, embedder, docs, qas, top_k=20):
    doc_ids = [d.doc_id for d in docs]
    doc_texts = [d.text for d in docs]

    # 1) 문서 임베딩
    doc_vecs = []
    for i in tqdm(range(0, len(doc_texts), 64), desc=f"[{name}] embed docs"):
        doc_vecs.append(embedder.embed(doc_texts[i:i+64]))
    doc_vecs = np.vstack(doc_vecs)

    # 2) 질의 임베딩
    q_vecs = []
    queries = [q.query for q in qas]
    for i in tqdm(range(0, len(queries), 64), desc=f"[{name}] embed queries"):
        q_vecs.append(embedder.embed(queries[i:i+64]))
    q_vecs = np.vstack(q_vecs)

    # 3) 검색 & 지표
    ranked = brute_force_search(q_vecs, doc_vecs, doc_ids, top_k=top_k)

    r20, mrr20 = 0.0, 0.0
    for q, cand in zip(qas, ranked):
        rel = set(q.relevant_doc_ids)
        r20 += recall_at_k(cand, rel, 20)
        mrr20 += mrr_at_k(cand, rel, 20)

    n = len(qas)
    return {"name": name, "recall@20": r20/n, "mrr@20": mrr20/n}

if __name__ == "__main__":
    docs = load_corpus("corpus.jsonl")
    qas = load_qas("qas.jsonl")

    results = []
    results.append(eval_embedder(
        "openai_3_large_1024d",
        OpenAIEmbedder(model="text-embedding-3-large", dimensions=1024),
        docs, qas
    ))
    results.append(eval_embedder(
        "cohere_multilingual_v3_float",
        CohereEmbedder(model="embed-multilingual-v3.0", input_type="search_document", embedding_types=("float",)),
        docs, qas
    ))
    results.append(eval_embedder(
        "bge_m3_local",
        BGEM3Embedder(model_name="BAAI/bge-m3", device="cpu"),
        docs, qas
    ))

    for r in results:
        print(r)

예상 출력(형태)

1
2
3
{'name': 'openai_3_large_1024d', 'recall@20': 0.91, 'mrr@20': 0.62}
{'name': 'cohere_multilingual_v3_float', 'recall@20': 0.89, 'mrr@20': 0.59}
{'name': 'bge_m3_local', 'recall@20': 0.93, 'mrr@20': 0.65}

숫자는 코퍼스/언어/질의 분포에 따라 완전히 달라집니다. 중요한 건 “우리 도메인 QA셋”으로 측정하는 것입니다.


⚡ 실전 팁 & 함정

Best Practice 1) “도메인별 선택”은 언어/질의 형태로 먼저 갈린다

  • 영어 중심 + 품질 최우선 + 운영 단순성: OpenAI text-embedding-3-large가 기본 선택이 되기 쉽고, dimensions로 스토리지/DB 제약을 맞추며 단계적으로 튜닝할 수 있습니다. (openai.com)
  • 다국어/코드스위칭이 실제 트래픽의 핵심: Cohere의 multilingual 라인이 실무에서 빠르게 성능 격차를 메웠다는 보고가 있고, 문서/쿼리 타입을 분리해 운영하기가 편합니다. (docs.cohere.com)
  • ID/상품명/약어가 중요한 검색 + 하이브리드가 필수: BGE-M3처럼 dense+sparse(+multi-vector) 성격을 활용하면 “embedding이 놓치는 lexical 단서”를 구조적으로 보완할 수 있습니다. (llmreference.com)

Best Practice 2) 저장비용은 “차원”만이 아니라 “dtype/압축”이 좌우한다

  • Cohere는 int8/binary embedding을 모델/플랫폼 차원에서 지원해, 대규모 코퍼스에서 벡터 저장 비용과 캐시 비용을 크게 줄이는 방향이 명확합니다. (cohere.com)
  • OpenAI는 차원 축소(dimensions)가 핵심 레버입니다(예: 3072 → 1024/256). (openai.com)
  • : “압축을 하면 품질이 얼마나 떨어지나?”는 일반론이 아니라 당신의 쿼리 분포에 종속됩니다. (고유명사/짧은 쿼리가 많을수록 손실이 눈에 띄는 경우가 흔함)

함정 1) 모델 스왑은 ‘한 줄 변경’이지만, 재임베딩은 절대 한 줄이 아니다

  • 임베딩 모델을 바꾸면 기존 벡터와 공간이 호환되지 않아 재임베딩이 필요합니다. 이때
    • 새 인덱스를 병렬 구축하고,
    • dual-read(구 인덱스 + 신 인덱스)로 점진 전환하며,
    • 온라인 지표(검색 클릭/다운스트림 해결률)로 검증 같은 운영 설계가 필요합니다. (reddit.com)

함정 2) “embedding만 바꿔서 좋아졌다”는 착각

  • chunking, top_k, 필터링, 언어 감지, 쿼리 정규화(특수문자/코드/스페이스) 같은 무료 개선 포인트를 고정하지 않으면, 모델 비교 실험이 노이즈 게임이 됩니다.
  • 특히 다국어는 “질의 언어 감지 실패”가 retrieval 실패로 바로 이어질 수 있습니다(세션 단위 고정/발화 단위 감지 등). (reddit.com)

🚀 마무리

핵심은 “2026년 5월 기준 어떤 모델이 제일 좋나?”가 아니라, 내 도메인에서 어떤 실패 모드를 가장 싸게/안전하게 막을 수 있나입니다.

  • OpenAI: text-embedding-3-large/small + dimensions로 품질↔비용을 미세 조정하기 좋고, 운영 단순성이 강점. (platform.openai.com)
  • Cohere: multilingual + 압축(int8/binary) 같은 대규모 운영 친화성이 매력. (docs.cohere.com)
  • BGE-M3: 온프레/오픈웨이트 선호, 그리고 dense+sparse(+multi-vector) 하이브리드 요구가 강한 도메인에서 설계 상 유리. (huggingface.co)

도입 판단 기준(실무 체크리스트) 1) 우리 트래픽에서 상위 50~200개의 “실패 질의”를 모아 QA셋을 만든다
2) chunking/top_k/필터를 고정한 뒤 embedding만 바꿔 Recall@20, MRR@20로 비교한다
3) 비용은 “토큰 + 저장(차원×dtype) + 재임베딩 마이그레이션”까지 포함해 계산한다
4) 다국어/ID 중심이면 하이브리드를 기본 전제로 설계한다

다음 단계로는, 위 평가 코드에 (a) BM25 결합, (b) Cohere int8/binary 경로, (c) reranker 추가까지 넣어 “embedding 선택이 아니라 retrieval 스택 선택”으로 확장하면, 모델 교체 빈도를 크게 줄이면서 품질을 더 안정적으로 끌어올릴 수 있습니다.

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