LLM 엔지니어링12장 · 멀티모달 RAG 와 데이터셋 자동 생성20 / 22← 이전목차다음 →MetaContents

12장. 멀티모달 RAG 와 데이터셋 자동 생성

프로젝트 1 — 생성한 데이터로 상담 모델 미세조정

실습 6에서 거른 out_sft_train.jsonl 로 작은 모델 Qwen/Qwen2.5-0.5B-Instruct 를 LoRA 로 미세조정하고, 사람이 쓴 시험 질문 12개로 미세조정 전과 뒤를 견주어 보세요. 같은 질문을 미세조정하지 않은 모델에 안내서의 절을 찾아 보여 주는 RAG 로도 물어, 외워 두기와 찾아보기를 견줍니다. 쇼핑몰과 안내서는 지어낸 것입니다. 실습 5, 6을 먼저 실행해야 하고, GPU 가 있으면 빠릅니다.

파일생김새
out_sft_train.jsonl실습 6이 거른 학습 데이터. 줄마다 messages(system, user, assistant)와 출처
care_test.jsonl사람이 쓴 시험 질문 12개와 열쇠
care_guide.md고객 안내서, 절 12개. RAG 와 견줄 때 쓴다

요구사항은 다섯 가지입니다.

  1. Ollama 의 모델을 내려 GPU 를 비운 뒤, Qwen/Qwen2.5-0.5B-Instruct 를 불러 시험 질문 12개에 미세조정 전의 답을 받습니다.
  2. 같은 모델에 안내서에서 가장 가까운 절 하나를 찾아 [자료]로 보여 주고 답을 받습니다(RAG).
  3. 학습 데이터를 5장처럼 채팅 템플릿으로 바꾸고, 답 부분만 채점하게 labels 를 만듭니다.
  4. LoRA(r 8, q·k·v·o)로 미세조정하고, 시험 질문 12개에 다시 답을 받습니다.
  5. 세 가지(미세조정 전, RAG, 미세조정 뒤)의 맞힌 수를 표로 견주고, 틀린 질문이 실습 6의 "빠진 사실"과 겹치는지 봅니다.

단계별 힌트입니다.

  1. Ollama 모델 내리기는 /api/generate 에 {"model": 이름, "keep_alive": 0} 을 보내면 됩니다. qwen2.5:3b 와 qwen2.5vl:3b 둘 다 내립니다.
  2. 5장 실습 3의 encode(), collate(), Trainer 설정을 그대로 씁니다. 데이터가 50쌍 안팎이므로 에포크를 넉넉히(8) 줍니다.
  3. RAG 의 절 찾기는 7장의 e5 임베딩으로 질문과 절 본문을 견줘 가장 가까운 절 하나를 고릅니다. 임베딩 모델은 CPU 로 불러 GPU 를 학습에 남깁니다.
  4. 채점은 열쇠 낱말이 답에 있는지로 합니다. "2,000원"과 "2000원"을 같게 보려고 쉼표와 빈칸을 빼고 견줍니다.
  5. 미세조정 전 모델을 먼저 채점한 뒤에 get_peft_model 을 씌웁니다. 씌운 뒤에는 원래 모델이 바뀝니다.

확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.

실제 데이터로 해 보려면

상품 사진과 설명이 함께 있는 공개 자료와, 질문-답을 만들 공개 문서로 같은 흐름을 연습할 수 있습니다. 내려받기 전에 이용 약관과 라이선스를 확인하고, 사람의 얼굴이나 개인정보가 든 그림은 쓰지 않습니다.

자료구하는 곳내용
상품 이미지와 설명AI Hub(aihub.or.kr)의 상품, 패션 이미지 데이터상품 사진과 분류, 속성. 신청과 승인이 필요하다
그림-글 짝 데이터Hugging Face 의 공개 데이터셋(라이선스를 확인한다)그림과 캡션의 짝. 멀티모달 검색 연습
질문-답을 만들 문서공공데이터포털(data.go.kr), 각 기관의 민원 안내와 FAQ생성 데이터의 재료. 사람이 쓴 FAQ 를 시험 묶음으로 쓸 수 있다

코드의 흐름

예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.

시험 준비, 전과 RAG, 템플릿, LoRA 학습, 뒤 채점의 다섯 상자를 화살표로 이은 상담 모델 미세조정 프로젝트의 코드 흐름도

