[Embedding] BGE-M3부터 ColBERT까지: 2026년 Embedding 모델 완벽가이드

당니·2026년 2월 3일

LLM

목록 보기
18/19
post-thumbnail

RAG 시스템에서 가장 많은 엔지니어들이 놓치는 포인트가 있습니다.
LLM 모델 교체보다 Embedding 모델 교체가 검색 정확도를 2배 이상 바꾸는 경우가 흔합니다.
이번 글에서는 Embedding의 원리부터, 2026년 현재 최신 모델 비교, 고급 기법(ColBERT, Matryoshka, Late Chunking), 그리고 한국어 특화 실무까지 깊이 다루겠습니다.


목차

  1. Embedding이 왜 중요한가?
  2. Embedding의 원리
  3. 모델 분류: Proprietary vs Open-Source
  4. 2026년 최신 모델 비교
  5. 핵심 고급 기법
  6. 한국어 특화 가이드
  7. Fine-Tuning: 도메인 특화
  8. 실무 구현 패턴
  9. 모델 선택 의사결정 프레임워크

1. Embedding이 왜 중요한가?

RAG 파이프라인에서의 위치

사용자 쿼리
    ↓
┌─────────────┐
│  Embedding  │  ← 여기서 검색 품질이 결정됨
│   모델      │
└─────────────┘
    ↓
벡터 검색 (Vector Search)
    ↓
Reranking (선택)
    ↓
LLM에 컨텍스트 전달
    ↓
최종 답변 생성

Embedding 모델은 쿼리와 문서를 동일한 벡터 공간으로 변환하는 역할입니다.
여기서 잘못되면 아무리 좋은 LLM과 Reranker를 붙여도 쓰레스크가 들어오는 구조입니다.

실제 영향도

Embedding 모델의 품질은 검색 정확도에 직접 영향을 미치며, 좋은 Embedding은 단순 키워드 매칭이 아닌 의미적 유사도를 파악하여 관련 문서를 찾을 수 있습니다.

실제로 AI 에이전트가 잘못된 컨텍스트를 반환하는 경우, 문제의 원인은 LLM이 아닌 Embedding 모델일 가능성이 높습니다.


2. Embedding의 원리

2.1 텍스트 → 벡터: 무엇이 일어나는가?

"한국어 NLP 튜토리얼"
        ↓  Tokenization
["한국어", "NLP", "튜토리얼"]
        ↓  Token Embedding (Lookup)
[0.12, -0.45, 0.78, ...]   — 각 토큰의 초기 벡터
        ↓  Transformer Layers (컨텍스트 학습)
[0.34, -0.21, 0.91, ...]   — 문장 전체의 의미를 담은 벡터
        ↓  Pooling (CLS / Mean)
[0.29, -0.38, 0.85, ...]   — 최종 문장 Embedding (예: 768차원)

2.2 유사도 측정

두 벡터 사이의 거리를 계산하여 유사도를 판단합니다.

import numpy as np

def cosine_similarity(vec_a, vec_b):
    """
    가장 많이 사용되는 유사도 함수
    범위: -1 (반대) ~ 0 (무관) ~ 1 (동일)
    """
    dot_product = np.dot(vec_a, vec_b)
    norm_a = np.linalg.norm(vec_a)
    norm_b = np.linalg.norm(vec_b)
    return dot_product / (norm_a * norm_b)

# 예시
query_vec   = embed("한국어 NLP 튜토리얼")   # [0.29, -0.38, ...]
doc_a_vec   = embed("한국어 자연어 처리 교재") # 유사하지 않은 문서
doc_b_vec   = embed("Python으로 배우는 NLP")  # 관련이 있지만 다른 주제

print(cosine_similarity(query_vec, doc_a_vec))  # 높은 값 (예: 0.87)
print(cosine_similarity(query_vec, doc_b_vec))  # 낮은 값 (예: 0.52)

2.3 Bi-Encoder vs Cross-Encoder

이 구분이 Embedding 모델의 아키텍처를 결정하는 핵심입니다.

┌──────────────────────────────────────────────┐
│            Bi-Encoder (쌍 독립 처리)          │
│                                              │
│   Query ──→ [Encoder] ──→ 벡터 Q            │
│   Doc   ──→ [Encoder] ──→ 벡터 D            │
│                                              │
│   유사도 = cosine_sim(Q, D)                  │
│                                              │
│   ✅ 빠름 (벡터를 미리 저장 가능)            │
│   ✅ 대량 검색에 최적                        │
│   ❌ 교차 정보 활용 불가                     │
└──────────────────────────────────────────────┘

┌──────────────────────────────────────────────┐
│          Cross-Encoder (쌍 결합 처리)         │
│                                              │
│   [Query + Doc] ──→ [Encoder] ──→ Score      │
│                                              │
│   ✅ 정확도 높음 (교차 어텐션)              │
│   ✅ Reranking에 최적                        │
│   ❌ 느림 (매번 쌍마다 계산)                │
│   ❌ 벡터 미리 저장 불가                     │
└──────────────────────────────────────────────┘

실무 패턴:

1차 검색: Bi-Encoder (빠르게 Top 100 후보 추출)
    ↓
