12장. 멀티모달 RAG 와 데이터셋 자동 생성
실습 5 — 안내서로 질문-답 데이터 만들기
고객 안내서(care_guide.md, 절 12개, 지어낸 것)를 절마다 이 PC 의 Ollama(qwen2.5:3b)에 주고 질문-답을 4쌍씩 만들게 합니다. 씨앗을 바꿔 두 번 만들어 out_generated_raw.jsonl 로 저장합니다. 거르기는 실습 6에서 합니다.
1단계. 안내서를 절로 나누기
import json, re, time, urllib.request
from openai import OpenAI
# 앞 실습의 비전 모델과 글 모델을 내린다. GPU 를 비우고, 오래 쓴 모델이 깨진 답을 내는 일을 막으려고(10차시)
for m in ("qwen2.5vl:3b", "qwen2.5: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()
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)]
print(len(sections), "절:", [t for t, _ in sections])12 절: ['배송', '교환과 반품', '환불', '머그컵 관리', '우산 관리', '운동화 세탁', '배낭 방수', '티셔츠 세탁', '모자 관리', '스탠드 조명', '화분 관리', '적립금과 포장']2단계. 예시를 붙인 생성 지시
llm = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
SYSTEM = ("당신은 쇼핑몰 상담원 교육 자료를 만드는 사람입니다. 주어진 글에 있는 사실로만 고객 질문과 상담원 답을 만듭니다. "
'JSON 으로만 답합니다. 꼴: {"쌍": [{"질문": "...", "답": "..."}]}')
# 다른 주제의 예시 한 묶음(퓨샷). 꼴과 말투를 보여 준다
EX_IN = "주차: 매장 주차장은 2시간까지 무료입니다. 그 뒤로는 30분에 1,000원입니다."
EX_OUT = json.dumps({"쌍": [{"질문": "주차는 몇 시간까지 무료예요?", "답": "매장 주차장은 2시간까지 무료입니다."},
{"질문": "2시간이 넘으면 주차비가 얼마예요?", "답": "2시간이 지나면 30분에 1,000원입니다."}]}, ensure_ascii=False)
PAIR = re.compile(r'"질문"\s*:\s*"([^"]+)"\s*,\s*"답"\s*:\s*"([^"]+)"') # 깨진 JSON 에서 쌍만 골라 읽는 꼴
stats = {"그대로 읽음": 0, "고쳐 읽음": 0, "다시 물음": 0, "실패": 0}
def parse(content):
"""JSON 으로 읽어 보고, 깨졌으면 정규식으로 질문-답 쌍만 골라 읽는다. 돌려주는 것: (쌍 목록, 읽은 방법)"""
try:
pairs = json.loads(content)["쌍"]
if isinstance(pairs, list) and pairs:
return [p for p in pairs if isinstance(p, dict)], "그대로 읽음"
except (json.JSONDecodeError, KeyError, TypeError):
pass
found = [{"질문": q, "답": a} for q, a in PAIR.findall(content)]
return found, "고쳐 읽음" if found else "실패"
def generate(title, body, seed):
"""절 하나로 질문-답을 4쌍 만들게 한다. 쌍을 하나도 못 읽으면 씨앗을 바꿔 한 번 더 묻는다"""
messages = [{"role": "system", "content": SYSTEM},
{"role": "user", "content": "글: " + EX_IN + "\n질문과 답을 2쌍 만드세요."},
{"role": "assistant", "content": EX_OUT},
{"role": "user", "content": f"글: {title}: {body}\n질문과 답을 4쌍 만드세요. 쌍마다 다른 사실을 묻습니다."}]
for attempt in range(2):
reply = llm.chat.completions.create(model="qwen2.5:3b", temperature=0.3, seed=seed + attempt * 100,
response_format={"type": "json_object"}, messages=messages)
pairs, how = parse(reply.choices[0].message.content)
if pairs:
stats[how] += 1
return pairs
stats["다시 물음"] += attempt == 0
stats["실패"] += 1
return []
print(json.dumps(generate(*sections[0], seed=42), ensure_ascii=False, indent=1))[
{
"질문": "4만원 이상 주문시 배송비는 없나요?",
"답": "네, 4만원 이상 주문시 배송비는 없습니다."
},
{
"질문": "평일 오후 2시까지 주문하면 언제까지 배송이 가능하나요?",
"답": "평일 오후 2시 전에 주문하면 그날 배송이 가능합니다."
},
{
"질문": "배송비는 얼마인가요?",
"답": "배송비는 3,500원입니다."
},
{
"질문": "주문이 들어오면 언제까지 배송이 가능할까요?",
"답": "평일 오후 2시까지 주문하면 받기까지 보통 2일 걸립니다."
}
]3단계. 모든 절을 두 번씩 만들기
stats = {k: 0 for k in stats} # 2단계의 시험 호출은 세지 않는다
raw = []
t = time.time()
for seed in (42, 7): # 씨앗을 바꿔 두 번 만든다
for title, body in sections:
for p in generate(title, body, seed):
raw.append({"출처": title, "질문": str(p.get("질문", "")).strip(), "답": str(p.get("답", "")).strip(), "씨앗": seed})
print(f"만든 쌍: {len(raw)} · {time.time() - t:.0f}초")
print("절 24번의 읽기:", stats)
with open("out_generated_raw.jsonl", "w", encoding="utf-8") as fp:
fp.writelines(json.dumps(r, ensure_ascii=False) + "\n" for r in raw)만든 쌍: 66 · 179초
절 24번의 읽기: {'그대로 읽음': 22, '고쳐 읽음': 0, '다시 물음': 2, '실패': 2}4단계. 만든 것 훑어보기
import pandas as pd
df = pd.DataFrame(raw)
print(df.groupby("출처").size().rename("쌍 수").to_dict())
for _, r in df.sample(8, random_state=0).iterrows(): # 무작위로 8쌍을 읽어 본다
print(f" [{r['출처']}] {r['질문']} → {r['답']}"){'교환과 반품': 8, '머그컵 관리': 6, '모자 관리': 4, '배낭 방수': 4, '배송': 8, '스탠드 조명': 4, '우산 관리': 4, '운동화 세탁': 8, '적립금과 포장': 4, '티셔츠 세탁': 6, '화분 관리': 4, '환불': 6}
[환불] 반품된 상품이 창고에 도착하면 언제 돌려드립니다? → 반품된 상품이 창고에 도착하면 2영업일 안에 돌려드립니다.
[화분 관리] 화분은 어디에 놓아야 하는지 물어봅니다. → 화분은 밝은 그늘에 놓아 주세요.
[화분 관리] 화분에 물을 주는 주기가 어떻게 바뀌는지 물어봅니다. → 화분에 물을 주는 주기는 흙이 마르면 바뀝니다.
[운동화 세탁] 세제를 어떻게 풀어 사용하나요? → 세제는 미지근한 물에 풀어 사용합니다.
[모자 관리] 모자는 어떤 물건에 말립을까요? → 모자는 챙이 휘지 않도록 둥근 물건에 씌워 말립니다.
[적립금과 포장] 적립금이 얼마나 되면 선물 포장비를 무료로 주나요? → 적립금이 결제 금액의 2%가 되면 선물 포장비를 무료로 제공합니다.
[우산 관리] 우산이 부러지면 언제까지 무료로 고쳐드릴까요? → 산 날부터 1년 안에는 무료로 고쳐 드립니다.
[머그컵 관리] 머그컵이 전자레인지에 맞는지 알고 싶어요. → 머그컵은 전자레인지에 써도 됩니다.이 실습이 뜻하는 것
이 실습은 교재를 주고 조교에게 연습 문제를 내게 하는 일과 같습니다. 교재를 단원 12개로 나누고(1단계), 다른 주제의 예시 문제 한 묶음을 보여 주며 같은 꼴로 내 달라고 부탁합니다(2단계). 단원마다 두 번씩, 매번 조금 다르게 문제를 내게 해 모으고(3단계), 몇 개를 골라 읽어 봅니다(4단계). 여기서 교재가 care_guide.md, 조교가 qwen2.5:3b, 예시 문제가 퓨샷(few-shot, 예시 몇 개) 예시, 매번 조금 다르게 하는 것이 씨앗(seed)을 바꾸는 것입니다. 비유와 다른 점은, 조교라면 교재에 없는 내용을 지어내지 않으리라 믿지만 모델은 지어내기도 한다는 것입니다(4단계).
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 절 나누기 | 12 절: ['배송', '교환과 반품', …, '적립금과 포장'] | 안내서가 주제 12개로 나뉘었습니다 |
| 2. 시험 삼아 | 질문-답 4쌍 | 배송 절 하나를 주자 JSON(JavaScript Object Notation) 꼴로 질문과 답이 4쌍 나왔습니다. 안내서와 맞는지는 실습 6에서 거릅니다 |
| 3. 모두 만들기 | 만든 쌍: 66 · 179초 | 절 12개를 두 번씩, 모두 24번 물어 66쌍을 3분쯤에 만들었습니다 |
| 3. 읽은 결과 | '그대로 읽음': 22, '고쳐 읽음': 0, '다시 물음': 2, '실패': 2 | 24번 가운데 22번은 JSON 을 바로 읽었습니다. 2번은 쌍을 하나도 못 읽어 다시 물었지만 그래도 실패했습니다 |
| 4. 절마다 | {'교환과 반품': 8, …, '환불': 6} | 절마다 4~8쌍으로, 고르게 나오지 않았습니다 |
| 4. 훑어보기 | …물어봅니다. →, 적립금이 결제 금액의 2%가 되면 … | "…물어봅니다."로 끝나는 어색한 질문과, 안내서에 없는 사실을 지어낸 답이 섞여 있습니다 |
한 줄로 줄이면, 문서 하나를 주면 LLM 이 몇 분 만에 질문-답 데이터 수십 쌍을 만들어 주지만, 그대로 쓰기에는 어색하거나 지어낸 쌍이 섞여 있다는 실습입니다. 실습 6에서 이 66쌍을 네 기준으로 거릅니다. 66쌍과 걸린 시간은 이 PC 에서 한 번 돌린 결과라, 다시 돌리면 쌍 수가 달라질 수 있습니다(코드 설명의 처음 실행은 18쌍이었습니다).
코드 설명
- 지시는 10장의 퓨샷 방법을 씁니다. 다른 주제(주차)의 예시를 user, assistant 차례로 보여 주고,
response_format={"type": "json_object"}로 JSON 만 받습니다.parse()는 JSON 이 깨지면 정규식으로"질문": "…", "답": "…"쌍만 골라 읽습니다. - 절 12개를 두 번씩 물어 66쌍을 만들었고 3분쯤 걸렸습니다. 24번 가운데 22번은 JSON 을 그대로 읽었고, 2번은 쌍을 하나도 못 읽어 다시 물었지만 그래도 실패했습니다. 교안을 만들며 처음 돌렸을 때는 Ollama 모델을 다시 올리지 않고 비전 모델 다음에 바로 이어 썼는데, 24번 가운데 18번이 깨지거나 비어 18쌍만 나왔습니다. 그래서 1단계에서 두 모델을 내렸다 다시 올리고, 고쳐 읽기와 다시 묻기를 넣었습니다.
- 4단계에서 8쌍을 읽어 보면 쓸 만한 쌍도 있지만, "…물어봅니다."로 끝나는 어색한 질문과, "적립금이 결제 금액의 2%가 되면 선물 포장비를 무료로 제공합니다"처럼 안내서에 없는 사실을 지어낸 답이 섞여 있습니다. 생성 데이터는 반드시 거르고 읽어 봐야 합니다.
temperature=0.3과seed는 같은 지시에서도 조금씩 다른 쌍이 나오게 하면서, 다시 돌려도 비슷한 결과가 나오게 하려는 값입니다. 씨앗을 둘로 해서 두 번 만들면 한 번에 빠진 사실을 다른 번이 채워 줄 수 있습니다.