모델별 실행 레시피

최종 업데이트

한국어 문서 검색에는 어떤 임베딩을 쓸까? Qwen3·BGE·EmbeddingGemma 2 비교

모델 이름보다 같은 질문에서 정답 문단을 찾는지 먼저 재요.

휴가 규정 PDF를 공통 테스트 문서로 삼아 모델별 검색 결과와 색인·질문 시간을 기록해요. CPU1에서 모델을 하나씩 불러 실행하는 예제와 Hit@5 계산을 제공합니다.

실행 조건과 핵심 내용
  • PDF·문단·질의·정답 ID를 고정하고 모델만 바꿔요.
  • 답이 있는 질의의 Hit@5와 색인·검색 시간을 따로 기록해요.

같은 휴가 규정 PDF로 비교해요

Qwen3-Embedding, BGE-M3, EmbeddingGemma 2는 질문과 문서를 숫자 벡터로 바꿔 관련 문단을 찾는 임베딩 모델이에요. 같은 휴가 규정 PDF와 질문으로 세 모델의 검색 결과를 비교해요. 리랭커는 검색 후보를 질문과 다시 대조해 순서를 조정하고, 최종 답변은 별도 LLM2이 만들어요.

휴가 규정 PDF에서 ‘입사 6개월 차 직원도 여름휴가를 쓸 수 있나요?’를 찾아보겠습니다. 문서를 추출해 원문과 대조하고, 질문마다 기대 문단 ID를 적으면 검색기가 조건을 제대로 찾았는지 확인할 수 있어요.

문서가 여러 검색 조각으로 나뉘고 일부가 로컬 컴퓨터로 향하는 장면
문서를 같은 크기로 나눠야 모델 차이를 비교할 수 있어요.

체크포인트와 입력 형식을 맞춰요

Qwen3-Embedding-0.6B는 공식 표에서 32K 입력과 1,024차원 벡터를 지원해요. Qwen은 검색 지시문을 질의에 붙이도록 권장합니다. BGE-M3는 8,192토큰3과 dense·sparse·multi-vector 경로를 제공하고, dense 예제에는 질의 지시문이 없어요. 여기서는 우선 dense 경로끼리 비교합니다.

EmbeddingGemma 2는 텍스트만 쓸 때 270M 구성으로 선택할 수 있고 128·256·512·768차원을 지원해요. 이번에는 텍스트 검색 지시를 적용해 기본 768차원으로 비교합니다. 매개변수 수나 차원 수만으로 디스크 색인 크기를 알 수 없으니 색인 파일도 따로 측정해요.

공식 카드에서 확인한 텍스트 검색 비교 조건
모델기본 벡터질의 입력
Qwen3-Embedding-0.6B1,024차원·32K영어 검색 지시문
BGE-M31,024차원·8,192dense 예제는 별도 지시문 없음
EmbeddingGemma 2768차원·8,192SearchQuery 접두사

CPU에서 한 모델씩 검색을 재요

같은 corpus.json과 questions.json으로 세 모델을 하나씩 비교해요. 두 파일이 없으면 스크립트가 멈춥니다. 정답 문서가 하나인 답변 가능 질의의 Hit@5는 상위 5개에 정답 ID가 포함된 수를 답변 가능 질의 수로 나눠요. gold_ids: [] 질의는 별도로 출력합니다.

모델 로드·문서 임베딩·질문 검색 시간을 출력해요. .npy 크기는 숫자 벡터 배열만의 크기라 실제 벡터 DB 색인이나 메타데이터 용량과 달라요. 이 코드는 배열 크기만 보여줍니다.

아래 두 JSON 배열을 각각 같은 이름의 파일로 저장하고, Python 코드는 qwen_retrieval_baseline.py로 저장하세요. 이 두 문서 예시는 형식 확인용이라 Hit@5 비교에 쓰지 마세요. 실제 평가는 더 큰 업무 코퍼스에서 하세요.

corpus.json
[
  {"id":"p-001","title":"여름휴가 자격","text":"입사 6개월 이상인 직원은 여름휴가를 신청할 수 있습니다."},
  {"id":"p-002","title":"신청 기한","text":"사용 희망일 14일 전까지 신청해야 합니다."}
]
업무 문단을 같은 필드 형식으로 추가하세요.
questions.json
[
  {"id":"q-001","text":"입사 6개월 차도 여름휴가를 신청할 수 있나요?","gold_ids":["p-001"]},
  {"id":"q-002","text":"퇴직 후 미사용 휴가를 현금으로 받나요?","gold_ids":[]}
]
정답이 없는 질의는 빈 `gold_ids` 배열을 써요.
비교 환경을 만들어요
python -m venv .venv
source .venv/bin/activate
python -m pip install -U pip
python -m pip install 'sentence-transformers>=5.0' numpy FlagEmbedding
python qwen_retrieval_baseline.py --model qwen
python qwen_retrieval_baseline.py --model bge
python qwen_retrieval_baseline.py --model gemma
CPU에서 모델 하나씩 시험하는 최소 구성입니다. 첫 실행 때 가중치를 내려받아요.
qwen_retrieval_baseline.py — 고정 코퍼스 실행
import argparse, json, time
from pathlib import Path
import numpy as np
MODELS = {"qwen":"Qwen/Qwen3-Embedding-0.6B", "bge":"BAAI/bge-m3", "gemma":"google/embeddinggemma-2"}
INSTRUCTION = "Instruct: Given a Korean policy question, retrieve passages that answer the question\nQuery: "
p = argparse.ArgumentParser(); p.add_argument("--model", choices=MODELS, required=True); args=p.parse_args()
for name in ("corpus.json", "questions.json"):
    if not Path(name).is_file(): raise SystemExit(f"Missing {name}; save the JSON example first.")
