티스토리 뷰

지난 글에서는 질의처리기를 규칙 기반에서 소형 LLM으로 옮겨보기로 한 이유를 적었다. 말은 그렇게 했지만, 막상 모델을 직접 학습시켜 본 적은 없었다. GPU가 달린 장비도 없었고, 비용을 들이기 전에 일단 "이게 되긴 되는가"부터 확인하고 싶었다. 그래서 무료 Colab T4 한 장으로 소형 모델을 파인튜닝해봤고, 이번 글에는 그 과정을 셀 단위로 적어둔다. 한 번에 끝난 게 아니라 데이터를 늘려가며 네 라운드를 돌렸는데, 처음 57%에서 96%까지 올라간 흐름도 같이 담는다.

 

목표는 단순하다. 사용자의 파일 검색 질의에서 필터 조건과 키워드를 JSON으로 뽑아내는 일이다. 예를 들어 "지난달 마케팅팀 보고서 pptx" 같은 문장을 받으면 아래처럼 정리해주면 된다.

{"file_types": ["pptx"], "paths": ["마케팅팀"], "keywords": ["보고서"], "owners": [], "date_expression": "지난달"}

지금은 이 작업을 LLM을 여러 번 호출하는 구조로 처리하고 있는데, 호출이 많아 느리고 매번 결과가 조금씩 달라지는 일관성 문제가 있었다. 이걸 작은 모델 한 번 호출로 줄여보는 게 큰 그림이었다.

[기존 구조]
질의 ─▶ LLM 여러 번 호출 ─▶ JSON

[목표 구조]
질의 ─▶ 소형 파인튜닝 모델 (1회) ─▶ JSON

1. 왜 QLoRA + Unsloth + 무료 Colab이었나

처음 고민한 건 환경이었다. 1.7B 모델이라고 해도 그냥 올리면 T4의 14GB 메모리에 부담스럽다. 그래서 두 가지를 택했다.

 

하나는 4bit 양자화(QLoRA)다. 모델 가중치를 4bit로 눌러 올리면 T4 한 장에도 들어간다. 다른 하나는 LoRA다. 전체 파라미터를 다 건드리는 full fine-tuning 대신, 작은 어댑터만 학습한다. 실제로 학습된 파라미터는 전체 17억 개 중 약 1,700만 개, 1%였다. 나머지는 그대로 두고 이 1%만 바꿔서 태스크에 맞춘 셈이다.

 

거기에 Unsloth를 얹었다. 같은 QLoRA 학습을 더 빠르고 메모리도 덜 쓰게 패치해주는 라이브러리인데, 무료 Colab처럼 자원이 빠듯한 환경에서 특히 도움이 됐다. 정리하면 "무료 T4 + 4bit + LoRA + Unsloth"가 돈 안 들이고 시작하기에 가장 만만한 조합이었다.

 

사용량을 초과했거나, 사용률이 많을때 기본 옵션이 CPU로 가있을 수 있어서 확인이 필요하다.

2. 데이터 준비

학습 데이터는 Alpaca 포맷으로 맞췄다. 세 부분으로 나뉘는 익숙한 형태다.

Alpaca 포맷 - 세 부분
  Instruction   파일 검색 질의에서 필터 정보를 JSON으로 추출하라 (고정 지시문)
  Input         실제 사용자 질의 - 예: "사업개발팀 폴더의 인수인계 문서"
  Response      원하는 JSON 출력

jsonl 한 줄은 instruction(고정 지시문)·input(질의)·output(정답 JSON) 세 키로 구성된다. 대략 이런 식이다.

{"instruction": "<고정 지시문>", "input": "사업개발팀 폴더의 인수인계 문서", "output": "{\"file_types\": [], \"paths\": [\"사업개발팀\"], \"keywords\": [\"인수인계\", \"문서\"], \"owners\": [], \"date_expression\": null}"}

여기서 한 가지 규칙을 두었다. 날짜는 모델이 계산하지 않게 했다. "지난달"은 언제 검색하느냐에 따라 가리키는 날짜가 달라진다. 6월에 찾으면 5월, 7월에 찾으면 6월이다. 그래서 모델은 "지난달"이라는 말만 그대로 뽑게 하고, 실제 날짜 계산은 검색 시점을 아는 코드가 맡도록 나눴다. 같은 이유로 확장자 정규화(ppt → pptx)도 모델이 아니라 서비스 로직이 처리한다. 모델은 "질의어를 그대로 잘라내는 일"만 하면 된다.

 

첫 라운드는 339개로 시작했다. 많지 않은 양이지만, 일단 파이프라인이 도는지부터 보는 게 목적이라 욕심내지 않았다.

3. 셀 단위로 따라가기

