Sentence Transformers 6.0 MultiVectorEncoder: 훈련 및 미세조정 가이드
요약
Hugging Face는 Sentence Transformers v6.0에 MultiVectorEncoder 모델 유형을 추가하고, 단일 RTX 3090에서 14.5시간 내에 ColBERT 스타일의 후기 상호작용 검색기(예: multi-vector-encoder/mLateOn-medical)를 미세조정하는 단계별 훈련 스크립트를 공개했습니다. 이 모델은 의료 검색 벤치마크에서 50개 이상의 밀도형, 희소형, 어휘형 및 다중 벡터 기반 모델 중에서 0.9139 NDCG@10의 최고 성능을 기록했습니다.
다중 벡터(후기 상호작용) 모델이란?
다중 벡터 모델은 전체 텍스트를 단일 벡터로 압축하는 대신, 각 토큰마다 하나의 임베딩을 유지합니다.
- 검색은 MaxSim 연산자를 사용합니다: 각 쿼리 토큰이 가장 잘 매칭되는 문서 토큰을 찾고, 점수를 합산합니다.
- 토큰 수준의 매칭은 단일 벡터 모델이 평균화하는 세부적인 관련성 신호를 유지하며, 일반적으로 더 강력한 검색 성능을 제공하지만 인덱스 크기가 커집니다.
- 자세한 아키텍처, 인코딩, 점수 계산 및 인덱싱에 대한 내용은 "Sentence Transformers와 함께하는 다중 벡터(후기 상호작용) 임베딩 모델"이라는 보충 포스트를 참조하세요.
"밀도형 임베딩 모델은 전체 텍스트를 단일 벡터로 압축하고, 유사도는 두 개의 요약 간의 단일 내적 연산으로 계산됩니다. 다중 벡터 모델은 … 각 토큰마다 작은 벡터를 유지하고, MaxSim 연산자를 사용해 쿼리를 문서와 비교합니다." – 블로그 발췌
왜 다중 벡터 모델을 미세조정해야 할까?
미세조정은 모델을 특정 도메인(예: 의료, 법률, 코드)의 어휘, 쿼리 스타일, 관련성 개념에 맞게 조정합니다. 주요 관찰 사항:
- 도메인 신호는 토큰 단위로 포착되므로, 적은 양의 도메인 내 데이터만으로도 큰 성능 향상이 가능합니다.
- 대부분의 공개 체크포인트는 문서를 180~512 토큰으로 자릅니다. 긴 문장(의료 데이터셋 MIRIAD의 평균 941 토큰)에서는 이로 인해 최대 0.24 NDCG@10의 성능 손실이 발생할 수 있습니다.
- 비지도 체크포인트(사전 대조적이지만 아직 지도 학습되지 않은)에서 시작하면 도메인 적응에 있어 완전히 지도된 체크포인트보다 일관되게 더 높은 성능을 보입니다.
- 전체 미세조정 파이프라인은 단일 소비자 GPU에서 실행 가능하므로, 대부분의 팀이 도메인 특화 검색기를 접근할 수 있습니다.
훈련 구성 요소 개요
| 구성 요소 | 역할 |
|---|---|
| 모델 | MultiVectorEncoder 인스턴스 – 사전 훈련된 체크포인트 또는 기본 트랜스포머 기반의 새 모델 |
| 데이터셋 | datasets.Dataset 또는 DatasetDict – 쿼리-문서 쌍(또는 손실에 필요한 다른 형식) 포함 |
| 손실 | 배치 내 음성 샘플을 사용한 대조 손실(CachedMultiVectorMultipleNegativesRankingLoss) 또는 지식 증류 손실 |
| 훈련 인수 | MultiVectorEncoderTrainingArguments – 배치 크기, 학습률, 프롬프트 등 |
| 평가기 | NDCG@10, acc@1 등에 대한 MultiVectorInformationRetrievalEvaluator |
| 트레이너 | 위 요소들을 조율하는 MultiVectorEncoderTrainer |
아래 각 섹션은 독립적으로 읽을 수 있으며, 코드 조각은 pip install -U "sentence-transformers[train]"로 완전히 실행 가능합니다.
모델 선택 및 구성
기존 체크포인트 미세조정
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder(
"lightonai/mLateOn-unsupervised",
model_kwargs={"torch_dtype": "float32"},
processor_kwargs={"model_max_length": 8192}, # 전체 길이 문서 허용
)
# 내장 길이 제한 제거 (예: 180~512 토큰)
model[0].query_length = None
model[0].document_length = None
# 선택 사항: 인덱스 크기 축소를 위해 구두점 토큰 건너뛰기
import string
model[2].skiplist_words = list(string.punctuation)
model[2].resolve_with_tokenizer(model.tokenizer)
- 체크포인트는 이미 쿼리/문서 마커 토큰, 프로젝션 헤드, 점수 계산용 스킵리스트를 포함하고 있습니다.
- 제한을 제거하면 모델이 최대 1,400 토큰의 의료 문장 전체를 처리할 수 있습니다.
기본 트랜스포머에서 새 모델 구축
from sentence_transformers import MultiVectorEncoder
model = MultiVectorEncoder(
"answerdotai/ModernBERT-base",
model_kwargs={"torch_dtype": "float32"},
)
- 파이프라인은 무작위 토큰 수준 프로젝션(128차원)과 일반적인 ColBERT 모듈을 추가합니다.
- 강력한 밀도형 백본(예:
Alibaba-NLP/gte-modernbert-base)도 25,000개의 쌍으로 훈련한 후 체크포인트 성능에 근접합니다.
어떤 시작점 선택할까?
25,000개의 MIRIAD 쌍을 기반으로 한 경험적 비교:
| 체크포인트 | 제로샷 NDCG@10 | 미세조정 후 | Δ |
|---|---|---|---|
lightonai/mLateOn-unsupervised |
0.9087 | 0.9398 | +0.0311 |
lightonai/mLateOn |
0.9277 | 0.9319 | +0.0042 |
lightonai/LateOn-unsupervised |
0.9026 | 0.9206 | +0.0180 |
lightonai/LateOn |
0.9185 | 0.9105 | –0.0080 |
lightonai/GTE-ModernColBERT-v1 |
0.9198 | 0.9007 | –0.0191 |
gte-modernbert-base에 새 헤드 |
– | 0.9177 | – |
| 결론: 비지도 훈련된 체크포인트가 가장 잘 적응하며, 완전히 지도된 체크포인트는 종종 성능 저하를 겪습니다. |
데이터셋 준비
트레이너는 어떤 datasets.Dataset(Hub 또는 로컬)도 수용합니다. 필요 형식은 손실에 따라 달라집니다:
- 손실이 감독 신호를 기대하는 경우 라벨 컬럼(
label또는score)이 필요합니다. - 입력 컬럼은 순서가 있으며, 첫 번째 컬럼은 쿼리로 간주되며, 이후 컬럼은 문서로 간주됩니다(단,
router_mapping로 재정의 가능).
Hub에서 로드하기 (MIRIAD 예시)
from datasets import load_dataset
train_dataset = load_dataset("tomaarsen/miriad-4.4M-split", split="train")
print(train_dataset)
# Dataset({features: ['question', 'passage_text'], num_rows: 4_467_542})
- 각 행은
(질문, passage_text)쌍을 제공하며, 문장의 평균 길이는 941 토큰입니다.
로컬 CSV/JSON 예시
from datasets import load_dataset
dataset = load_dataset("csv", data_files="my_file.csv")
# 또는 JSON
# dataset = load_dataset("json", data_files="my_file.json")
- 사용자 정의 전처리가 필요한 경우
Dataset.from_dict를 사용하세요.
손실 함수 선택
질문-답변 쌍의 경우 추천 손실은 배치 내 음성 샘플과 GradCache를 사용하는 것입니다:
from sentence_transformers.multi_vector_encoder.losses import CachedMultiVectorMultipleNegativesRankingLoss
loss = CachedMultiVectorMultipleNegativesRankingLoss(
model=model,
mini_batch_size=16, # 메모리용 청크 크기; 블로그에서는 효과적 배치 128 사용
)
mini_batch_size는 메모리 제어에 사용되며, 효과적인 대조 배치 크기는 여전히 128입니다.- 스케일 파라미터: MaxSim 점수에 대해 기본값
scale=1.0을 유지하세요. 밀도형 기본값(scale=20.0)을 사용하면 그래디언트가 포화됩니다. - 증류를 위한 경우
MultiVectorDistillKLDivLoss를 참조하세요(블로그 예시에서는 사용되지 않음).
훈련 인수
최고의 결과를 얻은 주요 설정:
from sentence_transformers import MultiVectorEncoderTrainingArguments
from sentence_transformers.base.sampler import BatchSamplers
args = MultiVectorEncoderTrainingArguments(
output_dir="models/mLateOn-medical",
num_train_epochs=1,
per_device_train_batch_size=128, # GradCache를 통한 효과적 배치
per_device_eval_batch_size=16,
learning_rate=1e-4,
warmup_steps=0.05,
prompts={"question": "[Q] ", "passage_text": "[D] "},
fp16=False,
bf16=True,
batch_sampler=BatchSamplers.NO_DUPLICATES,
eval_strategy="steps",
eval_steps=0.1,
save_strategy="steps",
save_steps=0.05,
logging_steps=0.01,
run_name="mLateOn-medical",
)
- 프롬프트는 명시적으로 제공되어야 하며, 모델은 저장된 마커를 자동으로 적용하지 않습니다.
max_length를 설정하지 않으면 훈련이 전체 문서 길이를 볼 수 있습니다.- 5e-6 → 2e-4 범위에서 탐색한 결과, 더 높은 학습률(
1e-4)이 가장 우수한 성능을 보였습니다.
검색용 평가기
가장 유의미한 평가기는 보류된 쿼리 세트와 방해물이 포함된 코퍼스를 기반으로 만든 MultiVectorInformationRetrievalEvaluator입니다:
from sentence_transformers.multi_vector_encoder.evaluation import MultiVectorInformationRetrievalEvaluator
# 코퍼스, 쿼리, 관련 문서 매핑 구축 (전체 루프는 블로그 참조)
evaluator = MultiVectorInformationRetrievalEvaluator(
queries=queries,
corpus=corpus,
relevant_docs=relevant_docs,
name="miriad-dev",
batch_size=16,
)
- 포화를 피하기 위해 어려운 방해 문장을 포함하세요. 블로그에서는 10,000개의 골드 세트에 약 19만 개의 랜덤 훈련 문장을 추가했습니다.
전체 훈련 스크립트
다음 스크립트는 mLateOn-medical 모델을 재현합니다:
import logging, string, traceback
from datasets import load_dataset
from sentence_transformers import (
MultiVectorEncoder,
MultiVectorEncoderModelCardData,
MultiVectorEncoderTrainer,
MultiVectorEncoderTrainingArguments,
)
from sentence_transformers.base.sampler import BatchSamplers
from sentence_transformers.multi_vector_encoder.losses import CachedMultiVectorMultipleNegativesRankingLoss
from sentence_transformers.multi_vector_encoder.evaluation import MultiVectorInformationRetrievalEvaluator
logging.basicConfig(format="%(asctime)s - %(message)s", level=logging.INFO)
def main():
# 1️⃣ 비지도 체크포인트 로드 및 제한 제거
model = MultiVectorEncoder(
"lightonai/mLateOn-unsupervised",
model_kwargs={"torch_dtype": "float32"},
processor_kwargs={"model_max_length": 8192},
model_card_data=MultiVectorEncoderModelCardData(
language="en",
license="apache-2.0",
model_name="mLateOn finetuned on MIRIAD medical retrieval",
),
)
model[0].query_length = None
model[0].document_length = None
model[2].skiplist_words = list(string.punctuation)
model[2].resolve_with_tokenizer(model.tokenizer)
# 2️⃣ 100만 개의 의료 QA 쌍 로드
train_dataset = load_dataset("tomaarsen/miriad-4.4M-split", split="train").select(range(1_000_000))
# 3️⃣ 손실 정의 (GradCache 배치 내 음성 샘플)
loss = CachedMultiVectorMultipleNegativesRankingLoss(model=model, mini_batch_size=16)
# 4️⃣ 가벼운 개발 평가기 (500개 쿼리)
eval_split = load_dataset("tomaarsen/miriad-4.4M-split", split="eval")
corpus, queries, relevant_docs, passage_to_id = {}, {}, {}, {}
for idx, row in enumerate(eval_split):
if row["passage_text"] not in passage_to_id:
pid = f"p{len(passage_to_id)}"
passage_to_id[row["passage_text"]] = pid
corpus[pid] = row["passage_text"]
if idx < 500:
qid = f"q{idx}"
queries[qid] = row["question"]
relevant_docs[qid] = {passage_to_id[row["passage_text"]]}
dev_evaluator = MultiVectorInformationRetrievalEvaluator(
queries=queries, corpus=corpus, relevant_docs=relevant_docs, name="miriad-dev", batch_size=16
)
# 5️⃣ 훈련 인수 (이전 섹션 참조)
args = MultiVectorEncoderTrainingArguments(
output_dir="models/mLateOn-medical",
num_train_epochs=1,
per_device_train_batch_size=128,
per_device_eval_batch_size=16,
learning_rate=1e-4,
warmup_steps=0.05,
prompts={"question": "[Q] ", "passage_text": "[D] "},
fp16=False,
bf16=True,
batch_sampler=BatchSamplers.NO_DUPLICATES,
eval_strategy="steps",
eval_steps=0.1,
save_strategy="steps",
save_steps=0.05,
logging_steps=0.01,
run_name="mLateOn-medical",
)
# 6️⃣ 트레이너 및 훈련
trainer = MultiVectorEncoderTrainer(
model=model,
args=args,
train_dataset=train_dataset,
loss=loss,
evaluator=dev_evaluator,
)
trainer.train()
# 7️⃣ 저장 및 선택적으로 Hub에 업로드
model.save_pretrained("models/mLateOn-medical/final")
try:
model.push_to_hub("mLateOn-medical")
except Exception:
logging.error("모델 업로드 실패: \n" + traceback.format_exc())
if __name__ == "__main__":
main()
- 실행 시간: 단일 RTX 3090에서 14.5시간 (피크 17.5GB VRAM).
- 데이터 효율성: 10만 개의 쌍(약 75분)만으로도 전체 백만 쌍 훈련 결과와 NDCG@10 차이가 0.012 이내입니다.
인덱스 크기 및 최적화
다중 벡터 인덱스는 각 토큰이 벡터를 생성하기 때문에 더 큽니다(941토큰 문장당 약 878개 벡터). 20만 개 문장의 원시 fp16 저장 용량은 약 45GB입니다.
토큰 풀링
HierarchicalTokenPooling(pool_factor=4)는 벡터 수를 4배 줄이며, NDCG@10 손실은 < 0.0033입니다.
from sentence_transformers.multi_vector_encoder.modules import HierarchicalTokenPooling
pooling = HierarchicalTokenPooling(pool_factor=4)
embeddings = model.encode_document(passages, token_pooling=pooling)
- 벡터 수의 ¼ → 약 11GB, NDCG@10 ≈ 0.8991.
양자화 및 절단 (PLAID)
1비트 잔차 양자화와 적절한 절단을 사용하면:
| 구성 | 유지된 벡터 | 인덱스 크기 | NDCG@10 |
|---|---|---|---|
| 1비트 PLAID, 모든 벡터 | 100% | 3.37GB | 0.8984 |
| 1비트 PLAID + 절단 | 65% | 2.23GB | 0.8830 |
| 1비트 PLAID + 절단 | 42% | 1.45GB | 0.8642 |
- 양자화는 13배 이상의 용량 감소를 제공하며, NDCG 손실은 0.02 이내로, 다중 벡터 검색이 밀도형 모델과 유사한 저장 용량을 갖게 합니다.
평가 결과
미세조정된 multi-vector-encoder/mLateOn-medical vs. 50개 이상의 기준 모델, 20만 문장 의료 벤치마크(1,000개 보류된 쿼리):
| 모델 | 가족 | NDCG@10 | acc@1 |
|---|---|---|---|
| multi-vector-encoder/mLateOn-medical | 다중 벡터(미세조정) | 0.9139 | 0.849 |
| lightonai/mLateOn | 다중 벡터(제로샷) | 0.8520 | 0.758 |
| lightonai/GTE-ModernColBERT-v1 (제한 제거) | 다중 벡터(제로샷) | 0.8502 | 0.763 |
| Qwen/Qwen3-Embedding-4B | 밀도형(제로샷) | 0.7817 | 0.669 |
| voyageai/voyage-4-nano | 밀도형(제로샷) | 0.7563 | 0.638 |
| BM25 | 어휘형 | 0.7501 | 0.641 |
| naver/splade-v3 | 희소형(제로샷) | 0.6853 | 0.574 |
| 결론: *문서 길이가 길 때 후기 상호작용 모델이 우세하며, 미세조정은 가장 강력한 제로샷 모델보다 +0.062 NDCG@10의 추가 성능 향상을 제공합니다.* |
실용적 통찰
- 비지도 체크포인트(예:
lightonai/mLateOn-unsupervised)에서 시작하여 도메인 미세조정을 수행하세요. - 문서 길이 제한을 제거하여 데이터 길이에 맞추세요. 그렇지 않으면 긴 문장에서 최대 0.24 NDCG@10의 성능 손실이 발생합니다.
- **GradCache(
CachedMultiVectorMultipleNegativesRankingLoss)**를 사용하여 단일 GPU에서 큰 효과적 배치 크기를 달성하세요. - 구두점 스킵리스트를 적용하여 인덱스 크기를 약 10% 줄이되 품질 손실 없이 진행하세요.
- 인덱스 압축(토큰 풀링, PLAID 양자화)에 투자하여 다중 벡터 저장 용량을 밀도형 모델과 유사한 수준으로 유지하세요.
- 적은 데이터(10만 쌍)만으로도 강력한 성능을 얻을 수 있으므로, 많은 조직이 이 접근법을 활용할 수 있습니다.
추가 자료
- 훈련 예제: MIRIAD 의료 미세조정, MS MARCO 대조 및 증류, 다중 모달 ColPali, PEFT LoRA 어댑터.
- 문서화: 설치, 빠른 시작, 사용법, 사용자 정의 모델 생성, 사전 훈련된 모델 목록, 훈련 및 손실 개요, API 참조.
- 보충 포스트(사용법): Sentence Transformers와 함께하는 다중 벡터(후기 상호작용) 임베딩 모델 – 인코딩, 인덱싱, 배포에 대해 다룹니다.
"다중 벡터 인덱스가 너무 크다는 주장은 적절하게 구성된 인덱스에서는 성립하지 않습니다." – 오마르 카타브에게 양자화 측정에 감사드립니다.
Sources
관련
- Dispatch
- Dispatch
- Dispatch
- Dispatch
- Dispatch