docs_data=json.loads(Path("corpus.json").read_text(encoding="utf-8")); questions=json.loads(Path("questions.json").read_text(encoding="utf-8"))
if not docs_data or not questions: raise SystemExit("Both JSON files need records.")
start=time.perf_counter()
if args.model=="bge":
    from FlagEmbedding import BGEM3FlagModel
    model=BGEM3FlagModel(MODELS[args.model], use_fp16=False, devices=["cpu"])
else:
    from sentence_transformers import SentenceTransformer
    kw={"device":"cpu"}
    if args.model=="gemma": kw["config_kwargs"]={"vision_config":None,"audio_config":None}
    model=SentenceTransformer(MODELS[args.model], **kw)
load_s=time.perf_counter()-start
docs=[f"title: {d.get('title','none')} | text: {d['text']}" for d in docs_data]
start=time.perf_counter()
if args.model=="bge": vectors=model.encode(docs,batch_size=2,max_length=8192)["dense_vecs"]
else: vectors=model.encode(docs,batch_size=2,normalize_embeddings=True,convert_to_numpy=True)
vectors=np.asarray(vectors,dtype=np.float32); index_s=time.perf_counter()-start
out=Path(f"{args.model}-vectors.npy"); np.save(out,vectors)
hits=answerable=0; no_answer=[]; rows=[]; start=time.perf_counter()
for q in questions:
    gold=q.get("gold_ids",[])
    if not gold: no_answer.append(q["id"]); continue
    if len(gold)!=1: raise ValueError(f"Exactly one gold ID required: {q['id']}")
    text=q["text"]
    if args.model=="qwen": text=INSTRUCTION+text
    if args.model=="gemma": text="task: search result | query: "+text
    if args.model=="bge": qv=model.encode([text],batch_size=1,max_length=8192)["dense_vecs"][0]
    else: qv=model.encode([text],normalize_embeddings=True,convert_to_numpy=True)[0]
    top=np.argsort(vectors@np.asarray(qv,dtype=np.float32))[-5:][::-1]; ids=[docs_data[i]["id"] for i in top]
    hit=gold[0] in ids; hits+=hit; answerable+=1; rows.append({"query_id":q["id"],"hit_at_5":bool(hit),"top5_ids":ids})
search_s=time.perf_counter()-start
print(json.dumps({"model":args.model,"model_load_s":round(load_s,3),"document_embedding_s":round(index_s,3),"query_search_s":round(search_s,3),"answerable_queries":answerable,"hit_at_5":hits/answerable if answerable else None,"no_answer_query_ids":no_answer,"raw_vector_array_bytes_in_ram":int(vectors.nbytes),"raw_npy_file_bytes_only":out.stat().st_size,"top5":rows},ensure_ascii=False,indent=2))
이 코드를 `qwen_retrieval_baseline.py`로 저장해 아래 명령을 실행하세요.
질문별 기대 근거가 카드로 정리된 모습
정답 문단을 지정하면 검색이 조건을 놓쳤는지 확인할 수 있어요.

리랭커는 같은 후보 묶음으로 별도 비교해요

이 코드의 검색 범위는 dense 1차 검색까지예요. 리랭커를 시험하려면 각 모델에서 같은 질문으로 뽑은 후보 ID를 저장해 공통 후보 묶음을 BAAI/bge-reranker-v2-m3 또는 Qwen/Qwen3-Reranker-0.6B에 따로 전달하세요.

후보 생성 시간과 리랭커 시간을 나누고 재정렬 전후의 정답 순위와 Hit@5를 비교하면 후처리 비용을 판단할 수 있어요. 현재 코드에는 리랭커 실행이 포함되지 않습니다.

두 장비에서 문서 색인과 검색 결과를 비교하는 장면
검색 품질과 시간을 함께 기록해 내 업무의 차이를 봅니다.

용어 각주

  1. CPU — 운영체제와 일반 프로그램 명령을 실행하고, AI에서는 전처리·데이터 이동 등 범용 연산을 맡는 중앙 처리 장치입니다.

    본문으로 돌아가기
  2. 대규모 언어 모델 — 대규모 텍스트 데이터에서 언어 패턴을 학습해 문맥에 맞는 텍스트를 처리하고 생성하는 모델입니다.

    본문으로 돌아가기
  3. 토큰 — 모델이 입력이나 출력을 나누어 처리하는 단위입니다.

    본문으로 돌아가기

자주 묻는 질문

이 글에서 가장 먼저 확인할 것은 무엇인가요?
휴가 규정 PDF를 공통 테스트 문서로 삼아 모델별 검색 결과와 색인·질문 시간을 기록해요. CPU에서 모델을 하나씩 불러 실행하는 예제와 Hit@5 계산을 제공합니다.