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와 같은 준비
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))준비한 프롬프트: 302단계. 세 모델 채점하기
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.53단계. 같은 질문의 세 답
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단계. 검색이 놓친 질문
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.5b | 15 · 8 · 1 · 6 · 19.7초 | 오답은 1개뿐이고 없는 질문 6개에 모두 모른다고 했지만, 답이 있는 질문 8개도 "찾을 수 없다"며 피했습니다(지나친 거절) |
| 2. 3b | 18 · 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 모델이 토요일이 주말이라는 것을 잇지 못한 것입니다. 검색을 고쳐도 모델이 한 번 더 생각해야 하는 질문이 남습니다. 더 큰 모델이나, 문서에 "토요일과 일요일"처럼 풀어 쓰는 것이 방법입니다.