에이전트·RAG

AGENT / 중급

문장 임베딩: SBERT와 의미 검색

단어 임베딩이 문장을 표현할 수 없는 이유, SBERT 샴 네트워크로 문장을 단일 벡터로 인코딩하는 원리, 코사인 유사도 기반 의미 검색 구현까지 sentence-transformers 코드로 완전히 해설한다.

PALDYN Team12 MIN READ

지난 글에서 ELMo와 BERT가 문맥에 따라 다른 벡터를 생성하는 문맥적 임베딩을 살펴봤다. 이제 현실적인 질문이 남는다. "AI 기술이 발전했다"와 "인공지능이 급속히 성장하고 있다" — 이 두 문장이 의미상 비슷한가? BERT는 문장의 각 토큰에 문맥적 벡터를 만들어주지만, 문장 전체를 하나의 벡터로 표현하는 것은 별도의 문제다. 문서 검색, 의미 유사도 측정, RAG(Retrieval-Augmented Generation)의 핵심인 문장 임베딩이 바로 이 공백을 메운다. 그 중심에는 2019년 Reimers & Gurevych가 발표한 SBERT(Sentence-BERT)가 있다.

왜 BERT를 그대로 쓰면 안 되는가

BERT로 두 문장의 유사도를 측정하는 가장 직관적인 방법은 두 문장을 함께 입력하는 것이다:

[CLS] 문장A [SEP] 문장B [SEP]

이 방식은 크로스 어텐션으로 두 문장의 관계를 깊이 모델링해 정확하다. 문제는 계산 비용이다. N개의 문장 중 가장 유사한 것을 찾으려면 O(N²) 번의 BERT 추론이 필요하다. 10,000개 문장이면 5,000만 번 — 실시간 검색에서는 사실상 불가능하다.

다른 방법은 각 문장을 독립적으로 BERT에 넣고 [CLS] 토큰 벡터를 추출하는 것이다:

# 나쁜 방법: [CLS] 벡터를 그냥 사용
out = bert(sentence)
vec = out.last_hidden_state[:, 0, :]  # [CLS] 토큰

이 방식은 빠르지만 품질이 낮다. 원래 BERT는 [CLS] 토큰이 문장 전체의 의미를 담도록 학습되지 않았다. 실험 결과, 이렇게 얻은 벡터의 유사도는 평균 풀링보다도 나쁜 경우가 많다.

평균 풀링(mean pooling)은 조금 낫다:

# 평균 풀링
hidden = out.last_hidden_state  # [1, seq_len, 768]
mask = inputs["attention_mask"].unsqueeze(-1)  # [1, seq_len, 1]
vec = (hidden * mask).sum(dim=1) / mask.sum(dim=1)

하지만 이것도 BERT가 문장 유사도를 위해 최적화된 것이 아니기 때문에, 실제 의미 유사도와 벡터 유사도가 잘 일치하지 않는다.

SBERT: 샴 네트워크로 문장을 비교하다

SBERT의 핵심 아이디어는 간단하다. BERT를 샴 네트워크(Siamese Network) 구조로 파인튜닝해, 문장의 의미 유사도를 직접 최적화하는 것이다.

SBERT 샴 네트워크 아키텍처

샴 네트워크 구조

두 개의 BERT 인코더가 가중치를 공유하면서 독립적으로 두 문장을 인코딩한다:

문장 A → BERT(shared) → 풀링 → 벡터 u ∈ R^768
문장 B → BERT(shared) → 풀링 → 벡터 v ∈ R^768

가중치 공유가 핵심이다. 두 인코더가 같은 파라미터를 가지므로, 동일한 의미는 동일한 방향의 벡터로 인코딩되어야 한다는 제약이 자연스럽게 생긴다.

풀링 전략

SBERT는 여러 풀링 전략을 실험했다:

def pool(hidden_states, attention_mask, strategy="mean"):
    if strategy == "cls":
        return hidden_states[:, 0, :]
    elif strategy == "mean":
        mask = attention_mask.unsqueeze(-1).float()
        return (hidden_states * mask).sum(1) / mask.sum(1)
    elif strategy == "max":
        mask = attention_mask.unsqueeze(-1).float()
        hidden_states[mask == 0] = -1e9
        return hidden_states.max(1).values

논문 결과, 평균 풀링(mean pooling)이 [CLS] 풀링과 최대 풀링보다 일반적으로 우수했다.

학습 목표

SBERT는 두 가지 주요 학습 방식을 사용한다.

1. NLI (Natural Language Inference) 방식

문장 쌍의 관계(함의/중립/모순)를 분류하도록 학습한다:

분류기 입력 = [u; v; |u-v|]  (연결)
출력 = softmax(W · [u; v; |u-v|])

이 방식으로 파인튜닝하면 BERT는 문장 간 논리적 관계를 벡터 공간에 반영하도록 학습된다.

2. STS (Semantic Textual Similarity) 방식

문장 쌍의 유사도 점수(0~5)를 직접 회귀하도록 학습한다:

예측값 = cos(u, v) * 5
손실 = MSE(예측값, 정답 점수)

3. 트리플렛 손실 (Triplet Loss)

L = max(cos(a, n) - cos(a, p) + ε, 0)

앵커(a), 양성 예시(p), 음성 예시(n) 세 문장을 사용해, 앵커와 양성 예시가 음성 예시보다 가까워지도록 학습한다.

코사인 유사도로 의미 검색하기

SBERT의 진짜 위력은 사전 계산(pre-computation)에 있다. N개의 문서에 대한 임베딩을 미리 계산해두면, 쿼리 문장 하나의 임베딩과 코사인 유사도를 계산해 O(N) 시간에 검색할 수 있다.

from sentence_transformers import (
    SentenceTransformer,
    util,
)
import torch

# 한국어 SBERT 로드
model = SentenceTransformer(
    "jhgan/ko-sroberta-multitask"
)

# 문서 임베딩 사전 계산
corpus = [
    "AI가 의료 진단에 혁명을 일으키고 있다",
    "기계 학습으로 질병을 예측한다",
    "자율주행 자동차의 안전성 연구",
    "딥러닝이 이미지 인식을 바꾸다",
    "오늘 점심 뭐 먹을까",
]
corpus_embeddings = model.encode(corpus, convert_to_tensor=True)

# 쿼리 문장
query = "인공지능이 의학에 미치는 영향"
query_embedding = model.encode(query, convert_to_tensor=True)

# 코사인 유사도로 검색
scores = util.cos_sim(query_embedding, corpus_embeddings)[0]
top_results = torch.topk(scores, k=3)

print("검색 결과:")
for score, idx in zip(top_results.values, top_results.indices):
    print(f"  {score:.4f}: {corpus[idx]}")
# → 0.87: AI가 의료 진단에 혁명을 일으키고 있다
# → 0.79: 기계 학습으로 질병을 예측한다
# → 0.45: 딥러닝이 이미지 인식을 바꾸다

SBERT 문장 임베딩 코드

주요 한국어 SBERT 모델

sentence-transformers 라이브러리에서 사용할 수 있는 대표적인 한국어 문장 임베딩 모델:

모델명 특징 임베딩 차원
jhgan/ko-sroberta-multitask 다양한 태스크 학습, 범용 768
snunlp/KR-SBERT-V40K-klueNLI-augSTS KLUE NLI + STS 768
BM-K/KoSimCSE-roberta SimCSE 방식, 대조 학습 768
upskyy/kf-deberta-multitask DeBERTa 기반 768

의미 검색 시스템 구축: FAISS 활용

문서 수가 수십만 건 이상이면 단순한 행렬 곱으로 충분하지 않을 수 있다. 이때 Facebook의 FAISS(Facebook AI Similarity Search) 라이브러리가 유용하다:

import faiss
import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("jhgan/ko-sroberta-multitask")

# 대규모 코퍼스 임베딩
corpus = [...]  # 수십만 문장
embeddings = model.encode(corpus, batch_size=128, show_progress_bar=True)
embeddings = np.array(embeddings, dtype=np.float32)

# FAISS 인덱스 구축
dimension = embeddings.shape[1]  # 768
index = faiss.IndexFlatIP(dimension)  # 내적(=정규화 후 코사인 유사도)

# L2 정규화 후 추가
faiss.normalize_L2(embeddings)
index.add(embeddings)
print(f"인덱스에 {index.ntotal}개 문서 추가됨")

# 검색
query = "인공지능 의료 진단"
query_vec = model.encode([query], normalize_embeddings=True)
D, I = index.search(query_vec, k=5)  # 상위 5개

for dist, idx in zip(D[0], I[0]):
    print(f"유사도 {dist:.4f}: {corpus[idx][:50]}")

FAISS의 IndexFlatIP는 정확한 최근접 이웃 탐색이다. 속도가 더 필요하다면 IndexIVFFlat (클러스터링 기반 근사 탐색)이나 IndexHNSWFlat (그래프 기반 근사 탐색)을 사용한다.

RAG에서의 문장 임베딩

현재 가장 실용적인 문장 임베딩 응용은 RAG(Retrieval-Augmented Generation)다.