2차 정제: Cross-Encoder Reranking (정확하게 Top 5 선정)
    ↓
LLM

3. 모델 분류: Proprietary vs Open-Source

3.1 Proprietary (폐쇄형) 모델

장점: 유지보수 불필요, 성능 안정적, API로 간단
단점: 비용, Vendor Lock-in, 데이터 프라이버시

# OpenAI Embedding
from openai import OpenAI

client = OpenAI()
response = client.embeddings.create(
    model="text-embedding-3-large",
    input="한국어 NLP 튜토리얼"
)
embedding = response.data[0].embedding  # 3072차원

# Cohere Embedding
import cohere

co = cohere.Client("API_KEY")
response = co.embed(
    texts=["한국어 NLP 튜토리얼"],
    model="embed-v4.0",
    input_type="search_document"  # ← 반드시 지정
)
embedding = response.embeddings[0]  # 1024차원

3.2 Open-Source (오픈형) 모델

장점: 무료, 프라이버시, Fine-Tuning 가능, 커스튜마이징
단점: 직접 운영 부담, GPU 필요

# Sentence-Transformers (오픈소스 표준)
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-m3")
embeddings = model.encode(
    ["한국어 NLP 튜토리얼", "Python 프로그래밍"],
    normalize_embeddings=True  # 코사인 유사도에 최적
)

오픈소스 모델은 무료로 실행하고 Fine-Tuning할 수 있어 Vendor Lock-in을 피할 수 있으며, 최근 일부 오픈 모델들이 Benchmark에서 폐쇄형 모델을 능가하는 경우도 있습니다.


4. 2026년 최신 모델 비교

4.1 MTEB Leaderboard 기준 순위 (2025년 11월 기준)

MTEB Leaderboard 기준으로 주요 모델들의 순위는 다음과 같습니다:

Rank모델MTEB Score차원비용/1M tokens주요 특징
1Cohere embed-v465.21024$0.10다국어, 검색 최적화
2OpenAI text-embedding-3-large64.63072$0.13범용 고정밀
3Voyage AI voyage-3-large63.81536$0.12도메인 튜닝 강점
4BGE-M363.01024Free자체 운영, 다국어
5E5-Mistral-7B-Instruct61.84096Free오픈소스 최고
6Nomic-embed-text-v1.559.4768$0.05예산 효율적
7all-MiniLM-L6-v256.3384Free프로토타입 최적
8OpenAI text-embedding-3-small55.81536$0.02비용 효율적

4.2 각 모델 깊은 분석

Cohere embed-v4 — 현재 MTEB 1위

import cohere

co = cohere.Client("API_KEY")

# ⚠️ input_type 지정이 핵심 — 반드시 구분해야 함
# "search_document" : 저장할 문서
# "search_query"    : 검색 쿼리
# "classification"  : 분류 작업
# "clustering"      : 클러스터링 작업

# 문서 저장 시
doc_embeddings = co.embed(
    texts=["한국어 NLP 튜토리얼 내용..."],
    model="embed-v4.0",
    input_type="search_document"
)

# 검색 시
query_embeddings = co.embed(
    texts=["한국어 NLP 튜토리얼 찾고 싶어"],
    model="embed-v4.0",
    input_type="search_query"
)

장점: input_type 구분으로 Query와 Document의 벡터 공간을 최적화
단점: Vendor Lock-in, 비용

BGE-M3 — 오픈소스의 왕

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("BAAI/bge-m3")

# BGE-M3의 핵심: 3가지 검색 방식 동시 지원
# 1. Dense Retrieval (벡터 검색)
# 2. Sparse Retrieval (키워드 검색)
# 3. Multi-Vector (ColBERT 스타일)

embeddings = model.encode(
    ["한국어 NLP 튜토리얼"],
    normalize_embeddings=True
)

# Query 시 prefix 추가 (BGE 권장)
query_embedding = model.encode(
    ["Represent this sentence for searching relevant passages: "
     "한국어 NLP 튜토리얼 찾고 싶어"],
    normalize_embeddings=True
)

BGE-M3의 삼중 능력:

Dense Vector  ──→ 의미적 검색 (주요 검색 엔진)
    +
Sparse Vector ──→ 키워드 매칭 (정확한 용어 검색)
    +
Multi-Vector  ──→ 토큰별 벡터 (ColBERT 스타일 정밀 검색)

E5 시리즈 — Instruction-based Embedding

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("intfloat/multilingual-e5-large-instruct")

# E5는 Instruction을 앞에 붙여야 최적 성능
# 문서 저장 시
doc_emb = model.encode(["passage: 한국어 NLP 튜토리얼 내용..."])

# 검색 시
query_emb = model.encode(["query: 한국어 NLP 튜토리얼 찾고 싶어"])

4.3 실제 Benchmark 결과의 "함정"

MTEB 점수만 믿으면 안 됩니다. 실제 검색 정확도는 다를 수 있습니다.

한 벤치마크에서 OpenAI의 text-embedding-3-small은 관련성 점수(Relevance Score)에서 48.6%를 기록했지만 정확도(Accuracy)는 39.2%로 낮었습니다. 이는 일반적인 주제 영역은 파악하지만, 정확한 답변을 찾는 데 어려움이 있다는 의미입니다.

