16장. vLLM 서빙 벤치마킹과 RAG 최적화
RAG 파이프라인의 병목
김밥집에서 김밥 한 줄이 늦게 나온다면, 밥 짓기, 재료 썰기, 말기, 썰어 담기 가운데 어느 칸이 느린지부터 봐야 합니다. 말기가 빠른 사람을 하나 더 뽑아도 밥이 늦게 되면 소용없습니다. RAG 도 여러 단계를 거치므로 단계마다 시간을 재야 어디를 고칠지 압니다.
1번 색인은 문서 조각을 임베딩해 두는 일, 2번 검색은 질문을 임베딩해 가까운 조각을 찾는 일, 3번 재정렬은 크로스 인코더로 후보의 순서를 다시 매기는 일(7장), 4번은 조각을 붙여 프롬프트를 만드는 일, 5번은 LLM 이 답을 쓰는 일입니다. 김밥집의 칸이 RAG 의 단계이고, 가장 느린 칸이 병목입니다.
단계마다 느려지는 까닭
| 단계 | 느려지는 흔한 까닭 | 고치는 법 |
|---|---|---|
| 색인 | 질문마다 문서를 다시 임베딩한다 | 한 번 만들어 저장한다 |
| 검색 | 문서가 아주 많다 | 벡터DB 의 근사 검색(11장) |
| 재정렬 | 후보를 너무 많이 넣는다 | 후보 수를 줄인다(예: 10개) |
| 프롬프트 | 조각을 너무 많이 붙인다 | 조각 수(k)를 줄인다 |
| 생성 | 답이 길고 모델이 크다 | 답 길이를 정하고, 알맞은 크기의 모델을 쓴다 |
단계마다 시간 재기
import time
from contextlib import contextmanager
timing = {}
@contextmanager
def stage(name):
"""with 블록 안의 코드가 걸린 시간을 timing[name] 에 더한다"""
start = time.perf_counter()
yield
timing[name] = timing.get(name, 0) + time.perf_counter() - start
with stage("검색"):
time.sleep(0.02) # 여기에 실제 검색 코드가 들어간다(예시로 잠깐 쉰다)
with stage("생성"):
time.sleep(0.10) # 여기에 실제 생성 코드가 들어간다
total = sum(timing.values())
for name, sec in timing.items():
print(f"{name}: {sec:.2f}초 ({sec / total:.0%})")코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
time.perf_counter() | perf 는 performance(성능)의 줄임말이다. 짧은 시간을 재는 데 쓰는 정밀한 초시계의 현재 값이다 |
@contextmanager | context(문맥, 둘레) manager(관리자)라는 뜻이다. 함수를 with 문에 쓸 수 있게 바꿔 준다 |
yield | "양보하다"라는 뜻이다. 여기서 멈추고 with 블록 안의 코드를 실행한 뒤, 끝나면 그 아래 줄을 이어서 실행한다 |
timing.get(name, 0) | 이름이 없으면 0 에서 시작해 시간을 더한다. 같은 단계를 여러 번 재면 쌓인다 |
time.sleep(0.02) | sleep(잠자다)이다. 0.02초 쉰다. 실제 검색 대신 넣은 흉내다 |
{sec / total:.0%} | 전체에서 차지하는 몫을 백분율 정수로 적는다 |
이 코드가 뜻하는 것
김밥집의 칸마다 초시계를 하나씩 두고, 칸에 들어갈 때 누르고 나올 때 다시 눌러 걸린 시간을 적는 일입니다. 여기서 칸이 RAG 의 단계, 초시계가 stage() 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
start = time.perf_counter() | 블록에 들어갈 때 시각을 적는다 | 칸에 들어갈 때 초시계를 누른다 |
yield | 블록 안의 일을 실행한다 | 그 칸의 일을 한다 |
timing[name] = ... | 나올 때 시각에서 들어갈 때 시각을 빼 더한다 | 나올 때 다시 눌러 기록장에 적는다 |
with stage("검색"): | 검색 칸을 잰다 | 재료 썰기 칸 |
마지막 for | 단계마다 초와 몫을 출력한다 | 칸마다 걸린 시간과 비율 |
출력에서 생성이 검색보다 훨씬 큰 몫을 차지합니다. 쉬는 시간을 0.02초와 0.10초로 정했으니 당연한 결과이고, 실제 숫자는 실행할 때마다 조금씩 다릅니다. 실제 RAG 에서도 생성이 가장 큰 몫인 경우가 많아, 먼저 생성의 시간을 줄일 곳을 찾습니다.
작게 줄여서 눈으로 보기
@contextmanager 함수가 어떤 차례로 도는지 시간 대신 글자로 찍어 봅니다.
from contextlib import contextmanager
@contextmanager
def stage(name):
print(f"[{name}] 시작") # with 에 들어갈 때
yield
print(f"[{name}] 끝") # with 를 나올 때
with stage("검색"):
print(" 검색하는 중")
print("with 밖")[검색] 시작
검색하는 중
[검색] 끝
with 밖| 결과 | 읽는 법 |
|---|---|
[검색] 시작 | with 에 들어가자 yield 위쪽이 먼저 돈다. 초시계를 누르는 자리다 |
검색하는 중 | yield 에서 멈춘 사이에 with 블록 안의 코드가 돈다 |
[검색] 끝 | 블록이 끝나면 yield 아래쪽이 돈다. 시간을 적는 자리다 |
with 밖 | 그다음에야 with 바깥 코드로 넘어간다 |
주의할 점
- 단계의 시간은 질문마다 다릅니다. 질문 한두 개가 아니라 묶음 전체의 평균과 몫을 봅니다.
- 처음 한 번만 드는 시간(모델 불러오기)과 질문마다 드는 시간을 나눠 적습니다. 서버는 모델을 한 번 불러 두고 계속 쓰기 때문입니다.