[사용자 질문] → [문장 임베딩] → [FAISS 검색] → [관련 문서 N개]

[LLM에게 질문 + 관련 문서를 함께 전달]

[근거 기반의 정확한 답변 생성]

LLM의 한계(환각, 최신 정보 미지원)를 보완하는 RAG에서 문장 임베딩 품질이 전체 시스템 성능을 좌우한다. 같은 의미의 질문이라도 다른 표현으로 들어올 때, 올바른 문서를 찾아낼 수 있어야 한다.

from sentence_transformers import SentenceTransformer, util

class SimpleRAG:
    def __init__(self, embed_model, llm_fn):
        self.model = SentenceTransformer(embed_model)
        self.llm = llm_fn
        self.corpus = []
        self.embeddings = None

    def add_documents(self, docs):
        self.corpus.extend(docs)
        new_embs = self.model.encode(docs, convert_to_tensor=True)
        if self.embeddings is None:
            self.embeddings = new_embs
        else:
            self.embeddings = torch.cat([self.embeddings, new_embs])

    def retrieve(self, query, top_k=3):
        q_emb = self.model.encode(query, convert_to_tensor=True)
        scores = util.cos_sim(q_emb, self.embeddings)[0]
        top = torch.topk(scores, k=top_k)
        return [self.corpus[i] for i in top.indices]

    def answer(self, query):
        context = self.retrieve(query)
        prompt = f"문서:\n" + "\n".join(context) + f"\n\n질문: {query}"
        return self.llm(prompt)

SBERT 기반 중복 문장 탐지

문장 임베딩의 또 다른 실용적 응용은 대규모 텍스트 데이터에서 중복(또는 유사) 문장 탐지다:

from sentence_transformers import SentenceTransformer, util

model = SentenceTransformer("jhgan/ko-sroberta-multitask")
sentences = [
    "오늘 날씨가 맑다",
    "날씨가 오늘 좋네요",
    "인공지능 기술이 발전했다",
    "AI 기술 수준이 크게 향상됐다",
    "내일 비가 올 예정이다",
]

embeddings = model.encode(sentences, convert_to_tensor=True)
cosine_scores = util.cos_sim(embeddings, embeddings)

# 유사도가 0.8 이상인 쌍 탐지
for i in range(len(sentences)):
    for j in range(i+1, len(sentences)):
        if cosine_scores[i][j] > 0.8:
            print(f"유사 쌍 ({cosine_scores[i][j]:.2f}):")
            print(f"  {sentences[i]}")
            print(f"  {sentences[j]}")

마무리: 임베딩의 흐름을 돌아보며

지금까지 우리는 임베딩의 진화를 함께 따라왔다.

Word2Vec — 신경망으로 지역 문맥을 학습해 의미 벡터를 최초로 실용화했다.

GloVe — 전역 공기 통계를 명시적으로 활용해 더 풍부한 의미 관계를 포착했다.

FastText — 문자 n-gram으로 OOV 문제를 해결하고 교착어 처리를 혁신했다.

ELMo / BERT — 정적 임베딩의 한계를 넘어, 문맥에 따라 동적으로 변하는 임베딩을 실현했다.

SBERT — 문장 수준의 의미를 하나의 벡터로 효율적으로 인코딩해, 의미 검색과 RAG의 기반을 만들었다.

다음 글에서는 이 임베딩의 지평을 텍스트 너머로 확장한다. 이미지와 텍스트를 같은 벡터 공간에 배치하는 멀티모달 임베딩 — CLIP과 그 후속 모델들 — 이 기다리고 있다.


지난 글: 문맥적 임베딩: ELMo부터 BERT까지

다음 글: 멀티모달 임베딩: 텍스트와 이미지를 같은 공간에


읽어주셔서 감사합니다. 😊

KEEP READING

이어 읽기

에이전트·RAG2026.05.29

에이전트 시스템 처음부터 구축하기: 실전 프로젝트

프레임워크 없이 순수 Python으로 Tool Use 에이전트를 구축한다. ReAct 루프, 도구 레지스트리, 메모리, 중단 조건까지 에이전트의 핵심 구성 요소를 손으로 직접 구현한다.

중급16 MIN
에이전트·RAG2026.05.29

RAG 시스템 처음부터 구축하기: 실전 프로젝트

문서 수집부터 청킹, 임베딩, 벡터 DB 저장, 검색, 생성까지 — LangChain 없이 순수 Python으로 RAG 시스템을 처음부터 구축하는 단계별 프로젝트 가이드.

중급11 MIN