"Relevance Trap" — 관련한 문서를 찾아오지만 정확한 문서는 못 찾는 모델이 존재합니다.
반드시 자신의 데이터로 직접 평가해야 합니다.

실제 오픈소스 모델 벤치마크에서 e5-small(118M 파라미터)이 Top-5 정확도에서 100%를 달성하며, 7B 이상의 큰 모델들(82-90%)을 초과한 사례가 있었습니다. 또한 e5-small은 큰 모델보다 약 14배 빠른 속도(16ms)로 처리했습니다.


5. 핵심 고급 기법

5.1 ColBERT — Late Interaction의 혁명

기존 Bi-Encoder는 문서 전체를 벡터 하나로 압축합니다. ColBERT는 토큰마다 벡터를 만들어 정밀도를 급격히 올립니다.

┌─── 기존 Bi-Encoder ──────────────────┐
│                                      │
│  "Python 튜토리얼 가이드"            │
│         ↓                            │
│  [단일 벡터: 0.34, -0.21, ...]       │  ← 정보 압축 손실
│                                      │
└──────────────────────────────────────┘

┌─── ColBERT (Late Interaction) ───────┐
│                                      │
│  "Python 튜토리얼 가이드"            │
│     ↓        ↓        ↓             │
│  [Python] [튜토리얼] [가이드]        │  ← 토큰마다 독립 벡터
│  vec_1    vec_2      vec_3           │
│                                      │
└──────────────────────────────────────┘

ColBERT의 유사도 계산

import torch
import torch.nn.functional as F

def colbert_score(query_tokens_vecs, doc_tokens_vecs):
    """
    ColBERT Late Interaction Scoring

    각 Query 토큰에 대해:
      → Doc 전체 토큰과 유사도 계산
      → 가장 높은 유사도만 선택 (MaxSim)
    → 모든 Query 토큰의 MaxSim 합산
    """
    # query_tokens_vecs: (num_query_tokens, dim)
    # doc_tokens_vecs:   (num_doc_tokens, dim)

    # 모든 쌍의 코사인 유사도 행렬 계산
    similarity_matrix = torch.matmul(
        query_tokens_vecs,
        doc_tokens_vecs.T
    )
    # shape: (num_query_tokens, num_doc_tokens)

    # 각 Query 토큰의 최대 유사도 (MaxSim)
    max_sim_per_query_token = similarity_matrix.max(dim=1).values
    # shape: (num_query_tokens,)

    # 최종 스코어: MaxSim 합산
    score = max_sim_per_query_token.sum()

    return score.item()

# 예시
# Query: "한국어 NLP"  →  [vec_한국어, vec_NLP]
# Doc:   "한국어 자연어 처리 튜토리얼" → [vec_한국어, vec_자연어, vec_처리, vec_튜토리얼]
#
# MaxSim(vec_한국어) = max(sim(한국어,한국어), sim(한국어,자연어), ...) → 높음
# MaxSim(vec_NLP)    = max(sim(NLP,한국어), sim(NLP,자연어), ...)    → 중간
# Score = MaxSim(한국어) + MaxSim(NLP)

ColBERT 실무 적용

# Jina ColBERT v2 — 다국어 ColBERT (89개 언어 지원)
from jina_embeddings_v2 import JinaColBERTv2

model = JinaColBERTv2()

# 문서 인덱싱
doc_vectors = model.encode_documents(
    ["한국어 NLP 튜토리얼", "Python 프로그래밍 가이드"],
    return_token_vectors=True  # 토큰별 벡터 반환
)

# 검색
query_vectors = model.encode_queries(
    ["한국어 자연어 처리 방법"],
    return_token_vectors=True
)

# Late Interaction Scoring으로 랭킹
scores = model.score(query_vectors, doc_vectors)

Jina ColBERT v2는 원본 ColBERT-v2 대비 6.5% 성능 향상과 89개 언어 다국어 지원을 제공하며, Matryoshka Representation Learning을 통해 128, 96, 64차원의 유연한 출력 크기를 지원합니다.

ColBERT의 장단점

장점:
  ✅ Bi-Encoder보다 정밀한 매칭 (토큰 수준)
  ✅ Cross-Encoder보다 빠름 (미리 벡터 저장 가능)
  ✅ 법률, 금융 등 정밀도가 중요한 영역에서 강함

단점:
  ❌ 저장 비용 높음 (토큰마다 벡터)
  ❌ 단일 벡터 모델보다 ~10x 저장 공간 필요

ColBERTv2에서는 공격적인 양자화를 적용하여 각 벡터의 크기를 256바이트에서 36바이트(2비트 압축)까지 줄일 수 있으며, 정확도를 유지하면서도 저장 비용을 크게 절감할 수 있습니다.

ColPali / ColQwen — 시각 문서 검색

기존 방식:
  PDF → OCR → 텍스트 추출 → Chunking → Embedding
  (표, 그래프, 이미지 정보 손실)

ColPali / ColQwen 방식:
  PDF → 페이지 스크린샷 → Vision Encoder → 토큰 벡터
  (시각 정보 그대로 유지)