여기서부터는 Colab 노트북을 셀 순서대로 적는다. 사전 준비는 두 가지뿐이다. 데이터 jsonl을 구글 드라이브에 올려두고, Colab 런타임을 GPU(T4)로 바꾸는 것.

 

패키지 설치

!pip install unsloth -q
!pip install datasets trl -q

드라이브 마운트

데이터를 드라이브에서 읽어오기 때문에 마운트부터 한다. 경로가 틀리면 뒤에서 한참 헤매니 assert로 파일 존재부터 확인했다.

from google.colab import drive
drive.mount('/content/drive')

import os
DATA_PATH = '/content/drive/MyDrive/query_filter_sft_v1.jsonl'
assert os.path.exists(DATA_PATH), f'데이터 파일 없음: {DATA_PATH}'
print('데이터 파일 확인 완료')

모델 로드 (4bit)

load_in_4bit=True 한 줄이 핵심이다. 이게 있어야 T4에 올라간다. 최대 시퀀스 길이는 질의가 짧으니 512로 잡았다.

from unsloth import FastLanguageModel

MAX_SEQ_LENGTH = 512

model, tokenizer = FastLanguageModel.from_pretrained(
    model_name='unsloth/Qwen3-1.7B',
    max_seq_length=MAX_SEQ_LENGTH,
    load_in_4bit=True,
)

이때 출력 로그에 Tesla T4. Num GPUs = 1. Max memory: 14.563 GB 같은 줄이 보이면 GPU가 제대로 잡힌 거다.

LoRA 설정

r=16, lora_alpha=32로 어댑터를 붙였다. 어텐션과 MLP의 주요 projection 모듈에 모두 LoRA를 걸었다.

model = FastLanguageModel.get_peft_model(
    model,
    r=16,
    lora_alpha=32,
    target_modules=[
        'q_proj', 'k_proj', 'v_proj', 'o_proj',
        'gate_proj', 'up_proj', 'down_proj'
    ],
    lora_dropout=0,
    bias='none',
    use_gradient_checkpointing='unsloth',
    random_state=42,
)

데이터 포맷 변환

jsonl을 읽어서 Alpaca 템플릿 문자열로 합친 뒤 Dataset으로 만든다.

import json
from datasets import Dataset

ALPACA_TEMPLATE = """### Instruction:
{instruction}

### Input:
{input}

### Response:
{output}"""

def load_jsonl(path):
    with open(path, encoding='utf-8') as f:
        return [json.loads(l) for l in f]

raw = load_jsonl(DATA_PATH)
formatted = [{'text': ALPACA_TEMPLATE.format(**item)} for item in raw]
dataset = Dataset.from_list(formatted)
print(f'데이터 로드: {len(dataset)}개')

학습

TRL의 SFTTrainer로 돌렸다. 3 epoch, 배치 4에 gradient accumulation 4를 줘서 실질 배치 16으로 학습했다.

from trl import SFTTrainer
from transformers import TrainingArguments

trainer = SFTTrainer(
    model=model,
    tokenizer=tokenizer,
    train_dataset=dataset,
    dataset_text_field='text',
    max_seq_length=MAX_SEQ_LENGTH,
    args=TrainingArguments(
        num_train_epochs=3,
        per_device_train_batch_size=4,
        gradient_accumulation_steps=4,
        learning_rate=2e-4,
        warmup_ratio=0.05,
        lr_scheduler_type='cosine',
        fp16=True,
        logging_steps=10,
        save_strategy='no',
        seed=42,
        output_dir='/content/qwen3_filter_trainer',
    ),
)
trainer.train()

339개 데이터, 총 66스텝 학습에 걸린 시간은 92초였다. 무료 T4로 1분 반 만에 한 라운드가 끝나니 생각보다 가벼웠다.

  [219/219 05:19, Epoch 3/3]

   Step   Training Loss
     10       2.5462
     20       0.7049
     30       0.3525
     50       0.2607
    100       0.1700
    150       0.1542
    200       0.1283
    210       0.1361

  TrainOutput(global_step=219, train_loss=0.3180, train_runtime=332.9s, epoch=3.0)
  (loss 2.55 → 0.13으로 수렴, 1,164개 · 219스텝 · 약 5분 20초)

 

어댑터 저장

학습한 건 LoRA 어댑터뿐이라 저장 용량도 작다. 드라이브에 어댑터와 토크나이저만 떨궈둔다.

ADAPTER_PATH = '/content/drive/MyDrive/qwen3_filter_adapter'
model.save_pretrained(ADAPTER_PATH)
tokenizer.save_pretrained(ADAPTER_PATH)

4. 첫 라운드 결과 

가장 궁금했던 건 결국 "JSON을 제대로 뱉느냐"였다. 학습이 끝난 모델에 테스트 질의 다섯 개를 넣어봤다.

