LLM 엔지니어링6장 · RAG 의 원리와 로컬 모델로 옮기기17 / 23← 이전목차다음 →MetaContents

6장. RAG 의 원리와 로컬 모델로 옮기기

실습 3 — 키워드 검색 만들기

실습 2의 조항 25개로 TF-IDF 키워드 검색기를 만들고, 문서에 있는 질문 24개에서 정답 조항을 얼마나 잘 찾는지 잽니다. scikit-learn 만 씁니다.

1단계. TF-IDF 표 만들기

Python
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단계. 질문 하나 검색하기

Python
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문항으로 재기

Python
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단계. 낱말 단위와 견주고, 없는 질문의 점수 보기

Python
# 낱말(띄어쓰기) 단위로 나누면 조사 때문에 잘 겹치지 않는다
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.9171등에 정답이 온 것이 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 이라, 점수만으로 "문서에 없는 질문"을 가려낼 수 없습니다.
← 이전 절6장 · 실습 2 — 정책 문서를 조각으로 나누기다음 절 →6장 · 실습 4 — 근거를 붙여 답하게 하기