ColPali와 ColQwen은 PDF를 이미지로 처리하여, OCR과 복잡한 Chunking 없이 텍스트와 표, 그래프 등의 시각 정보를 함께 검색할 수 있게 합니다.


5.2 Matryoshka Representation Learning (MRL)

러시아 인켜 인기 완구(마트료시카)처럼, 하나의 학습으로 여러 차원의 벡터를 생성할 수 있는 기법입니다.

핵심 아이디어

학습 시:
  Full Embedding: [d1, d2, d3, ..., d512, ..., d1024]
                   └───┘  └────────┘  └──────────────┘
                  64차원   256차원       1024차원
                   ↑         ↑            ↑
              접두사(prefix)로 잘라도 유효한 Embedding!

추론 시:
  상황에 따라 차원을 자유롭게 선택

실무 적용: 2단계 검색

from sentence_transformers import SentenceTransformer

# Matryoshka 지원 모델
model = SentenceTransformer("nomic-ai/nomic-embed-text-v1.5")

# 전체 Embedding 생성 (768차원)
full_embedding = model.encode("한국어 NLP 튜토리얼")

# ✅ 1차 검색: 작은 차원으로 빠르게 후보 추출
candidate_embedding = full_embedding[:256]  # 256차원만 사용
candidates = fast_vector_search(candidate_embedding, top_k=100)

# ✅ 2차 정제: 전체 차원으로 정밀 검색
final_embedding = full_embedding  # 768차원 전체 사용
results = precise_rerank(final_embedding, candidates, top_k=5)

비용 vs 정확도 트라이오프

차원    │ 저장 비용  │ 검색 속도  │ 정확도
────────┼────────────┼────────────┼────────
 64     │ ████░░░░░░ │ ██████████ │ ██░░░░
128     │ ██████░░░░ │ ████████░░ │ ███░░░
256     │ ████████░░ │ ██████░░░░ │ █████░
512     │ █████████░ │ ████░░░░░░ │ ██████
1024    │ ██████████ │ ██░░░░░░░░ │ ███████

EmbeddingGemma-300M은 Matryoshka Representation Learning을 활용하여 768 → 512 → 256 → 128차원의 출력 축소를 지원하며, 이를 통해 저장 공간과 검색 속도의 균형을 유연하게 조절할 수 있습니다.


5.3 Late Chunking — Embedding과 Chunking의 순서 뒤집기

이전 글(Chunking 가이드)에서도 언급했지만, 이번에는 Embedding 관점에서 깊이 설명합니다.

문제: 기존 방식의 맥락 손실

문서:
  "Alice는 숲에서 산책 중 토끼 구멍을 발견했다.
   그녀는 구멍에 빠져 이상한 세계로 떨어져 했다."

기존 방식 (Chunk → Embed):
  Chunk 1: "Alice는 숲에서 산책 중 토끼 구멍을 발견했다."
  Chunk 2: "그녀는 구멍에 빠져 이상한 세계로 떨어져 했다."
           ↑
           "그녀"가 Alice임을 알 수 없음! (맥락 단절)

  → 검색: "Alice가 어디에 떨어졌나?"
  → Chunk 2의 Embedding에 "Alice" 정보가 없어 검색 실패

Late Chunking의 해결

Late Chunking은 긴 컨텍스트 Embedding 모델을 활용하여 먼저 전체 텍스트의 모든 토큰을 Embedding한 후, Transformer 모델 이후 Mean Pooling 직전에 Chunking을 적용합니다. 이를 통해 각 청크의 Embedding이 전체 문서의 맥락 정보를 포함할 수 있습니다.

Late Chunking 방식 (Embed → Chunk):

  1. 전체 문서를 한 번에 Embedding
     "Alice는 숲에서... 그녀는 구멍에..."
     ↓ Transformer (전체 맥락 학습)
     [tok_Alice, tok_는, tok_숲에서, ..., tok_그녀, tok_는, ...]
      ← "그녀"의 벡터가 이미 "Alice"의 맥락을 포함 →

  2. Chunking은 Pooling 직전에만 수행
     Chunk 1 tokens: [tok_Alice, tok_는, tok_숲에서, ...]
     Chunk 2 tokens: [tok_그녀, tok_는, tok_구멍에, ...]
     ↓ Mean Pooling (각 청크별)
     Chunk 1 Embedding: [0.34, -0.21, ...]  — Alice 맥락 포함
     Chunk 2 Embedding: [0.41, -0.18, ...]  — Alice 맥락 포함 ✅

구현

import torch
import numpy as np
from transformers import AutoTokenizer, AutoModel

