6장. RAG 의 원리와 로컬 모델로 옮기기
실습 4 — 근거를 붙여 답하게 하기
실습 1의 모델에 실습 3의 검색을 붙여 RAG 를 완성합니다. 같은 질문 30개를 다시 채점해 실습 1과 견줍니다. GPU 에서 몇 분 걸립니다.
1단계. 실습 1·3과 같은 준비
import json
from collections import Counter
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
# 실습 1과 같은 준비: 모델과 chat(), grade()
name = "Qwen/Qwen2.5-0.5B-Instruct"
device = "cuda" if torch.cuda.is_available() else "cpu"
tok = AutoTokenizer.from_pretrained(name)
model = AutoModelForCausalLM.from_pretrained(name, dtype=torch.float16 if device == "cuda" else torch.float32).to(device)
def chat(messages, max_new_tokens=100):
enc = tok.apply_chat_template(messages, add_generation_prompt=True, return_tensors="pt", return_dict=True).to(device)
with torch.no_grad():
out = model.generate(**enc, max_new_tokens=max_new_tokens, do_sample=False)
return tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).strip()
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 "오답"
# 실습 3과 같은 준비: 조항 나누기와 검색
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]]
print("조항", len(chunks), "개, 질문", len(qa), "개 준비")조항 25 개, 질문 30 개 준비2단계. 프롬프트 만들기
SYSTEM = ("당신은 쇼핑몰 상담원입니다. 아래 [문서]에 적힌 내용만 근거로 짧게 답하세요. "
"문서에 답이 없으면 '문서에서 찾지 못했습니다.'라고만 답하세요.")
def build_messages(question, k=3):
"""검색한 조항 k 개를 [문서] 로 붙인 메시지 목록. 본문 "근거를 붙인 프롬프트"의 함수에 검색을 더했다"""
found = [chunks[i] for i, _ in search(question, k)]
user = "[문서]\n" + "\n".join(found) + "\n\n[질문]\n" + question
return [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}]
msgs = build_messages("휴대폰으로 결제하면 한도가 있나요?")
print(msgs[1]["content"])
# 모델에 실제로 들어가는 글(채팅 템플릿을 씌운 것)의 길이를 잰다. 문서를 붙인 만큼 길어진다
prompt_text = tok.apply_chat_template(msgs, add_generation_prompt=True, tokenize=False)
print("프롬프트 길이:", len(prompt_text), "글자 /", len(tok(prompt_text)["input_ids"]), "토큰")[문서]
제14조(결제 수단) 신용카드, 계좌이체, 간편결제, 휴대폰 결제를 쓸 수 있습니다. 휴대폰 결제는 한 달에 50만원까지입니다.
제15조(무이자 할부) 5만원 이상을 신용카드로 결제하면 3개월까지 무이자 할부가 됩니다.
제3조(출고 마감) 평일 오후 2시까지 결제한 주문은 그날 출고합니다. 오후 2시가 지나서 결제하면 다음 영업일에 출고합니다.
[질문]
휴대폰으로 결제하면 한도가 있나요?
프롬프트 길이: 385 글자 / 240 토큰3단계. 문서 없이와 문서 붙여 견주기
NO_DOC = [{"role": "system", "content": "당신은 쇼핑몰 상담원입니다. 짧게 답하세요."}]
for q in ["휴대폰으로 결제하면 한도가 있나요?", "골드 등급이 되려면 얼마나 사야 해요?", "새벽 배송도 해 주나요?"]:
print("질문 :", q)
print("문서 없이:", chat(NO_DOC + [{"role": "user", "content": q}]).replace("\n", " ")[:90])
print("문서 붙여:", chat(build_messages(q)).replace("\n", " ")[:90])질문 : 휴대폰으로 결제하면 한도가 있나요?
문서 없이: 결제 금액에 대한 한도는 상점마다 다르지만, 일반적으로 10만원 이상의 결제를 원하는 경우가 많습니다. 이 금액을 기준으로 상점에서 할인이나 특별한 혜택을 제공할
문서 붙여: 휴대폰 결제는 한 달에 50만원까지 무이자 할부가 가능하며, 이는 5만원 이상의 신용카드 결제 시 발생합니다.
질문 : 골드 등급이 되려면 얼마나 사야 해요?
문서 없이: 골드 등급을 구할 때는 몇 가지 요소가 중요합니다: 1. 가격: 골드의 가격에 따라 가치가 달라집니다. 2. 브랜드와 제조사: 브랜드와 제조사를 고려해야 합니
문서 붙여: 골드 등급을 받기 위해서는 60만원 이상의 산 회원이어야 합니다.
질문 : 새벽 배송도 해 주나요?
문서 없이: 네, 새벽 배송을 지원합니다. 저희는 고객님의 주문에 따라 최선을 다해 배송을 진행하겠습니다. 언제든지 도움이 필요하시면 말씀해주세요!
문서 붙여: 문서에서 "제4조(해외 배송)"에 따르면, 해외 배송은 미국, 일본, 캐나다 세 나라에만 보냅니다. 따라서 새벽 배송은 제4조에 명시된 국가가 아닌 다른 국가에만4단계. 30개 채점하기
results = [grade(r["정답열쇠"], chat(build_messages(r["질문"]))) for r in qa]
print("문서에 있는 질문:", Counter(x for x, r in zip(results, qa) if r["근거조항"]))
print("문서에 없는 질문:", Counter(x for x, r in zip(results, qa) if not r["근거조항"]))문서에 있는 질문: Counter({'정답': 17, '오답': 6, '거절': 1})
문서에 없는 질문: Counter({'지어냄': 6})이 실습이 뜻하는 것
이 실습은 실습 1의 아르바이트생에게, 이번에는 질문마다 사서가 찾아 준 안내서 세 쪽을 함께 건네고 답하게 하는 것과 같습니다. 모델과 색인을 다시 준비하고(1단계), 질문과 찾은 조항 3개를 한 장의 지시문으로 엮고(2단계), 같은 질문 세 개를 안내서 없이와 안내서를 붙여 견주고(3단계), 30문제를 다시 채점합니다(4단계). 여기서 안내서 세 쪽이 검색한 조항 3개, 한 장의 지시문이 프롬프트, 찾아서 붙이고 답하는 이 흐름 전체가 RAG(Retrieval-Augmented Generation, 검색 증강 생성)입니다. 비유와 다른 점은, 사람은 받은 쪽에서 맞는 줄만 골라 읽지만 작은 모델은 붙여 준 조항을 뒤섞어 엉뚱한 문장을 만들기도 한다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 준비 | 조항 25 개, 질문 30 개 준비 | 실습 2·3의 조항 25개와 실습 1의 질문 30개를 그대로 씁니다 |
| 2. 프롬프트 | [문서] 아래 제14조, 제15조, 제3조 | 질문 앞에 검색한 조항 3개가 붙었습니다. 정답이 든 제14조가 맨 위에 있습니다 |
| 2. 길이 | 385 글자 / 240 토큰 | 모델이 읽을 글이 385글자, 토큰(모델이 글을 자르는 단위)으로 240개입니다. 외부 서비스를 쓰면 이 토큰 수만큼 요금이 붙습니다 |
| 3. 휴대폰 결제 | "50만원까지 무이자 할부가 가능하며…" | "50만원까지"는 맞혔지만 옆 조항의 "무이자 할부"를 섞어 틀린 문장을 만들었습니다. 열쇠 낱말이 있어 정답으로 셉니다 |
| 3. 골드 등급 | "60만원 이상의 산 회원이어야 합니다" | 문서 없이는 금 이야기를 했는데, 문서를 붙이자 금액을 바로 답했습니다 |
| 3. 새벽 배송 | "제4조(해외 배송)에 따르면…" | 문서에 없는 질문인데, 엉뚱한 해외 배송 조항을 끌어와 답했습니다 |
| 4. 문서에 있는 질문 | '정답': 17, '오답': 6, '거절': 1 | 정답이 실습 1의 2개에서 17개로 늘었습니다. 거절 1개는 답이 있는데도 찾지 못했다고 한 것입니다 |
| 4. 문서에 없는 질문 | '지어냄': 6 | "없으면 찾지 못했다고 하라"고 시켰지만 6문제 모두 여전히 지어냈습니다 |
한 줄로 줄이면, 근거를 찾아 붙여 주자 같은 모델의 정답이 24문제 중 2개에서 17개로 뛰었다는 것이 이 실습의 결과입니다. 하지만 0.5B(파라미터 약 5억 개) 모델은 문서에 답이 없을 때 모른다고 하지 못했고, 붙인 조항을 섞기도 했습니다. 실습 5, 6에서 같은 일을 Ollama 로 옮기고 더 큰 모델로 바꿔 끼워 이 약점이 나아지는지 봅니다. 열쇠 낱말 채점이라 정답 17개 안에도 휴대폰 결제처럼 뜻이 틀린 답이 섞여 있습니다.
코드 설명
build_messages()는 본문의 함수에search()를 넣어, 질문만 주면 검색한 조항 3개를 붙인 메시지를 만듭니다. 검색, 증강이 이 함수 안에서, 생성이chat()안에서 일어납니다.- 조항 3개를 붙인 프롬프트는 385글자, 240토큰입니다. 질문만 보낼 때보다 몇 배 길고, 외부 API 라면 이 토큰 수만큼 비용이 듭니다.
- 문서를 붙이자 골드 등급은 "60만원 이상"으로 바르게 답합니다. 휴대폰 결제는 "50만원까지"를 말했지만 붙여 준 제15조의 "무이자 할부"를 섞어 틀린 문장을 만들었고, 열쇠 낱말이 있어 정답으로 셉니다. 새벽 배송은 엉뚱하게 해외 배송 조항을 끌어와 답합니다. 0.5B 모델은 문서를 읽어도 섞고 지어냅니다.
- 문서에 있는 질문은 정답이 2개에서 17개로 늘었습니다(오답 6, 거절 1). 그러나 문서에 없는 6개는 여전히 모두 지어냈습니다. "없으면 찾지 못했다고 답하라"는 지시를 0.5B 모델은 따르지 못합니다.