
RAG 시스템에서 가장 많은 엔지니어들이 놓치는 포인트가 있습니다.
LLM 모델 교체보다 Embedding 모델 교체가 검색 정확도를 2배 이상 바꾸는 경우가 흔합니다.
이번 글에서는 Embedding의 원리부터, 2026년 현재 최신 모델 비교, 고급 기법(ColBERT, Matryoshka, Late Chunking), 그리고 한국어 특화 실무까지 깊이 다루겠습니다.
사용자 쿼리
↓
┌─────────────┐
│ Embedding │ ← 여기서 검색 품질이 결정됨
│ 모델 │
└─────────────┘
↓
벡터 검색 (Vector Search)
↓
Reranking (선택)
↓
LLM에 컨텍스트 전달
↓
최종 답변 생성
Embedding 모델은 쿼리와 문서를 동일한 벡터 공간으로 변환하는 역할입니다.
여기서 잘못되면 아무리 좋은 LLM과 Reranker를 붙여도 쓰레스크가 들어오는 구조입니다.
Embedding 모델의 품질은 검색 정확도에 직접 영향을 미치며, 좋은 Embedding은 단순 키워드 매칭이 아닌 의미적 유사도를 파악하여 관련 문서를 찾을 수 있습니다.
실제로 AI 에이전트가 잘못된 컨텍스트를 반환하는 경우, 문제의 원인은 LLM이 아닌 Embedding 모델일 가능성이 높습니다.
"한국어 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차원)
두 벡터 사이의 거리를 계산하여 유사도를 판단합니다.
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)
이 구분이 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
장점: 유지보수 불필요, 성능 안정적, 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차원
장점: 무료, 프라이버시, 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에서 폐쇄형 모델을 능가하는 경우도 있습니다.
MTEB Leaderboard 기준으로 주요 모델들의 순위는 다음과 같습니다:
| Rank | 모델 | MTEB Score | 차원 | 비용/1M tokens | 주요 특징 |
|---|---|---|---|---|---|
| 1 | Cohere embed-v4 | 65.2 | 1024 | $0.10 | 다국어, 검색 최적화 |
| 2 | OpenAI text-embedding-3-large | 64.6 | 3072 | $0.13 | 범용 고정밀 |
| 3 | Voyage AI voyage-3-large | 63.8 | 1536 | $0.12 | 도메인 튜닝 강점 |
| 4 | BGE-M3 | 63.0 | 1024 | Free | 자체 운영, 다국어 |
| 5 | E5-Mistral-7B-Instruct | 61.8 | 4096 | Free | 오픈소스 최고 |
| 6 | Nomic-embed-text-v1.5 | 59.4 | 768 | $0.05 | 예산 효율적 |
| 7 | all-MiniLM-L6-v2 | 56.3 | 384 | Free | 프로토타입 최적 |
| 8 | OpenAI text-embedding-3-small | 55.8 | 1536 | $0.02 | 비용 효율적 |
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, 비용
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 스타일 정밀 검색)
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 튜토리얼 찾고 싶어"])
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)로 처리했습니다.
기존 Bi-Encoder는 문서 전체를 벡터 하나로 압축합니다. ColBERT는 토큰마다 벡터를 만들어 정밀도를 급격히 올립니다.
┌─── 기존 Bi-Encoder ──────────────────┐
│ │
│ "Python 튜토리얼 가이드" │
│ ↓ │
│ [단일 벡터: 0.34, -0.21, ...] │ ← 정보 압축 손실
│ │
└──────────────────────────────────────┘
┌─── ColBERT (Late Interaction) ───────┐
│ │
│ "Python 튜토리얼 가이드" │
│ ↓ ↓ ↓ │
│ [Python] [튜토리얼] [가이드] │ ← 토큰마다 독립 벡터
│ vec_1 vec_2 vec_3 │
│ │
└──────────────────────────────────────┘
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)
# 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차원의 유연한 출력 크기를 지원합니다.
장점:
✅ Bi-Encoder보다 정밀한 매칭 (토큰 수준)
✅ Cross-Encoder보다 빠름 (미리 벡터 저장 가능)
✅ 법률, 금융 등 정밀도가 중요한 영역에서 강함
단점:
❌ 저장 비용 높음 (토큰마다 벡터)
❌ 단일 벡터 모델보다 ~10x 저장 공간 필요
ColBERTv2에서는 공격적인 양자화를 적용하여 각 벡터의 크기를 256바이트에서 36바이트(2비트 압축)까지 줄일 수 있으며, 정확도를 유지하면서도 저장 비용을 크게 절감할 수 있습니다.
기존 방식:
PDF → OCR → 텍스트 추출 → Chunking → Embedding
(표, 그래프, 이미지 정보 손실)
ColPali / ColQwen 방식:
PDF → 페이지 스크린샷 → Vision Encoder → 토큰 벡터
(시각 정보 그대로 유지)
ColPali와 ColQwen은 PDF를 이미지로 처리하여, OCR과 복잡한 Chunking 없이 텍스트와 표, 그래프 등의 시각 정보를 함께 검색할 수 있게 합니다.
러시아 인켜 인기 완구(마트료시카)처럼, 하나의 학습으로 여러 차원의 벡터를 생성할 수 있는 기법입니다.
학습 시:
Full Embedding: [d1, d2, d3, ..., d512, ..., d1024]
└───┘ └────────┘ └──────────────┘
64차원 256차원 1024차원
↑ ↑ ↑
접두사(prefix)로 잘라도 유효한 Embedding!
추론 시:
상황에 따라 차원을 자유롭게 선택
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)
차원 │ 저장 비용 │ 검색 속도 │ 정확도
────────┼────────────┼────────────┼────────
64 │ ████░░░░░░ │ ██████████ │ ██░░░░
128 │ ██████░░░░ │ ████████░░ │ ███░░░
256 │ ████████░░ │ ██████░░░░ │ █████░
512 │ █████████░ │ ████░░░░░░ │ ██████
1024 │ ██████████ │ ██░░░░░░░░ │ ███████
EmbeddingGemma-300M은 Matryoshka Representation Learning을 활용하여 768 → 512 → 256 → 128차원의 출력 축소를 지원하며, 이를 통해 저장 공간과 검색 속도의 균형을 유연하게 조절할 수 있습니다.
이전 글(Chunking 가이드)에서도 언급했지만, 이번에는 Embedding 관점에서 깊이 설명합니다.
문서:
"Alice는 숲에서 산책 중 토끼 구멍을 발견했다.
그녀는 구멍에 빠져 이상한 세계로 떨어져 했다."
기존 방식 (Chunk → Embed):
Chunk 1: "Alice는 숲에서 산책 중 토끼 구멍을 발견했다."
Chunk 2: "그녀는 구멍에 빠져 이상한 세계로 떨어져 했다."
↑
"그녀"가 Alice임을 알 수 없음! (맥락 단절)
→ 검색: "Alice가 어디에 떨어졌나?"
→ Chunk 2의 Embedding에 "Alice" 정보가 없어 검색 실패
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)을 제공하며, 기존 방식과 동일한 저장 공간으로도 맥락 정보를 보존할 수 있습니다.
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% 향상시킬 수 있습니다.
영어: "I love natural language processing"
한국어: "자연어 처리를 좋아해요"
영어는 공백으로 단어가 명확히 분리됨
한국어는 조사(를), 어미(해요)가 붙어 형태변환이 복잡함
→ 한국어 적합한 Tokenizer & Pre-training이 중요
| 모델 | 타입 | 한국어 성능 | 추천 시 |
|---|---|---|---|
| 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초로 다른 모델들에 비해 빠른 편이었습니다.
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)
Korea University에서 공개한 KURE(Korea University Retrieval Embedding)는 BGE-M3를 기반으로 한국어 Retrieval 전용으로 학습된 모델이며, MTEB-ko-retrieval Leaderboard가 함께 공개되었습니다.
참고 리더보드:
Fine-Tuning 필요한 경우:
✅ 특수 도메인 용어가 많음 (법률, 의료, 금융)
✅ 기본 모델의 검색 정확도가 낮음 (<60%)
✅ 회사 내부 용어나 프로세스가 특수
✅ 데이터가 충분 (최소 500쌍 이상)
Fine-Tuning 불필요한 경우:
❌ 일반적인 질문/답변 시스템
❌ 데이터가 적음 (<100쌍)
❌ 빠른 프로토타입 필요
❌ 기본 모델 정확도가 충분 (>80%)
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")
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)}")
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" 로 구분
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
)
여러 검색 결과를 순위 기반으로 최종 결합하는 방법입니다.
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), ...]
시작
│
├─ 한국어만 사용?
│ ├─ 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
Embedding: OpenAI text-embedding-3-small ($0.02/1M tokens)
검색: Simple cosine similarity
Reranking: 없음 (초기단계)
총 비용: 월 ~$5-20
Embedding: BGE-M3 (자체 호스팅, 무료)
검색: Hybrid Search (Dense + BM25)
Reranking: Cross-Encoder (BGE Reranker)
Vector DB: Qdrant / Weaviate
총 비용: GPU 운영비만
Embedding: Cohere embed-v4 + Fine-Tuning
검색: Hybrid + ColBERT
Reranking: Cohere Rerank
Context: Contextual Retrieval (voyage-context-3)
Vector DB: Pinecone / Weaviate Enterprise
총 비용: 월 $500-2000+
Embedding: BGE-M3 Fine-Tuned (온프리미스)
검색: Hybrid Search
Reranking: BGE Reranker (온프리미스)
Vector DB: Qdrant (자체 호스팅)
총 비용: GPU 하드웨어 비용
Embedding 모델 선택 시 반드시 확인:
□ 지원 언어 (특히 한국어 포함 여부)
□ 최대 입력 토큰 수 (모델별로 크게 다름)
□ 출력 차원 (저장 비용과 직결)
□ Prefix/Instruction 필요 여부 (BGE, E5 등)
□ 라이선스 (상업 사용 가능 여부)
□ 자신의 데이터로 직접 평가 수행 (MTEB 점수만 믿지 않음)
□ Hybrid Search 지원 여부 (Sparse Vector 포함)
□ Fine-Tuning 가능 여부 및 학습 데이터 준비 상황
□ 비용 구조 파악 (API 과금 vs 자체 호스팅)
□ Matryoshka 지원 여부 (차원 축소로 비용 절감)
2026년의 Embedding 세계는 단일 벡터의 한계를 넘어서는 방향으로 진화하고 있습니다.
| 트렌드 | 내용 |
|---|---|
| Multi-Vector | ColBERT 스타일로 토큰 수준 정밀도 향상 |
| Contextualized Embedding | Late Chunking, voyage-context-3으로 맥락 보존 |
| Matryoshka | 차원 유연성으로 비용과 속도 최적화 |
| Multimodal | ColPali/ColQwen으로 시각 문서도 벡터 검색 |
| On-device | EmbeddingGemma-300M 등 엣지 디바이스 배포 |
핵심 메시지:
1. MTEB 점수보다 자신의 데이터로 평가하는 것이 중요합니다.
2. 한국어 RAG의 기본 선택은 BGE-M3로 시작하세요.
3. 정밀도가 중요하면 ColBERT + Reranking을 추가하세요.
4. 비용이 커지면 Matryoshka와 양자화를 적용하세요.
다음 글: Reranking 전략 — Dense 검색 이후의 정밀 정제 단계
참고 자료