LLM 엔지니어링6장 · RAG 의 원리와 로컬 모델로 옮기기20 / 23← 이전목차다음 →MetaContents

6장. RAG 의 원리와 로컬 모델로 옮기기

실습 6 — 모델 바꿔 끼우기

실습 5의 코드에서 모델 이름만 바꿔 qwen2.5:0.5b, qwen2.5:1.5b, qwen2.5:3b 를 같은 질문 30개로 견줍니다. 세 모델을 미리 ollama pull 로 받아 두어야 합니다(합쳐서 3.3GB 쯤). 3B 는 4GB GPU 에 들어가지만, 다른 프로그램이 GPU 를 쓰고 있으면 CPU 로 넘어가 느려집니다.

1단계. 실습 5와 같은 준비

Python
import json, time
from collections import Counter
import pandas as pd
from openai import OpenAI, APIError
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
text = open("shop_policy.txt", encoding="utf-8").read()
chunks = [c.strip() for c in text.split("\n\n")[1:]]
qa = [json.loads(line) for line in open("shop_policy_qa.jsonl", encoding="utf-8")]
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3))
X = vec.fit_transform(chunks)
def search(question, k=3):
    scores = cosine_similarity(vec.transform([question]), X)[0]
    return [(int(i), float(scores[i])) for i in scores.argsort()[::-1][:k]]
SYSTEM = ("당신은 쇼핑몰 상담원입니다. 아래 [문서]에 적힌 내용만 근거로 짧게 답하세요. "
          "문서에 답이 없으면 '문서에서 찾지 못했습니다.'라고만 답하세요.")
def build_messages(question, k=3):
    found = [chunks[i] for i, _ in search(question, k)]
    return [{"role": "system", "content": SYSTEM},
            {"role": "user", "content": "[문서]\n" + "\n".join(found) + "\n\n[질문]\n" + question}]
REFUSE = ["찾지 못", "찾을 수 없", "나와 있지 않", "제공되지 않"]
def grade(key, answer):
    refused = any(w in answer for w in REFUSE)
    if key == "":
        return "모름 맞음" if refused else "지어냄"
    if any(k in answer for k in key.split("|")):
        return "정답"
    return "거절" if refused else "오답"
def safe_ask(messages, model):
    try:
        resp = client.chat.completions.create(model=model, messages=messages, temperature=0, seed=42, max_tokens=120)
        return resp.choices[0].message.content.strip()
    except APIError as e:
        return f"[오류] {e.message[:40]}"

# 질문마다 프롬프트는 한 번만 만들어 세 모델에 똑같이 넣는다
prompts = [build_messages(r["질문"]) for r in qa]
print("준비한 프롬프트:", len(prompts))
출력
준비한 프롬프트: 30

2단계. 세 모델 채점하기

Python
MODELS = ["qwen2.5:0.5b", "qwen2.5:1.5b", "qwen2.5:3b"]
answers, rows = {}, []
for m in MODELS:
    safe_ask([{"role": "user", "content": "안녕하세요"}], m)        # 모델을 먼저 올려 두고 시간을 잰다
    start = time.time()
    answers[m] = [safe_ask(p, m) for p in prompts]
    sec = time.time() - start
    res = [grade(r["정답열쇠"], a) for r, a in zip(qa, answers[m])]
    inside = Counter(x for x, r in zip(res, qa) if r["근거조항"])
    outside = Counter(x for x, r in zip(res, qa) if not r["근거조항"])
    rows.append({"모델": m, "정답(/24)": inside["정답"], "지나친 거절": inside["거절"], "오답": inside["오답"],
                 "모름 맞음(/6)": outside["모름 맞음"], "30문항 초": round(sec, 1)})
print(pd.DataFrame(rows).to_string(index=False))
출력
          모델  정답(/24)  지나친 거절  오답  모름 맞음(/6)  30문항 초
qwen2.5:0.5b       13       1  10          0    36.4
qwen2.5:1.5b       15       8   1          6    19.7
  qwen2.5:3b       18       4   2          6   129.5

3단계. 같은 질문의 세 답

