6장. RAG 의 원리와 로컬 모델로 옮기기
키워드 검색
도서관 책의 맨 뒤에는 "찾아보기"가 있습니다. "적립금"을 찾으면 그 낱말이 나오는 쪽이 적혀 있습니다. "그리고", "있습니다"처럼 어디에나 나오는 말은 찾아보기에 올리지 않습니다. 키워드 검색은 이 찾아보기를 컴퓨터로 만드는 것입니다.
1번에서 질문과 조항을 2~3 글자 조각으로 나눕니다. 2번에서 조각마다 TF-IDF 무게를 매겨 숫자 줄(벡터)로 바꿉니다. 3번에서 질문 벡터와 조항 벡터의 코사인 유사도를 재고, 4번에서 높은 순서로 k 개를 고릅니다. 여기서 찾아보기가 TF-IDF 표, 쪽 번호가 조항 번호입니다.
TF-IDF 의 두 부분
| 부분 | 뜻 | 커지는 때 |
|---|---|---|
| TF(낱말 빈도) | 그 조각 안에 그 낱말이 몇 번 나오나 | 한 조각에 자주 나올 때 |
| IDF(역문서 빈도) | 그 낱말이 들어간 조각이 얼마나 드문가 | 다른 조각에는 거의 없을 때 |
| TF × IDF | 두 값을 곱한 무게 | 이 조각에만 자주 나오는 낱말일 때 |
한국어는 "결제하면", "결제는", "결제를"처럼 낱말 뒤에 조사와 어미가 붙어 낱말 단위로는 잘 겹치지 않습니다. 그래서 낱말이 아니라 2~3 글자 조각(n-gram)으로 나누면 "결제"가 서로 겹칩니다. scikit-learn 의 TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3)) 가 이렇게 나눕니다.
작은 예로 점수 보기
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
docs = ["휴대폰 결제는 한 달에 50만원까지입니다.",
"쿠폰은 주문 하나에 한 장만 쓸 수 있습니다.",
"골드 등급은 적립률이 2%입니다."]
# char_wb: 띄어쓰기 안에서 글자 조각을 만든다. (2, 3): 2글자와 3글자 조각
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3))
X = vec.fit_transform(docs) # 조항 3개를 숫자 줄 3개로
q = vec.transform(["휴대폰으로 결제하면 한도가 있나요?"])
print(vec.build_analyzer()("결제하면")) # 한 낱말이 어떤 조각으로 나뉘는지
print(cosine_similarity(q, X).round(3))"결제하면"은 앞뒤에 빈칸이 붙은 " 결", "결제", "제하", "하면", "면 "과 세 글자 조각 " 결제", "결제하" 등 9개로 나뉩니다. 점수는 휴대폰 결제 조항이 0.454 로 가장 높고, 쿠폰 조항은 " 한", " 있" 같은 흔한 조각만 겹쳐 0.082, 골드 등급 조항은 0 입니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
TfidfVectorizer(...) | 글을 TF-IDF(Term Frequency–Inverse Document Frequency, 단어 빈도–역문서 빈도) 숫자 줄로 바꾸는 도구다. vectorizer 는 "벡터로 바꾸는 것"이라는 뜻이다 |
analyzer="char_wb" | 낱말이 아니라 글자로 자른다. wb 는 word boundary(낱말 경계)의 줄임말로, 띄어쓰기를 넘어 자르지 않고 낱말 앞뒤에 빈칸을 붙인다 |
ngram_range=(2, 3) | 2글자 조각과 3글자 조각을 모두 쓴다 |
vec.fit_transform(docs) | fit 은 "조항들을 보고 조각 목록과 IDF 를 배운다", transform 은 "숫자 줄로 바꾼다"이다. 둘을 한 번에 한다 |
vec.transform([...]) | 배운 조각 목록 그대로 질문을 숫자 줄로 바꾼다. 질문으로는 새로 배우지 않는다 |
vec.build_analyzer() | 글을 조각으로 자르는 함수를 꺼내 준다. 어떻게 잘리는지 볼 때 쓴다 |
cosine_similarity(q, X) | 질문 숫자 줄과 조항 숫자 줄마다 코사인 유사도를 잰다. 결과는 1줄 3칸의 표다 |
이 코드가 뜻하는 것
책 뒤의 찾아보기를 만드는 일과 같습니다. 먼저 조항들을 읽어 찾아보기를 만들고(fit), 손님의 질문도 같은 찾아보기 말로 옮긴 뒤(transform), 질문과 가장 많이 겹치는 쪽을 찾습니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
vec = TfidfVectorizer(...) | 글자 2~3개씩 자르는 TF-IDF 도구를 만든다 | 찾아보기를 만들 규칙을 정한다 |
X = vec.fit_transform(docs) | 조항 3개를 보고 조각 목록을 만든 뒤 조항마다 숫자 줄로 바꾼다 | 찾아보기를 만들고 쪽마다 낱말 무게를 적는다 |
q = vec.transform([...]) | 질문을 같은 조각 목록으로 숫자 줄로 바꾼다 | 질문을 같은 찾아보기 말로 옮긴다 |
print(vec.build_analyzer()(...)) | "결제하면"이 어떤 조각으로 잘리는지 찍는다 | 찾아보기에 어떤 낱말이 올라가는지 본다 |
print(cosine_similarity(q, X).round(3)) | 질문과 조항 셋의 닮은 정도를 소수 셋째 자리로 찍는다 | 쪽마다 질문과 얼마나 맞는지 점수를 매긴다 |
결과의 [[0.454 0.082 0. ]] 에서 칸 하나가 조항 하나입니다. 0.454 인 휴대폰 결제 조항이 가장 잘 맞고, 겹치는 조각이 하나도 없는 골드 등급 조항은 0 입니다.
작게 줄여서 눈으로 보기
흔한 조각에는 무게를 덜 준다는 IDF 를 직접 찍어 봅니다. 같은 조항 셋으로 TF-IDF 를 배운 뒤, 조각 셋의 IDF 를 봅니다.
from sklearn.feature_extraction.text import TfidfVectorizer
docs = ["휴대폰 결제는 한 달에 50만원까지입니다.",
"쿠폰은 주문 하나에 한 장만 쓸 수 있습니다.",
"골드 등급은 적립률이 2%입니다."]
vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3))
vec.fit(docs)
vocab = vec.vocabulary_ # 조각 → 칸 번호
# 흔한 조각과 드문 조각의 IDF(드물수록 크다)
for p in ["니다", " 한", "결제"]:
print(repr(p), round(vec.idf_[vocab[p]], 3))
print("조각 종류:", len(vocab))'니다' 1.0
' 한' 1.288
'결제' 1.693
조각 종류: 104| 결과 | 읽는 법 |
|---|---|
'니다' 1.0 | 세 조항 모두에 들어 있다("입니다", "습니다"). 가장 흔해서 IDF 가 가장 작다 |
' 한' 1.288 | 두 조항에 들어 있다. 중간쯤의 무게다 |
'결제' 1.693 | 휴대폰 결제 조항 하나에만 있다. 드물어서 IDF 가 가장 크다 |
조각 종류: 104 | 조항 셋에서 나온 서로 다른 2~3글자 조각의 수다. 숫자 줄 하나의 칸 수가 104 라는 뜻이다 |
그래서 질문과 "결제"가 겹치면 "니다"가 겹칠 때보다 점수가 더 오릅니다. vec.vocabulary_ 는 조각마다 칸 번호를 적은 사전이고, vec.idf_ 는 칸마다의 IDF 값입니다.
주의할 점
- 키워드 검색은 낱말이 겹쳐야만 찾습니다. "토요일에도 보내 주나요?"는 "주말과 공휴일에는 출고하지 않습니다"와 겹치는 글자가 거의 없어 놓칩니다. 뜻으로 찾는 검색(임베딩)은 7장에서 다룹니다.
- 점수의 크기는 문서마다 달라서, "0.2 보다 낮으면 관련 없음" 같은 기준을 한 번 정해 두고 어디에나 쓰기 어렵습니다. 실습 3에서 문서에 없는 질문의 점수를 함께 봅니다.