12장. 멀티모달 RAG 와 데이터셋 자동 생성
실습 6 — 생성 데이터 거르고 나누기
실습 5의 out_generated_raw.jsonl 을 꼴, 글자, 근거, 중복의 네 기준으로 거르고, 사람이 쓴 시험 질문(care_test.jsonl, 12개)의 사실이 남은 데이터에 들어 있는지, 시험 질문과 겹치는 학습 질문이 없는지 확인합니다. 남은 데이터는 채팅 꼴로 out_sft_train.jsonl 에, 만든 기록은 out_dataset_card.json 에 저장합니다.
1단계. 꼴과 글자로 거르기
import json, re
import pandas as pd
raw = pd.DataFrame([json.loads(line) for line in open("out_generated_raw.jsonl", encoding="utf-8")])
text = open("care_guide.md", encoding="utf-8").read()
source = {m.group(1): m.group(2).strip() for m in re.finditer(r"## (.+)\n\n(.+)", text)} # 절 제목 → 본문
count = {"만든 쌍": len(raw)}
# 꼴: 질문은 5글자 이상, 답은 비지 않게
df = raw[(raw["질문"].str.len() >= 5) & (raw["답"].str.len() > 0)]
count["꼴 통과"] = len(df)
# 글자: 한자, 일본 가나, 러시아 문자(키릴)가 섞이면 뺀다
FOREIGN = re.compile(r"[一-鿿-ヿЀ-ӿ]")
bad_chars = df[df["질문"].str.contains(FOREIGN) | df["답"].str.contains(FOREIGN)]
df = df.drop(bad_chars.index)
count["글자 통과"] = len(df)
print(count)
for _, r in bad_chars.iterrows():
print(" 글자로 걸림:", r["질문"], "→", r["답"]){'만든 쌍': 66, '꼴 통과': 66, '글자 통과': 64}
글자로 걸림: 스탠드 조명에 쓰이는 LED 전구의 최대功率는 얼마인가요? → 스탠드에는 E26 규격의 LED 전구를 씁니다. 10W 가 넘는 전구는 쓰지 않습니다.
글자로 걸림: 화분에 물을 주는 주기是什么? → 화분에 물을 주는 주기는 일주일에 한 번입니다.2단계. 근거로 거르기
def grounded(answer, src):
"""답의 숫자가 모두 원래 절에 있고, 답의 두 글자 묶음이 절반 넘게 원래 절에 있으면 True"""
nums = re.findall(r"\d[\d,]*", answer)
if any(n.replace(",", "") not in src.replace(",", "") for n in nums):
return False
a, s = answer.replace(" ", ""), src.replace(" ", "")
pairs = [a[i:i + 2] for i in range(len(a) - 1)]
return sum(p in s for p in pairs) / max(len(pairs), 1) > 0.5
ok = [grounded(a, source[t]) for a, t in zip(df["답"], df["출처"])]
not_grounded = df[[not x for x in ok]]
df = df[ok]
count["근거 통과"] = len(df)
print(count)
for _, r in not_grounded.head(6).iterrows():
print(f" 근거로 걸림: [{r['출처']}] {r['질문']} → {r['답']}"){'만든 쌍': 66, '꼴 통과': 66, '글자 통과': 64, '근거 통과': 55}
근거로 걸림: [운동화 세탁] 세탁 과정에서 어떤 물을 사용하나요? → 세탁 과정에서 미지근한 물을 사용합니다.
근거로 걸림: [운동화 세탁] 세제는 어떤 종류를 사용하나요? → 세탁 과정에서 중성세제를 사용합니다.
근거로 걸림: [티셔츠 세탁] 처음 세 번은 다른 옷과 따로 빵니다. 왜 그럴까요? → 처음 세 번은 다른 옷과 따로 빵니다. 이렇게 하면 세탁물이 서로 오염되지 않아집니다.
근거로 걸림: [화분 관리] 화분에 물을 주려면 흙이 얼마나 마르는지 물어봅니다. → 화분에 물을 주려면 흙이 마르면 흠뻑 주세요.
근거로 걸림: [화분 관리] 화분은 어디에 놓아야 하는지 물어봅니다. → 화분은 밝은 그늘에 놓아 주세요.
근거로 걸림: [화분 관리] 화분에 물을 주는 주기가 어떻게 바뀌는지 물어봅니다. → 화분에 물을 주는 주기는 흙이 마르면 바뀝니다.3단계. 비슷한 질문 하나만 남기기
import numpy as np
from sentence_transformers import SentenceTransformer
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
Q = emb.encode(["query: " + q for q in df["질문"]], normalize_embeddings=True)
keep = []
for i in range(len(df)): # 앞에서부터 보며, 이미 남긴 질문과 0.92 이상 닮았으면 뺀다
if not keep or (Q[keep] @ Q[i]).max() < 0.92:
keep.append(i)
dups = df.iloc[[i for i in range(len(df)) if i not in keep]]
df = df.iloc[keep]
count["중복 통과"] = len(df)
print(count)
for _, r in dups.head(4).iterrows():
print(" 중복으로 걸림:", r["질문"]){'만든 쌍': 66, '꼴 통과': 66, '글자 통과': 64, '근거 통과': 55, '중복 통과': 29}
중복으로 걸림: 배송비는 얼마인가요?
중복으로 걸림: 4만원 이상 주문시 배송비는 없나요?
중복으로 걸림: 상품에 흠이 없더라도 14일 안에 교환과 반품을 신청해야 하는 이유는 무엇인가요?
중복으로 걸림: 반품한 상품이 창고에 도착하면 결제한 금액으로 돌려받을 수 있나요?4단계. 시험 사실이 남았는지, 누출은 없는지 보고 저장하기
test = [json.loads(line) for line in open("care_test.jsonl", encoding="utf-8")]
norm = lambda s: s.replace(" ", "").replace(",", "")
# 시험 질문의 열쇠(정답 낱말)가 학습 답 가운데 하나에라도 있으면 "그 사실을 배울 수 있다"
covered = [t for t in test if any(norm(t["열쇠"]) in norm(a) for a in df["답"])]
print(f"시험 사실이 학습 데이터에 있음: {len(covered)} / {len(test)}")
print(" 빠진 사실:", [t["열쇠"] for t in test if t not in covered])
# 누출: 띄어쓰기와 물음표만 다른 같은 질문이 학습 데이터에 있나
train_set = {norm(q).rstrip("?") for q in df["질문"]}
print("시험과 겹친 학습 질문:", [t["질문"] for t in test if norm(t["질문"]).rstrip("?") in train_set])
SYSTEM = "당신은 솔빛생활(가상 쇼핑몰)의 상담원입니다."
with open("out_sft_train.jsonl", "w", encoding="utf-8") as fp:
for _, r in df.iterrows():
fp.write(json.dumps({"messages": [{"role": "system", "content": SYSTEM}, {"role": "user", "content": r["질문"]},
{"role": "assistant", "content": r["답"]}], "출처": r["출처"]}, ensure_ascii=False) + "\n")
card = {"원래 문서": "care_guide.md (절 12개)", "생성 모델": "qwen2.5:3b (Ollama)", "온도": 0.3, "씨앗": [42, 7],
"거르기": count, "시험 묶음": "care_test.jsonl (사람이 쓴 12개)", "시험 사실이 든 수": len(covered)}
json.dump(card, open("out_dataset_card.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(json.dumps(card, ensure_ascii=False))시험 사실이 학습 데이터에 있음: 9 / 12
빠진 사실: ['일주일', '2%', '1500원']
시험과 겹친 학습 질문: []
{"원래 문서": "care_guide.md (절 12개)", "생성 모델": "qwen2.5:3b (Ollama)", "온도": 0.3, "씨앗": [42, 7], "거르기": {"만든 쌍": 66, "꼴 통과": 66, "글자 통과": 64, "근거 통과": 55, "중복 통과": 29}, "시험 묶음": "care_test.jsonl (사람이 쓴 12개)", "시험 사실이 든 수": 9}이 실습이 뜻하는 것
이 실습은 조교가 낸 연습 문제를 선생님이 검수하는 일과 같습니다. 문제의 꼴과 글자(외국 글자가 섞였는지)를 보고(1단계), 답이 교재에 실제로 있는 말인지 대조하고(2단계), 거의 같은 문제는 하나만 남깁니다(3단계). 끝으로 기말 시험 문제의 내용이 연습 문제에 들어 있는지, 시험 문제가 그대로 새어 나가지 않았는지 보고 문제집과 검수 기록을 저장합니다(4단계). 여기서 기말 시험이 care_test.jsonl, 문제집이 out_sft_train.jsonl, 검수 기록이 데이터셋 카드 out_dataset_card.json, 시험 문제가 새는 것이 누출입니다. 비유와 다른 점은, 선생님은 뜻을 읽고 판단하지만 2단계 규칙은 숫자와 두 글자 묶음만 견주므로 뜻이 맞는 답을 버리기도, 틀린 답을 통과시키기도 한다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 꼴과 글자 | '만든 쌍': 66, '꼴 통과': 66, '글자 통과': 64 | 꼴로는 하나도 안 걸렸고, "최대功率", "주기是什么"처럼 중국어가 섞인 2쌍이 빠졌습니다 |
| 2. 근거 | '근거 통과': 55 | 9쌍이 빠졌습니다. "화분은 밝은 그늘에 놓아 주세요"처럼 말을 많이 바꾼 답도 걸렸습니다 |
| 3. 중복 | '중복 통과': 29 | 26쌍이 빠져 절반 남짓만 남았습니다. 씨앗을 바꿔 두 번 만들어 같은 사실을 비슷하게 물은 질문이 많았기 때문입니다 |
| 4. 시험 사실 | 9 / 12, 빠진 사실 ['일주일', '2%', '1500원'] | 시험 질문 12개 가운데 9개의 정답 낱말이 학습 데이터에 있습니다. 빠진 셋은 미세조정해도 배울 수 없습니다 |
| 4. 누출 | 시험과 겹친 학습 질문: [] | 띄어쓰기와 물음표만 다른 같은 질문은 학습 데이터에 없었습니다 |
| 4. 카드 | {"원래 문서": …, "시험 사실이 든 수": 9} | 무엇으로, 어떤 설정으로 만들고 단계마다 몇 쌍이 남았는지를 한 파일에 남겼습니다 |
한 줄로 줄이면, 만든 66쌍 가운데 29쌍만 남았고, 그 29쌍으로는 시험 사실 12개 가운데 9개만 배울 수 있다는 것을 미리 확인한 실습입니다. 프로젝트 1에서 이 29쌍으로 실제로 미세조정해 봅니다. 통과한 29쌍이 모두 옳다는 뜻은 아닙니다. 코드 설명의 "7,000원" 예처럼 숫자와 낱말이 맞아도 틀린 사실이 남을 수 있고, 누출 확인도 글자가 거의 같은 질문만 찾습니다.
코드 설명
- 1단계에서 꼴로는 하나도 걸리지 않았고, 글자로 2쌍이 걸렸습니다. "최대功率", "주기是什么"처럼 중국어가 섞인 질문입니다. 정규식의
\u4e00-\u9fff는 한자,\u3040-\u30ff는 일본 가나,\u0400-\u04ff는 키릴 문자의 범위입니다. - 2단계에서 근거로 9쌍이 걸려 55쌍이 남았습니다. "화분은 밝은 그늘에 놓아 주세요"처럼 뜻은 맞지만 말을 많이 바꾼 답도 걸렸습니다. 두 글자 묶음을 견주는 단순한 규칙이라 뜻이 맞는 답을 버리기도 하고, 반대로 틀린 답을 통과시키기도 합니다. 남은 데이터의 "상품에 흠이 있으면 … 솔빛생활이 왕복 배송비 7,000원을 부담합니다"는 숫자와 낱말이 모두 원래 절에 있어 통과했지만, 7,000원은 단순 변심일 때 고객이 내는 돈이라 틀린 사실입니다.
- 3단계에서 26쌍이 중복으로 걸려 29쌍이 남았습니다. 씨앗을 바꿔 두 번 만들었기 때문에 같은 사실을 비슷하게 물은 질문이 많았습니다. 다양한 데이터를 남기려는 거르기지만, 같은 사실을 여러 말로 물은 쌍은 외우는 데에는 도움이 되므로 기준(0.92)을 정할 때 이 점도 생각합니다.
- 4단계에서 시험 질문 12개의 사실 가운데 9개가 학습 데이터에 남았고, 화분 물 주기(일주일), 적립금(2%), 선물 포장(1,500원)은 빠졌습니다. 이 셋은 미세조정해도 배울 수 없습니다. 시험 질문과 겹친 학습 질문은 없었습니다.