LLM 엔지니어링8장 · Project 01. 내 데이터로 답하는 LLM 서비스5 / 11← 이전목차다음 →MetaContents

8장. Project 01. 내 데이터로 답하는 LLM 서비스

서비스의 짜임

택배 회사의 물류 센터는 물건 받기, 분류, 포장, 배송, 배송 확인이 줄지어 이어집니다. 칸마다 맡은 일이 정해져 있어 한 칸만 바꿔 끼울 수 있습니다. LLM 서비스도 부품을 줄지어 이어, 한 부품만 바꿔 가며 고칩니다.

문서 정리, 검색, 프롬프트, LLM, 답과 출처의 다섯 부품을 잇는 흐름도

1번에서 문서를 조각으로 정리하고, 2번에서 질문에 맞는 조각을 찾고, 3번에서 조각과 질문을 프롬프트로 묶고, 4번에서 LLM 이 답을 쓰고, 5번에서 답과 출처를 돌려줍니다. 부품마다 6장과 7장에서 만든 함수가 들어갑니다. 여기서 물류 센터의 칸이 서비스의 부품입니다.

부품과 바꿔 볼 것

부품이 장의 기본바꿔 볼 것
문서 정리조항, 절 단위로 나누기표를 행 단위 문장으로, 장 제목 붙이기
검색하이브리드(의미 0.7, 키워드 0.3)조각 수 k, 재정렬, 후속 질문 처리
프롬프트근거만 보고 답하기, 출처, 모를 때의 말공지 우선 규칙, 출처 이름을 조각에 넣기
LLMOllama qwen2.5:3b7장의 다른 모델
넘기기모른다고 답하기점수가 낮으면 담당 부서 안내

공통 검색 부품

세 프로젝트의 예시 풀이는 7장의 하이브리드 검색을 작은 클래스로 묶어 씁니다. 짧은 글 셋으로 써 보면 이렇습니다.

Python
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] 은 첫째 조각 "재활용품은 수요일과 일요일에 냅니다."의 번호입니다. "분리수거"라는 글자가 없어도 뜻을 아는 사서가 높은 점수를 주어 이 조각이 뽑힌 것입니다.

작게 줄여서 눈으로 보기

임베딩 모델을 부르지 않고, 점수 섞기만 숫자로 따라가 봅니다. 조각 셋의 의미 점수와 키워드 점수를 지어낸 설명용 숫자로 넣었습니다.

Python
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 이라서다

주의할 점

  • 부품을 클래스나 함수로 묶어 두면, 한 부품만 바꿔 끼우고 나머지는 그대로 둘 수 있습니다. 바꾼 부품마다 성적을 기록합니다.
  • 서비스로 내놓을 때는 13장의 API, 14장의 화면으로 이 부품들을 감쌉니다. 이 장에서는 노트북(코드)에서 돌아가는 데까지 만듭니다.
← 이전 절8장 · 세 프로젝트 한눈에다음 절 →8장 · 평가표와 발표