주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 GPU 를 비우고 care_test.jsonl 의 시험 질문을 준비하며, 2단계(마젠타)에서 ask() 로 미세조정 전과 RAG 를 먼저 채점합니다. 3단계(다홍)는 encode() 로 학습 데이터를 채팅 템플릿 꼴로 바꾸고, 4단계(빨강)는 Trainer 로 LoRA 를 학습해 어댑터를 저장하며, 5단계(짙은 청록)는 미세조정 뒤를 채점해 세 열의 O·X 표로 견줍니다.

결과 보기 (예시 풀이)

1단계. GPU 를 비우고 시험 질문 준비하기

Python
import json, re, time, urllib.request
import numpy as np
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM, Trainer, TrainingArguments, set_seed
from peft import LoraConfig, get_peft_model
from datasets import Dataset
from sentence_transformers import SentenceTransformer

# Ollama 가 GPU 에 올려 둔 모델을 내린다. 학습에 GPU 를 쓰려고
for m in ("qwen2.5:3b", "qwen2.5vl:3b"):
    urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/generate", headers={"Content-Type": "application/json"},
                                                  data=json.dumps({"model": m, "keep_alive": 0}).encode())).read()

train_rows = [json.loads(line) for line in open("out_sft_train.jsonl", encoding="utf-8")]
test = [json.loads(line) for line in open("care_test.jsonl", encoding="utf-8")]
text = open("care_guide.md", encoding="utf-8").read()
sections = [(m.group(1), m.group(2).strip()) for m in re.finditer(r"## (.+)\n\n(.+)", text)]
SYSTEM = train_rows[0]["messages"][0]["content"]
norm = lambda s: s.replace(" ", "").replace(",", "")
print("학습", len(train_rows), "쌍 · 시험", len(test), "개 · GPU:", torch.cuda.is_available())
출력
학습 29 쌍 · 시험 12 개 · GPU: True

2단계. 미세조정 전과 RAG 로 채점하기

Python
name = "Qwen/Qwen2.5-0.5B-Instruct"
tok = AutoTokenizer.from_pretrained(name)
device = "cuda" if torch.cuda.is_available() else "cpu"
set_seed(42)
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float16 if device == "cuda" else torch.float32).to(device)


def ask(m, question, context=None):
    """질문(과 자료)을 채팅 템플릿으로 묻고 답을 받는다. 탐욕적 선택이라 다시 돌려도 같다"""
    user = f"[자료]\n{context}\n\n[질문]\n{question}" if context else question
    msgs = [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}]
    enc = tok.apply_chat_template(msgs, add_generation_prompt=True, return_tensors="pt", return_dict=True).to(m.device)
    with torch.no_grad():
        out = m.generate(**enc, max_new_tokens=60, do_sample=False)
    return tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).strip().replace("\n", " ")


# RAG 의 절 찾기: e5 로 질문과 가장 가까운 절 하나
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
S = emb.encode([f"passage: {t}: {b}" for t, b in sections], normalize_embeddings=True)
results = {"미세조정 전": [], "RAG(절 하나)": [], "미세조정 뒤": []}
answers = {}
for t in test:
    best = sections[int(np.argmax(S @ emb.encode("query: " + t["질문"], normalize_embeddings=True)))]
    for setting, a in [("미세조정 전", ask(model, t["질문"])), ("RAG(절 하나)", ask(model, t["질문"], f"{best[0]}: {best[1]}"))]:
        results[setting].append(norm(t["열쇠"]) in norm(a))
        answers[(setting, t["질문"])] = a
print({k: f"{sum(v)}/{len(test)}" for k, v in results.items() if v})
for t in test[:3]:
    print("Q:", t["질문"], f"(열쇠 {t['열쇠']})")
    print("   전:", answers[("미세조정 전", t["질문"])][:70])
    print("   RAG:", answers[("RAG(절 하나)", t["질문"])][:70])
출력
{'미세조정 전': '0/12', 'RAG(절 하나)': '9/12'}
Q: 오후 몇 시 전에 주문해야 당일에 출고되나요? (열쇠 2시)
   전: 주문 시간은 고객님의 개인적인 취향과 가격, 그리고 주문 기간에 따라 달라집니다. 일반적으로는 10시부터 20시까지로 추천됩니
   RAG: 오후 2시 전에 주문하면 그날 보낼 수 있습니다.
Q: 얼마 이상 사면 배송비가 무료인가요? (열쇠 4만원)
   전: 죄송합니다, 저는 인공지능으로서 실제 배송비를 계산하거나 확인할 수 없습니다. 하지만 일반적으로 가상 쇼핑몰에서 배송비는 상품
   RAG: 네, 배송비는 무료입니다. 이는 일반적으로 배송비를 포함하는 모든 요금을 무료로 제공합니다. 하지만 특정 상품이나 구매 조건이
