모델별 실행 레시피
최종 업데이트
한국어 문서 검색에는 어떤 임베딩을 쓸까? Qwen3·BGE·EmbeddingGemma 2 비교
모델 이름보다 같은 질문에서 정답 문단을 찾는지 먼저 재요.
휴가 규정 PDF를 공통 테스트 문서로 삼아 모델별 검색 결과와 색인·질문 시간을 기록해요. CPU1에서 모델을 하나씩 불러 실행하는 예제와 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.6B | 1,024차원·32K | 영어 검색 지시문 |
| BGE-M3 | 1,024차원·8,192 | dense 예제는 별도 지시문 없음 |
| EmbeddingGemma 2 | 768차원·8,192 | SearchQuery 접두사 |
CPU에서 한 모델씩 검색을 재요
같은 corpus.json과 questions.json으로 세 모델을 하나씩 비교해요. 두 파일이 없으면 스크립트가 멈춥니다. 정답 문서가 하나인 답변 가능 질의의 Hit@5는 상위 5개에 정답 ID가 포함된 수를 답변 가능 질의 수로 나눠요. gold_ids: [] 질의는 별도로 출력합니다.
모델 로드·문서 임베딩·질문 검색 시간을 출력해요. .npy 크기는 숫자 벡터 배열만의 크기라 실제 벡터 DB 색인이나 메타데이터 용량과 달라요. 이 코드는 배열 크기만 보여줍니다.
아래 두 JSON 배열을 각각 같은 이름의 파일로 저장하고, Python 코드는 qwen_retrieval_baseline.py로 저장하세요. 이 두 문서 예시는 형식 확인용이라 Hit@5 비교에 쓰지 마세요. 실제 평가는 더 큰 업무 코퍼스에서 하세요.
[
{"id":"p-001","title":"여름휴가 자격","text":"입사 6개월 이상인 직원은 여름휴가를 신청할 수 있습니다."},
{"id":"p-002","title":"신청 기한","text":"사용 희망일 14일 전까지 신청해야 합니다."}
][
{"id":"q-001","text":"입사 6개월 차도 여름휴가를 신청할 수 있나요?","gold_ids":["p-001"]},
{"id":"q-002","text":"퇴직 후 미사용 휴가를 현금으로 받나요?","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 gemmaimport 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))
리랭커는 같은 후보 묶음으로 별도 비교해요
이 코드의 검색 범위는 dense 1차 검색까지예요. 리랭커를 시험하려면 각 모델에서 같은 질문으로 뽑은 후보 ID를 저장해 공통 후보 묶음을 BAAI/bge-reranker-v2-m3 또는 Qwen/Qwen3-Reranker-0.6B에 따로 전달하세요.
후보 생성 시간과 리랭커 시간을 나누고 재정렬 전후의 정답 순위와 Hit@5를 비교하면 후처리 비용을 판단할 수 있어요. 현재 코드에는 리랭커 실행이 포함되지 않습니다.

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