8장. RAG 성능을 높이는 LLM 파인튜닝
출처: 『RAG 마스터: 랭체인으로 완성하는 LLM 서비스』(브라이스 유·조경아·박수진·김재웅, 프리렉 2025) | 공식: https://www.langchain.com/
검색기가 좋은 문서를 가져와도, LLM이 네거티브 샘플을 무시하고 출처를 정확히 인용하는 능력을 갖춰야 RAG가 완성된다. 이 장은 RAFT 논문의 핵심 아이디어(distractor 문서 + CoT)로 학습 데이터를 설계하고, 로컬 Qwen 모델을 LoRA로 파인튜닝하는 전 과정을 실습한다.
실습 — 책 공식 노트북: Ch08. Fine-tuning for RAG 폴더. 코랩 바로 열기: RAG 데이터셋 구축·Qwen RAG 파인튜닝 — RAFT + LoRA/QLoRA 종합.
학습 목표
이 장을 끝내면 다음을 할 수 있다.
- RAFT 논문의 핵심 아이디어(네거티브 샘플 + CoT)가 일반 파인튜닝과 어떻게 다른지 설명한다.
- distractor 문서 포함·no_answer·출처 인용 데이터를 설계하고 파인튜닝 학습셋을 구성한다.
- LoRA와 QLoRA의 차이를 파라미터 효율·GPU 메모리 관점에서 비교하고 적합한 방법을 선택한다.
- TRL SFTTrainer와 PEFT LoraConfig를 활용해 Qwen 모델을 로컬 파인튜닝하는 코드를 구현한다.
- 파인튜닝된 모델을 기반 모델과 비교 평가하고 RAG 파이프라인에 통합하는 방법을 설명한다.
전체 흐름도
RAFT 방식으로 학습 데이터를 설계하고 Qwen 모델을 LoRA로 파인튜닝해 RAG 성능을 높이는 전 과정.
[ 학습 데이터 준비 (RAFT 형식) ]
├─ 포지티브 샘플 (oracle 문서 + CoT 답변)
├─ 네거티브 샘플 (distractor 문서 포함)
└─ no_answer 샘플 (모든 문서가 무관한 경우)
│
▼
[ 베이스 모델 선택 (Qwen2-7B-Instruct) ]
│ 챗 템플릿 형식 확인 필수
▼
[ LoRA / QLoRA 학습 설정 ]
├─ LoraConfig: r, lora_alpha, target_modules
└─ SFTConfig: epochs, batch_size, learning_rate
│
▼
[ SFTTrainer 학습 실행 ]
│ RunPod A100 환경 (약 30분)
▼
[ 체크포인트 평가 ]
│ 파인튜닝 전·후 비교 (출처 인용 / distractor 무시 능력)
▼
[ RAG 파이프라인 통합 ]
└─ 파인튜닝 모델을 LLM 컴포넌트로 교체
0. 사전 필수 용어
참고 — 딥러닝·RAG 기초(1~4장)를 알고 있다면 충분하다. GPU 실습은 런팟 유료 계정이 필요하다.
- 파인튜닝(Fine-tuning) — 사전 학습된 LLM에 특정 도메인·태스크 데이터를 추가 학습시켜 성능을 높이는 작업. 처음부터 학습하는 것보다 훨씬 적은 비용으로 도메인 전문성을 얻는다.
- RAFT(Retrieval-Augmented Fine-Tuning) — UC Berkeley 논문(2024). 검색 증강 생성에 특화된 파인튜닝 방법. 포지티브 + 네거티브(distractor) 문서를 함께 넣어 모델이 관련 문서만 골라 쓰도록 훈련한다.
- 네거티브 샘플(Negative Sample) — 검색은 됐지만 질문 답변에는 도움이 안 되는 문서. RAFT에서 이를 학습 데이터에 포함시켜 모델이 distractor를 걸러내는 능력을 기른다.
- 포지티브 샘플(Positive Sample) — 질문에 대한 정답이 포함된 문서. oracle 문서라고도 부른다.
- 생각의 사슬(CoT, Chain of Thought) — '문제→답' 대신 '문제→근거→답' 순서로 풀게 해 정확도를 높이는 프롬프팅 기법. RAFT는 학습 데이터 자체에 CoT를 박아 넣는다.
- LoRA(Low-Rank Adaptation) — 전체 가중치를 업데이트하지 않고, 저랭크(low-rank) 보조 행렬만 학습하는 파라미터 효율적 파인튜닝(PEFT) 방법. GPU 메모리를 대폭 줄인다.
- QLoRA — LoRA + 4비트 양자화(NF4). 소비자 GPU에서도 70B 수준 모델을 학습할 수 있게 해준다.
- PEFT(Parameter-Efficient Fine-Tuning) — 전체 파라미터 중 일부만 업데이트하는 기법들의 총칭. LoRA·Prefix Tuning·Adapter 등이 포함된다. Hugging Face
peft라이브러리로 구현. - SFT(Supervised Fine-Tuning) — 정답(label)이 있는 데이터로 지도 학습 방식의 파인튜닝. TRL 라이브러리의
SFTTrainer가 이를 담당한다. - Qwen — 중국 알리바바가 공개한 오픈소스 LLM 시리즈. 한국어 성능이 뛰어나며 이 장 실습에
Qwen2-7B-Instruct를 사용한다. - 정수 인코딩(토크나이즈) — 텍스트를 토크나이저로 정수 시퀀스로 변환하는 과정.
input_ids가 전체 입력,labels는 어시스턴트 응답 부분만 실제 정수로 채우고 나머지는-100으로 마스킹한다. - 런팟(RunPod) — 시간당 과금 방식의 클라우드 GPU 임대 서비스. 저렴한 A100·RTX 서버를 온디맨드로 빌려 쓸 수 있다.
1. RAFT 논문 살펴보기
RAG를 위한 파인튜닝을 설계할 때 가장 먼저 참고할 논문이 RAFT다.
RAFT 논문: https://arxiv.org/abs/2403.10131 Zhang, Tianjun et al. "RAFT: Adapting Language Model to Domain Specific RAG" (2024)
1.1 네거티브 샘플
실제 RAG 검색 결과에는 정답 문서(포지티브 샘플) 와 관계없는 문서(네거티브 샘플/distractor) 가 섞인다.
- 예: "한글을 창제한 조선의 왕은?"에 장영실·이순신·이방원·세종대왕 문서가 함께 검색된다면, 세종대왕 문서만 포지티브 샘플이고 나머지 셋은 네거티브 샘플.
- 일반 파인튜닝 데이터는 포지티브 샘플만 담는 경우가 많다. 그러면 모델이 항상 답이 있는 문서만 보는 환경 에서 학습되므로, 실제 환경의 노이즈에 취약하다.
- RAFT는 학습 데이터에 네거티브 샘플을 의도적으로 포함시켜 모델이 distractor를 무시하고 oracle 문서만 참고하는 능력을 기른다.
# RAFT 스타일 학습 데이터 구조 (개념)
raft_sample = {
"question": "한글을 창제한 조선의 왕은?",
"documents": [
{"id": "doc1", "text": "장영실은 조선의 과학자...", "is_oracle": False}, # distractor
{"id": "doc2", "text": "이순신은 임진왜란...", "is_oracle": False}, # distractor
{"id": "doc3", "text": "세종대왕은 1443년 훈민정음...", "is_oracle": True}, # oracle
{"id": "doc4", "text": "이방원은 태종으로...", "is_oracle": False}, # distractor
],
"cot_answer": "##이유: 문서에서 ##인용시작## 세종대왕은 1443년 훈민정음... ##인용종료## "
"이므로 한글을 창제한 왕은 세종대왕입니다. ##답변: 세종대왕"
}
1.2 생각의 사슬 (CoT)
RAFT는 단순히 '답'을 학습시키는 대신, 답변 전에 원문 인용 근거를 작성하도록 강제한다.
RAFT 프롬프트 구조:
- 입력: 질문 + 검색 문서(oracle + distractor)
- 지시사항: "답변하기 전에 근거를 먼저 ##이유: 에 작성하라. 원문 인용은 ##인용시작## ~ ##인용종료## 로 감싸라."
- 출력 형식: ##이유: {원문 인용 + 논리} ##답변: {최종 답}
왜 효과적인가: 1. 모델이 근거를 먼저 쓰면서 hallucination이 줄어든다 (원문에 없는 내용을 못 인용). 2. 학습 시 어떤 문서를 참고했는지 trace가 명확해진다. 3. 추론 품질이 CoT가 없을 때보다 일관적으로 높다.
# RAFT CoT 답변 예시 (실제 RAFT 논문 형식)
cot_answer = """
##이유: 문서에서 ##인용시작## 오베로이 그룹은 본사가 델리에 있는 호텔 회사입니다.
##인용종료## 는 오베로이 그룹의 본사가 델리에 있음을 명시합니다.
따라서 오베로이 가문은 본사가 델리에 있는 호텔 회사의 일부입니다.
##답변: 델리
"""
2. 성능 향상을 위한 팁
RAFT 논문 외에, 실전 RAG 파인튜닝 데이터셋을 풍부하게 만들기 위한 두 가지 추가 기법이다.
2.1 답변 없음 데이터
실제 서비스에서 자주 발생하는 시나리오: 검색된 모든 문서가 네거티브 샘플 인 경우.
원인: - 사용자의 질문이 보유 데이터베이스 범위 밖인 경우 - 검색 성능이 낮아 관련 문서가 하나도 안 걸린 경우
만약 이 상황을 학습 데이터에 포함하지 않으면, 모델은 답이 없어도 스스로 만들어내려다가 hallucination을 발생시킨다.
해결책: no_answer 타입 데이터를 학습셋에 추가한다.
- answer 필드: "검색 결과에는 해당 질문에 대한 내용이 없습니다."
- extracted_ref_numbers: [] (빈 리스트 — 어떤 문서도 인용하지 않음)
2.2 출처 인용
각 답변 문장마다 어떤 문서에서 가져왔는지 [[ref문서번호]] 형태로 표기하도록 학습한다.
질문: 노키즈존에 대한 설문조사 결과 정리해줘.
# 파인튜닝된 모델의 답변 (출처 인용 포함)
2015년 7월 육아 커뮤니티 회원 3,650명 조사에서 73.2%가 찬성했습니다.
주요 찬성 이유는 조용한 환경에서의 휴식 욕구입니다 [docs3].
2016년 11월 시민 500명 조사에서 64%가 찬성했습니다 [docs4].
출처 인용의 두 가지 효과: 1. 신뢰도: 사용자가 원문을 직접 검증할 수 있다. 2. 성능: 모델이 "이 문장을 인용한 출처가 어디인가"를 생각하면서 hallucination이 줄어든다.
3. RAG 학습 데이터셋 살펴보기
3.1 학습 데이터 소개
이 장에서 사용하는 데이터셋: iamjoon/klue-mrc-ko-rag-dataset
- Hugging Face Hub: https://huggingface.co/datasets/iamjoon/klue-mrc-ko-rag-dataset
- 전체 1,884개 행, 한국어 RAG 파인튜닝용
| 컬럼 | 설명 |
|---|---|
question |
사용자 질문 |
search_result |
검색 결과 문서 리스트 |
answer |
정답 (출처 [[refN]] 포함) |
extracted_ref_numbers |
답변에서 인용된 문서 번호 리스트 |
type |
데이터 유형 (5가지) |
5가지 데이터 유형 — 실제 RAG 시나리오의 다양한 케이스를 커버한다:
| type | 특징 |
|---|---|
mrc_question |
지엽적 질문. 검색 결과 항상 5개. 단답 가능 |
mrc_question_with_1_to_4_negative |
지엽적 질문. 검색 결과 1~4개 (수 가변) |
paraphrased_question |
지엽적 질문이지만 명사구 형태 (문장 X) |
synthetic_question |
포괄적 질문. 여러 문서를 동시 인용 |
no_answer |
검색 결과에 답 없음. [[ref]] 없는 "없음" 답변 |
3.2 학습 데이터 탐색
import numpy as np
import matplotlib.pyplot as plt
from datasets import load_dataset
# 허깅페이스에서 데이터 로드
dataset = load_dataset("iamjoon/klue-mrc-ko-rag-dataset")
df = dataset["train"].to_pandas()
df = df[["question", "search_result", "answer", "extracted_ref_numbers", "type"]]
# 데이터 유형 확인
print("데이터 타입 종류:", df["type"].unique())
# ['mrc_question', 'mrc_question_with_1_to_4_negative',
# 'synthetic_question', 'paraphrased_question', 'no_answer']
# 데이터 유형별 통계 시각화 함수
def plot_lengths_by_type(df, plot_type):
"""특정 type의 검색 문서 수 / 인용 문서 수 분포를 시각화한다."""
if plot_type not in df["type"].unique():
print(f"'{plot_type}'은 유효하지 않은 타입입니다.")
return
df_type = df[df["type"] == plot_type]
total_count = len(df_type)
# 인용 문서 수 / 검색 문서 수 계산
df["search_result_len"] = df["search_result"].apply(len)
df["extracted_ref_len"] = df["extracted_ref_numbers"].apply(len)
extracted_counts = df_type["extracted_ref_len"].value_counts().sort_index()
search_counts = df_type["search_result_len"].value_counts().sort_index()
print(f"타입: {plot_type}, 전체 데이터 수: {total_count:,}개")
for length, count in extracted_counts.items():
print(f" {length}개 문서 인용 샘플: {count}개")
# 막대 그래프 2개 나란히
fig, axes = plt.subplots(1, 2, figsize=(5, 2.5))
plt.subplots_adjust(wspace=0.6)
extracted_counts.plot(kind="bar", ax=axes[0], xlabel="extracted_ref_len",
ylabel="Count", grid=True)
search_counts.plot(kind="bar", ax=axes[1], xlabel="search_result_len",
ylabel="Count", grid=True)
plt.tight_layout()
plt.show()
# 예: mrc_question 유형 시각화
plot_lengths_by_type(df, "mrc_question")
# → 491개, 대부분 1개 문서 인용, 검색 결과는 전부 5개
주요 관찰:
- mrc_question: 491개, 90% 이상이 1개 문서 인용, 검색 5개 고정
- mrc_question_with_1_to_4_negative: 296개, 검색 1~4개 균등 분포
- synthetic_question: 497개, 2개 이상 문서 인용 비율 높음 (포괄 질문 특성)
- paraphrased_question: 196개, 명사구 형태, 대부분 1개 문서 인용
- no_answer: 404개, 모두 0개 인용
4. 로컬 LLM Qwen 파인튜닝
4.1 런팟(RunPod) 환경 설정
런팟은 시간당 과금 방식의 클라우드 GPU 서비스다. URL: https://www.runpod.io/
실습 절차 요약:
1. 회원가입 → Billing → Pay with Card ($10~20 충전)
2. Pods → +Deploy → GPU 선택 (이 장 실습: A100 SXM 1개로 충분)
3. Edit Template → Container Disk를 50GB로 늘리기 (모델·데이터 용량 확보)
4. "Start Jupyter Notebook" 체크박스 → Deploy On-Demand
5. 서버 Running 상태 → Connect → Connect to Jupyter Lab [Port 8888]
6. 파일명: ch08_RAG_TUNING.ipynb
비용 주의: 실습 후 반드시 GPU 서버를 Terminate(완전 삭제)해야 한다. Stop Pod는 일시 중지이며 소량 과금이 계속된다.
4.2 데이터 전처리
라이브러리 설치:
!pip install torch==2.4.0 transformers==4.45.1 datasets==3.0.1 \
accelerate==0.34.2 trl==0.11.1 peft==0.13.0
핵심 임포트:
from datasets import load_dataset, Dataset
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
from peft import LoraConfig
from trl import SFTConfig, SFTTrainer
| 도구 | 역할 |
|---|---|
AutoModelForCausalLM |
HF Hub에서 인과적 언어 모델 로드 |
AutoTokenizer |
해당 모델의 텍스트→정수 변환기 |
LoraConfig |
LoRA 학습 설정값 (r, alpha, target_modules 등) |
SFTConfig |
학습 하이퍼파라미터 (lr, epochs, batch_size 등) |
SFTTrainer |
SFT 학습 실행 (LoRA + 데이터셋 + 설정을 한번에) |
데이터 로드 및 전처리:
# 1. 데이터셋 로드
dataset = load_dataset("iamjoon/klue-mrc-ko-rag-dataset", split="train")
# 2. 시스템 프롬프트 정의 (RAG 지침 포함)
system_message = """당신은 검색 결과를 바탕으로 질문에 답변해야 합니다.
다음의 지시사항을 따르십시오.
1. 질문과 검색 결과를 바탕으로 답변하십시오.
2. 검색 결과에 없는 내용을 답변하려고 하지 마십시오.
3. 질문에 대한 답이 검색 결과에 없다면 "해당 질문~에 대한 내용이 없습니다."라고 답변하십시오.
4. 답변 시 특정 문서를 참고한 경우 뒤에 [[refN]] 형태로 출처를 남기십시오.
5. 예: 1번과 5번 문서를 인용했다면 [[ref1]], [[ref5]]라고 기재하십시오.
6. 최대한 다수의 문서를 인용하여 답변하십시오.
검색 결과:
{search_result}"""
# 3. type별 균형 유지 train/test 분할 (test_ratio=0.8 → train 20%)
test_ratio = 0.8
train_data, test_data = [], []
for type_name in set(dataset["type"]):
curr_type_data = [i for i in range(len(dataset)) if dataset[i]["type"] == type_name]
test_size = int(len(curr_type_data) * test_ratio)
test_data.extend(curr_type_data[:test_size])
train_data.extend(curr_type_data[test_size:])
# 4. OpenAI 형식으로 변환 (messages: system/user/assistant)
def format_data(sample):
search_result = "\n--\n".join(
[f"문서{idx + 1}: {result}" for idx, result in enumerate(sample["search_result"])]
)
return {
"messages": [
{"role": "system", "content": system_message.format(search_result=search_result)},
{"role": "user", "content": sample["question"]},
{"role": "assistant", "content": sample["answer"]},
]
}
train_dataset = [format_data(dataset[i]) for i in train_data]
test_dataset = [format_data(dataset[i]) for i in test_data]
# 5. Dataset 객체로 변환
train_dataset = Dataset.from_list(train_dataset)
test_dataset = Dataset.from_list(test_dataset)
print(f"Train {len(train_dataset)}개, Test {len(test_dataset)}개")
# → Train 380개, Test 1504개
왜 type별로 분할하는가? 무작위로 자르면 특정 유형(no_answer 등)이 학습셋에서 빠질 수 있다. type별 80:20 유지로 모든 시나리오가 학습셋에 균등 포함되도록 한다.
4.3 Qwen 챗 템플릿 이해
각 LLM은 고유한 챗 템플릿으로 사전 학습돼 있다. 파인튜닝할 때 이 형식을 반드시 맞춰야 한다. 형식이 다르면 모델이 문맥을 인식 못 하거나 엉뚱한 답변을 낸다.
Qwen의 챗 템플릿 형식:
<|im_start|>system
시스템 프롬프트<|im_end|>
<|im_start|>user
사용자 질문<|im_end|>
<|im_start|>assistant
모델이 생성해야 할 답변<|im_end|>
# 모델과 토크나이저 로드
model_id = "Qwen/Qwen2-7B-Instruct"
model = AutoModelForCausalLM.from_pretrained(
model_id,
device_map="auto",
torch_dtype=torch.bfloat16,
)
tokenizer = AutoTokenizer.from_pretrained(model_id)
# OpenAI 형식 → Qwen 챗 템플릿 변환 확인
text = tokenizer.apply_chat_template(
train_dataset[0]["messages"],
tokenize=False,
add_generation_prompt=False
)
print(text)
# → <|im_start|>system\n당신은 검색 결과를...
4.4 LoRA 학습 설정값
LoRA의 핵심 아이디어: 대형 가중치 행렬 W를 직접 업데이트하지 않고, 작은 저랭크 행렬 A·B를 붙여 ΔW = A × B만 학습한다. 업데이트할 파라미터 수가 수십~수백 배 줄어든다.
peft_config = LoraConfig(
lora_alpha=32, # LoRA 영향 강도. 클수록 파인튜닝 효과 강함
lora_dropout=0.1, # 드롭아웃 비율 (과적합 방지)
r=16, # 랭크. 클수록 표현력↑ / 메모리↑
bias="none", # 편향값은 업데이트하지 않음
target_modules=["q_proj", "v_proj"], # 어텐션 Q·V 행렬에만 적용
task_type="CAUSAL_LM", # 자동회귀 언어 모델
)
| 파라미터 | 역할 | 지침 |
|---|---|---|
r |
저랭크 차원. 학습 범위 결정 | 작으면 메모리 절약, 크면 표현력↑. 8~64 범위 |
lora_alpha |
학습 강도 (스케일링). 일반적으로 r×2 | 너무 크면 기존 모델 특성 손실 |
lora_dropout |
과적합 방지 정규화 | 0.05~0.1 권장 |
target_modules |
적용 대상 모듈 | Q·V 행렬이 기본. K·O 추가 가능 |
bias |
편향 업데이트 여부 | "none"이 기본 (안정적) |
4.5 SFTConfig 학습 설정값
args = SFTConfig(
output_dir="qwen2-7b-rag-ko", # 모델 저장 디렉터리
num_train_epochs=3, # 전체 데이터 3회 반복
per_device_train_batch_size=2, # GPU당 배치 크기
gradient_accumulation_steps=2, # 2배치 누적 후 업데이트 (실질 배치=4)
gradient_checkpointing=True, # 메모리 절약 (속도 약간 감소)
optim="adamw_torch_fused", # 최적화기
logging_steps=10, # 10스텝마다 로그
save_strategy="steps",
save_steps=50, # 50스텝마다 체크포인트 저장
bf16=True, # bfloat16 혼합 정밀도 (A100에 최적)
learning_rate=1e-4, # 학습률
max_grad_norm=0.3, # 그래디언트 클리핑
warmup_ratio=0.03, # 초반 3% 구간 워밍업
lr_scheduler_type="constant", # 학습률 일정 유지
push_to_hub=False,
remove_unused_columns=False,
dataset_kwargs={"skip_prepare_dataset": True},
report_to=None,
)
핵심 설정 이해:
- gradient_accumulation_steps=2 + per_device_batch_size=2 → 실질 배치 크기 4. 작은 GPU 메모리로도 큰 배치 효과.
- bf16=True: A100 등 Ampere 이상 GPU에서 bfloat16은 fp16보다 수치 안정성이 좋다.
- warmup_ratio=0.03: 학습 초반에 급격한 가중치 변화를 방지해 학습 안정성 향상.
4.6 정수 인코딩 (토크나이즈)
LLM은 텍스트가 아닌 정수 시퀀스로 학습한다. 이 단계는 두 가지 핵심 변수를 만든다:
input_ids: 전체 대화(system + user + assistant)를 정수로 인코딩한 것.labels:input_ids와 같은 길이. 어시스턴트 응답 부분만 실제 정수를 넣고, 나머지(system, user 부분)는-100으로 마스킹. PyTorch는-100위치의 손실을 무시하므로 모델은 어시스턴트 답변 생성만 학습한다.
def collate_fn(batch):
"""텍스트 배치를 input_ids / attention_mask / labels 텐서로 변환한다."""
new_batch = {"input_ids": [], "attention_mask": [], "labels": []}
max_seq_length = 8192 # 최대 토큰 길이
for example in batch:
# 개행문자 정리 후 챗 템플릿 적용
clean_messages = [
{"role": m["role"], "content": m["content"]}
for m in example["messages"]
]
text = tokenizer.apply_chat_template(
clean_messages, tokenize=False, add_generation_prompt=False
).strip()
# 텍스트 → 정수
tokenized = tokenizer(
text, truncation=True, max_length=max_seq_length,
padding=False, return_tensors=None
)
input_ids = tokenized["input_ids"]
attention_mask = tokenized["attention_mask"]
# labels 초기화 (전부 -100)
labels = [-100] * len(input_ids)
# <|im_start|>assistant ... <|im_end|> 구간만 실제 값 복사
im_start_tokens = tokenizer.encode("<|im_start|>", add_special_tokens=False)
im_end_tokens = tokenizer.encode("<|im_end|>", add_special_tokens=False)
assistant_tokens = tokenizer.encode("assistant", add_special_tokens=False)
i = 0
while i < len(input_ids):
if input_ids[i:i+len(im_start_tokens)] == im_start_tokens:
assistant_pos = i + len(im_start_tokens)
if input_ids[assistant_pos:assistant_pos+len(assistant_tokens)] == assistant_tokens:
current_pos = assistant_pos + len(assistant_tokens)
while current_pos < len(input_ids):
if input_ids[current_pos:current_pos+len(im_end_tokens)] == im_end_tokens:
for j in range(len(im_end_tokens)):
labels[current_pos + j] = input_ids[current_pos + j]
break
labels[current_pos] = input_ids[current_pos]
current_pos += 1
i = current_pos
continue
i += 1
new_batch["input_ids"].append(input_ids)
new_batch["attention_mask"].append(attention_mask)
new_batch["labels"].append(labels)
# 패딩 (배치 내 최대 길이에 맞춤)
max_length = max(len(ids) for ids in new_batch["input_ids"])
for i in range(len(new_batch["input_ids"])):
padding_length = max_length - len(new_batch["input_ids"][i])
new_batch["input_ids"][i].extend([tokenizer.pad_token_id] * padding_length)
new_batch["attention_mask"][i].extend([0] * padding_length)
new_batch["labels"][i].extend([-100] * padding_length)
# 리스트 → 텐서
for k, v in new_batch.items():
new_batch[k] = torch.tensor(v)
return new_batch
비유: input_ids는 시험지 전체(문제+풀이), labels는 채점표(정답 부분만 표시, 나머지는 채점 제외). 모델은 채점표(-100 아닌 곳)에서만 배운다.
# 학습 실행
max_seq_length = 8192
trainer = SFTTrainer(
model=model,
args=args,
max_seq_length=max_seq_length,
train_dataset=train_dataset,
data_collator=collate_fn,
peft_config=peft_config,
)
trainer.train()
trainer.save_model()
# → 약 30분 소요, qwen2-7b-rag-ko/ 에 체크포인트 저장
4.7 모델 테스트
학습 후 생성된 체크포인트(checkpoint-N) 중 최종 체크포인트로 파인튜닝 전·후를 비교한다.
from peft import AutoPeftModelForCausalLM
from transformers import AutoTokenizer, pipeline
# 파인튜닝 전: 기본 모델
base_model = AutoModelForCausalLM.from_pretrained(
"Qwen/Qwen2-7B-Instruct", device_map="auto", torch_dtype=torch.float16
)
pipe_base = pipeline("text-generation", model=base_model, tokenizer=tokenizer)
# 파인튜닝 후: LoRA 어댑터 포함 모델
fine_tuned_model = AutoPeftModelForCausalLM.from_pretrained(
"qwen2-7b-rag-ko/checkpoint-285", device_map="auto", torch_dtype=torch.float16
)
pipe_ft = pipeline("text-generation", model=fine_tuned_model, tokenizer=tokenizer)
eos_token = tokenizer("<|im_end|>", add_special_tokens=False)["input_ids"][0]
def test_inference(pipe, prompt):
outputs = pipe(prompt, max_new_tokens=1024, eos_token_id=eos_token, do_sample=False)
return outputs[0]["generated_text"][len(prompt):].strip()
# 테스트 데이터 전처리 (입력 / 정답 분리)
prompt_lst, label_lst = [], []
for prompt in test_dataset["messages"]:
text = tokenizer.apply_chat_template(prompt, tokenize=False, add_generation_prompt=False)
inp = text.split("<|im_start|>assistant")[0] + "<|im_start|>assistant"
lab = text.split("<|im_start|>assistant")[1]
prompt_lst.append(inp)
label_lst.append(lab)
# 42번 샘플 비교
print("기본 모델 예측:", test_inference(pipe_base, prompt_lst[42]))
print("파인튜닝 모델 예측:", test_inference(pipe_ft, prompt_lst[42]))
print("정답:", label_lst[42])
비교 결과 핵심: 기본 모델은 [[ref문서번호]] 형식 인용을 하지 않고 답변 길이도 짧다. 파인튜닝 후 모델은 지시사항(출처 인용, 검색 결과 기반 답변)을 충실히 이행한다.
4.8 GPU 종료
반드시 완전 종료(Terminate)! Stop Pod(일시 중지)는 소량 과금이 계속된다.
Runpod 웹 → Pods → 휴지통 버튼 → "Yes" 클릭 → 과금 완전 중단.
핵심 개념 정리
| 개념 | 한 줄 요약 |
|---|---|
| RAFT | distractor 문서 + CoT 인용으로 RAG에 특화된 파인튜닝 방법 |
| 네거티브 샘플 | 검색됐지만 답 없는 문서. 학습 데이터에 포함해 distractor 무시 능력 훈련 |
| no_answer 데이터 | 모든 검색 결과가 무관할 때 "없음" 답변하도록 학습. hallucination 방지 |
| 출처 인용 | [[refN]] 형태로 인용 문서 표기. 신뢰도↑, 오답 가능성↓ |
| LoRA | 저랭크 보조 행렬만 학습. 파라미터 대폭 절감 (풀 파인튜닝 대비 GPU 수십 배 절약) |
r, lora_alpha |
LoRA 핵심 하이퍼파라미터. r=랭크(표현력), alpha=영향 강도 |
| SFTTrainer | TRL 라이브러리. 모델·데이터·설정 한 번에 입력해 SFT 학습 자동 수행 |
| input_ids / labels | 학습 입력(전체 텍스트)과 학습 목표(어시스턴트 응답만, 나머지 -100 마스킹) |
| 챗 템플릿 | 사전 학습 시 사용한 형식. 파인튜닝 시 반드시 동일 형식 유지 |
| 런팟(RunPod) | 시간당 과금 클라우드 GPU. 실습 후 반드시 Terminate 필수 |
한 문장 요약 — RAFT의 네거티브 샘플 + CoT 전략으로 설계한 학습 데이터를 만들고, LoRA/SFTTrainer로 효율적으로 Qwen을 파인튜닝하면 RAG 답변의 정확도와 출처 인용 능력이 크게 향상된다.
실무 체크리스트
- [ ] 학습 데이터에 포지티브 + 네거티브 문서를 함께 포함(RAFT). 포지티브만 넣으면 distractor 무시 능력이 생기지 않는다.
- [ ]
no_answer유형 데이터를 학습셋에 반드시 포함. 없으면 모든 검색 결과가 관계없어도 hallucination으로 답변한다. - [ ] 데이터 type별 균형 분할. 무작위 분할은 특정 유형 편향을 만든다.
- [ ] 파인튜닝 전 모델의 챗 템플릿 형식 확인. 다른 형식으로 학습하면 성능 급락.
- [ ] LoRA
r·lora_alpha값은 메모리와 성능의 트레이드오프. 8~32로 시작해 조정. - [ ] 런팟 실습 후 반드시 Terminate(완전 삭제). Stop Pod는 과금 계속됨.
- [ ] 최종 체크포인트가 항상 최고 성능인 것은 아님. 모든 체크포인트를 테스트 데이터로 평가 후 선택.
연습문제
- 개념. RAFT에서 네거티브 샘플을 학습 데이터에 포함하는 이유는 무엇인가? 포함하지 않으면 어떤 문제가 생기는가?
- 비교. LoRA와 풀 파인튜닝의 차이를 GPU 메모리 사용량과 학습 파라미터 관점에서 비교하라. 언제 각각을 선택해야 하는가?
- 코드.
collate_fn에서labels를 전부-100으로 초기화한 후 어시스턴트 응답 부분만 실제 값으로 채우는 이유를 설명하라. 이렇게 하지 않으면 어떤 일이 생기는가? - 적용. 의료 도메인 RAG 챗봇을 파인튜닝한다고 가정한다.
mrc_question,synthetic_question,no_answer외에 어떤 데이터 유형을 추가로 설계하면 좋을지 제안하고 이유를 설명하라. - 설계. Qwen2-7B 대신 더 큰 모델(예: 70B)을 같은 GPU 환경에서 파인튜닝하려면 어떤 추가 기법이 필요한가? QLoRA와 LoRA의 차이를 포함해 설명하라.
최신 동향 (2026-05 기준)
최신 동향 (검증 2026-05-21) — 책은 2025년 기준이므로 아래만 보완해 익히면 된다.
- RAFT + LoRA/QLoRA 조합이 도메인 특화 RAG 파인튜닝 실무 표준으로 자리잡음. QLoRA(4비트 양자화)를 추가하면 소비자 GPU에서도 13B~70B 모델 파인튜닝이 가능하다. HF PEFT 라이브러리
BitsAndBytesConfig로 활성화. (출처: https://huggingface.co/docs/peft/conceptual_guides/lora) - TRL
SFTTrainer가 파인튜닝 표준 인터페이스로 정착.trl>=0.11부터SFTConfig로 설정을 통합·단순화. 책의SFTConfig+SFTTrainer패턴이 현재도 동일하게 유지. (출처: https://huggingface.co/docs/trl/sft_trainer) - Qwen 시리즈는 2.5·3으로 업데이트됨.
Qwen2-7B-Instruct는 책 기준 최신이었으나 현재는 후속 버전이 출시됐다. 실제 사용 시 Qwen 공식 HF 페이지에서 현행 모델을 확인하라. (출처: https://huggingface.co/Qwen)
부록 A. 용어 사전
| 한글 | 영문 | 의미 |
|---|---|---|
| 파인튜닝 | Fine-tuning | 사전 학습된 LLM에 특정 도메인·태스크 데이터를 추가 학습시켜 성능을 높이는 작업 |
| 검색 증강 파인튜닝 | RAFT (Retrieval-Augmented Fine-Tuning) | 포지티브 + 네거티브 문서와 CoT를 결합해 RAG에 특화된 능력을 학습하는 방법 |
| 네거티브 샘플 | Negative Sample / Distractor | 검색됐지만 질문 답변과 무관한 문서. 모델이 걸러내는 능력을 키우기 위해 학습 데이터에 포함 |
| 포지티브 샘플 | Positive Sample / Oracle | 질문의 정답 근거가 담긴 문서 |
| 생각의 사슬 | CoT (Chain of Thought) | 답 전에 근거를 먼저 작성하도록 유도해 정확도를 높이는 기법 |
| 파라미터 효율 파인튜닝 | PEFT (Parameter-Efficient Fine-Tuning) | 전체 파라미터 중 일부만 업데이트해 GPU 비용을 줄이는 기법 총칭 |
| 저랭크 적응 | LoRA (Low-Rank Adaptation) | 저랭크 보조 행렬만 학습해 GPU 메모리를 대폭 절감하는 PEFT 방법 |
| 양자화 LoRA | QLoRA | LoRA + 4비트 양자화(NF4). 소비자 GPU에서 대형 모델 파인튜닝 가능 |
| 지도 파인튜닝 | SFT (Supervised Fine-Tuning) | 정답 레이블이 있는 데이터로 지도 학습 방식의 파인튜닝을 수행하는 방법 |
| 레이블 마스킹 | Label Masking (-100) | 어시스턴트 응답 외 나머지 토큰 위치에 -100을 넣어 손실 계산에서 제외하는 기법 |
| 챗 템플릿 | Chat Template | 각 LLM이 사전 학습 시 사용한 대화 형식. 파인튜닝 시 동일 형식 준수 필수 |
| 그래디언트 누적 | Gradient Accumulation | 여러 배치의 그래디언트를 합산 후 한 번에 업데이트해 실질 배치 크기를 늘리는 기법 |
부록 B. 핵심 비교표
전체 파인튜닝 vs LoRA vs QLoRA
| 구분 | 전체 파인튜닝 | LoRA | QLoRA |
|---|---|---|---|
| 업데이트 대상 | 전체 파라미터 | 저랭크 보조 행렬(A, B)만 | 저랭크 보조 행렬 (4비트 양자화 기반) |
| GPU 메모리 | 매우 높음 (모델 전체) | 중간 (수십 배 절감) | 낮음 (소비자 GPU 가능) |
| 학습 속도 | 느림 | 빠름 | 중간 (양자화 오버헤드 있음) |
| 성능 | 최고 (데이터 충분 시) | 전체 파인튜닝에 근접 | LoRA보다 소폭 낮을 수 있음 |
| 적합 상황 | 충분한 데이터 + 대형 클러스터 | 도메인 적응, GPU 제한 환경 | 소비자 GPU(13B~70B 모델) |
RAG vs 파인튜닝 — 접근 방식 비교
| 구분 | RAG | 파인튜닝 |
|---|---|---|
| 지식 업데이트 방식 | 검색 데이터베이스 교체 (실시간) | 재학습 필요 |
| 최신성 | 높음 (검색 인덱스에 의존) | 학습 데이터 기준 시점에 고정 |
| 비용 | 검색 인프라 비용 | GPU 학습 비용 (초기 고비용) |
| 사실 오류 위험 | 낮음 (원문 인용 기반) | 높음 (hallucination 가능) |
| 형식 준수 능력 | 프롬프트 의존 | 학습으로 강제 가능 (RAFT) |
| 최적 조합 | RAFT로 파인튜닝한 LLM을 RAG의 생성 컴포넌트로 사용 |
부록 C. 추천 참고 자료
검증된 외부 자료 (Tier 1 공식, 생존 확인 2026-05-21)
| 자료 | 링크 |
|---|---|
| RAFT 논문 (UC Berkeley, 2024) | arxiv.org/abs/2403.10131 |
| PEFT/LoRA 개념 가이드 | huggingface.co/docs/peft |
| TRL SFTTrainer 공식 문서 | huggingface.co/docs/trl |
| Qwen 모델 허브 | huggingface.co/Qwen |
| 실습 데이터셋 (klue-mrc-ko-rag) | huggingface.co/datasets/iamjoon/klue-mrc-ko-rag-dataset |
| RunPod 공식 사이트 | runpod.io |
| 실습 코드 (깃허브 Ch08) | github.com/langchain-kr/langchain-tutorial |
| 자료 | 설명 |
|---|---|
| 책 7장 | ReAct 에이전트 — 도구 사용 LLM (RAFT 이전 단계) |
| 책 9장 | 임베딩 모델 파인튜닝으로 검색 성능까지 높이는 방법 |
부록 D. 연습문제 풀이
-
(distractor 무시 능력 훈련 — hallucination 방지) 실제 RAG 검색 결과에는 포지티브 샘플(oracle 문서)과 무관한 네거티브 샘플(distractor)이 섞여 반환된다. 포지티브 샘플만으로 학습한 모델은 "답이 항상 문서에 있는 환경"에서만 훈련되어 실제 노이즈에 취약하고, 관계없는 문서를 참고해 잘못된 답을 만들어낸다(hallucination). RAFT는 학습 데이터에 네거티브 샘플을 의도적으로 포함시켜 모델이 oracle 문서만 선택적으로 참고하는 능력을 기른다.
-
(풀 파인튜닝 = 전체 파라미터·최대 메모리 / LoRA = 저랭크 보조 행렬만·메모리 대폭 절감) 풀 파인튜닝은 모델의 모든 가중치를 업데이트하므로 GPU 메모리 사용량이 매우 높고 학습 속도도 느리지만, 충분한 데이터와 대형 클러스터가 있으면 최고 성능을 낸다. LoRA는 대형 가중치 행렬 W 대신 저랭크 보조 행렬 A·B(
ΔW = A × B)만 학습하여 업데이트 파라미터 수가 수십~수백 배 줄고 GPU 메모리도 크게 절감된다. GPU 제한 환경에서 도메인 적응이 목표라면 LoRA, 충분한 데이터와 대형 클러스터를 보유한 경우에는 풀 파인튜닝을 선택한다. -
(어시스턴트 응답 부분만 학습 목표로 삼기 위해 — 시스템·유저 토큰을 학습에서 제외)
labels를 전부-100으로 초기화하고<|im_start|>assistant~<|im_end|>구간의 토큰만 실제 값으로 채우면, PyTorch가-100위치의 손실을 무시하므로 모델은 어시스턴트 답변 생성만 학습하게 된다. 만약 이 마스킹 없이 전체input_ids를 그대로labels로 사용하면, 시스템 프롬프트와 사용자 질문 토큰까지 모두 예측 대상이 되어 모델이 "검색 결과를 생성하는 법"이나 "질문을 생성하는 법"도 함께 학습하게 되고, 실제 원하는 답변 생성 능력은 희석된다.```python
핵심 마스킹 로직 요약
labels = [-100] * len(input_ids) # 전체 -100 초기화
<|im_start|>assistant ... <|im_end|> 구간만 실제 token id 복사
labels[current_pos] = input_ids[current_pos] ```
-
(의료 특화 데이터 유형 추가 제안) 기존 5가지 유형(mrc_question, synthetic_question, no_answer 등) 외에, 의료 도메인에서는 다음 두 유형을 추가 설계하면 효과적이다. 첫째, 충돌 문서 유형(conflict_documents) — 서로 다른 의학 가이드라인이 상충하는 문서를 함께 제공하고, 모델이 "문서 간 견해가 다릅니다"라고 답변하도록 학습한다(hallucination 방지 + 신뢰도 향상). 둘째, 면책 조항 유형(disclaimer_required) — 검색 결과에 정보가 있더라도 "의사 상담을 권장합니다"와 같은 안전 문구를 출처 인용과 함께 반드시 포함하도록 학습한다. 이는
no_answer유형의 변형으로, 실제 서비스 운영 시 법적·안전 요건을 충족하는 데 필요하다. -
(QLoRA = LoRA + 4비트 양자화(NF4) — 소비자 GPU에서 대형 모델 파인튜닝 가능) 70B 수준의 모델은 LoRA만으로도 GPU 메모리 요구가 크기 때문에, QLoRA를 적용해야 한다. QLoRA는 LoRA에 4비트 NF4 양자화를 결합한 기법으로, 기반 모델 가중치를 4비트로 압축해 메모리 사용량을 대폭 낮추면서 저랭크 보조 행렬(A·B)은 더 높은 정밀도로 학습한다. 덕분에 소비자 GPU에서도 13B~70B 모델의 파인튜닝이 가능해진다. LoRA(중간 메모리, bf16·fp16)와 비교해 QLoRA는 메모리가 낮지만 양자화 오버헤드로 학습 속도가 소폭 느리고 성능이 미세하게 낮을 수 있다. Hugging Face PEFT의
BitsAndBytesConfig로 활성화하며, 동일 GPU 환경에서 더 큰 모델을 다루려면 반드시 QLoRA를 선택해야 한다.
클릭하거나 Space를 눌러 뒤집기