Q: 교환은 상품을 받고 며칠 안에 신청해야 하나요? (열쇠 14일)
   전: 교환은 상품을 받는 데 필요한 시간이 필요하지 않습니다. 하지만, 상품이 완전히 재고되지 않았거나, 교환 시점에서 상품이 이미
   RAG: 교환은 상품을 받고 14일 이내에 신청해야 합니다.

3단계. 학습 데이터를 채팅 템플릿으로

Python
def encode(batch):
    """질문 부분은 -100(채점 안 함), 답 부분만 채점하게 labels 를 만든다(5차시와 같다)"""
    input_ids, labels = [], []
    for msgs in batch["messages"]:
        p = tok(tok.apply_chat_template(msgs[:2], add_generation_prompt=True, tokenize=False), add_special_tokens=False)["input_ids"]
        f = tok(tok.apply_chat_template(msgs, tokenize=False), add_special_tokens=False)["input_ids"]
        input_ids.append(f)
        labels.append([-100] * len(p) + f[len(p):])
    return {"input_ids": input_ids, "labels": labels}


def collate(features):
    """가장 긴 줄에 맞춰 채우고, 채운 자리는 보지도 채점하지도 않는다"""
    L = max(len(x["input_ids"]) for x in features)
    ids = torch.full((len(features), L), tok.pad_token_id)
    lab = torch.full((len(features), L), -100)
    att = torch.zeros((len(features), L), dtype=torch.long)
    for i, x in enumerate(features):
        n = len(x["input_ids"])
        ids[i, :n], lab[i, :n], att[i, :n] = torch.tensor(x["input_ids"]), torch.tensor(x["labels"]), 1
    return {"input_ids": ids, "labels": lab, "attention_mask": att}


data = Dataset.from_list([{"messages": r["messages"]} for r in train_rows]).map(encode, batched=True, remove_columns=["messages"])
first = data[0]
print("토큰 수:", len(first["input_ids"]), "· 채점하는 부분:", repr(tok.decode([t for t in first["labels"] if t != -100])))
출력
토큰 수: 68 · 채점하는 부분: '네, 4만원 이상 주문시 배송비는 없습니다.<|im_end|>\n'

4단계. LoRA 로 미세조정하기

Python
model = get_peft_model(model, LoraConfig(r=8, lora_alpha=16, lora_dropout=0.05,
                                         target_modules=["q_proj", "k_proj", "v_proj", "o_proj"], task_type="CAUSAL_LM"))
args = TrainingArguments("out_lora_care_ckpt", per_device_train_batch_size=8, num_train_epochs=8, learning_rate=5e-4,
                         logging_steps=5, save_strategy="no", report_to="none", seed=42,
                         fp16=torch.cuda.is_available(), disable_tqdm=True)
trainer = Trainer(model=model, args=args, train_dataset=data, data_collator=collate)
start = time.perf_counter()
trainer.train()
losses = [h["loss"] for h in trainer.state.log_history if "loss" in h]
print(f"학습 시간: {time.perf_counter() - start:.0f}초 · 손실: {losses[0]:.3f} → {losses[-1]:.3f}")
model = trainer.model.eval()
model.save_pretrained("out_lora_care")                    # 어댑터만 저장한다
출력
{'loss': '1.64', 'grad_norm': '1.838', 'learning_rate': '0.0004375', 'epoch': '1.25'}
{'loss': '1.299', 'grad_norm': '1.86', 'learning_rate': '0.0003594', 'epoch': '2.5'}
{'loss': '0.8714', 'grad_norm': '2.137', 'learning_rate': '0.0002813', 'epoch': '3.75'}
{'loss': '0.6658', 'grad_norm': '2.497', 'learning_rate': '0.0002031', 'epoch': '5'}
{'loss': '0.4483', 'grad_norm': '1.414', 'learning_rate': '0.000125', 'epoch': '6.25'}
{'loss': '0.4717', 'grad_norm': '2.218', 'learning_rate': '4.688e-05', 'epoch': '7.5'}
{'train_runtime': '137.2', 'train_samples_per_second': '1.691', 'train_steps_per_second': '0.233', 'train_loss': '0.8613', 'epoch': '8'}
학습 시간: 137초 · 손실: 1.640 → 0.472

5단계. 미세조정 뒤에 채점하고 견주기

