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

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

키워드 검색

도서관 책의 맨 뒤에는 "찾아보기"가 있습니다. "적립금"을 찾으면 그 낱말이 나오는 쪽이 적혀 있습니다. "그리고", "있습니다"처럼 어디에나 나오는 말은 찾아보기에 올리지 않습니다. 키워드 검색은 이 찾아보기를 컴퓨터로 만드는 것입니다.

질문과 조항을 글자 조각으로 나누고 TF-IDF 벡터로 바꾼 뒤 코사인 유사도가 높은 조항을 고르는 흐름도

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)) 가 이렇게 나눕니다.

작은 예로 점수 보기

Python
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 를 봅니다.

Python
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에서 문서에 없는 질문의 점수를 함께 봅니다.
← 이전 절6장 · 문서를 조각으로 나누기다음 절 →6장 · 근거를 붙인 프롬프트