목차
텍스트 임베딩 모델 만들기
앞의 「토큰과 임베딩」 글에서는 단어와 토큰을 숫자로 바꾸는 과정을 살펴봤다. 이번에는 문장 전체를 하나의 벡터로 표현해 검색에 사용하는 모델을 만들어 본다. 여기서 “만든다”는 것은 거대한 언어 모델을 처음부터 사전 훈련한다는 뜻이 아니다. 이미 학습된 인코더를 가져와, 우리가 찾으려는 문서에 맞게 문장 간 거리를 조정한다는 뜻이다.
예를 들어 고객이 “비밀번호를 잊어버렸어요”라고 검색했을 때, 문서 제목이 “계정 암호 재설정 방법”이어도 찾아야 한다. 두 문장에 같은 단어가 거의 없으므로 단어 일치만으로는 부족하다. 좋은 임베딩 모델이라면 이 둘을 가까이 두고, “비밀번호 변경 정책”처럼 비슷한 단어를 쓰지만 다른 의도를 가진 문서는 구별해야 한다.
flowchart LR
Q[질문: 비밀번호를 잊어버렸어요] --> E[동일한 임베딩 모델]
D1[문서: 계정 암호 재설정 방법] --> E
D2[문서: 비밀번호 변경 정책] --> E
E --> V[문장별 벡터]
V --> S[코사인 유사도 계산]
S --> R[관련 문서 순위]
그림처럼 질문과 문서에 같은 모델을 적용하고, 만들어진 벡터의 유사도로 순위를 정한다. 문서를 미리 벡터로 저장해 두면 새 질문이 들어올 때 질문만 임베딩하면 된다.
어떤 벡터를 만들 것인가
트랜스포머 인코더는 토큰마다 벡터를 출력한다. 검색에 쓰려면 토큰 벡터들을 하나의 문장 벡터로 합쳐야 한다. 가장 단순한 방법은 유효한 토큰의 벡터를 평균 내는 평균 풀링(mean pooling)이다. 패딩 토큰까지 평균에 넣으면 짧은 문장의 표현이 왜곡되므로 attention mask를 적용한다.
여기서 는 번째 토큰의 출력 벡터, 는 실제 토큰이면 1이고 패딩이면 0이다. 벡터의 길이를 1로 정규화하면 내적과 코사인 유사도가 같아져 검색 단계가 간단해진다. 다만 풀링 방법과 정규화 여부는 학습과 추론에서 같아야 한다. Sentence Transformers의 Pooling 모듈 문서에서 지원하는 방식들을 확인할 수 있다.
여기서 중요한 구분은 토큰 임베딩과 문장 임베딩이다. “비밀번호 재설정”을 토큰으로 나누면 각 토큰에도 벡터가 생긴다. 검색에서는 질문 하나와 문서 하나를 비교해야 하므로 문장 또는 문서 조각마다 하나의 벡터가 필요하다. 위 풀링 수식은 토큰별 표현을 하나로 합치는 과정이다. 그렇지만 평균을 내기만 했다고 곧바로 좋은 검색 모델이 되는 것은 아니다. 그 벡터가 어떤 문장을 가까이 둬야 하는지 알려 주는 훈련 목표가 필요하다.
코사인 유사도는 두 벡터의 방향을 비교한다. 점수가 높다는 것은 이 모델과 이 데이터에서 비슷하게 표현됐다는 뜻이지, 두 문장이 같은 사실을 주장한다는 보증은 아니다. “계정을 삭제할 수 있다”와 “계정을 삭제할 수 없다”처럼 핵심 단어를 공유하지만 의미가 반대인 문장도 헷갈릴 수 있다. 고객지원 검색이라면 이런 부정 표현을 평가 질문에 꼭 포함한다.
학습 데이터: 질문과 정답 문서
임베딩 모델에는 보통 (질문, 관련 문서) 쌍을 준비한다. 다음은 형식을 보여주기 위한 가상 고객지원 데이터다.
| 질문(anchor) | 관련 문서(positive) | 헷갈리지만 다른 문서(hard negative) |
|---|---|---|
| 비밀번호를 잊어버렸어요 | 계정 암호 재설정 절차 | 정기적인 비밀번호 변경 정책 |
| 주문을 취소하고 싶어요 | 결제 전 주문 취소 방법 | 주문 상품 교환 절차 |
| 영수증은 어디서 받나요? | 결제 영수증 다운로드 | 환불 신청서 다운로드 |
같은 문서에 연결되는 질문의 표현을 다양하게 모으는 것이 중요하다. “영수증 받기”, “결제 내역 출력”, “구매 증빙 내려받기”를 모두 같은 문서의 양성 예제로 만들 수 있다. 반대로 학습 데이터의 질문과 문서 제목을 단순 복사하면, 모델이 의미보다는 표면상의 단어 겹침을 익힐 수 있다.
분할할 때는 질문 행만 무작위로 나누지 말고 문서 또는 주제 단위로 나누는 편이 안전하다. 같은 문서의 문장만 바꾼 예제가 학습과 테스트 양쪽에 있으면 새 문서에 대한 검색 성능을 과대평가할 수 있기 때문이다. 실제 서비스에선 시간 기준으로 오래된 문서로 학습하고 새 문서로 평가하는 방식도 유용하다.
양성·음성 예제를 어떻게 고를까?
양성 문서는 “질문에 답이 들어 있는가?”로 판정한다. 제목이 비슷해도 본문에 답이 없으면 양성이 아니다. 질문 하나가 여러 문서에서 답을 얻을 수 있다면 관련 문서 ID를 모두 기록한다. 평가 때 한 문서만 정답으로 적어 두면 모델이 또 다른 정답을 찾아도 오답으로 계산된다.
음성 예제는 난이도를 나눈다. “비밀번호” 질문에 “배송 기간” 문서는 쉬운 음성이다. “비밀번호 변경 정책”은 단어가 비슷해서 어려운 음성이다. 어려운 음성은 모델의 구별 능력을 높이는 데 도움이 될 수 있지만, 실제로 그 문서도 질문에 답한다면 잘못된 라벨이다. 어려운 음성을 채굴한 뒤 사람이 관련성을 확인하는 단계가 필요한 이유다.
문서가 길면 전체를 하나의 벡터로 만들기보다 주제별 조각(chunk)으로 나눈다. 예를 들어 “계정 관리” 문서 안에 로그인, 암호 변경, 탈퇴 절차가 각각 있다면 세 조각을 별도로 색인한다. 검색 결과에는 조각의 원문과 문서 ID를 함께 보관한다. 조각이 너무 짧으면 앞뒤 조건이 사라지고, 너무 길면 필요한 한 문장이 다른 주제 사이에 묻힐 수 있다. 적절한 길이는 실제 질문의 정답을 포함하는지 평가하며 조정한다.
대조 학습으로 거리를 조정하기
동일한 인코더가 질문과 문서를 각각 벡터로 바꾸는 구조를 바이 인코더(bi-encoder)라고 한다. 한 배치에 여러 (질문, 정답 문서) 쌍을 넣고 각 질문이 자신의 정답 문서를 다른 문서보다 높게 평가하도록 학습할 수 있다. 아래의 는 두 벡터의 유사도이며, 는 점수의 분포를 조절하는 온도다.
분모의 다른 문서들이 해당 질문의 음성 예제 역할을 한다. Sentence Transformers의 MultipleNegativesRankingLoss가 이런 배치 내 음성 예제 방식을 제공한다. 단, 배치 안에 서로 다른 질문이 같은 정답 문서를 가리키면 실제로 관련된 문서를 음성 예제로 취급할 수 있다. 중복 양성 쌍을 점검하고 배치를 구성해야 하는 이유다.
수식을 한 행으로 읽어 보자. 질문 가 “비밀번호를 잊어버렸어요”라면 분자의 는 “계정 암호 재설정 절차”다. 분모에는 그 문서와 배치에 함께 들어온 다른 문서들이 있다. 학습은 정답 문서의 유사도를 높이고 다른 문서의 유사도를 상대적으로 낮추도록 모델 가중치를 바꾼다. 절대 유사도 0.8이 되도록 학습하는 것이 아니라 정답이 다른 후보보다 높은 순위를 갖도록 학습한다는 점이 중요하다.
배치 크기를 늘리면 한 질문이 비교할 음성 후보도 많아지지만 메모리 비용이 늘어난다. 같은 문서가 여러 행에 반복되는 배치는 실제 관련 문서를 음성으로 취급할 위험도 키운다. 처음에는 데이터 정합성과 작은 검증 세트를 먼저 만들고, 이후 배치 크기나 음성 예제 전략을 조정한다.
flowchart TD
A[질문-정답 문서 쌍 수집] --> B[문서 단위 train / validation / test 분리]
B --> C[기존 다국어 임베딩 모델로 기준선 측정]
C --> D[대조 손실로 미세 튜닝]
D --> E[검증 질문의 검색 순위 확인]
E --> F{기준선보다 나은가?}
F -- 예 --> G[모델과 인덱스 함께 갱신]
F -- 아니요 --> H[라벨·음성 예제·잘린 문서 조사]
H --> A
이 과정에서 검증 결과가 좋지 않으면 모델 크기를 먼저 늘리기보다 정답 쌍과 잘못된 음성 예제를 검토한다. 검색할 수 없는 문서가 평가 목록에 빠졌다면 모델을 고쳐도 정답을 찾을 수 없다.
작은 학습 예제
아래 코드는 훈련 흐름을 보이기 위한 최소 예시다. 세 쌍만으로는 쓸 만한 검색 모델이 만들어지지 않는다. 실제로는 별도의 검증·테스트 쿼리와 충분한 문서 집합을 준비해야 한다. 설치와 인자별 최신 사용법은 공식 Training Overview를 기준으로 확인한다.
from datasets import Dataset
from sentence_transformers import (
SentenceTransformer,
SentenceTransformerTrainer,
SentenceTransformerTrainingArguments,
losses,
)
from sentence_transformers.training_args import BatchSamplers
pairs = Dataset.from_dict({
"anchor": [
"비밀번호를 잊어버렸어요",
"주문을 취소하고 싶어요",
"영수증은 어디서 받나요?",
],
"positive": [
"계정 암호 재설정 절차",
"결제 전 주문 취소 방법",
"결제 영수증 다운로드",
],
})
model = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)
args = SentenceTransformerTrainingArguments(
output_dir="models/help-center-embedding",
num_train_epochs=1,
per_device_train_batch_size=3,
batch_sampler=BatchSamplers.NO_DUPLICATES,
)
trainer = SentenceTransformerTrainer(
model=model,
args=args,
train_dataset=pairs,
loss=losses.MultipleNegativesRankingLoss(model),
)
trainer.train()
model.save_pretrained("models/help-center-embedding")
기존 모델을 기준선으로 삼은 이유는 미세 튜닝이 항상 이득을 주지는 않기 때문이다. 일반적인 문장 의미를 잘 표현하던 모델도 좁고 작은 데이터에 맞추다 보면 다른 주제의 검색 품질이 나빠질 수 있다.
코드에서 anchor와 positive의 같은 행이 정답 쌍이다. SentenceTransformer는 기존 다국어 문장 모델을 불러오고, 손실 함수는 같은 배치의 다른 positive를 비교 대상으로 사용한다. NO_DUPLICATES 배치 샘플러는 중복을 줄이는 데 도움이 되지만 서로 다른 문장이 사실상 같은 뜻인지까지 판단하지는 않는다. 데이터 의미 검수는 별도로 해야 한다. trainer.train()은 가중치를 실제로 갱신하므로, 실험에서는 재현 가능한 데이터 버전과 모델 설정을 기록해 둔다.
학습 전에 기준선 검색해 보기
벡터 데이터베이스 없이도 작은 문서 집합에서 검색이 어떻게 작동하는지 확인할 수 있다. 아래 코드는 학습 전 모델로 기준선을 확인하는 예시다. 결과 순위는 여기서 실행하지 않았으므로 특정 문서가 1위라고 주장하지 않는다.
import numpy as np
from sentence_transformers import SentenceTransformer
documents = [
"계정 암호 재설정 절차",
"정기적인 비밀번호 변경 정책",
"결제 영수증 다운로드",
]
query = "비밀번호를 잊어버렸어요"
model = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)
document_vectors = model.encode(documents, normalize_embeddings=True)
query_vector = model.encode(query, normalize_embeddings=True)
scores = document_vectors @ query_vector
for index in np.argsort(scores)[::-1]:
print(f"{scores[index]:.3f} {documents[index]}")
encode는 문서마다 벡터 하나를 만든다. 정규화한 벡터끼리의 내적 @는 코사인 유사도와 같다. argsort로 점수 순서를 얻고 [::-1]로 높은 순서부터 출력한다. 실제 서비스에선 문서 벡터를 미리 계산해 색인에 저장한다. 새 질문이 오면 질문 벡터만 만들고, 색인에서 가까운 문서를 찾는다. 문서 수가 많아지면 전체 벡터를 매번 비교하는 코드보다 검색 인덱스가 필요하다.
검색 품질 평가하기
검증 질문마다 정답 문서 ID를 정하고 전체 후보 문서에서 순위를 매긴다. Recall@k는 질문별 전체 관련 문서 중 상위 개 안에 찾은 문서의 비율을 평균 낸다. 정답이 질문마다 하나뿐이면 상위 개 안에 정답이 들어온 질문의 비율과 같다. MRR은 첫 정답 문서 순위의 역수를 평균 낸다. 정답이 1위면 1, 2위면 0.5로 계산한다. 하나의 질문에 여러 정답이 있고 순서도 중요하다면 nDCG@k도 함께 본다. InformationRetrievalEvaluator 같은 도구로 고정된 검색 평가 세트를 유지할 수 있다.
계산을 작게 해 보자. 질문 세 개에서 첫 정답 문서의 순위가 각각 1위, 3위, 상위 5개 밖이라면 Recall@5는 , MRR@5는 다. 이것은 설명용 가상 결과다. Recall@5만 보면 1위와 5위가 같게 계산되므로, 상단 순서가 중요한 화면이라면 MRR이나 nDCG도 함께 본다. “정답 없음” 질문은 별도로 평가해 낮은 유사도의 문서를 억지로 답변 근거로 내보내지 않도록 해야 한다.
| 확인할 것 | 실패 사례 | 다음 조치 |
|---|---|---|
| Recall@5 | 정답 문서가 후보에도 없음 | 문서 분할, 질문 표현, 누락된 문서를 확인 |
| 상위 1위 적합성 | 비슷한 제목의 다른 절차가 1위 | 헷갈리는 음성 예제를 추가 |
| 문서 길이 | 긴 문서의 핵심 내용이 잘림 | 문서를 의미 단위로 나누고 청크별로 색인 |
| 운영 성능 | 새 문서가 검색되지 않음 | 문서 추가 시 동일 모델로 다시 임베딩하고 색인 갱신 |
임베딩 검색은 후보를 빠르게 좁히는 단계다. 정말 비슷한 문서 여러 개의 최종 순서가 중요하다면 후보를 다시 읽어 비교하는 재순위화 모델을 붙일 수 있다. 이 경우 검색과 재순위화 각각의 성능을 따로 측정해야 병목을 알 수 있다.
운영에서는 모델 파일만 교체하면 끝나지 않는다. 새 모델은 벡터 공간을 바꾸므로 기존 문서 벡터를 새 모델로 다시 계산해야 한다. 모델 버전과 색인 버전을 같이 관리하고, 재색인하는 동안 질문 벡터와 문서 벡터가 서로 다른 버전으로 섞이지 않게 한다. 문서 수정·삭제가 색인에 반영되는 시간도 기록하면, “검색 성능 저하”가 모델 문제인지 오래된 색인 때문인지 구분할 수 있다.
정리
텍스트 임베딩 모델을 만든다는 것은 문장 표현을 실제 검색 목표에 맞춰 조정하는 작업이다. 질문·정답 문서 쌍, 잘 설계된 분할, 기존 모델 기준선, 검색 지표가 함께 있어야 개선 여부를 판단할 수 있다. 모델을 바꾼 뒤에는 저장된 문서 벡터도 새 모델로 다시 만들어야 질문과 문서가 같은 벡터 공간에서 비교된다.