Python
for t in test:
    a = ask(model, t["질문"])
    results["미세조정 뒤"].append(norm(t["열쇠"]) in norm(a))
    answers[("미세조정 뒤", t["질문"])] = a

import pandas as pd
table = pd.DataFrame({"열쇠": [t["열쇠"] for t in test],
                      **{k: ["O" if x else "X" for x in v] for k, v in results.items()}})
table.index = [t["질문"][:18] for t in test]
print(table.to_string())
print({k: f"{sum(v)}/{len(test)}" for k, v in results.items()})
for t, ok in zip(test, results["미세조정 뒤"]):
    if not ok:
        print("뒤에도 틀림:", t["질문"], "→", answers[("미세조정 뒤", t["질문"])][:80])
출력
                       열쇠 미세조정 전 RAG(절 하나) 미세조정 뒤
오후 몇 시 전에 주문해야 당일에     2시      X         O      X
얼마 이상 사면 배송비가 무료인가    4만원      X         X      X
교환은 상품을 받고 며칠 안에 신    14일      X         O      X
마음이 바뀌어 반품하면 배송비를   7000원      X         O      X
반품하면 환불은 며칠 안에 되나요   2영업일      X         X      X
우산살이 부러졌는데 언제까지 무료     1년      X         O      X
배낭 방수 덮개는 얼마에 파나요?  2000원      X         O      X
스탠드에 맞는 전구 규격은 무엇인    E26      X         O      X
스탠드 조명의 보증 기간은 얼마나     2년      X         O      O
화분에는 물을 얼마나 자주 줘야     일주일      X         X      X
적립금은 결제 금액의 몇 퍼센트가     2%      X         O      X
선물 포장 비용은 얼마인가요?    1500원      X         O      X
{'미세조정 전': '0/12', 'RAG(절 하나)': '9/12', '미세조정 뒤': '1/12'}
뒤에도 틀림: 오후 몇 시 전에 주문해야 당일에 출고되나요? → 주문 전에는 10시부터 시작합니다.
뒤에도 틀림: 얼마 이상 사면 배송비가 무료인가요? → 네, 얼만큼 사면 배송비는 무료입니다.
뒤에도 틀림: 교환은 상품을 받고 며칠 안에 신청해야 하나요? → 상품을 받고 3개월 안에 교환합니다.
뒤에도 틀림: 마음이 바뀌어 반품하면 배송비를 얼마 내나요? → 반품하면 배송비는 2,000원을 내니다.
뒤에도 틀림: 반품하면 환불은 며칠 안에 되나요? → 반품하면 환불은 3일 안에 되는 방식입니다.
뒤에도 틀림: 우산살이 부러졌는데 언제까지 무료로 고쳐 주나요? → Gratuitous repair is free for 30 days from the date of purchase.
뒤에도 틀림: 배낭 방수 덮개는 얼마에 파나요? → 배낭 방수 덮개는 10,000원에 파나요.
뒤에도 틀림: 스탠드에 맞는 전구 규격은 무엇인가요? → 스탠드에 60W 규격의 전구를 사용합니다.
뒤에도 틀림: 화분에는 물을 얼마나 자주 줘야 하나요? → 화분에는 물이 적게 자주 줬습니다.
뒤에도 틀림: 적립금은 결제 금액의 몇 퍼센트가 쌓이나요? → 적립금은 결제 금액의 5퍼센트 쌓니다.
뒤에도 틀림: 선물 포장 비용은 얼마인가요? → 선물 포장비는 1,000원이 됩니다.

미세조정 전의 0.5B 모델은 12개 가운데 하나도 맞히지 못했습니다. 우리 쇼핑몰의 규정을 모르니 "10시부터 20시까지" 같은 일반적인 답을 지어냈습니다. 같은 모델에 안내서의 절 하나를 찾아 보여 주자(RAG) 9개를 맞혔습니다. 틀린 셋 가운데 "4만원" 질문은 맞는 절을 받고도 "배송비는 무료입니다"라고만 답했습니다. 학습 데이터 29쌍으로 8에포크 미세조정하자 손실은 1.640 에서 0.472 로 내려갔지만, 시험은 1개(스탠드 보증 2년)만 맞혔습니다. 답의 말투는 "…입니다"처럼 짧은 상담원 꼴로 바뀌었지만, 숫자는 "3개월", "2,000원"처럼 틀렸습니다. 교안을 만들며 같은 데이터로 20에포크(344초)를 돌려 보니 손실은 0.005 까지 내려갔고 시험은 3개(1년, 2,000원, 2년)를 맞혔습니다. 데이터에서 빠진 세 사실(일주일, 2%, 1,500원)은 두 경우 모두 틀렸습니다. 이 실험에서 작은 모델은 질문 하나에 한두 번만 나온 사실을 다른 말로 물으면 꺼내지 못했습니다. 5장에서 24/24 를 맞힌 것은 사실마다 사람이 쓴 질문 셋으로 72쌍을 만들었기 때문입니다. 값처럼 정확해야 하는 사실은 RAG 로 보여 주고, 미세조정은 말투와 꼴을 맞추는 데 쓰거나, 사실마다 여러 꼴의 질문이 들어가도록 생성과 거르기를 다시 설계해야 합니다.