FastLanguageModel.for_inference(model)

TEST_QUERIES = [
    '지난달 마케팅팀 보고서 pptx',
    '홍길동이 만든 계약서 pdf',
    '2025년 3월 15일 작성된 기획안',
]
for query in TEST_QUERIES:
    prompt = f'### Instruction:\n{INSTRUCTION}\n\n### Input:\n{query}\n\n### Response:\n'
    inputs = tokenizer(prompt, return_tensors='pt').to('cuda')
    outputs = model.generate(**inputs, max_new_tokens=128, temperature=0.1, do_sample=True)
    print(tokenizer.decode(outputs[0][inputs['input_ids'].shape[1]:], skip_special_tokens=True))

결과는 이랬다.

입력: 지난달 마케팅팀 보고서 pptx
출력: {"file_types": ["pptx"], "paths": ["마케팅팀"], "keywords": ["보고서", "pptx"], "owners": [], "date_expression": "지난달"}

입력: 홍길동이 만든 계약서 pdf
출력: {"file_types": ["pdf"], "paths": [], "keywords": ["계약서"], "owners": ["홍길동"], "date_expression": null}

입력: 2025년 3월 15일 작성된 기획안
출력: {"file_types": [], "paths": [], "keywords": ["기획안"], "owners": [], "date_expression": "2025년 3월 15일"}

다섯 개 모두 JSON 파싱에 성공했다. 작성자("홍길동")를 owners로, 확장자("pdf")를 file_types로 정확히 갈라냈고, 날짜는 의도한 대로 표현 그대로 date_expression에 들어갔다. 처음으로 "어, 진짜 되네" 싶었던 순간이었다.

 

다만 여기서 멈추면 안 된다. 이건 손에 잡히는 다섯 개 예시였을 뿐이고, 평가셋 100개를 제대로 돌려보니 이 첫 라운드(Round 1)의 전체 정확도는 57% 수준이었다.

 

표준 어순의 쉬운 질의는 잘 맞췄지만 경로나 복합 조건이 섞인 질의에서 많이 흔들렸다. 심지어 미디어 감지는 기존 규칙 기반(50%)보다 퇴보한 25%였다. 파이프라인이 도는 걸 확인했다는 의미는 있었어도 바로 쓸 수 있는 성능은 아니었다.

5. 데이터를 늘려가며 — 57%에서 96%까지

그래서 결국 데이터를 늘리는 싸움이 됐다. 평가셋에서 틀리는 패턴을 보고, 그 패턴을 학습 데이터에 보강하고, 다시 돌리는 일을 반복했다.

 

 

라운드별로 정리하면 이렇다.

  라운드              모델           데이터    v1    핵심 변화
  ---------------------------------------------------------------------------------
  기존 query_parser   -              -         50%   베이스라인
  Round 1             Qwen3-1.7B     339개     57%   날짜·확장자 개선, 미디어 퇴보
  Round 2             Qwen3-1.7B     563개     67%   미디어 회복, 복합 개선 시작
  Round 3             Qwen3-1.7B     763개     92%   경로·복합 대폭 개선, 실용 도달
  Round 4             Qwen3-1.7B     1,164개   93%   미디어 보강
  Round 4 (best)      Qwen3.5-0.8B   1,164개   96%   전 카테고리 안정화

가장 크게 뛴 건 Round 2 → Round 3 구간이었다. 67%에서 92%로 올랐는데, 이때 경로(25% → 92%)와 복합 조건(23% → 77%) 데이터를 집중적으로 채웠다. 데이터가 763개 정도 모이니 비로소 "실용 수준"이라 부를 만한 선에 닿았다.

 

흥미로운 건 마지막 줄이다. Round 4에서 데이터는 그대로 둔 채 베이스 모델만 Qwen3-1.7B에서 더 최신 세대인 Qwen3.5-0.8B로 바꿨더니, 파라미터 수는 절반 이하인데 오히려 93% → 96%로 올랐다. 모델 크기보다 세대가 더 중요할 수 있다는 걸 여기서 체감했다.

데이터를 어떻게 늘렸나

무작정 양만 늘린 건 아니었다. Round 3에서 92%를 찍고 나서, 실사용 질의를 던져보니 평가셋에는 없던 실패가 보였다.

스프링부트 다니엘이 만든 보고서  →  owners=['스프링부트 다니엘']   (owner 경계 오염)
백엔드 김철수가 만든 보고서       →  keywords=[]                  (keyword 누락)
마이클이 올린 도커 설정 파일       →  file_types=['docker']        (필드 오분류)