Python
for q in ["골드 등급이 되려면 얼마나 사야 해요?", "새벽 배송도 해 주나요?"]:
    i = next(k for k, r in enumerate(qa) if r["질문"] == q)
    print("질문:", q)
    for m in MODELS:
        print(f"  {m:13s}", answers[m][i].replace("\n", " ")[:80])
출력
질문: 골드 등급이 되려면 얼마나 사야 해요?
  qwen2.5:0.5b  골드 등급은 60만원 이상의 산 회원이어야 합니다.
  qwen2.5:1.5b  골드 등급을 받기 위해서는 60만원 이상을 구매해야 합니다.
  qwen2.5:3b    골드 등급이 되려면 60만원 이상을 구매해야 합니다.
질문: 새벽 배송도 해 주나요?
  qwen2.5:0.5b  문서에서 "제4조(해외 배송)"에 따르면, 해외 배송은 미국, 일본, 캐나다 세 나라에만 보냅니다. 따라서, 새벽 배송은 제4조에 따르는 국가에
  qwen2.5:1.5b  문서에서 새벽 배송을 제공하는 내용은 찾을 수 없습니다.
  qwen2.5:3b    문서에서 새벽 배송에 대한 정보는 제공되지 않았습니다. 따라서 새벽 배송이 가능한지 확인하기 위해서는 쇼핑몰의 추가 정보나 고객센터에 문의하시는

4단계. 검색이 놓친 질문

Python
q = "토요일에도 상품을 보내 주나요?"
print("검색한 조항:", [chunks[i][:chunks[i].index(")") + 1] for i, _ in search(q)])
print("3b 의 답   :", safe_ask(build_messages(q), "qwen2.5:3b"))

# 정답 조항(제1조)을 손으로 넣어 주면 어떻게 답하는지 본다
fixed = [{"role": "system", "content": SYSTEM},
         {"role": "user", "content": "[문서]\n" + chunks[0] + "\n\n[질문]\n" + q}]
print("제1조를 넣으면:", safe_ask(fixed, "qwen2.5:3b"))
출력
검색한 조항: ['제11조(반품)', '제8조(교환 기한)', '제21조(대량 구매)']
3b 의 답   : 문서에서 해당 정보를 찾을 수 없습니다. 상품 배송에 대한 정보는 제공되지 않았습니다.
제1조를 넣으면: 문서에서 찾지 못했습니다.

이 실습이 뜻하는 것

이 실습은 같은 안내서 세 쪽과 같은 질문지를 경험이 다른 상담원 세 명에게 똑같이 주고 성적표를 견주는 것과 같습니다. 질문지를 한 번만 만들어 두고(1단계), 세 상담원에게 똑같이 풀게 해 채점하고(2단계), 같은 질문의 세 답을 나란히 읽고(3단계), 사서가 엉뚱한 쪽을 건넸던 질문을 다시 봅니다(4단계). 여기서 세 상담원이 qwen2.5:0.5b, qwen2.5:1.5b, qwen2.5:3b(파라미터 약 5억, 15억, 30억 개) 모델, 질문지가 미리 만든 프롬프트 30개입니다. 비유와 다른 점은, 사람의 경험과 달리 모델의 크기는 정답을 늘리는 대신 속도와 메모리를 더 쓴다는 것입니다.

