6장. RAG 의 원리와 로컬 모델로 옮기기
실습 3 — 키워드 검색 만들기
실습 2의 조항 25개로 TF-IDF 키워드 검색기를 만들고, 문서에 있는 질문 24개에서 정답 조항을 얼마나 잘 찾는지 잽니다. scikit-learn 만 씁니다.
1단계. TF-IDF 표 만들기
import json
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
# 실습 2와 같은 준비: 빈 줄로 조항을 나눈다
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")]
# 띄어쓰기 안에서 2~3 글자 조각을 만들어 TF-IDF 무게를 매긴다
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3))
X = vec.fit_transform(chunks)
print("표의 모양(조항 수, 글자 조각 수):", X.shape)
print("질문이 나뉘는 조각:", vec.build_analyzer()("휴대폰 한도")[:8])표의 모양(조항 수, 글자 조각 수): (25, 1490)
질문이 나뉘는 조각: [' 휴', '휴대', '대폰', '폰 ', ' 휴대', '휴대폰', '대폰 ', ' 한']2단계. 질문 하나 검색하기
def title(c):
"""조각 맨 앞의 '제14조(결제 수단)' 부분만 돌려준다"""
return c[:c.index(")") + 1]
def search(question, k=3):
"""질문과 코사인 유사도가 높은 조항 k 개의 (번호, 점수)를 돌려준다. 번호는 0 부터"""
scores = cosine_similarity(vec.transform([question]), X)[0]
top = scores.argsort()[::-1][:k] # 점수가 높은 순서
return [(int(i), round(float(scores[i]), 3)) for i in top]
for i, s in search("휴대폰으로 결제하면 한도가 있나요?"):
print(s, title(chunks[i]))0.376 제14조(결제 수단)
0.168 제15조(무이자 할부)
0.164 제3조(출고 마감)3단계. 24문항으로 재기
hit1 = hit3 = 0
misses = []
for r in qa:
if r["근거조항"] == 0: # 문서에 없는 질문은 여기서 빼고 센다
continue
found = [i + 1 for i, _ in search(r["질문"])] # 조항 번호는 1 부터라 1 을 더한다
hit1 += found[0] == r["근거조항"] # 첫째 자리에 정답 조항이 있나
hit3 += r["근거조항"] in found # 위 3개 안에 있나
if r["근거조항"] not in found:
misses.append((r["질문"], [title(chunks[i - 1]) for i in found]))
print(f"hit@1 {hit1}/24 = {hit1 / 24:.3f} · hit@3 {hit3}/24 = {hit3 / 24:.3f}")
for q, got in misses:
print("놓친 질문:", q, "→", got)hit@1 19/24 = 0.792 · hit@3 22/24 = 0.917
놓친 질문: 토요일에도 상품을 보내 주나요? → ['제11조(반품)', '제8조(교환 기한)', '제21조(대량 구매)']
놓친 질문: 마음이 바뀌어 교환하면 비용이 얼마예요? → ['제8조(교환 기한)', '제10조(교환과 반품이 안 되는 경우)', '제7조(배송지 변경)']4단계. 낱말 단위와 견주고, 없는 질문의 점수 보기
# 낱말(띄어쓰기) 단위로 나누면 조사 때문에 잘 겹치지 않는다
wvec = TfidfVectorizer(analyzer="word")
WX = wvec.fit_transform(chunks)
w1 = w3 = 0
for r in qa:
if r["근거조항"]:
top = (cosine_similarity(wvec.transform([r["질문"]]), WX)[0].argsort()[::-1][:3] + 1).tolist()
w1 += top[0] == r["근거조항"]
w3 += r["근거조항"] in top
print(f"낱말 단위: hit@1 {w1}/24 · hit@3 {w3}/24")
# 첫째 자리 점수: 문서에 있는 질문과 없는 질문을 견준다
inside = [search(r["질문"])[0][1] for r in qa if r["근거조항"]]
outside = [search(r["질문"])[0][1] for r in qa if not r["근거조항"]]
print("있는 질문의 첫째 점수: 가장 낮은", min(inside), "· 없는 질문의 첫째 점수: 가장 높은", max(outside))낱말 단위: hit@1 10/24 · hit@3 13/24
있는 질문의 첫째 점수: 가장 낮은 0.129 · 없는 질문의 첫째 점수: 가장 높은 0.268이 실습이 뜻하는 것
이 실습은 도서관 사서가 책 뒤의 찾아보기(색인)로 질문에 맞는 쪽을 찾아 주는 것과 같습니다. 조항마다 어떤 글자 조각이 얼마나 특별하게 들어 있는지 색인표를 만들고(1단계), 질문 하나로 가장 비슷한 조항 3개를 찾아보고(2단계), 답이 있는 질문 24개로 몇 번 맞게 찾는지 재고(3단계), 낱말 단위 색인과 견주면서 답이 없는 질문의 점수도 봅니다(4단계). 여기서 색인표가 TF-IDF(Term Frequency-Inverse Document Frequency, 단어 빈도-역문서 빈도) 표, 비슷한 정도가 코사인 유사도, 맞게 찾은 비율이 hit@k(위 k개 안에 정답이 든 비율)입니다. 비유와 다른 점은, 사서는 뜻을 헤아려 찾지만 이 색인은 글자가 겹치는지만 본다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 표 만들기 | (25, 1490) | 조항 25개를 서로 다른 글자 조각 1,490가지로 나타낸 표입니다. 칸마다 그 조각의 무게가 들어갑니다 |
| 1. 질문 나누기 | [' 휴', '휴대', '대폰', …] | 질문도 같은 2~3글자 조각으로 잘라 표와 견줍니다. 그래서 "휴대폰으로"와 "휴대폰"처럼 끝이 달라도 "휴대", "대폰"이 겹칩니다 |
| 2. 검색 하나 | 0.376 제14조(결제 수단) | 휴대폰 결제 질문에 정답 조항이 1등으로 나왔습니다. 2, 3등(0.168, 0.164)은 "결제"라는 글자만 겹친 조항입니다 |
| 3. 24문항 | hit@1 19/24 = 0.792 · hit@3 22/24 = 0.917 | 1등에 정답이 온 것이 24문제 중 19개(약 79%), 위 3개 안에 든 것이 22개(약 92%)입니다 |
| 3. 놓친 질문 | 토요일 → 제11조(반품) …, 마음이 바뀌어 → 제8조(교환 기한) … | 문서는 "주말", "단순 변심"이라고 쓰고 질문은 "토요일", "마음이 바뀌어"라고 써서 글자가 겹치지 않았습니다 |
| 4. 낱말 단위 | hit@1 10/24 · hit@3 13/24 | 띄어쓰기로 자른 낱말로 색인하면 크게 떨어집니다. "결제하면"과 "결제를"이 서로 다른 낱말이 되기 때문입니다 |
| 4. 점수 견주기 | 가장 낮은 0.129 · 가장 높은 0.268 | 답이 있는 질문의 1등 점수가 0.129 까지 낮고, 답이 없는 질문의 1등 점수가 0.268 까지 높습니다. 점수가 겹쳐서 "몇 점 아래면 문서에 없다"는 선을 그을 수 없습니다 |
한 줄로 줄이면, 글자 조각 검색만으로도 답이 있는 질문의 92% 에서 정답 조항을 위 3개 안에 찾아 준다는 것을 보인 실습입니다. 실습 4는 이 위 3개를 모델에 붙여 답하게 합니다. 남은 둘처럼 뜻은 같고 낱말이 다른 질문은 이 방법으로 찾지 못하며, 그 해결은 7장의 의미 검색에서 다룹니다. 24문제로 잰 값이라 한 문제에 비율이 4%포인트쯤 움직이니, 소수 셋째 자리까지 믿지는 않습니다.
코드 설명
X.shape는 (25, 1490)으로, 조항 25개를 글자 조각 1,490가지의 무게로 나타냈습니다. "휴대폰 한도"는 " 휴", "휴대", "대폰", "휴대폰" 같은 2~3 글자 조각으로 나뉩니다.- "휴대폰으로 결제하면 한도가 있나요?"의 첫째는 제14조(결제 수단, 0.376)이고, "결제"가 겹친 제15조(무이자 할부)와 제3조(출고 마감)가 0.168, 0.164 로 뒤따릅니다.
- hit@1 은 19/24(0.792), hit@3 은 22/24(0.917)입니다. 놓친 둘은 "토요일에도 상품을 보내 주나요?"(문서는 "주말"), "마음이 바뀌어 교환하면 비용이 얼마예요?"(문서는 "단순 변심")로, 뜻은 같고 낱말이 다른 질문입니다. 본문 "키워드 검색"의 주의할 점 그대로입니다.
- 낱말 단위로 나누면 hit@1 10/24, hit@3 13/24 로 크게 떨어집니다. "결제하면"과 "결제를"이 서로 다른 낱말이 되기 때문입니다. 또 문서에 있는 질문의 첫째 점수는 낮게는 0.129 인데, 문서에 없는 질문은 높게는 0.268 이라, 점수만으로 "문서에 없는 질문"을 가려낼 수 없습니다.