8장. Project 01. 내 데이터로 답하는 LLM 서비스
서비스의 짜임
택배 회사의 물류 센터는 물건 받기, 분류, 포장, 배송, 배송 확인이 줄지어 이어집니다. 칸마다 맡은 일이 정해져 있어 한 칸만 바꿔 끼울 수 있습니다. LLM 서비스도 부품을 줄지어 이어, 한 부품만 바꿔 가며 고칩니다.
1번에서 문서를 조각으로 정리하고, 2번에서 질문에 맞는 조각을 찾고, 3번에서 조각과 질문을 프롬프트로 묶고, 4번에서 LLM 이 답을 쓰고, 5번에서 답과 출처를 돌려줍니다. 부품마다 6장과 7장에서 만든 함수가 들어갑니다. 여기서 물류 센터의 칸이 서비스의 부품입니다.
부품과 바꿔 볼 것
| 부품 | 이 장의 기본 | 바꿔 볼 것 |
|---|---|---|
| 문서 정리 | 조항, 절 단위로 나누기 | 표를 행 단위 문장으로, 장 제목 붙이기 |
| 검색 | 하이브리드(의미 0.7, 키워드 0.3) | 조각 수 k, 재정렬, 후속 질문 처리 |
| 프롬프트 | 근거만 보고 답하기, 출처, 모를 때의 말 | 공지 우선 규칙, 출처 이름을 조각에 넣기 |
| LLM | Ollama qwen2.5:3b | 7장의 다른 모델 |
| 넘기기 | 모른다고 답하기 | 점수가 낮으면 담당 부서 안내 |
공통 검색 부품
세 프로젝트의 예시 풀이는 7장의 하이브리드 검색을 작은 클래스로 묶어 씁니다. 짧은 글 셋으로 써 보면 이렇습니다.
import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
class Search:
"""7장의 하이브리드 검색: 의미 점수와 키워드 점수를 0~1 로 맞춰 alpha 대 1 - alpha 로 섞는다"""
def __init__(self, docs):
self.docs = docs
self.E = emb.encode(["passage: " + d for d in docs], normalize_embeddings=True)
self.vec = TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3))
self.X = self.vec.fit_transform(docs)
def top(self, question, k=3, alpha=0.7):
sem = self.E @ emb.encode("query: " + question, normalize_embeddings=True)
kw = cosine_similarity(self.vec.transform([question]), self.X)[0]
mm = lambda a: (a - a.min()) / (a.max() - a.min() + 1e-9) # 0~1 로 맞추기
return np.argsort(-(alpha * mm(sem) + (1 - alpha) * mm(kw)))[:k].tolist()
s = Search(["재활용품은 수요일과 일요일에 냅니다.", "헬스장은 오전 6시에 엽니다.", "관리비는 25일까지 냅니다."])
print(s.top("분리수거 하는 날이 언제예요?", k=1))[0] 이 나와, "분리수거"라는 낱말이 없어도 재활용 조각을 찾습니다. 1e-9 는 점수가 모두 같을 때 0 으로 나누지 않으려고 더한 아주 작은 수입니다.
코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
SentenceTransformer("intfloat/multilingual-e5-small", device="cpu") | 문장을 숫자 벡터로 바꾸는 임베딩 모델을 불러온다. 7장에서 쓴 작은 다국어 모델이다. device="cpu" 는 GPU 를 Ollama 에 비워 두려고 CPU 에서 돌리라는 뜻이다 |
emb.encode([...], normalize_embeddings=True) | 글을 벡터로 바꾼다. normalize_embeddings=True 는 벡터의 길이를 1 로 맞추라는 뜻이다(normalize, 정규화) |
"passage: ", "query: " | e5 모델이 바라는 머리말이다. 문서 조각에는 passage(글의 한 대목), 질문에는 query(물음)를 붙인다 |
TfidfVectorizer(analyzer="char_wb", ngram_range=(2, 3)) | 6장의 키워드 검색 도구다. TF-IDF 는 Term Frequency-Inverse Document Frequency(낱말 빈도와 역문서 빈도)의 머리글자다. char_wb 는 낱말 안에서 글자 단위로 자르라는 뜻이고, (2, 3) 은 두 글자, 세 글자 묶음을 쓰라는 뜻이다 |
fit_transform(docs), transform([question]) | 앞의 것은 문서에서 글자 묶음 목록을 배우고(fit) 바로 숫자로 바꾼다(transform). 뒤의 것은 배운 목록으로 질문만 숫자로 바꾼다 |
self.E @ emb.encode(...) | @ 는 행렬 곱이다. 길이를 1 로 맞춘 벡터끼리 곱하면 코사인 유사도(두 벡터가 같은 쪽을 가리키는 정도)가 되어, 조각마다 의미 점수가 하나씩 나온다 |
cosine_similarity(a, b)[0] | 키워드 숫자끼리의 코사인 유사도를 구한다. [0] 은 질문이 하나라 첫 줄만 꺼낸다는 뜻이다 |
np.argsort(-점수)[:k].tolist() | 점수가 큰 순서의 조각 번호를 구해 위 k 개만 목록으로 돌려준다. 빼기를 붙이면 큰 것부터 줄 선다 |
이 코드가 뜻하는 것
도서관에 사서가 둘 있다고 해 봅시다. 한 사람은 질문의 뜻을 알아듣고 책을 고르고(의미 검색), 다른 사람은 질문과 같은 글자가 든 책을 고릅니다(키워드 검색). 두 사람이 매기는 점수의 잣대가 서로 달라서, 먼저 둘 다 0~1 로 맞춘 뒤 뜻을 아는 사서의 말을 7, 글자를 맞춘 사서의 말을 3 의 비중으로 섞습니다. 여기서 두 사서가 sem 과 kw 이고, 비중 7 이 alpha=0.7 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
self.E = emb.encode(...) | 조각마다 의미 벡터를 미리 만들어 둔다 | 뜻을 아는 사서가 책마다 요약 카드를 써 둔다 |
self.X = self.vec.fit_transform(docs) | 조각마다 글자 묶음 숫자를 미리 만들어 둔다 | 글자 사서가 책마다 낱말 색인을 만들어 둔다 |
sem = ... | 질문과 조각마다의 의미 점수를 구한다 | 뜻을 아는 사서의 점수 |
kw = ... | 질문과 조각마다의 키워드 점수를 구한다 | 글자 사서의 점수 |
mm = lambda a: ... | 가장 낮은 점수를 0, 가장 높은 점수를 1 로 늘이거나 줄인다. min-max(최솟값-최댓값) 맞추기다 | 두 사서의 잣대를 같은 100점 만점으로 바꾼다 |
alpha * mm(sem) + (1 - alpha) * mm(kw) | 0.7 대 0.3 으로 섞는다 | 두 사서의 말을 7 대 3 으로 듣는다 |
s.top("분리수거 하는 날이 언제예요?", k=1) | 섞은 점수가 가장 높은 조각 하나의 번호를 돌려준다 | 가장 알맞은 책 한 권을 건네받는다 |
결과 [0] 은 첫째 조각 "재활용품은 수요일과 일요일에 냅니다."의 번호입니다. "분리수거"라는 글자가 없어도 뜻을 아는 사서가 높은 점수를 주어 이 조각이 뽑힌 것입니다.
작게 줄여서 눈으로 보기
임베딩 모델을 부르지 않고, 점수 섞기만 숫자로 따라가 봅니다. 조각 셋의 의미 점수와 키워드 점수를 지어낸 설명용 숫자로 넣었습니다.
import numpy as np
# 조각 셋의 점수(설명용 숫자). 의미 점수와 키워드 점수는 잣대가 다르다
sem = np.array([0.82, 0.78, 0.80]) # 의미 점수: 셋이 비슷하다
kw = np.array([0.00, 0.30, 0.10]) # 키워드 점수: 겹치는 글자 조각의 정도
mm = lambda a: (a - a.min()) / (a.max() - a.min() + 1e-9) # 0~1 로 맞추기
print("의미 0~1 :", mm(sem).round(3))
print("키워드 0~1:", mm(kw).round(3))
mix = 0.7 * mm(sem) + 0.3 * mm(kw)
print("섞은 점수 :", mix.round(3))
print("높은 순서 :", np.argsort(-mix).tolist())의미 0~1 : [1. 0. 0.5]
키워드 0~1: [0. 1. 0.333]
섞은 점수 : [0.7 0.3 0.45]
높은 순서 : [0, 2, 1]| 결과 | 읽는 법 |
|---|---|
의미 [1. 0. 0.5] | 0.82, 0.78, 0.80 처럼 붙어 있던 점수가 가장 높은 것은 1, 가장 낮은 것은 0 으로 벌어졌다 |
키워드 [0. 1. 0.333] | 0.30 이 1, 0.00 이 0 이 되고, 0.10 은 그 사이의 3분의 1 자리인 0.333 이 되었다 |
섞은 점수 [0.7 0.3 0.45] | 0번은 0.7×1 + 0.3×0 = 0.7, 2번은 0.7×0.5 + 0.3×0.333 = 0.45 다 |
높은 순서 [0, 2, 1] | 키워드에서 1등인 1번 조각보다 의미에서 1등인 0번 조각이 앞선다. 의미 쪽 비중이 0.7 이라서다 |