def late_chunking(
    text: str,
    chunk_boundaries: list[tuple[int, int]],
    model_name: str = "jinaai/jina-embeddings-v2-base-en"
):
    """
    Late Chunking 구현

    Args:
        text: 원본 텍스트 (전체)
        chunk_boundaries: [(start_token, end_token), ...]
        model_name: Long-context 임베딩 모델
    """
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    model = AutoModel.from_pretrained(model_name)

    # 1. 전체 텍스트를 한 번에 Tokenize & Encode
    inputs = tokenizer(text, return_tensors="pt", truncation=False)
    with torch.no_grad():
        outputs = model(**inputs)

    # 2. 토큰 레벨 Embedding 추출 (마지막 레이어)
    token_embeddings = outputs.last_hidden_state[0]
    # shape: (num_tokens, hidden_dim)

    # 3. Chunk 경계별로 Mean Pooling
    chunk_embeddings = []
    for start, end in chunk_boundaries:
        chunk_emb = token_embeddings[start:end].mean(dim=0)
        chunk_embeddings.append(chunk_emb.numpy())

    return np.array(chunk_embeddings)

Late Chunking은 ColBERT의 정밀도와 기존 Naive 접근법의 비용 효율성 사이의 균형점(Goldilocks)을 제공하며, 기존 방식과 동일한 저장 공간으로도 맥락 정보를 보존할 수 있습니다.


5.4 Voyage-context-3 — Contextualized Chunk Embedding의 최신

Voyage-context-3은 청크별 세부 내용과 전체 문서 맥락을 동시에 캡처하는 컨텍스트화된 청크 Embedding 모델로, OpenAI-v3-large 대비 청크 수준 검색에서 14.24%, 문서 수준 검색에서 12.56% 향상을 보여줍니다.

import voyageai

vo = voyageai.Client()

# 기존 모델과 동일한 API — Drop-in Replacement
embeddings = vo.embed(
    texts=["청크 내용..."],
    model="voyage-context-3",
    input_type="document"
)
# 내부적으로 문서 맥락을 자동 캡처 — 수동 작업 불필요

Voyage-context-3은 Matryoshka 학습과 양자화 옵션을 지원하여, OpenAI-v3-large(float, 3072차원)와 비교했을 때 벡터 DB 비용을 83% 절감하면서도 검색 품질을 8.60% 향상시킬 수 있습니다.


6. 한국어 특화 가이드

6.1 한국어 Embedding의 특수성

영어:  "I love natural language processing"
한국어: "자연어 처리를 좋아해요"

영어는 공백으로 단어가 명확히 분리됨
한국어는 조사(를), 어미(해요)가 붙어 형태변환이 복잡함

→ 한국어 적합한 Tokenizer & Pre-training이 중요

6.2 모델 선택: 다국어 vs 한국어 특화

모델타입한국어 성능추천 시
BGE-M3다국어⭐⭐⭐⭐⭐범용 한국어 RAG (최우선 추천)
multilingual-e5-large-instruct다국어⭐⭐⭐⭐⭐Instruction-based 검색
ko-sroberta-multitask한국어 특화⭐⭐⭐⭐경량, 빠른 한국어 검색
KoSimCSE-roberta한국어 특화⭐⭐⭐⭐문장 유사도 중심
KURE한국어 특화⭐⭐⭐⭐⭐한국어 Retrieval 전용
Qwen3-Embedding다국어⭐⭐⭐⭐중국어+한국어 혼합 시

한국어 임베딩 모델 비교 테스트에서 BGE-M3가 retrieval_f1 점수 0.35로 최고, retrieval_recall 점수 0.7로 가장 높은 점수를 기록했으며, 실행 시간도 0.21초로 다른 모델들에 비해 빠른 편이었습니다.

6.3 한국어 모델 실제 사용

from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np

# ===== Option A: BGE-M3 (한국어 포함 다국어, 최우선 추천) =====
model_bge = SentenceTransformer("BAAI/bge-m3")

# ===== Option B: 한국어 특화 경량 모델 =====
model_ko = SentenceTransformer("jhgan/ko-sroberta-multitask")

# ===== Option C: KURE (한국어 Retrieval 전용) =====
model_kure = SentenceTransformer("nlpai-lab/KURE-base")

# ---------------------------------------------------
# 실제 비교 테스트
# ---------------------------------------------------
queries = [
    "한국어 자연어 처리 방법은?",
    "RAG 시스템 구축 가이드"
]

documents = [
    "한국어 NLP는 형태소 분석과 의사결정 등을 포함합니다.",
    "RAG는 Retrieval Augmented Generation으로 외부 지식을 활용하여 답변을 생성합니다.",
    "Python은 프로그래밍 언어입니다."
]

def evaluate_model(model, queries, documents):
    doc_embs = model.encode(documents, normalize_embeddings=True)
    for query in queries:
        q_emb = model.encode([query], normalize_embeddings=True)
        scores = cosine_similarity(q_emb, doc_embs)[0]
        ranked = np.argsort(scores)[::-1]
        print(f"\nQuery: {query}")
        for rank, idx in enumerate(ranked):
            print(f"  #{rank+1} (score={scores[idx]:.3f}): {documents[idx][:40]}...")

print("===== BGE-M3 =====")
evaluate_model(model_bge, queries, documents)

print("\n===== ko-sroberta =====")
evaluate_model(model_ko, queries, documents)

6.4 한국어 리더보드 참고

Korea University에서 공개한 KURE(Korea University Retrieval Embedding)는 BGE-M3를 기반으로 한국어 Retrieval 전용으로 학습된 모델이며, MTEB-ko-retrieval Leaderboard가 함께 공개되었습니다.

참고 리더보드:


7. Fine-Tuning: 도메인 특화

7.1 언제 Fine-Tuning이 필요한가?

Fine-Tuning 필요한 경우:
  ✅ 특수 도메인 용어가 많음 (법률, 의료, 금융)
  ✅ 기본 모델의 검색 정확도가 낮음 (<60%)
  ✅ 회사 내부 용어나 프로세스가 특수
  ✅ 데이터가 충분 (최소 500쌍 이상)

Fine-Tuning 불필요한 경우:
  ❌ 일반적인 질문/답변 시스템
  ❌ 데이터가 적음 (<100쌍)
  ❌ 빠른 프로토타입 필요
  ❌ 기본 모델 정확도가 충분 (>80%)

7.2 Fine-Tuning 파이프라인

from sentence_transformers import SentenceTransformer, InputExample, losses
from torch.utils.data import DataLoader

# ===== 1단계: 학습 데이터 준비 =====
# (positive pair: 관련 쿼리-문서 쌍)
train_examples = [
    InputExample(
        texts=[
            "환자의 혈당 수치 확인 방법은?",           # Query
            "당뇨 환자의 혈당 관리 가이드라인..."       # Positive Doc
        ]
    ),
    InputExample(
        texts=[
            "계약서 해제 조건은 어떻게 되나요?",
            "계약 해제 및 위반 시 처리 절차..."
        ]
    ),
    # ... 최소 500쌍 이상
]

# ===== 2단계: 기본 모델 로드 =====
model = SentenceTransformer("BAAI/bge-m3")  # 기본 모델

# ===== 3단계: Loss 함수 선택 =====
# MultipleNegativesRankingLoss: 가장 많이 사용
train_dataloader = DataLoader(train_examples, shuffle=True, batch_size=16)
train_loss = losses.MultipleNegativesRankingLoss(model)

# ===== 4단계: Fine-Tuning =====
model.fit(
    train_objectives=[(train_dataloader, train_loss)],
    epochs=3,
    warmup_steps=100,
    show_progress_bar=True
)

# ===== 5단계: 저장 & 평가 =====
model.save("./my-domain-embedding-model")

7.3 Hard Negative Mining — Fine-Tuning 품질을 올리는 핵심

def mine_hard_negatives(model, queries, documents, top_k=10):
    """
    Hard Negative Mining:
    모델이 '정말 비슷하지만 틀린' 문서를 찾아서 학습 데이터로 활용
    → 모델이 미묘한 차이를 학습
    """
    # 현재 모델로 검색
    q_embs = model.encode(queries)
    d_embs = model.encode(documents)
    scores = cosine_similarity(q_embs, d_embs)

    hard_negative_pairs = []
    for i, query in enumerate(queries):
        # 가장 유사한 문서 중에서 정답이 아닌 것 선택
        ranked_indices = scores[i].argsort()[::-1]
        for idx in ranked_indices[:top_k]:
            if documents[idx] != ground_truth[i]:  # 정답 제외
                hard_negative_pairs.append(
                    InputExample(
                        texts=[query, ground_truth[i]],  # Positive
                        label=1.0
                    )
                )
                hard_negative_pairs.append(
                    InputExample(
                        texts=[query, documents[idx]],   # Hard Negative
                        label=0.0
                    )
                )
                break

    return hard_negative_pairs

# 반복적 개선
for iteration in range(3):
    hard_negatives = mine_hard_negatives(model, queries, documents)
    # Fine-Tuning 수행
    model.fit(...)
    print(f"Iteration {iteration}: Accuracy = {evaluate(model)}")

8. 실무 구현 패턴

8.1 Full Embedding Pipeline

import os
import numpy as np
from sentence_transformers import SentenceTransformer
from typing import List, Dict
import logging

logger = logging.getLogger(__name__)

class EmbeddingPipeline:
    """
    프로덕션 Embedding 파이프라인
    """

    def __init__(self, model_name: str = "BAAI/bge-m3"):
        self.model = SentenceTransformer(model_name)
        self.model_name = model_name
        logger.info(f"Loaded embedding model: {model_name}")

    def embed_documents(
        self,
        documents: List[str],
        batch_size: int = 64,
        prefix: str = ""
    ) -> np.ndarray:
        """
        문서 배치 Embedding

        Args:
            documents: 문서 리스트
            batch_size: 배치 크기
            prefix: 모델별 권장 prefix
                    BGE: "Represent this document: "
                    E5:  "passage: "
        """
        prefixed_docs = [f"{prefix}{doc}" for doc in documents]

        embeddings = self.model.encode(
            prefixed_docs,
            batch_size=batch_size,
            normalize_embeddings=True,  # L2 정규화
            show_progress_bar=True
        )

        logger.info(f"Embedded {len(documents)} documents → shape {embeddings.shape}")
        return embeddings

    def embed_query(self, query: str, prefix: str = "") -> np.ndarray:
        """
        단일 Query Embedding

        BGE: "Represent this sentence for searching relevant passages: "
        E5:  "query: "
        """
        prefixed_query = f"{prefix}{query}"

        embedding = self.model.encode(
            [prefixed_query],
            normalize_embeddings=True
        )

        return embedding[0]

    def search(
        self,
        query: str,
        doc_embeddings: np.ndarray,
        documents: List[str],
        top_k: int = 5,
        query_prefix: str = "",
        doc_prefix: str = ""
    ) -> List[Dict]:
        """
        유사도 검색
        """
        from sklearn.metrics.pairwise import cosine_similarity

        q_emb = self.embed_query(query, prefix=query_prefix)
        scores = cosine_similarity([q_emb], doc_embeddings)[0]

        # Top-k 결과 정렬
        top_indices = scores.argsort()[-top_k:][::-1]

        results = []
        for rank, idx in enumerate(top_indices):
            results.append({
                "rank": rank + 1,
                "document": documents[idx],
                "score": float(scores[idx]),
                "index": int(idx)
            })

        return results


