검색 품질이 기대에 못 미칠 때, 많은 팀이 곧바로 더 큰 LLM이나 복잡한 리랭커부터 생각합니다. 하지만 시맨틱 검색과 RAG의 품질은 대부분 임베딩 모델에서 결정됩니다. 어떤 임베딩을 쓰느냐, 그리고 그것을 도메인에 얼마나 잘 맞추느냐가 상위 검색 결과의 관련성을 좌우하죠.

이 글에서는 임베딩 모델을 고르는 기준부터, 언제 파인튜닝이 필요한지, 대조 학습으로 도메인 임베딩을 만드는 실전 절차, 그리고 개선을 객관적으로 측정하는 방법까지 다룹니다. 파인튜닝은 만능이 아니며, 그 전에 해야 할 값싼 개선들이 많다는 점도 함께 짚습니다.

임베딩 모델을 고르는 기준

임베딩 선택은 벤치마크 점수만 보고 결정할 문제가 아닙니다. 실제로 고려할 축은 다음과 같습니다.

  • 도메인 적합성: 일반 웹 텍스트 기반 모델은 법률·의료·사내 용어에서 약할 수 있음
  • 언어: 한국어 성능은 다국어 모델마다 편차가 큼. 반드시 자국어 데이터로 검증
  • 차원과 비용: 차원이 크면 정확도는 오르지만 벡터 저장·검색 비용도 증가
  • 시퀀스 길이: 긴 문서를 다룬다면 최대 토큰 길이가 청킹 전략을 좌우
  • 배포 형태: API 호출 vs 자체 호스팅(데이터 반출 제약, 지연, 비용)

MTEB 같은 공개 벤치마크는 출발점일 뿐입니다. 벤치마크 상위 모델이 당신의 데이터에서도 최고라는 보장은 없습니다. 후보를 2~3개로 좁힌 뒤, 실제 쿼리-문서 쌍으로 직접 평가하는 것이 유일하게 신뢰할 수 있는 방법입니다.

from sentence_transformers import SentenceTransformer
import numpy as np

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

# e5 계열은 프리픽스 규약이 중요하다 (쿼리/문서 구분)
q = model.encode(["query: 환불 규정이 어떻게 되나요?"], normalize_embeddings=True)
docs = model.encode(
    ["passage: 상품 수령 후 7일 이내 환불 신청이 가능합니다.",
     "passage: 배송은 영업일 기준 2~3일 소요됩니다."],
    normalize_embeddings=True,
)
scores = (q @ docs.T)[0]
print(np.round(scores, 3))   # 첫 문서가 더 높아야 정상

파인튜닝 전에 해야 할 값싼 개선들

파인튜닝은 데이터·시간·검증 비용이 큽니다. 그 전에 훨씬 저렴하게 품질을 끌어올릴 방법들을 먼저 소진해야 합니다.

  • 청킹 개선: 문단 경계·의미 단위로 자르기, 오버랩 추가, 너무 짧은/긴 청크 제거
  • 프리픽스 규약 준수: e5·bge 계열은 쿼리/문서 프리픽스, instruction 유무가 점수를 크게 바꿈
  • 하이브리드 검색: 밀집 벡터 + BM25 키워드를 결합하면 고유명사·희귀어 검색이 강해짐
  • 리랭커 도입: 상위 후보만 cross-encoder로 재정렬하면 파인튜닝 없이 큰 개선
# 하이브리드: 밀집 점수와 BM25 점수를 정규화 후 결합 (RRF 방식)
def reciprocal_rank_fusion(dense_ranks, sparse_ranks, k=60):
    scores = {}
    for rank_list in (dense_ranks, sparse_ranks):
        for rank, doc_id in enumerate(rank_list):
            scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

실무 경험상 하이브리드 + 리랭커 조합만으로도 상당수 검색 품질 문제가 해결됩니다. 이것들을 다 해봤는데도 도메인 특유의 의미 관계를 모델이 이해하지 못한다면, 그때가 파인튜닝을 고려할 시점입니다.

학습 데이터: 쿼리-문서 쌍 만들기

임베딩 파인튜닝의 성패는 데이터에 달려 있습니다. 필요한 것은 (쿼리, 관련 문서) 양성 쌍이며, 가능하면 하드 네거티브(관련 없지만 표면상 비슷한 문서)까지 있으면 이상적입니다.

데이터 확보 경로는 여러 가지입니다.

  • 운영 로그의 클릭·전환 데이터: 사용자가 클릭한 문서를 양성으로
  • 기존 FAQ·문서의 제목-본문, 질문-답변 쌍
  • LLM으로 각 문서에서 가상의 질문 생성(합성 데이터) — 저렴하지만 검수 필요
