BERT가 등장한 후 자연어 이해 성능이 크게 향상됐다. 그런데 “이 두 문장이 얼마나 비슷한가"를 BERT로 계산하려면 두 문장을 쌍으로 묶어 함께 입력해야 한다. 10,000개 문장 데이터베이스에서 가장 유사한 문장을 찾으려면 쿼리와 10,000개 문장의 조합 50,000,000쌍을 전부 BERT에 통과시켜야 한다. 현실적으로 불가능한 방식이다.

2019년 Reimers와 Gurevych의 “Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks"가 이 문제를 해결했다.

핵심 아이디어

문장을 미리 고정 크기 벡터(임베딩)로 변환해 저장해 둔다. 유사도 계산은 벡터 간 코사인 유사도로 끝낸다.

문장 A → SBERT → 벡터 A (768차원)
문장 B → SBERT → 벡터 B (768차원)

유사도 = cosine_similarity(벡터 A, 벡터 B)

10,000개 문장의 임베딩을 미리 계산해두면, 이후 검색은 벡터 연산 한 번이다. BERT 방식 대비 속도가 수천~수만 배 빠르다.

샴 네트워크 (Siamese Network)

SBERT는 같은 BERT 가중치를 공유하는 두 개의 인코더로 구성된 샴 네트워크(Siamese Network) 구조로 학습한다. 샴 쌍둥이처럼 동일한 가중치를 가진 두 경로가 나란히 실행된다.

문장 A ─→ [공유 BERT] ─→ 풀링 ─→ 벡터 A ──┐
                                             ├→ 손실 함수
문장 B ─→ [공유 BERT] ─→ 풀링 ─→ 벡터 B ──┘

레이블이 “두 문장은 같은 의미다"이면 두 벡터가 가까워지도록, “다른 의미다"이면 멀어지도록 가중치를 업데이트한다.

풀링 전략

BERT의 출력은 토큰별 벡터 시퀀스다. 문장을 하나의 벡터로 만들려면 시퀀스를 합산해야 한다. 이것이 풀링(pooling)이다.

CLS 토큰 풀링: BERT는 문장 시작에 [CLS] 토큰을 추가하고, 이 토큰의 출력이 문장 전체의 표현을 담도록 학습한다. 이 벡터 하나를 사용하는 방식이다.

Mean 풀링: 모든 토큰 벡터의 평균을 낸다. SBERT 논문에서 실험 결과 CLS 풀링보다 Mean 풀링이 더 좋은 성능을 보였다. 현재 대부분의 모델이 Mean 풀링을 기본으로 사용한다.

Max 풀링: 각 차원에서 최댓값을 취한다. 특정 특징의 존재 여부를 포착하는 데 유리하다.

from sentence_transformers import SentenceTransformer

model = SentenceTransformer('all-MiniLM-L6-v2')

sentences = [
    "오늘 날씨가 맑다",
    "오늘은 화창한 날씨다",
    "파이썬으로 웹 서버를 만들었다",
]

embeddings = model.encode(sentences)
# embeddings.shape: (3, 384)

학습 방식

NLI 기반 학습 (원 논문)

자연어 추론(NLI) 데이터셋을 사용한다. 두 문장의 관계가 “함의(entailment)”, “중립(neutral)”, “모순(contradiction)” 세 가지로 레이블돼 있다.

소프트맥스 손실(Softmax Loss)로 학습한다. 두 벡터의 차이(|u-v|)와 원소별 곱(u×v)을 이어 붙여 분류기에 통과시킨다.

Triplet Loss

앵커(anchor), 포지티브(positive, 유사한 문장), 네거티브(negative, 다른 문장) 세 가지를 동시에 사용한다.

L = max(||s_a - s_p||² - ||s_a - s_n||² + ε, 0)

앵커와 포지티브의 거리가 앵커와 네거티브의 거리보다 ε만큼 작아지도록 학습한다. 검색 태스크에서 효과적이다.

Contrastive Learning (현대적 접근)

SimCSE, E5, BGE 등 현재 널리 쓰이는 모델들은 대조 학습(contrastive learning)을 사용한다. 같은 문장을 드롭아웃을 다르게 적용해 두 번 인코딩하면 포지티브 쌍이 되고, 배치 내 다른 문장들이 자동으로 네거티브가 된다(in-batch negatives). 레이블 없이도 고품질 임베딩을 학습할 수 있다.