# ===== 모델별 사용법 차이 (prefix 중요!) =====

# 1. BGE-M3
pipeline_bge = EmbeddingPipeline("BAAI/bge-m3")
results = pipeline_bge.search(
    query="한국어 NLP 튜토리얼",
    doc_embeddings=doc_embs,
    documents=docs,
    query_prefix="Represent this sentence for searching relevant passages: ",
    doc_prefix=""  # 문서는 prefix 불필요
)

# 2. E5 시리즈
pipeline_e5 = EmbeddingPipeline("intfloat/multilingual-e5-large-instruct")
results = pipeline_e5.search(
    query="한국어 NLP 튜토리얼",
    doc_embeddings=doc_embs,
    documents=docs,
    query_prefix="query: ",
    doc_prefix="passage: "
)

# 3. Cohere (API)
# → input_type="search_query" / "search_document" 로 구분

8.2 Hybrid Search: Dense + Sparse

from rank_bm25 import BM25Okapi
import numpy as np

class HybridSearchEngine:
    """
    Dense (벡터) + Sparse (BM25) 혼합 검색
    """

    def __init__(self, embedding_pipeline: EmbeddingPipeline):
        self.embedding = embedding_pipeline
        self.bm25 = None
        self.documents = []
        self.doc_embeddings = None

    def index(self, documents: List[str]):
        """문서 인덱싱"""
        self.documents = documents

        # Dense 인덱스
        self.doc_embeddings = self.embedding.embed_documents(documents)

        # Sparse 인덱스 (BM25)
        tokenized_docs = [doc.split() for doc in documents]
        self.bm25 = BM25Okapi(tokenized_docs)

    def search(
        self,
        query: str,
        top_k: int = 5,
        alpha: float = 0.7  # Dense 비율 (0~1)
    ) -> List[Dict]:
        """
        Hybrid 검색

        Args:
            alpha: Dense 가중치 (1-alpha = Sparse 가중치)
                   0.7이면 Dense 70%, BM25 30%
        """
        from sklearn.metrics.pairwise import cosine_similarity

        # Dense 스코어
        q_emb = self.embedding.embed_query(query)
        dense_scores = cosine_similarity([q_emb], self.doc_embeddings)[0]

        # Sparse 스코어 (BM25)
        tokenized_query = query.split()
        sparse_scores = self.bm25.get_scores(tokenized_query)

        # 스코어 정규화 (0~1 범위)
        dense_norm = dense_scores / (dense_scores.max() + 1e-10)
        sparse_norm = sparse_scores / (sparse_scores.max() + 1e-10)

        # 하이브리드 스코어 결합
        hybrid_scores = alpha * dense_norm + (1 - alpha) * sparse_norm

        # Top-k 정렬
        top_indices = hybrid_scores.argsort()[-top_k:][::-1]

        results = []
        for rank, idx in enumerate(top_indices):
            results.append({
                "rank": rank + 1,
                "document": self.documents[idx],
                "hybrid_score": float(hybrid_scores[idx]),
                "dense_score": float(dense_norm[idx]),
                "sparse_score": float(sparse_norm[idx])
            })

        return results


# ===== 사용 예시 =====
pipeline = EmbeddingPipeline("BAAI/bge-m3")
hybrid = HybridSearchEngine(pipeline)

# 인덱싱
hybrid.index(documents=[
    "한국어 자연어 처리 튜토리얼...",
    "Python 프로그래밍 가이드...",
    "RAG 시스템 구축 방법...",
])

# 검색
results = hybrid.search(
    query="한국어 NLP 튜토리얼",
    alpha=0.7,  # Dense 70%, BM25 30%
    top_k=3
)

8.3 Reciprocal Rank Fusion (RRF)

여러 검색 결과를 순위 기반으로 최종 결합하는 방법입니다.

def reciprocal_rank_fusion(
    ranked_lists: List[List[int]],
    k: int = 60
) -> List[tuple]:
    """
    RRF: 여러 검색 결과의 순위를 하나로 결합

    Args:
        ranked_lists: [[doc_idx, ...], [doc_idx, ...], ...]
                      각 검색 방법의 순위 리스트
        k: 상수 (통상 60)

    Returns:
        [(doc_idx, rrf_score), ...] — RRF 스코어로 정렬
    """
    scores = {}

    for ranked_list in ranked_lists:
        for rank, doc_idx in enumerate(ranked_list):
            if doc_idx not in scores:
                scores[doc_idx] = 0.0
            scores[doc_idx] += 1.0 / (k + rank + 1)

    # 스코어 내림차순 정렬
    sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
    return sorted_results