이 프로젝트가 뜻하는 것

이 프로젝트는 연습 문제집 29장으로 신입 상담원을 공부시키고 시험을 세 번 보게 하는 일과 같습니다. 먼저 교실을 비우고 시험지를 준비합니다(1단계). 공부하기 전에 맨손으로 한 번, 교재의 맞는 쪽을 펴 준 채로 한 번 시험을 봅니다(2단계). 문제집을 상담원이 읽는 꼴로 옮기고 답 부분만 채점하게 표시한 뒤(3단계), 머리에 작은 메모장만 덧대어 공부시키고(4단계), 교재 없이 다시 시험을 봅니다(5단계). 여기서 교실이 GPU, 맨손 시험이 미세조정 전, 교재를 펴 준 시험이 RAG(Retrieval-Augmented Generation, 찾은 자료를 붙여 답을 만들기), 작은 메모장이 LoRA(Low-Rank Adaptation, 작은 행렬만 덧붙여 학습하는 방법) 어댑터입니다. 비유와 다른 점은, 사람은 외운 사실을 다른 말로 물어도 답하지만 이 작은 모델은 그러지 못했다는 것입니다(5단계).

단계출력뜻
1. 준비학습 29 쌍 · 시험 12 개 · GPU: True실습 6에서 남은 29쌍으로 배우고, 사람이 쓴 12문제로 시험합니다. GPU 를 씁니다
2. 전과 RAG'미세조정 전': '0/12', 'RAG(절 하나)': '9/12'모르는 쇼핑몰 규정은 하나도 못 맞혔고, 안내서의 절 하나를 보여 주자 9개를 맞혔습니다
2. 답 예전: "10시부터 20시까지로 추천됩니", RAG: "오후 2시 전에 주문하면 그날 보낼 수 있습니다."근거가 없으면 일반적인 답을 지어내고, 근거가 있으면 그 문장을 옮겨 답합니다
3. 템플릿토큰 수: 68 · 채점하는 부분: '네, 4만원 이상 주문시 배송비는 없습니다.…'첫 쌍이 토큰 68개가 되었고, 그 가운데 상담원의 답 부분만 점수를 매깁니다
4. 학습학습 시간: 137초 · 손실: 1.640 → 0.4722분 남짓 학습하는 동안 손실(문제집의 답과 다른 정도)이 처음의 3분의 1 아래로 줄었습니다
5. 견주기'미세조정 전': '0/12', 'RAG(절 하나)': '9/12', '미세조정 뒤': '1/12'미세조정 뒤에도 1개(스탠드 보증 2년)만 맞혔습니다. 말투는 짧은 상담원 꼴이 됐지만 숫자는 "3개월", "5퍼센트"처럼 틀렸습니다
5. 빠진 사실일주일, 2%, 1500원의 줄실습 6에서 데이터에 없던 세 사실은 미세조정 뒤에 모두 틀렸고, RAG 는 이 가운데 2%와 1500원을 맞혔습니다

한 줄로 줄이면, 이 실험에서는 값을 정확히 답해야 하는 사실에 찾아 보여 주기(RAG)가 외워 두기(미세조정)보다 훨씬 나았다는 프로젝트입니다. 손실이 줄었다는 것은 문제집의 답을 잘 따라 쓰게 되었다는 뜻이지, 다른 말로 물은 시험 문제에 답한다는 뜻이 아닙니다. 시험 12문제, 학습 29쌍으로 낸 결과라 숫자 하나하나보다 흐름으로 읽고, 정답 낱말이 들어 있는지만 본 채점이라 답은 눈으로 함께 확인해야 합니다. 20에포크로 더 돌린 결과와 5장와의 견줌은 위의 결론 문단에 있습니다.

← 이전 절12장 · 실습 6 — 생성 데이터 거르고 나누기다음 절 →12장 · 정리