# 하드 네거티브 마이닝: 현재 모델로 상위 유사 문서를 뽑되
# 정답이 아닌 것을 네거티브로 사용 (가장 헷갈리는 오답을 학습)
from sentence_transformers.util import semantic_search

def mine_hard_negatives(query_emb, corpus_emb, gold_id, top_k=10):
    hits = semantic_search(query_emb, corpus_emb, top_k=top_k)[0]
    return [h["corpus_id"] for h in hits if h["corpus_id"] != gold_id][:3]

주의할 점: 합성 데이터는 편리하지만 모델의 기존 편향을 강화할 수 있습니다. LLM이 만든 질문이 실제 사용자 질문과 어투·의도가 다르면, 벤치마크는 좋아 보여도 실사용에서 개선이 없을 수 있습니다. 소량이라도 실제 쿼리로 검증셋을 구성하는 것이 필수입니다.

대조 학습으로 파인튜닝하기

임베딩 파인튜닝의 표준은 대조 학습(contrastive learning)입니다. 관련 쌍은 벡터 공간에서 가깝게, 무관 쌍은 멀게 당깁니다. sentence-transformers의 MultipleNegativesRankingLoss는 배치 내 다른 예시들을 자동으로 네거티브로 쓰는 효율적인 방식입니다.

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

model = SentenceTransformer("intfloat/multilingual-e5-base")

# (anchor=쿼리, positive=관련문서, negative=하드네거티브)
examples = [
    InputExample(texts=[
        "query: 환불은 며칠 안에 가능한가요",
        "passage: 상품 수령 후 7일 이내 환불 신청 가능",
        "passage: 포인트 적립은 결제 완료 시 지급됩니다",
    ]),
    # ... 수천 쌍
]
loader = DataLoader(examples, shuffle=True, batch_size=32)
loss = losses.MultipleNegativesRankingLoss(model)

model.fit(
    train_objectives=[(loader, loss)],
    epochs=1,                     # 보통 1~3 epoch면 충분, 과적합 주의
    warmup_steps=100,
    output_path="./e5-finetuned-domain",
)

몇 가지 실전 조언입니다. 학습률을 낮게(대개 1e-5~2e-5) 두어 사전학습 지식을 망가뜨리지 않게 하고, epoch를 적게 유지해 과적합을 막습니다. 배치가 클수록 인배치 네거티브가 많아져 대조 학습에 유리하므로, GPU 메모리가 허락하는 한 배치를 키우는 것이 도움이 됩니다.

측정: 개선을 숫자로 증명하기

파인튜닝이 실제로 나아졌는지는 고정된 검증셋과 검색 지표로 증명해야 합니다. 대표적으로 Recall@k(정답이 상위 k개 안에 포함된 비율), MRR(정답의 순위 역수 평균), NDCG(순위 품질)를 씁니다.

def recall_at_k(retrieved_ids, gold_ids, k):
    hits = sum(1 for g in gold_ids
               if g in set(retrieved_ids[:k]))
    return hits / len(gold_ids)

def mrr(retrieved_ids, gold_id):
    for rank, doc_id in enumerate(retrieved_ids, start=1):
        if doc_id == gold_id:
            return 1.0 / rank
    return 0.0

# 파인튜닝 전/후 동일 검증셋으로 반드시 대조
# base:  Recall@5=0.71  MRR=0.58
# tuned: Recall@5=0.83  MRR=0.69   <- 이런 식으로 정량 비교

중요한 원칙: 검증셋은 학습에 절대 섞이지 않아야 하고, 실제 사용자 쿼리 분포를 반영해야 합니다. 또한 오프라인 지표가 좋아졌다고 끝이 아니라, 가능하면 온라인 A/B로 클릭률·해결률 같은 사용자 지표까지 확인해야 진짜 개선인지 알 수 있습니다. 벤치마크 과적합으로 오프라인만 좋고 실사용은 그대로인 경우가 드물지 않습니다.

마무리

검색 품질 개선은 "임베딩 선택 → 값싼 개선(청킹·하이브리드·리랭커) → 그래도 부족하면 파인튜닝 → 정량 측정"이라는 순서로 접근하는 것이 합리적입니다. 파인튜닝은 강력하지만, 좋은 학습 데이터와 하드 네거티브, 과적합을 피하는 절제된 학습, 그리고 고정 검증셋 기반의 정직한 측정이 뒷받침될 때만 가치가 있습니다. 무엇보다 개선을 감이 아니라 Recall@k와 MRR 같은 숫자로 증명하는 습관이, 검색 품질을 지속적으로 끌어올리는 가장 확실한 방법입니다.