# 사용 예시: Dense + Sparse + GraphRAG 결과 결합
dense_results   = [3, 7, 1, 4, 9]   # Dense 검색 순위
sparse_results  = [7, 2, 3, 8, 1]   # BM25 검색 순위
graph_results   = [1, 3, 5, 7, 2]   # GraphRAG 검색 순위

final_ranking = reciprocal_rank_fusion(
    [dense_results, sparse_results, graph_results]
)
# → [(doc_3, 0.048), (doc_7, 0.046), (doc_1, 0.045), ...]

9. 모델 선택 의사결정 프레임워크

9.1 빠른 결정 트리

시작
  │
  ├─ 한국어만 사용?
  │   ├─ YES → BGE-M3 또는 multilingual-e5-large (최우선)
  │   └─ NO  → 다음
  │
  ├─ 다국어 지원 필요?
  │   ├─ YES → BGE-M3 / Cohere embed-v4 / Qwen3-Embedding
  │   └─ NO  → 다음
  │
  ├─ 프라이버시 중요 (데이터를 외부 API에 보내면 안 됨)?
  │   ├─ YES → BGE-M3 / E5 (자체 호스팅)
  │   └─ NO  → 다음
  │
  ├─ 비용 최소화 우선?
  │   ├─ YES → all-MiniLM-L6-v2 (프로토타입) / BGE-M3 (프로덕션)
  │   └─ NO  → 다음
  │
  ├─ 최고 정밀도 필요? (법률/의료/금융)
  │   ├─ YES → ColBERT (Jina ColBERT v2) + Reranking
  │   └─ NO  → 다음
  │
  └─ 유지보수 최소화 원하는가?
      ├─ YES → Cohere embed-v4 / OpenAI text-embedding-3-large
      └─ NO  → BGE-M3 + Fine-Tuning

9.2 상황별 권장 스택

Scenario A: 스타트업 — 빠르고 저렴하게

Embedding:  OpenAI text-embedding-3-small ($0.02/1M tokens)
검색:       Simple cosine similarity
Reranking:  없음 (초기단계)
총 비용:    월 ~$5-20

Scenario B: 중규모 기업 — 균형잡힌 접근

Embedding:  BGE-M3 (자체 호스팅, 무료)
검색:       Hybrid Search (Dense + BM25)
Reranking:  Cross-Encoder (BGE Reranker)
Vector DB:  Qdrant / Weaviate
총 비용:    GPU 운영비만

Scenario C: 엔터프라이즈 — 최고 정밀도

Embedding:  Cohere embed-v4 + Fine-Tuning
검색:       Hybrid + ColBERT
Reranking:  Cohere Rerank
Context:    Contextual Retrieval (voyage-context-3)
Vector DB:  Pinecone / Weaviate Enterprise
총 비용:    월 $500-2000+

Scenario D: 프라이버시 우선 (금융/의료)

Embedding:  BGE-M3 Fine-Tuned (온프리미스)
검색:       Hybrid Search
Reranking:  BGE Reranker (온프리미스)
Vector DB:  Qdrant (자체 호스팅)
총 비용:    GPU 하드웨어 비용

9.3 체크리스트

Embedding 모델 선택 시 반드시 확인:

□ 지원 언어 (특히 한국어 포함 여부)
□ 최대 입력 토큰 수 (모델별로 크게 다름)
□ 출력 차원 (저장 비용과 직결)
□ Prefix/Instruction 필요 여부 (BGE, E5 등)
□ 라이선스 (상업 사용 가능 여부)
□ 자신의 데이터로 직접 평가 수행 (MTEB 점수만 믿지 않음)
□ Hybrid Search 지원 여부 (Sparse Vector 포함)
□ Fine-Tuning 가능 여부 및 학습 데이터 준비 상황
□ 비용 구조 파악 (API 과금 vs 자체 호스팅)
□ Matryoshka 지원 여부 (차원 축소로 비용 절감)

마치며

2026년의 Embedding 세계는 단일 벡터의 한계를 넘어서는 방향으로 진화하고 있습니다.

트렌드내용
Multi-VectorColBERT 스타일로 토큰 수준 정밀도 향상
Contextualized EmbeddingLate Chunking, voyage-context-3으로 맥락 보존
Matryoshka차원 유연성으로 비용과 속도 최적화
MultimodalColPali/ColQwen으로 시각 문서도 벡터 검색
On-deviceEmbeddingGemma-300M 등 엣지 디바이스 배포

핵심 메시지:
1. MTEB 점수보다 자신의 데이터로 평가하는 것이 중요합니다.
2. 한국어 RAG의 기본 선택은 BGE-M3로 시작하세요.
3. 정밀도가 중요하면 ColBERT + Reranking을 추가하세요.
4. 비용이 커지면 Matryoshka양자화를 적용하세요.


다음 글: Reranking 전략 — Dense 검색 이후의 정밀 정제 단계


참고 자료

profile
👩🏻‍💻

0개의 댓글