원인을 좁혀보니 역순 어순이 핵심이었다. "김철수가 만든 마케팅 기획서"(정상)는 잘 맞추는데, "마케팅 김철수가 만든 기획서"(역순)처럼 키워드가 이름 앞으로 오면 키워드를 통째로 흘리거나 이름 경계를 잘못 잡았다. 여기에 한글 외래어 이름("다니엘", "프레디")이 겹치면 owner에 앞 단어까지 빨려 들어갔다.

 

그래서 Round 4에서는 이 실패 패턴을 겨눠 300개를 추가했다.

Round 4에서 추가한 300개
  역순 작성자   50개   순한글 25 + 외래어 25
  역순 + 복합   30개   날짜·파일타입·경로 조합
  다양 주제     20개   마케팅·인사·재무·법무 등 비개발 도메인

이런 식으로 "틀리는 걸 보고 → 그걸 데이터로 만들고 → 다시 학습"하는 루프를 돈 게 결국 점수를 끌어올린 동력이었다. 모델을 더 키우는 것보다 데이터의 다양성을 채우는 쪽이 효과가 컸다.

6. 모델 크기는 어디까지 줄일 수 있나

성능이 잡힌 뒤엔 "얼마나 작게 가도 되나"를 봤다. 같은 Round 4 데이터로 크기가 다른 세 모델을 비교했다.

 

  모델                  v1 정확도   응답시간(CPU)   생성 토큰   크기
  ---------------------------------------------------------------------
  Qwen3-0.6B            46%         1457ms          203개       ~395MB
  Qwen3-1.7B            93%         707ms           40개        ~1.75GB
  Qwen3.5-0.8B (best)   96%         782ms           39개        ~775MB

여기서 의외였던 건 가장 작은 0.6B가 오히려 가장 느렸다는 점이다. 학습이 덜 돼서 JSON을 다 뱉고도 멈추지 못하고 토큰을 203개까지 계속 생성했다. 반대로 0.8B와 1.7B는 39~40개에서 깔끔하게 끝났고 CPU 추론에서는 메모리 대역폭이 병목이라 둘의 속도 차이가 75ms에 불과했다. 결국 정확도 96% · 크기 775MB · 속도 782ms의 Qwen3.5-0.8B가 온디바이스에 올리기 가장 균형 잡힌 선택이었다.

 

서빙은 GGUF Q8_0으로 변환해 Ollama에 올렸다. 등록하고 나면 이렇게 한 줄로 돌려볼 수 있다.

ollama run qwen3-filter-0.8b "작년 팀장님이 작성한 pdf 제안서"

입력: 작년 팀장님이 작성한 pdf 제안서
출력: {"file_types": ["pdf"], "keywords": ["제안서"], "owners": ["팀장님"], "date_expression": "작년"}

 

7. 마치며

이번 작업으로 확인한 건 세 가지다.

 

첫째, 무료 Colab으로도 소형 모델 파인튜닝 진입장벽이 생각보다 낮았다. 4bit + LoRA + Unsloth 조합이면 1.7B 모델도 T4 한 장에서 1분 반이면 한 라운드가 돈다. GPU 장비나 비용 없이 "되긴 되는가"를 확인하기에 충분했다.

 

둘째, 점수를 올린 건 모델이 아니라 데이터였다. 57% → 96%까지의 대부분은 "틀리는 패턴을 데이터로 메우는" 반복에서 나왔다. 특히 역순 어순처럼 실사용에서만 드러나는 실패는, 평가셋을 실제 입력에 가깝게 늘려야 비로소 보였다.

 

셋째, 작아도 충분할 수 있다. 세대가 최신이면 0.8B로도 1.7B를 앞섰고, 온디바이스에 올리기에 크기·속도·정확도가 모두 납득할 만한 선이었다.

 

물론 남은 숙제도 있다. owners 필드는 실제 메타데이터(Gildong Hong)와 사용자 표현("길동", "팀장님")이 달라 계정 매핑 없이는 신뢰하기 어렵고, 역순·외래어 같은 예외 표현(v2) 점수는 끝까지 낮았다.

 

사실 이 실험은 여기서 멈췄다. 점수를 끌어올린 방식이 "틀리는 패턴을 데이터로 메우는" 루프였는데, 예외 표현 쪽은 데이터를 아무리 더해도 잘 따라오지 않았다. 어느 순간부터는 모델이 일반화되는 게 아니라 평가셋에 맞춰 오버피팅되는 느낌이 들었다. 데이터를 계속 붓는 게 정말 모델을 좋게 만드는 건지 확신이 서지 않아 거기서 손을 뗐다.

 

그래도 "규칙 기반 50%"에서 "소형 모델 96%"까지 온 건, 무료 GPU 한 장에서 시작한 것치고는 충분히 와볼 만한 거리였다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/06   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함