단계출력뜻
1. 준비준비한 프롬프트: 30질문마다 검색과 붙이기를 한 번만 해 두어, 세 모델이 똑같은 글을 받습니다
2. 0.5b정답 13 · 지나친 거절 1 · 오답 10 · 모름 맞음 0 · 36.4초가장 작은 모델은 답이 있는 24문제 중 13개를 맞히고, 답이 없는 6문제에는 한 번도 모른다고 하지 않았습니다
2. 1.5b15 · 8 · 1 · 6 · 19.7초오답은 1개뿐이고 없는 질문 6개에 모두 모른다고 했지만, 답이 있는 질문 8개도 "찾을 수 없다"며 피했습니다(지나친 거절)
2. 3b18 · 4 · 2 · 6 · 129.5초정답이 가장 많고 없는 질문에도 모두 모른다고 했습니다. 시간이 가장 긴 것은 앞 모델들이 GPU 에 남아 3B 의 일부가 CPU(Central Processing Unit, 중앙 처리 장치)에서 돌았기 때문입니다
3. 골드 등급세 모델 모두 "60만원 이상"답이 문서에 또렷이 있는 질문은 작은 모델도 맞힙니다
3. 새벽 배송0.5b 는 해외 배송 이야기, 1.5b 는 "찾을 수 없습니다", 3b 는 "제공되지 않았습니다"문서에 없는 질문에서 모델 크기의 차이가 드러납니다
4. 검색한 조항['제11조(반품)', '제8조(교환 기한)', '제21조(대량 구매)']실습 3에서 검색이 놓쳤던 토요일 질문이라, 정답 조항(제1조)이 빠진 채 붙었습니다
4. 3b 의 답"문서에서 해당 정보를 찾을 수 없습니다…"엉뚱한 조항을 받고 모른다고 한 것은 바른 행동입니다
4. 제1조를 넣으면문서에서 찾지 못했습니다.정답 조항을 손으로 넣어 줘도 "주말"과 "토요일"을 잇지 못해 답하지 못했습니다

한 줄로 줄이면, 코드는 그대로 두고 모델만 키우자 정답(13, 15, 18개)과 모를 때 모른다고 하기(0, 6, 6개)가 함께 나아졌다는 실습입니다. 다만 1.5B 처럼 모른다는 말이 늘면 답이 있는 질문까지 피하는 일이 생기므로, 정답만 보지 말고 지나친 거절도 함께 봐야 합니다. 시간은 GPU 에 무엇이 남아 있느냐에 따라 크게 달라져서, 129.5초를 3B 모델 본래의 속도로 읽으면 안 됩니다. 0.5B 의 정답이 실습 5(14개)와 하나 다른 것도 실행마다의 흔들림입니다. 프로젝트 1은 가장 나았던 qwen2.5:3b 로 아파트 관리규약 Q&A 를 만듭니다.

코드 설명

  • 모델 이름(MODELS 의 한 줄)만 바꾸고 나머지 코드는 그대로입니다. 본문 "바꿔 끼우는 짜임"에서 말한 대로, 서버와 모델을 바꿔도 코드는 같습니다.
  • 정답은 0.5B 13개, 1.5B 15개, 3B 18개로 모델이 클수록 늘고, 문서에 없는 질문에 모른다고 한 것은 0개, 6개, 6개입니다. 1.5B 는 지나친 거절이 8개로 가장 많습니다. 모른다는 말은 잘 하지만, 답이 있는 질문도 피합니다. 0.5B 의 정답이 실습 5(14개)와 하나 다른 것처럼, Ollama 는 temperature=0 이어도 실행마다 답이 조금 달라질 수 있습니다.
  • 골드 등급은 세 모델 모두 "60만원 이상"으로 맞힙니다. 새벽 배송은 0.5B 만 해외 배송 조항을 끌어와 지어내고, 1.5B 와 3B 는 문서에서 찾을 수 없다고 답합니다. 3B 의 시간이 129.5초로 가장 긴 것은, 앞의 두 모델이 GPU 에 남아 있어 3B 가 68% 를 CPU 에서 돌았기 때문입니다(ollama ps 로 확인).
  • "토요일에도 상품을 보내 주나요?"는 검색이 반품, 교환 기한, 대량 구매 조항을 가져와 3B 가 "찾을 수 없습니다"라고 답합니다. 검색 실패에서 모른다고 한 것은 바른 행동입니다. 그런데 정답 조항(제1조)을 손으로 넣어 줘도 "찾지 못했습니다"라고 답합니다. 문서에는 "주말"이라고만 있어서, 3B 모델이 토요일이 주말이라는 것을 잇지 못한 것입니다. 검색을 고쳐도 모델이 한 번 더 생각해야 하는 질문이 남습니다. 더 큰 모델이나, 문서에 "토요일과 일요일"처럼 풀어 쓰는 것이 방법입니다.
← 이전 절6장 · 실습 5 — Ollama 로 같은 일 하기다음 절 →6장 · 프로젝트 1 — 아파트 관리규약 Q&A