김태은/ AI · ML Research Intern
- 1. 왜 FlagEmbedding을 선택했는가?
- 2. 설치 과정
- 3. 하드 네거티브 마이닝을 통한 학습 데이터 준비
- 4. BGE-M3 학습(파인튜닝)
- 5. 모델 평가
- 커스텀 데이터로 모델 평가
- 6. 트러블슈팅 팁
이 아티클에서는 FlagEmbedding 라이브러리를 사용한 하드 네거티브 마이닝, 파인튜닝 후 평가하는 과정을 다룹니다. 또한, FlagEmbedding을 사용하면서 트러블슈팅을 통해 얻은 주요 통찰도 공유합니다.
1. 왜 FlagEmbedding을 선택했는가?
FlagEmbedding은 BAAI에서 개발된 임베딩 모델 학습 라이브러리로, 한국어 검색 및 RAG 등이 가능한 BGE-M3(BAAI General Embeddings) 모델을 제공합니다.
- bge-m3은 한국어에서 성능은 아래 벤치마크에서 참고할 수 있는데, bge-multilingual-gemma2와 비교했을 때도 뛰어난 결과를 보이는 것을 알 수 있습니다.
- FlagEmbedding은 다국어 모델을 지원하며, 특히 영어와 중국어를 중심으로 설계되었습니다.
- 이 프로젝트는 지속적으로 연구와 업데이트가 진행 중입니다. GitHub에서의 최근 업데이트 덕분에 troubleshooting이 비교적 쉽다는 장점이 있습니다.
2. 설치 과정
FlagEmbedding 사용의 전체 절차는 FlagEmbedding 라이브러리를 위한 격리된 환경을 유지할 수 있는 Docker 환경에서 진행되었습니다.
- FlagEmbedding 클론 및 필수 라이브러리 설치
- 모델 평가를 위한
pytrec_eval
과faiss
설치
git clone https://github.com/FlagEmbedding/FlagEmbedding.git
cd FlagEmbedding
pip install -e .[finetune]
pip install pytrec_eval
pip install https://github.com/kyamagu/faiss-wheels/releases/download/v1.7.3/faiss_gpu-1.7.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
3. 하드 네거티브 마이닝을 통한 학습 데이터 준비
일반적으로 임베딩 모델은 대조 학습(contrastive learning)을 통해 파인튜닝됩니다. 대조 학습은 모델이 서로 다른 데이터 포인트 간의 거리를 학습하도록 하는 자기 지도 학습(self-supervised learning)의 한 유형입니다. 대조 학습에서는 세 가지 주요 요소가 있습니다: anchor (쿼리), positive (정답 문서), negative (관련이 없거나 불충분한 문서). 간단히 말해, 모델이 포지티브 데이터 포인트를 쿼리와 가깝게, 네거티브 데이터 포인트를 멀리 배치하도록 학습시키는 것입니다.
따라서, 임베딩 모델을 학습시키기 위해 다음과 같은 구조의 데이터셋이 필요합니다:
{
"query": "한국의 수도는 어디인가요?",
"pos": ["한국의 수도는 서울입니다."],
"neg": ["강남은 한국에서 가장 아름다운 도시입니다", "닭발은 최고의 야식입니다",)
}
모든 네거티브가 동일하지는 않습니다. 위의 예제 데이터를 보면, 하나는 쿼리와 완전히 무관한 반면 다른 하나는 쿼리와 꽤 관련성이 있는 것을 알 수 있습니다. 예를 들어, 쿼리가 “한국의 수도”를 묻고 있는데, “닭발은 최고의 야식입니다” 라는 문장은 쿼리와 아무 연관이 없습니다. 우리도 직관적으로 이 두 문장이 전혀 상관없다는 걸 알 수 있듯이, 임베딩 모델도 이 차이를 구분해서 쿼리와 이 문장을 벡터화할 때 서로 멀리 떨어진 위치에 놓을 가능성이 높습니다.
반면, “강남은 한국에서 가장 아름다운 도시입니다” 라는 문장은 쿼리와 어느 정도 연관이 있습니다. “도시” 라는 공통 주제를 포함하고 있기 때문에, 이 문장의 벡터는 쿼리와 더 가까운 위치에 있을 가능성이 높습니다.
네거티브 데이터의 난이도에는 여러 단계가 있습니다. 일부는 쉽게 구별 가능하고 쿼리에 답하는 데 중요하지 않은 반면, 다른 일부는 매우 관련성이 높아 충분한 답변인지 여부를 판단하기 어렵습니다. 우리는 후자를 하드 네거티브(hard negatives)라고 부릅니다. 일반적으로 쉬운 네거티브는 이미 임베딩 모델의 벡터 공간에서 앵커와 멀리 떨어져 있습니다. 성능을 최대화하려면, 기본 모델의 벡터 공간에서 쿼리와 유사한 하드 네거티브를 확보하는 것이 중요합니다. 이렇게 하면 대조 학습 동안 모델이 두 가지를 구별하고 차이를 강조하도록 학습할 수 있습니다.
Hard Negative Mining (HNM)은 학습 데이터의 네거티브 문서가 쿼리와 유사하고, 이상적으로 정답 바로 아래 수준이 되도록 보장합니다. HNM 전략에는 다양한 구현 방식이 있지만, FlagEmbedding은 간단한 Dense Retrieval 기반의 HNM 스크립트를 제공합니다.
예시 커맨드:
python scripts/hn_mine.py \
--model_name_or_path BAAI/bge-m3\
--input_file toy_finetune_data.jsonl \
--output_file toy_finetune_data_minedHN.jsonl \
--range_for_sampling 2-200 \
--negative_number 15 \
--use_gpu_for_searching
- 이 아티클에서 다루지는 않지만 FlagEmbedding 내에 데이터를 분할하고 reranker teacher 점수를 부여하는 스크립트도 존재합니다. 참고자료
- range_for_sampling:
2-200
은 상위 2~200개 문서에서 네거티브를 샘플링한다는 뜻입니다. 값이 클수록 네거티브의 난이도가 낮아집니다. - negative_number: 데이터셋에 따라 네거티브 수를 조정할 수 있습니다.
- 제 경우, HNM 스크립트는 QA 데이터셋에서 실행되었으며, QA 데이터셋의 정답들이 모아져 하드 네거티브를 마이닝하는 코퍼스가 됩니다. 이는 쿼리당 하나의 포지티브 문서를 생성합니다.
이 과정을 성공적으로 실행하면, bge-m3
가 코퍼스에서 상위 2~200개의 가장 유사한 문서 중 임의로 샘플링한 15개의 하드 네거티브가 포함된 데이터셋을 얻게 됩니다.
모델을 이 데이터로 평가하려는 경우, 이 시점에서 데이터를 test/train으로 분할해야 합니다.
4. BGE-M3 학습(파인튜닝)
이 명령은 bge-m3
를 파인튜닝하는 데 특화되어 있습니다. 다른 모델을 튜닝하실 경우 이 문서를 참고해 주세요.
- train_group_size: 하나의 학습 배치에 그룹화되는 데이터 샘플 수를 결정합니다.
- query_max_len 및 passage_max_len: 쿼리와 문서의 최대 토큰 길이를 설정합니다. 이는 데이터와 리소스 가용성에 따라 조정할 수 있습니다.
- knowledge_distillation: teacher score를 지정하지 않았으므로
False
로 설정되었습니다. 따라서 모델 디스틸레이션을 활용하지 않습니다. - same_dataset_within_batch: 배치 내에서 동일한 데이터셋을 사용할지 여부를 지정합니다.
False
로 설정하면 데이터 다양성이 증가합니다. - self-distillation: unify fine-tuning 과정에서 500 스텝 이후 시작됩니다. 이 방식은 모델이 자신의 예측 결과를 학습 신호로 사용하여 임베딩을 정제하는 것을 포함합니다. 초기에는 원래의 정답 라벨로 학습하고, 이후 단계에서 모델의 자체 출력을 추가적으로 활용합니다.
CUDA_VISIBLE_DEVICES="1,2,3,4,5,6,7" \
torchrun --nproc_per_node 5 \
-m FlagEmbedding.finetune.embedder.encoder_only.m3 \
--model_name_or_path BAAI/bge-m3 \
--cache_dir ./cache/model \
--train_data toy_finetune_data_minedHN.jsonl \
toy_finetune_data_minedHN2.jsonl \
toy_finetune_data_minedHN3.jsonl \
--cache_path ./cache/data \
--train_group_size 8 \
--query_max_len 128 \
--passage_max_len 400 \
--pad_to_multiple_of 8 \
--knowledge_distillation False \
--same_dataset_within_batch False \
--small_threshold 0 \
--drop_threshold 0 \
--output_dir finetuned_bge-m3 \
--deepspeed examples/finetune/ds_stage0.json \
--overwrite_output_dir \
--learning_rate 5e-6 \
--num_train_epochs 2 \
--per_device_train_batch_size 2 \
--dataloader_drop_last True \
--warmup_ratio 0.1 \
--gradient_checkpointing \
--logging_steps 20 \
--save_steps 5000 \
--negatives_cross_device \
--temperature 0.05 \
--sentence_pooling_method cls \
--normalize_embeddings True \
--kd_loss_type m3_kd_loss \
--unified_finetuning True \
--use_self_distill True \
--fix_encoder False \
--self_distill_start_step 500
학습 loss 추적 기능 추가
/FlagEmbedding/FlagEmbedding/finetune/embedder/encoder_only/m3/trainer.py
파일에서, EncoderOnlyEmbedderM3Trainer
클래스 내부에 학습 진행 상황을 모니터링하기 위해 손실을 추적하고 기록하는 기능을 추가했습니다. 매 100 스텝마다 이 기록은 JSON 형식으로 로그 파일에 저장됩니다.
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.loss_history = []
# Create loss file in output directory
self.log_file = os.path.join(self.args.output_dir, "training_loss.json")
def _save(self, output_dir: Optional[str] = None, state_dict=None):
# rest of the function
self._save_loss_log()
def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
loss, outputs = super().compute_loss(model, inputs, return_outputs=True);
self.loss_history.append({
"step": self.state.global_step,
"loss": loss.item(),
})
if self.state.global_step % 100 == 0:
self._save_loss_log()
return (loss, outputs) if return_outputs else loss
def _save_loss_log(self):
if self.is_world_process_zero():
os.makedirs(os.path.dirname(self.log_file), exist_ok=True)
with open(self.log_file, 'w') as f:
json.dump(self.loss_history, f, indent=2)
5. 모델 평가
커스텀 데이터로 모델 평가
FlagEmbedding은 MIRACL
, MLDR
과 같은 한국어를 지원하는 여러 평가 데이터셋을 제공합니다. 하지만 모든 데이터가 동일하지 않고, 하나의 데이터셋으로 학습한다고 해서 다른 데이터셋에서 성능 향상이 보장되지 않기 때문에, 학습 데이터셋의 테스트 데이터를 사용하여 파인튜닝된 모델을 평가하는 것이 중요할 수 있습니다. 이번 실습에서는 커스텀 데이터셋을 사용하여 평가를 진행합니다.
평가를 위해 커스텀 데이터셋을 사용할 경우, 다음 세 가지 파일이 필요합니다: corpus.jsonl
, test_queries.jsonl
, test_qrels.jsonl
. 각 파일은 아래와 같은 구조를 가져야 합니다.
예제 corpus.jsonl
{"id": "101", "title": "", "text": "사업자등록증을 분실했을 경우, 관할 세무서 또는 홈택스를 통해 재발급 신청이 가능합니다."}
{"id": "102", "title": "", "text": "부가세 신고 중 잘못 입력한 경우, 신고 기한 내에 수정신고서를 제출하여 수정 가능합니다."}
{"id": "103", "title": "", "text": "퇴직금 계산 시 기본급, 상여금, 연차수당 등이 포함됩니다."}
{"id": "104", "title": "", "text": "외국인 근로자의 소득세 신고를 위해 여권, 비자, 급여 명세서가 필요합니다."}
{"id": "105", "title": "", "text": "정부 지원금 신청은 중소벤처기업부 사이트에서 신청서를 작성하고 관련 서류를 제출해야 합니다."}
{"id": "106", "title": "", "text": "대표자 변경 시 은행 계좌 변경을 위해 신분증, 사업자등록증, 변경 증명 서류가 필요합니다."}
예제 test_queries.jsonl
{"id": "1", "text": "사업자등록증 분실 시 대처 방법은?"}
{"id": "2", "text": "부가세 신고 시 실수로 잘못 입력한 경우 수정할 수 있나요?"}
{"id": "3", "text": "퇴직금 계산 기준에 포함되는 항목은 무엇인가요?"}
{"id": "4", "text": "외국인 근로자 세금 신고 서류는 무엇이 필요한가요?"}
예제 test_qrels.jsonl
{"qid": "1", "docid": "101", "relevance": 1}
{"qid": "2", "docid": "102", "relevance": 1}
{"qid": "3", "docid": "103", "relevance": 1}
{"qid": "4", "docid": "104", "relevance": 1}
아래는 학습에 사용된 QA 데이터셋을 기반으로 위 세 가지 파일을 생성하는 예제 스크립트입니다.
import json
import hashlib
def get_document_id(text):
return "doc_" + hashlib.md5(text.encode('utf-8')).hexdigest()[:12]
def generate_files(data, output_path):
corpus_entries = []
query_entries = []
qrel_entries = []
seen_texts = set()
for idx, item in enumerate(data, start=1):
query_id = str(idx)
# Add query entry
query_entries.append({
"id": query_id,
"text": item["query"]
})
# Add positive document and relevance judgment
pos_text = item["pos"][0]
pos_doc_id = get_document_id(pos_text)
if pos_text not in seen_texts:
seen_texts.add(pos_text)
corpus_entries.append({
"id": pos_doc_id,
"title": "",
"text": pos_text
})
qrel_entries.append({
"qid": query_id,
"docid": pos_doc_id,
"relevance": 1
})
# Add negative documents and relevance judgments
for neg_text in item["neg"]:
neg_doc_id = get_document_id(neg_text)
if neg_text not in seen_texts:
seen_texts.add(neg_text)
corpus_entries.append({
"id": neg_doc_id,
"title": "",
"text": neg_text
})
qrel_entries.append({
"qid": query_id,
"docid": neg_doc_id,
"relevance": 0
})
# corpus.jsonl
with open(f"{output_path}/corpus.jsonl", "w", encoding="utf-8") as f:
for entry in corpus_entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# test_queries.jsonl
with open(f"{output_path}/test_queries.jsonl", "w", encoding="utf-8") as f:
for entry in query_entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
# test_qrels.jsonl
with open(f"{output_path}/test_qrels.jsonl", "w", encoding="utf-8") as f:
for entry in qrel_entries:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
test_data = []
with open("toy_finetune_data_minedHN.jsonl", "r", encoding="utf-8") as f:
for line in f:
if line.strip():
data = json.loads(line.strip())
test_data.append(data)
output_path = "./output_path"
generate_files(test_data, output_path)
파일을 생성한 후, 모든 파일을 단일 디렉토리에 넣고 아래 명령어를 실행합니다.
python -m FlagEmbedding.evaluation.custom \
--eval_name your_data_name \
--dataset_dir ./your_data_path \
--splits test \
--corpus_embd_save_dir ./your_data_name/corpus_embd \
--output_dir ./your_data_name/search_results \
--search_top_k 50 \
--cache_path ./cache/data \
--overwrite True \
--eval_output_method markdown \
--eval_output_path ./your_data_name/eval_results.md \
--eval_metrics ndcg_at_1 ndcg_at_3 ndcg_at_5 ndcg_at_10 recall_at_1 recall_at_3 recall_at_5 recall_at_10 \
--embedder_name_or_path finetuned_bge-m3 \
--embedder_model_class encoder-only-m3 \
--devices cuda:1 \
--cache_dir ./cache/model
평가 metric에는 두 가지 주요 메트릭인 NDCG와 Recall을 사용합니다.
- NDCG: 상위 k개에서의 순위와 관련성을 평가합니다.
- Recall: 상위 k개에서 시스템이 모든 관련 문서를 검색했는지 측정합니다.
- Precision을 사용하지 않는 이유: Precision은 검색된 문서 중 관련 문서의 비율을 측정하지만, 쿼리당 단일 포지티브 데이터셋에서는 k가 증가할수록 덜 유용해집니다. NDCG와 Recall이 이 실습의 경우 더 적합한 메트릭입니다.
6. 트러블슈팅 팁
“Error while creating shared memory segment”
- Docker를 사용해서 설치하는 경우, shared memory 에러를 우회하기 위해 다음과 같은 예시
docker run
커맨드가 필요합니다.
docker run --shm-size=4g -it -d --gpus all --name flagembed -v $(pwd):/workspace <IMAGE_NAME>
- 이 명령을 통해 도커 컨테이너의 공유 메모리 크기를 4GB로 설정하여 공유 메모리 부족으로 인한 오류를 방지할 수 있습니다.
"Bus error, core dumped" 오류 발생 시
- 이 경우 CUDA 디바이스 설정을
cuda:0
에서cuda:1
로 변경하면 문제가 해결될 수 있습니다.