의미 검색 구현

from sentence_transformers import SentenceTransformer, util

model = SentenceTransformer('all-MiniLM-L6-v2')

# 문서 데이터베이스 (미리 계산)
docs = [
    "k8s에서 HPA는 CPU 사용률 기반으로 Pod을 자동 스케일아웃한다",
    "Crossplane은 k8s에서 AWS 인프라를 관리하는 도구다",
    "Stable Diffusion은 잠재 확산 모델 기반 이미지 생성 모델이다",
]
doc_embeddings = model.encode(docs, convert_to_tensor=True)

# 쿼리
query = "파드 자동 확장 방법"
query_embedding = model.encode(query, convert_to_tensor=True)

# 코사인 유사도 검색
scores = util.cos_sim(query_embedding, doc_embeddings)[0]
top_result = scores.argmax()

print(docs[top_result])
# "k8s에서 HPA는 CPU 사용률 기반으로 Pod을 자동 스케일아웃한다"

Bi-Encoder vs Cross-Encoder

Sentence Transformers 생태계에서 자주 나오는 두 가지 아키텍처다.

Bi-Encoder (SBERT가 여기에 해당)

두 문장을 각자 독립적으로 인코딩해 벡터를 만든다. 벡터를 미리 계산해 저장할 수 있어 검색 속도가 빠르다. 대규모 검색의 첫 번째 단계(retrieval)에 사용한다.

Cross-Encoder

두 문장을 쌍으로 묶어 BERT에 함께 입력한다. 두 문장이 서로 영향을 미치면서 인코딩되므로 정확도가 더 높다. 하지만 미리 계산이 불가능하고 쌍마다 인코딩해야 하므로 느리다. Bi-Encoder가 추린 상위 후보를 다시 정렬하는 두 번째 단계(reranking)에 사용한다.

쿼리 → Bi-Encoder → 상위 100개 후보
상위 100개 → Cross-Encoder → 최종 상위 10개 (정확도 높음)

이 두 단계 파이프라인이 현재 RAG(Retrieval-Augmented Generation) 시스템의 기본 구조다.

주요 모델

모델차원특징
all-MiniLM-L6-v2384빠르고 작음, 범용
all-mpnet-base-v2768균형 잡힌 성능
bge-m31024다국어, 한국어 포함
text-embedding-3-small1536OpenAI API
intfloat/multilingual-e5-large1024다국어 강점

한국어를 포함한 다국어 검색이 필요하면 bge-m3multilingual-e5가 현실적인 선택이다.

RAG에서의 역할

Sentence Transformers는 RAG 파이프라인의 핵심 컴포넌트다.

문서 수집
    ↓ 청킹 (chunk)
텍스트 조각들
    ↓ Sentence Transformers
벡터 임베딩들
    ↓ 벡터 DB 저장 (Pinecone, Qdrant, pgvector)

─── 쿼리 시 ───

사용자 질문
    ↓ Sentence Transformers
쿼리 벡터
    ↓ 벡터 DB 유사도 검색
관련 문서 조각들
    ↓ LLM에 컨텍스트로 주입
최종 답변

임베딩 품질이 RAG 전체의 검색 성능을 결정한다. 좋은 임베딩 모델은 “k8s Pod 스케일링 방법"이라는 쿼리가 “HPA를 이용한 수평적 자동 확장"이라는 문서 조각과 매핑될 수 있도록 의미적 유사성을 포착한다.

트레이드오프

임베딩 차원이 높을수록 표현력이 좋지만 저장 공간과 검색 속도가 나빠진다. 384차원과 1536차원은 메모리 사용량이 4배 차이 난다. 수백만 개의 문서를 인덱싱하면 이 차이가 의미 있어진다.

Bi-Encoder는 두 문장을 독립적으로 인코딩하므로 두 문장 사이의 미묘한 관계를 놓칠 수 있다. “A가 B보다 크다"와 “B가 A보다 크다"는 단어 구성이 같아도 의미가 반대지만, Bi-Encoder는 두 문장의 임베딩을 비슷하게 만들 수 있다. 정밀도가 중요한 최종 랭킹 단계에서는 Cross-Encoder가 필요하다.