파인튜닝 vs RAG vs 프롬프팅
세 가지 적응 기법의 작동 원리, 선택 기준, 그리고 PEFT/LoRA로 실제 모델을 튜닝하는 엔드투엔드 가이드
LLM을 도메인에 맞게 적응시키는 방법은 크게 세 가지입니다. 프롬프팅(prompting), 검색 증강 생성(RAG), 파인튜닝(fine-tuning). 문제는 이 셋이 서로 배타적인 선택지가 아니라는 점입니다. 많은 팀이 "우리 데이터로 모델을 학습시켜야 한다"는 직감으로 파인튜닝부터 시도하다가, 수주를 태우고 나서야 사실 RAG 한 줄이면 됐다는 걸 깨닫습니다. 반대로 톤·포맷·구조화된 출력이 핵심인데 RAG로 컨텍스트만 계속 욱여넣다가 비용과 지연만 키우는 경우도 흔합니다.
핵심 직관은 이렇습니다. 프롬프팅과 RAG는 모델의 지식(knowledge) 을 바꾸고, 파인튜닝은 모델의 행동(behavior) 을 바꿉니다. "우리 회사 정책 문서를 모델이 알게 하고 싶다"는 지식 문제 → RAG. "모델이 항상 이 JSON 스키마로, 이 말투로 답하게 하고 싶다"는 행동 문제 → 파인튜닝. "몇 가지 예시만 주면 충분히 잘 한다"는 프롬프팅. 이 구분을 못 하면 잘못된 도구로 잘못된 문제를 풀게 됩니다.
이 가이드는 세 기법의 작동 원리를 먼저 정리하고, 실무에서 쓰는 결정 트리와 비용/지연 트레이드오프를 표로 제시한 뒤, 가장 오해가 많은 파인튜닝을 PEFT/LoRA 실습으로 끝까지 따라갑니다. 데이터셋 포맷팅, transformers+peft+trl 학습 스크립트, QLoRA 4비트 양자화, 어댑터 병합·서빙, 그리고 평가까지 실제로 돌아가는 코드로 다룹니다. 마지막에는 세 기법을 조합하는 현실적인 아키텍처와 흔한 함정 모음으로 마무리합니다.
전제: 이 글의 코드는 Hugging Face 생태계(
transformers,peft,trl,bitsandbytes,datasets)와 NVIDIA GPU(CUDA) 환경을 기준으로 합니다. 라이브러리 API는 버전에 따라 인자가 바뀌므로, 핵심 개념을 이해한 뒤 본인 환경의 설치 버전 문서를 함께 확인하세요.
세 가지 기법이 실제로 무엇을 바꾸는가
먼저 각 기법이 LLM 추론 파이프라인의 어느 지점에 개입하는지를 명확히 합시다. 이게 모든 선택의 출발점입니다.
[사용자 입력] → [프롬프트 구성] → [모델 가중치 W] → [디코딩] → [출력]
▲ ▲
프롬프팅/RAG가 파인튜닝이
개입하는 지점 개입하는 지점
- 프롬프팅: 모델 가중치는 건드리지 않고, 입력 텍스트만 바꿉니다. 지시(instruction), 예시(few-shot), 출력 형식 명세 등을 프롬프트에 넣어 행동을 유도합니다. 가중치
W는 불변. - RAG: 역시 가중치를 안 바꾸지만, 프롬프트에 들어가는 컨텍스트를 동적으로 검색해서 채웁니다. 외부 지식 베이스(벡터 DB 등)에서 질문과 관련된 문서를 가져와 프롬프트에 주입합니다. 본질적으로 "프롬프팅의 컨텍스트를 자동·동적으로 구성하는 것".
- 파인튜닝: 모델 가중치
W자체를 추가 학습으로 갱신합니다(W → W'). 풀 파인튜닝은 전체 파라미터를, LoRA/PEFT는 일부 작은 행렬만 갱신합니다.
핵심 구분표:
| 질문 | 적합한 기법 |
|---|---|
| 모델이 모르는 사실/문서를 답에 반영해야 하나? | RAG |
| 사실은 자주 바뀌나(가격, 재고, 최신 뉴스)? | RAG (학습은 시점 고정이라 부적합) |
| 출력 형식·톤·스타일·구조를 일관되게 강제해야 하나? | 파인튜닝 (또는 강한 프롬프팅) |
| 새로운 능력/태스크 패턴(예: 특정 코드 변환 규칙)을 내재화해야 하나? | 파인튜닝 |
| 예시 2~5개만 줘도 충분히 잘 하나? | 프롬프팅 |
| 프롬프트가 너무 길어 비용/지연이 문제인가? | 파인튜닝으로 지시를 "압축" |
가장 흔한 오해: "우리 사내 문서로 파인튜닝하면 모델이 그 내용을 외운다". 아닙니다. 파인튜닝은 사실 암기에 비효율적이고 환각을 유발하기 쉽습니다. 사실 주입은 RAG가, 행동 변경은 파인튜닝이 담당한다는 분업을 기억하세요. 문서 100개를 외우게 하려고 파인튜닝하면, 모델은 문서의 스타일은 흉내 내지만 내용은 어설프게 뒤섞어 만들어냅니다.
결정 가이드 — 무엇부터 시도할 것인가
현업에서 검증된 원칙은 **"싸고 빠른 것부터, 비싼 것은 나중에"**입니다. 거의 항상 이 순서가 맞습니다.
1. 강한 프롬프팅 (system prompt + few-shot + 출력 스키마 명세)
└ 부족하면 ↓
2. RAG (지식이 부족하거나 자주 바뀌는 경우)
└ 그래도 부족하면 ↓
3. 파인튜닝 (행동/포맷/톤이 프롬프트로 안정화 안 될 때)
└ 보통 RAG와 함께 씀
파인튜닝을 건너뛰어야 하는 신호:
- 데이터가 1000개 미만이고 태스크가 일반적이다 → 프롬프팅으로 충분할 가능성이 높음
- 답에 들어갈 정보가 자주 바뀐다 → 파인튜닝은 시점이 고정되므로 부적합, RAG로
- "정확한 사실"이 핵심이다 → 파인튜닝은 사실 정확도를 보장하지 않음
- 빠르게 반복(iterate)해야 한다 → 프롬프트는 즉시 수정, 파인튜닝은 학습 사이클이 필요
파인튜닝이 정당화되는 신호:
- 출력 형식/구조/톤을 수천 건에 걸쳐 결정론적으로 강제해야 한다(예: 항상 특정 JSON 스키마)
- 프롬프트가 비대해져 토큰 비용·지연이 운영을 압박한다(긴 지시를 가중치로 "흡수")
- 작은 모델을 특정 좁은 태스크에서 큰 모델 수준으로 끌어올려 비용을 낮추고 싶다
- 일관된 도메인 어휘/스타일(법률·의료·특정 브랜드 보이스)이 필요하고 라벨링된 예시가 충분(수천~수만)하다
의사결정 체크리스트(순서대로 자문):
- system 프롬프트에 명확한 지시 + 출력 예시 3개를 넣어봤는가? (안 했으면 여기부터)
- 실패 사례가 "모르는 정보" 때문인가, "행동이 안 따라줘서"인가?
- 정보 문제면 → 그 정보를 컨텍스트에 수동으로 붙였을 때 잘 답하는가? (Yes면 RAG로 자동화)
- 행동 문제면 → few-shot 예시를 늘렸을 때 개선되는가? (개선되지만 비싸면 파인튜닝 후보)
- 라벨 데이터가 최소 수백~수천 건 확보 가능한가? (아니면 파인튜닝 보류)
실무 팁: 파인튜닝을 결정하기 전에 반드시 "이 동작을 프롬프트의 system 메시지와 few-shot으로 강제했을 때 몇 %나 되는가"를 먼저 측정하세요. 베이스라인 없이 파인튜닝부터 들어가면 개선 효과를 입증할 수도, 회귀를 잡을 수도 없습니다.
프롬프팅을 끝까지 짜내기 — 파인튜닝 전 마지막 관문
파인튜닝으로 넘어가기 전에 프롬프팅을 "제대로" 해봤는지 점검해야 합니다. 대충 한 프롬프팅과 파인튜닝을 비교하는 건 공정하지 않습니다. 강한 프롬프팅의 구성 요소:
1) 역할·지시·제약을 system에 분리
SYSTEM:
당신은 사내 정책 Q&A 어시스턴트입니다. 다음 규칙을 항상 지키세요.
- 제공된 <context> 안의 정보만 사용. 없으면 "제공된 자료에 없음"이라고 답한다.
- 출력은 항상 JSON: {"answer": string, "sources": string[]}
- 추측 금지. 확실하지 않으면 sources를 빈 배열로.
2) Few-shot으로 형식·엣지케이스를 못박기. 정상 케이스 1~2개 + 어려운/거부해야 하는 케이스 1개를 함께 넣는 것이 핵심입니다. 거부 예시가 없으면 모델은 항상 뭔가를 지어냅니다.
3) 출력 스키마를 강제. JSON 스키마 기반 제약 디코딩(구조화 출력)을 지원하는 런타임이라면 적극 활용하세요. 프롬프트에 "JSON으로 답해"라고 부탁하는 것보다, 디코딩 단계에서 스키마를 강제하는 편이 훨씬 안정적입니다.
4) 사고 과정 유도가 필요하면 단계 분리. "먼저 근거를 나열하고, 그다음 결론"처럼 단계를 명시하면 정확도가 오릅니다. 단, 최종 출력에서 사고 과정을 노출할지 여부는 분리해서 제어하세요.
흔한 함정:
- few-shot 예시 안에 실제 답에 새어 나가면 안 되는 정보(특정 고객명 등)를 넣으면 모델이 그걸 그대로 베껴 쓴다. 예시는 형식만 보여주고 내용은 중립적으로.
- 예시가 서로 형식이 미묘하게 다르면(어떤 건 마침표 있고 어떤 건 없고) 모델이 그 불일치를 학습한다. 예시 간 형식을 1바이트까지 통일하라.
- "항상 ~하지 마" 같은 부정 지시는 긍정 지시("항상 ~하라")보다 약하게 작동한다. 가능하면 긍정형으로.
프롬프팅을 끝까지 짜냈는데도 (a) 형식 준수율이 운영 기준에 못 미치거나, (b) 프롬프트가 수천 토큰으로 비대해져 비용이 문제거나, (c) 모델이 미묘한 도메인 스타일을 못 잡으면 — 그때 파인튜닝으로 넘어갑니다.
RAG의 작동 원리와 최소 구현
RAG는 "질문과 관련된 문서 조각을 검색해 프롬프트에 끼워 넣는다"는 단순한 아이디어지만, 품질은 검색(retrieval) 단계에서 갈립니다. 파이프라인은 인덱싱과 질의 두 단계입니다.
인덱싱(오프라인): 문서 → 청킹(chunking) → 임베딩 → 벡터 DB 저장
질의(온라인): 질문 임베딩 → 유사도 top-k 검색 → 프롬프트에 주입 → LLM 생성
최소 구현(임베딩 모델 + numpy 코사인 유사도, 의존성 최소화):
from sentence_transformers import SentenceTransformer
import numpy as np
embedder = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
# 1) 인덱싱: 문서를 청크로 쪼개 임베딩
docs = [
"환불은 구매 후 14일 이내 가능합니다.",
"배송은 결제 완료 후 평균 2~3일 소요됩니다.",
"포인트는 1년간 유효하며 현금 환산되지 않습니다.",
]
doc_vecs = embedder.encode(docs, normalize_embeddings=True) # (N, d)
def retrieve(query, k=2):
q = embedder.encode([query], normalize_embeddings=True)[0]
sims = doc_vecs @ q # 정규화했으므로 내적 = 코사인 유사도
idx = np.argsort(-sims)[:k]
return [docs[i] for i in idx]
# 2) 질의: 검색 결과를 컨텍스트로 주입
query = "환불 기간이 어떻게 되나요?"
context = "\n".join(f"- {c}" for c in retrieve(query))
prompt = f"""다음 자료만 근거로 답하세요. 없으면 '자료에 없음'.
<context>
{context}
</context>
질문: {query}"""
# prompt 를 LLM에 전달
실서비스에서는 numpy 대신 전용 벡터 DB(예: pgvector, FAISS, Qdrant 등)를 쓰고, 한국어가 섞이면 다국어 임베딩 모델(예: multilingual-e5 계열)을 선택해야 합니다. 영어 전용 임베딩에 한국어를 넣으면 검색 품질이 급락합니다.
RAG 품질을 좌우하는 실전 변수:
- 청크 크기: 너무 크면 노이즈가 섞이고, 너무 작으면 맥락이 끊긴다. 보통 200~500토큰 + 오버랩 10~20%로 시작해 튜닝.
- top-k: 많이 넣을수록 빠짐없지만 프롬프트가 길어지고 무관 청크가 답을 오염시킨다.
- 하이브리드 검색: 임베딩(의미)만으로는 고유명사·코드·정확한 키워드를 놓친다. BM25 같은 키워드 검색과 결합(하이브리드)하면 크게 개선된다.
- 리랭킹: top-k를 넉넉히 뽑은 뒤 cross-encoder 리랭커로 재정렬하면 정확도가 오른다.
함정: 검색이 틀리면 LLM은 자신 있게 틀린 답을 한다. RAG의 환각은 대부분 "엉뚱한 청크를 가져온" 검색 실패다. 답이 이상할 때 LLM 프롬프트부터 의심하지 말고 검색 결과(retrieved chunks)를 먼저 로그로 찍어 확인하라.
파인튜닝의 종류 — 풀 파인튜닝 vs PEFT vs LoRA vs QLoRA
"파인튜닝"은 한 가지가 아닙니다. 비용·메모리·품질 트레이드오프가 다른 여러 방식이 있습니다.
| 방식 | 갱신 파라미터 | VRAM 요구 | 특징 |
|---|---|---|---|
| 풀 파인튜닝(Full FT) | 전체 (수십억) | 매우 큼 (모델의 ~수배) | 최고 유연성, 비용 최대, 재앙적 망각 위험 |
| PEFT (총칭) | 일부 | 작음 | Parameter-Efficient Fine-Tuning의 총칭 |
| LoRA | 저랭크 어댑터 행렬만 | 작음 | 원본 가중치 동결, 작은 ΔW만 학습 |
| QLoRA | LoRA 어댑터 (베이스는 4비트) | 가장 작음 | 베이스를 4비트로 양자화해 메모리 대폭 절감 |
LoRA의 핵심 아이디어: 사전학습된 가중치 행렬 W(예: 어텐션의 q_proj, v_proj)를 동결하고, 그 옆에 저랭크 분해 ΔW = B·A(여기서 A: r×d, B: d×r, 랭크 r은 8·16·32처럼 작게)를 더합니다. 추론 시 W + (α/r)·B·A로 동작합니다.
h = W·x → h = W·x + (α/r)·(B·A)·x
└ 동결 ┘ └─ 학습 대상(작음) ─┘
전체 파라미터가 70억개라도 LoRA로 학습되는 파라미터는 보통 전체의 1% 미만입니다. 덕분에:
- VRAM이 풀 파인튜닝의 일부만 필요
- 학습 산출물(어댑터)이 수십~수백 MB로 작아 여러 태스크용 어댑터를 갈아끼우기 쉬움
- 베이스 모델은 그대로라 재앙적 망각(catastrophic forgetting)이 덜함
QLoRA는 여기에 더해 베이스 모델을 4비트(NF4)로 양자화해 메모리에 올립니다. 베이스는 4비트로 동결, LoRA 어댑터만 16비트로 학습. 이 조합 덕분에 단일 소비자급 GPU에서도 수십억 파라미터 모델을 튜닝할 수 있게 됐습니다.
선택 가이드:
- 소규모 태스크, GPU 1장, 빠른 실험 → QLoRA (대부분의 출발점)
- VRAM 여유 있고 LoRA 어댑터 추론 오버헤드를 피하고 싶다 → LoRA(병합 가능) 또는 풀 FT
- 최대 품질이 필요하고 예산·데이터가 충분 → 풀 FT 고려(단, 대부분 LoRA로 충분)
망각 주의: 풀 파인튜닝은 좁은 데이터에 과적합되며 기존 일반 능력을 잃기 쉽습니다("내 데이터로 학습했더니 일반 질문에 바보가 됐다"). LoRA는 베이스를 동결하므로 이 위험이 구조적으로 작습니다. 이게 LoRA가 사실상 기본값이 된 이유 중 하나입니다.
LoRA 실습 1 — 환경 준비와 데이터셋 포맷팅
이제 실제로 LoRA를 돌립니다. 환경은 NVIDIA GPU(CUDA) + Hugging Face 생태계 기준입니다.
설치:
pip install "transformers" "datasets" "accelerate" "peft" "trl" "bitsandbytes"
# bitsandbytes는 QLoRA(4비트)용. CUDA 환경 필수.
버전 주의:
trl/peft/transformers는 API가 자주 바뀝니다(특히SFTTrainer의 인자명,SFTConfig분리 여부). 설치 후pip show trl transformers peft로 버전을 확인하고, 본인 버전의 예제와 대조하세요. 아래 코드는 개념 흐름을 보여주며, 인자명은 버전에 맞게 조정이 필요할 수 있습니다.
데이터셋이 가장 중요합니다. LoRA 품질의 80%는 데이터에서 결정됩니다. 지시 튜닝(instruction tuning)이라면 보통 다음 형태로 만듭니다.
{"instruction": "다음 문의를 카테고리로 분류하라", "input": "환불 받고 싶어요", "output": "{\"category\": \"refund\"}"}
{"instruction": "다음 문의를 카테고리로 분류하라", "input": "배송이 언제 오나요", "output": "{\"category\": \"shipping\"}"}
학습 시에는 이걸 모델의 채팅 템플릿에 맞춰 하나의 텍스트로 합쳐야 합니다. 모델마다 채팅 포맷(특수 토큰)이 다르므로 토크나이저의 apply_chat_template을 쓰는 게 안전합니다.
from datasets import load_dataset
from transformers import AutoTokenizer
MODEL_ID = "meta-llama/Llama-3.1-8B-Instruct" # 예시. 라이선스/접근 권한 확인
tok = AutoTokenizer.from_pretrained(MODEL_ID)
if tok.pad_token is None:
tok.pad_token = tok.eos_token # pad 토큰 없으면 eos로 (흔한 함정)
dataset = load_dataset("json", data_files="train.jsonl", split="train")
def to_text(ex):
messages = [
{"role": "system", "content": "문의를 정확한 JSON 카테고리로 분류한다."},
{"role": "user", "content": f"{ex['instruction']}\n\n{ex['input']}"},
{"role": "assistant", "content": ex["output"]},
]
return {"text": tok.apply_chat_template(messages, tokenize=False)}
dataset = dataset.map(to_text, remove_columns=dataset.column_names)
print(dataset[0]["text"]) # 반드시 눈으로 한 번 확인!
데이터 함정(매우 흔함):
- 학습 시 포맷과 추론 시 포맷이 다르면 망한다. 학습 때 채팅 템플릿으로 쌌으면 추론 때도 똑같은 템플릿으로 싸야 한다. 이 불일치가 "학습은 됐는데 실서비스에서 이상하게 답한다"의 1순위 원인.
- 데이터 품질 > 수량. 노이즈 1만 건보다 깨끗한 1천 건이 낫다. 출력 라벨의 형식을 1바이트까지 통일하라(JSON이면 공백·키 순서까지).
- 클래스/스타일 불균형을 점검하라. 특정 패턴이 80%면 모델이 그것만 학습한다.
- EOS 토큰을 빠뜨리면 모델이 답을 언제 끝낼지 못 배워 무한 생성한다. 채팅 템플릿이 보통 처리하지만, 직접 포맷할 땐 끝에 EOS를 명시하라.
LoRA 실습 2 — QLoRA 학습 스크립트
이제 4비트 양자화(QLoRA)로 베이스를 올리고 LoRA 어댑터를 학습합니다. 단계는 (1) 4비트 로드, (2) LoRA 설정, (3) 학습입니다.
import torch
from transformers import AutoModelForCausalLM, BitsAndBytesConfig
from peft import LoraConfig, prepare_model_for_kbit_training
from trl import SFTTrainer, SFTConfig
# (1) 베이스 모델을 4비트(NF4)로 로드 — QLoRA의 핵심
bnb = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.bfloat16, # 연산은 bf16으로
bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained(
MODEL_ID,
quantization_config=bnb,
device_map="auto",
)
model = prepare_model_for_kbit_training(model) # 4비트 학습 안정화
# (2) LoRA 설정 — 어텐션/MLP 투영 행렬을 타깃
lora = LoraConfig(
r=16, # 랭크. 8~64. 클수록 표현력↑ 메모리↑
lora_alpha=32, # 스케일링. 보통 r의 2배로 시작
lora_dropout=0.05,
bias="none",
task_type="CAUSAL_LM",
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj"],
)
# (3) 학습
cfg = SFTConfig(
output_dir="./lora-out",
num_train_epochs=3,
per_device_train_batch_size=2,
gradient_accumulation_steps=8, # 유효 배치 = 2 * 8 = 16
learning_rate=2e-4, # LoRA는 풀FT보다 높은 lr이 정석
lr_scheduler_type="cosine",
warmup_ratio=0.03,
logging_steps=10,
save_strategy="epoch",
bf16=True,
max_seq_length=1024,
packing=False, # 짧은 샘플 묶기. 처음엔 끄고 검증
)
trainer = SFTTrainer(
model=model,
args=cfg,
train_dataset=dataset, # 'text' 컬럼
peft_config=lora,
processing_class=tok,
)
trainer.train()
trainer.save_model("./lora-out") # 어댑터만 저장 (수십~수백 MB)
하이퍼파라미터 직관:
r(랭크): 작업이 단순하면 8~16, 복잡한 스타일/도메인이면 32~64. 무작정 키우면 과적합·메모리만 늘 수 있음.lora_alpha: 보통r의 2배로 시작. 실효 스케일은alpha/r.learning_rate: LoRA는1e-4 ~ 3e-4가 흔하다. 풀 FT(보통~1e-5)보다 한 자릿수 높다.target_modules: 어텐션 투영만 vs MLP까지 포함. QLoRA 논문 권고는 모든 선형층에 적용. 작은 작업은 q/v만으로도 충분할 때가 많다.- 에폭: 보통 1~3. LoRA는 과적합이 빠르다. 3에폭 넘기면 대개 과적합을 의심하라.
메모리가 터질 때(OOM) 대응 순서:
per_device_train_batch_size줄이고gradient_accumulation_steps늘려 유효 배치 유지max_seq_length줄이기 (길이가 메모리에 제곱 가까이 영향)- gradient checkpointing 활성화
r줄이기, target_modules 줄이기
함정: 4비트 모델에 LoRA를 붙일 때 prepare_model_for_kbit_training을 빼먹으면 학습이 불안정하거나 그래디언트가 안 흐른다. 또한 4비트 베이스는 추론 품질이 풀정밀도보다 약간 떨어질 수 있으므로, 평가는 반드시 실제 서빙할 정밀도와 동일 조건에서 하라.
LoRA 실습 3 — 추론, 어댑터 병합, 서빙
학습이 끝나면 어댑터를 베이스에 얹어 추론합니다. 두 가지 운영 방식이 있습니다.
방식 A — 어댑터를 런타임에 동적으로 로드(병합 안 함):
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import PeftModel
base = AutoModelForCausalLM.from_pretrained(
MODEL_ID, torch_dtype=torch.bfloat16, device_map="auto")
model = PeftModel.from_pretrained(base, "./lora-out") # 어댑터 부착
model.eval()
tok = AutoTokenizer.from_pretrained(MODEL_ID)
messages = [
{"role": "system", "content": "문의를 정확한 JSON 카테고리로 분류한다."},
{"role": "user", "content": "환불 절차 알려주세요"},
]
prompt = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = tok(prompt, return_tensors="pt").to(model.device)
out = model.generate(**inputs, max_new_tokens=64, do_sample=False)
print(tok.decode(out[0][len(inputs.input_ids[0]):], skip_special_tokens=True))
장점: 베이스 하나에 여러 어댑터를 갈아끼울 수 있다(태스크별 A/B 운영). 단점: 어댑터 연산이 추론마다 더해져 약간의 오버헤드.
방식 B — 어댑터를 베이스에 병합(merge)해 단일 모델로:
from peft import PeftModel
from transformers import AutoModelForCausalLM
base = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=torch.bfloat16)
merged = PeftModel.from_pretrained(base, "./lora-out").merge_and_unload()
merged.save_pretrained("./merged-model") # 일반 모델처럼 저장/배포
장점: 추론 오버헤드 0, 표준 추론 서버(vLLM 등)에 그대로 올림. 단점: 어댑터 교체 유연성 상실, 디스크에 풀 모델 한 벌.
중요한 함정 — QLoRA 병합: 4비트로 학습한 어댑터를 4비트 베이스에 바로 병합하면 정밀도 손실/오류가 날 수 있다. 병합은 풀정밀도(또는 fp16/bf16) 베이스에 올려서 하는 것이 안전하다. 즉 "학습은 4비트, 병합은 16비트 베이스에 어댑터 적용 후 병합"이 정석.
서빙 선택:
- 처리량이 중요하면 vLLM 같은 고성능 추론 서버 + 병합 모델
- 다태스크 어댑터 스위칭이 필요하면 어댑터 동적 로딩을 지원하는 서버 구성
- 단순 내부 도구면
transformers+generate로도 충분
추론 시 반드시 점검: 학습 때 쓴 채팅 템플릿과 동일하게 add_generation_prompt=True로 프롬프트를 구성했는가? 이게 어긋나면 학습한 행동이 발현되지 않는다.
평가 — "잘 됐는지"를 숫자로 증명하기
파인튜닝의 가장 흔한 실패는 평가 없이 "좋아 보인다"로 끝내는 것입니다. 반드시 학습에 안 쓴 홀드아웃 셋으로, 파인튜닝 전 베이스라인과 비교해 측정하세요.
1) 태스크별 자동 지표. 출력이 구조화돼 있으면(분류, JSON 추출) 정확히 채점할 수 있습니다.
import json
def exact_json_match(pred_str, gold_str):
try:
return json.loads(pred_str) == json.loads(gold_str)
except json.JSONDecodeError:
return False # JSON 파싱 실패도 오답으로 집계
# 홀드아웃 전체에 대해
correct = sum(exact_json_match(p, g) for p, g in zip(preds, golds))
print(f"accuracy = {correct/len(golds):.3f}")
# 추가로: JSON 파싱 성공률(형식 준수율)을 따로 측정 — 파인튜닝의 핵심 효과
분류·추출 태스크에서는 **형식 준수율(valid JSON 비율)**과 정답률을 분리해 보고하세요. 파인튜닝이 정답률은 그대로여도 형식 준수율을 99%로 올렸다면 그것만으로 큰 가치입니다(후처리 파싱 에러 제거).
2) 생성 품질(자유 텍스트). 정답이 하나가 아니면 정확 일치가 안 됩니다. 이때는:
- LLM-as-judge: 더 강한 모델에게 기준(정확성·톤·형식)을 주고 채점하게 함. 단, 판정 기준을 명시하고 페어와이즈(베이스 vs 튜닝) 비교가 단일 점수보다 신뢰도 높음.
- 사람 평가: 소량이라도 블라인드로 A/B 비교.
3) 회귀 점검(절대 빠뜨리지 말 것). 좁은 데이터로 튜닝하면 일반 능력이 퇴화할 수 있습니다. 학습 도메인 밖의 일반 질문 셋을 별도로 두고, 튜닝 후에도 성능이 안 떨어졌는지 확인하세요. LoRA는 이 회귀가 작지만 0은 아닙니다.
평가 설계 체크리스트:
- 홀드아웃 셋은 학습/검증과 완전히 분리됐는가(데이터 누수 없음)?
- 베이스라인(파인튜닝 전 + 강한 프롬프팅)과 같은 조건에서 비교했는가?
- 형식 준수율과 정답률을 분리 측정했는가?
- 일반 능력 회귀를 별도 셋으로 확인했는가?
- 평가는 실제 서빙할 정밀도/템플릿과 동일 조건인가?
데이터 누수 함정: 데이터를 만들 때 같은 출처 문서가 학습과 평가에 쪼개져 들어가면 점수가 부풀려집니다. 분할은 샘플 단위가 아니라 출처/엔터티 단위로 하세요(같은 고객/문서의 변형이 양쪽에 안 가게).
조합 아키텍처 — RAG + 파인튜닝을 함께 쓰기
현실의 강한 시스템은 셋 중 하나가 아니라 조합입니다. 가장 흔하고 강력한 패턴은 RAG로 지식을 주입하고, 파인튜닝으로 "RAG 컨텍스트를 다루는 행동"을 가르치는 것입니다.
[질문]
│
├─ 검색(RAG): 관련 문서 top-k 가져오기
│
▼
[파인튜닝된 모델] ← 다음을 학습함:
- 주어진 <context> 안에서만 답하기
- 근거 없으면 "자료에 없음" 정확히 출력
- 항상 정해진 JSON 스키마로 답 + sources 인용
▼
[구조화된 답 + 출처]
왜 이 분업이 강력한가:
- 지식(자주 바뀜) → RAG가 담당. 문서가 업데이트되면 인덱스만 갱신, 재학습 불필요.
- 행동(거의 안 바뀜) → 파인튜닝이 담당. "컨텍스트 충실성, 거부, 인용 형식"은 한 번 가르치면 됨.
이 조합의 학습 데이터는 일반 QA가 아니라 (질문 + 검색된 컨텍스트 → 충실한 답) 형태로 만들어야 합니다. 즉 학습 샘플에 실제와 같은 형태의 <context>를 포함시키고, 컨텍스트에 답이 없는 케이스(거부해야 하는 샘플)도 반드시 섞으세요. 이게 "환각 대신 모른다고 말하기"를 가르치는 핵심입니다.
다른 조합 패턴:
- 프롬프팅 + RAG: 가장 가볍고 흔한 시작점. 파인튜닝 없이 강한 system 프롬프트 + 검색만으로 많은 문제가 풀린다.
- 파인튜닝으로 작은 모델 특화 + RAG: 큰 모델 대신 작은 모델을 좁은 태스크에 튜닝해 비용을 낮추고, 지식은 RAG로 보충. 추론 비용 최적화에 강력.
- 라우터 + 다중 어댑터: 베이스 하나에 태스크별 LoRA 어댑터 여러 개. 입력을 분류해 적절한 어댑터로 라우팅.
의사결정 요약 표:
| 증상 | 처방 |
|---|---|
| 최신/가변 정보가 필요 | RAG |
| 출력 형식·톤이 안 잡힘 | 파인튜닝(또는 강한 프롬프팅) |
| 프롬프트가 너무 길어 비싸다 | 파인튜닝으로 지시 흡수 |
| 큰 모델이 비싸다, 태스크는 좁다 | 작은 모델 파인튜닝 + RAG |
| 컨텍스트는 있는데 환각·형식 위반 | RAG + 파인튜닝 조합 |
흔한 함정 모음 (체크리스트)
실무에서 반복적으로 사람들을 무너뜨리는 함정들입니다. 작업 전후로 훑어보세요.
전략 함정
- ❌ 프롬프팅을 제대로 안 해보고 파인튜닝부터 → 시간·비용 낭비. 강한 프롬프팅 베이스라인을 먼저 만들어라.
- ❌ 사실 주입을 파인튜닝으로 시도 → 환각 유발. 사실은 RAG로.
- ❌ 자주 바뀌는 정보를 가중치에 학습 → 시점 고정 문제. RAG로.
- ❌ 베이스라인 없이 파인튜닝 → 개선/회귀를 증명·검출 불가.
데이터 함정
- ❌ 학습 포맷 ≠ 추론 포맷 → 1순위 실패 원인. 채팅 템플릿을 양쪽 동일하게.
- ❌ 라벨 형식 불일치(JSON 공백·키 순서·마침표) → 모델이 불일치를 학습. 1바이트까지 통일.
- ❌ EOS 누락 → 무한 생성/끝내는 법 미학습.
- ❌ 데이터 누수(출처 단위 미분리) → 평가 점수 부풀림.
- ❌ 거부 샘플(컨텍스트에 답 없는 케이스) 미포함 → 모델이 항상 지어냄.
학습 함정
- ❌ QLoRA에서
prepare_model_for_kbit_training누락 → 그래디언트 불안정. - ❌ 에폭 과다(>3) → 과적합. LoRA는 빨리 과적합한다.
- ❌
pad_token미설정 → 배치 패딩 에러/이상 학습. - ❌ 풀 FT를 좁은 데이터로 → 재앙적 망각. 의심되면 LoRA로 전환.
서빙·평가 함정
- ❌ 4비트 베이스에 직접 어댑터 병합 → 정밀도 손상. 병합은 16비트 베이스에.
- ❌ 추론 시
add_generation_prompt누락/템플릿 불일치 → 학습 행동 미발현. - ❌ 형식 준수율과 정답률을 합쳐서 보고 → 효과 분석 불가. 분리 측정.
- ❌ 일반 능력 회귀 미점검 → 좁은 태스크는 잘하는데 나머지가 퇴화.
- ❌ 평가 정밀도/템플릿이 서빙과 다름 → 오프라인 점수와 실서비스 괴리.
비용·운영 함정
- ❌ RAG에서 top-k를 무작정 키움 → 프롬프트 길이·비용 증가, 무관 청크가 답 오염.
- ❌ 영어 임베딩에 한국어 입력 → 검색 품질 급락. 다국어 임베딩 사용.
- ❌ 검색 결과를 로깅 안 함 → RAG 환각 디버깅 불가. retrieved chunks를 항상 로그.
마지막 원칙: 셋 다 "한 번 하고 끝"이 아니다. 프롬프트는 즉시 반복하고, RAG는 인덱스·청킹·리랭킹을 지속 튜닝하고, 파인튜닝은 데이터 품질을 올려 재학습한다. 가장 싼 레버(프롬프트)부터 돌리고, 측정으로 다음 레버의 필요성을 입증한 뒤에만 비싼 레버(파인튜닝)로 넘어가라. 이 순서를 지키는 것이 시간과 비용을 가장 크게 아끼는 단일 결정이다.