16장. vLLM 서빙 벤치마킹과 RAG 최적화
프로젝트 1 — 응답 시간 절반으로 줄이기
실습 4의 느린 사내 규정 RAG 를, 정답 수를 거의 잃지 않으면서 질문 하나의 평균 응답 시간이 절반 이하가 되도록 고쳐 보세요. 고친 뒤에는 직원들의 질문 기록 80줄을 처리하는 시간도 잽니다. 회사, 규정, 질문 기록은 지어낸 연습용 데이터입니다.
| 파일 | 생김새 |
|---|---|
company_rules.txt | 7장의 솔내기술 사내 규정집. 조항 30개 |
company_rules_qa.jsonl | 질문, 정답열쇠(답이 여럿이면 세로줄 | 로 나눔), 근거조항. 25줄 |
rag_query_log.csv | 순번, 직원, 질문. 80줄. 같은 질문이 띄어쓰기와 물음표만 다르게 되풀이된다 |
요구사항은 다섯 가지입니다.
- 실습 4의 느린 RAG 를 기준으로, 질문 25개의 평균·p50·p95 시간과 정답 수를 잽니다.
- 단계별 시간을 보고 병목부터 고칩니다. 고친 곳마다 무엇을 왜 바꿨는지 적습니다.
- 고친 RAG 로 같은 질문 25개를 다시 재어, 평균 시간이 기준의 절반 이하이고 정답 수가 기준보다 2개 넘게 줄지 않는지 확인합니다.
- 질문 기록 80줄에 답 캐시를 붙여 전체 처리 시간을 잽니다.
- 기준과 고친 것을 한 표로 견주고, 더 빠르게 하려면 무엇을 할 수 있는지(vLLM, 더 작은 모델 등)와 그때 잃을 것을 적습니다.
단계별 힌트입니다.
- 실습 4의
slow_rag()와chat()을 그대로 가져와 기준을 잽니다. 몸풀기 요청을 먼저 보냅니다. - 색인은 한 번만 만들고, 재정렬 후보는 10개로 줄입니다(실습 5).
- 조각 수와 답 길이는 실습 6의 표에서 정답 수를 지키는 가장 짧은 설정을 고릅니다.
- 답 캐시의 열쇠는 다듬은 질문으로 만듭니다(실습 5의
normalize()). - Ollama 의 답은 실행마다 조금 달라질 수 있습니다. 정답 수가 한두 개 차이면 비슷하다고 봅니다.
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
실제 데이터로 해 보려면
공개된 질의응답 자료로 같은 최적화를 해 볼 수 있습니다. 자료와 모델을 받기 전에 라이선스와 약관을 확인합니다.
| 자료 | 구하는 곳 | 내용 |
|---|---|---|
| KorQuAD 1.0 | korquad.github.io (CC BY-ND 2.0 KR) | 위키백과 문단과 질문, 답. 문단을 조각으로 쓰고 질문으로 시간과 정답을 잰다 |
| AI Hub 행정 문서 대상 기계독해 데이터 | aihub.or.kr (회원가입 뒤 신청) | 행정 문서와 질의응답. 규정 문서 RAG 와 가깝다 |
| vLLM 벤치마크 데이터 | vLLM 깃허브의 benchmarks 안내 | 벤치마크 명령이 쓰는 공개 대화 자료(ShareGPT 등)를 받는 법 |
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
주황 화살표가 실행 차례이고, 상자의 번호는 예시 풀이의 단계 번호와 같습니다. 1단계(청록)에서 기준인 slow_rag 와 고친 fast_rag 두 RAG 를 준비하고, 2단계(마젠타)에서 measure() 로 같은 질문 25개를 물어 평균·p95 시간과 정답 수를 잽니다. 3단계(다홍)는 normalize() 로 다듬은 열쇠로 질문 기록 80줄에 답 캐시를 붙이고, 4단계(빨강)는 saved 표로 단계마다 줄어든 시간과 줄어든 몫을 보며 무엇이 빨라졌나를 짚습니다.
결과 보기 (예시 풀이)
1단계. 준비와 두 RAG
import json, re, time, urllib.request, urllib.error
import numpy as np
import pandas as pd
from sentence_transformers import SentenceTransformer, CrossEncoder
HOST = "http://127.0.0.1:11434"
MODEL = "qwen2.5:3b"
BROKEN = re.compile(r"(.)\1{9,}")
retried = {"수": 0}
def post(path, body):
req = urllib.request.Request(HOST + path, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
return json.loads(urllib.request.urlopen(req, timeout=300).read())
def unload_all():
for m in json.loads(urllib.request.urlopen(HOST + "/api/ps").read())["models"]:
post("/api/generate", {"model": m["name"], "keep_alive": 0})
def chat(messages, num_predict):
"""실습 4와 같은 함수: 서버 오류나 깨진 답이면 모델을 내렸다 다시 묻는다"""
for _ in range(3):
try:
r = post("/api/chat", {"model": MODEL, "stream": False, "messages": messages,
"options": {"temperature": 0, "seed": 42, "num_predict": num_predict}})
if not BROKEN.search(r["message"]["content"]):
return r
except urllib.error.HTTPError:
pass
retried["수"] += 1
post("/api/generate", {"model": MODEL, "keep_alive": 0})
return r
def correct(answer, key):
return any(k in answer.replace(" ", "") for k in key.replace(" ", "").split("|"))
def normalize(q):
return re.sub(r"\s+", " ", q).strip().rstrip("?.").strip()
articles = [l.strip() for l in open("company_rules.txt", encoding="utf-8") if re.match(r"^제\d+조\(", l)]
qa = [json.loads(l) for l in open("company_rules_qa.jsonl", encoding="utf-8")]
log = pd.read_csv("rag_query_log.csv")
SYSTEM = "당신은 솔내기술(가상 회사)의 규정 도우미입니다. [조항]에 있는 내용만으로 답하세요."
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
ce = CrossEncoder("cross-encoder/mmarco-mMiniLMv2-L12-H384-v1", device="cpu")
def slow_rag(q):
"""기준: 실습 4의 느린 RAG"""
t = {}
s = time.perf_counter()
A = emb.encode(["passage: " + a for a in articles], normalize_embeddings=True)
t["색인"] = time.perf_counter() - s; s = time.perf_counter()
qv = emb.encode(["query: " + q], normalize_embeddings=True)[0]
cand = np.argsort(-(A @ qv))
t["검색"] = time.perf_counter() - s; s = time.perf_counter()
cand = cand[np.argsort(-ce.predict([(q, articles[i]) for i in cand]))]
t["재정렬"] = time.perf_counter() - s; s = time.perf_counter()
ctx = "\n".join(articles[i] for i in cand[:8])
r = chat([{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"[조항]\n{ctx}\n\n[질문]\n{q}\n자세히 설명해 주세요."}], num_predict=300)
t["생성"] = time.perf_counter() - s
return r["message"]["content"].strip(), t
# 고친 RAG 의 색인: 서버가 뜰 때 한 번만 만든다
A_INDEX = emb.encode(["passage: " + a for a in articles], normalize_embeddings=True)
K = 5 # 실습 6에서 정답 수를 지킨 조각 수
def fast_rag(q):
"""고친 RAG: 색인 다시 쓰기, 재정렬 후보 10개, 조각 K 개, 한 문장 답"""
t = {"색인": 0.0}
s = time.perf_counter()
qv = emb.encode(["query: " + q], normalize_embeddings=True)[0]
cand = np.argsort(-(A_INDEX @ qv))[:10] # 후보를 30개에서 10개로
t["검색"] = time.perf_counter() - s; s = time.perf_counter()
cand = cand[np.argsort(-ce.predict([(q, articles[i]) for i in cand]))]
t["재정렬"] = time.perf_counter() - s; s = time.perf_counter()
ctx = "\n".join(articles[i] for i in cand[:K])
r = chat([{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"[조항]\n{ctx}\n\n[질문]\n{q}\n한 문장으로 답하세요."}], num_predict=80)
t["생성"] = time.perf_counter() - s
return r["message"]["content"].strip(), t
unload_all()
chat([{"role": "user", "content": "안녕"}], 5) # 몸풀기
print("준비 끝 · 조항", len(articles), "· 질문", len(qa), "· 기록", len(log))준비 끝 · 조항 30 · 질문 25 · 기록 802단계. 기준과 고친 RAG 를 같은 질문으로 재기
def measure(fn, label):
"""질문 25개를 fn 으로 묻고 단계별 평균, 평균·p50·p95 시간, 정답 수를 돌려준다"""
rows = []
for x in qa:
a, t = fn(x["질문"])
rows.append({**t, "전체": sum(t.values()), "정답": correct(a, x["정답열쇠"])})
d = pd.DataFrame(rows)
return {"RAG": label, **{k: round(d[k].mean(), 3) for k in ["색인", "검색", "재정렬", "생성"]},
"평균(초)": round(d["전체"].mean(), 2), "p50(초)": round(d["전체"].median(), 2),
"p95(초)": round(d["전체"].quantile(0.95), 2), "정답": int(d["정답"].sum())}
result = pd.DataFrame([measure(slow_rag, "기준(느린 RAG)"), measure(fast_rag, "고친 RAG")])
# 결과: 단계별 평균 시간, 전체의 평균·p50·p95, 정답 수
print(result.to_string(index=False))
ratio = result["평균(초)"][1] / result["평균(초)"][0]
print(f"평균 시간 비율 {ratio:.2f} · 정답 차이 {result['정답'][1] - result['정답'][0]} · 다시 물음 {retried['수']}") RAG 색인 검색 재정렬 생성 평균(초) p50(초) p95(초) 정답
기준(느린 RAG) 0.428 0.024 0.699 3.646 4.80 4.65 6.06 24
고친 RAG 0.000 0.020 0.148 2.250 2.42 2.00 7.37 23
평균 시간 비율 0.50 · 정답 차이 -1 · 다시 물음 23단계. 질문 기록에 답 캐시 붙이기
cache, hits = {}, 0
start = time.perf_counter()
for q in log["질문"]:
key = normalize(q) # 띄어쓰기와 물음표를 다듬은 열쇠
if key in cache:
hits += 1
else:
cache[key] = fast_rag(q)[0]
cached_sec = time.perf_counter() - start
# 기준 RAG 로 80줄을 모두 처리하면 걸릴 시간은 평균 × 80 으로 어림한다(실제로 돌리면 몇 분 걸린다)
estimate = result["평균(초)"][0] * len(log)
print(f"고친 RAG + 답 캐시: {cached_sec:.1f}초 (캐시 맞힘 {hits}/{len(log)})")
print(f"기준 RAG 로 어림: {estimate:.0f}초 → {estimate / cached_sec:.1f}배 빠름")고친 RAG + 답 캐시: 48.1초 (캐시 맞힘 59/80)
기준 RAG 로 어림: 384초 → 8.0배 빠름4단계. 무엇이 빨라졌나
base, fast = result.iloc[0], result.iloc[1]
saved = pd.DataFrame({"단계": ["색인", "검색", "재정렬", "생성"],
"기준(초)": [base[s] for s in ["색인", "검색", "재정렬", "생성"]],
"고친 뒤(초)": [fast[s] for s in ["색인", "검색", "재정렬", "생성"]]})
saved["줄어든 시간(초)"] = (saved["기준(초)"] - saved["고친 뒤(초)"]).round(3)
saved["줄어든 몫"] = (saved["줄어든 시간(초)"] / saved["줄어든 시간(초)"].sum()).round(3)
# 결과: 단계마다 줄어든 시간과, 전체 단축에서 차지하는 몫
print(saved.to_string(index=False)) 단계 기준(초) 고친 뒤(초) 줄어든 시간(초) 줄어든 몫
색인 0.428 0.000 0.428 0.180
검색 0.024 0.020 0.004 0.002
재정렬 0.699 0.148 0.551 0.232
생성 3.646 2.250 1.396 0.587기준인 느린 RAG 는 질문 하나에 평균 4.80초(p50 4.65, p95 6.06초)가 걸리고 25개 가운데 24개를 맞혔습니다. 병목인 생성부터 조각 5개, 한 문장 답으로 줄이고, 질문마다 다시 하던 색인을 없애고, 재정렬 후보를 10개로 줄였습니다. 고친 RAG 는 평균 2.42초, p50 2.00초, 정답 23개였습니다. p50 은 기준의 43% 로 절반 아래였지만, 평균은 기준의 절반(2.40초)을 0.02초 넘겨 조건을 아슬아슬하게 못 맞췄습니다. p95 가 7.37초로 기준보다 오히려 길게 나온 것과 같은 까닭으로, 실행 중에 깨진 답과 서버 오류로 두 번 모델을 내렸다 다시 물은 시간이 몇 개 질문에 섞였습니다. 줄어든 2.38초 가운데 58.7% 가 생성, 23.2% 가 재정렬, 18.0% 가 색인에서 나왔습니다. 질문 기록 80줄은 답 캐시가 59번 맞혀 48.1초에 끝났고, 기준 RAG 로 어림한 384초보다 8배 빨랐습니다. 더 줄이려면 실습 6처럼 조각을 3개로 줄이거나(정답 하나쯤을 잃는다), 1.5B 모델을 쓰거나(15장에서 정답 20/25 로 3개를 잃었다), GPU 서버에 vLLM 을 띄워 동시 사용자를 배치로 처리할 수 있습니다. 다시 묻는 일이 지연의 꼬리(p95)를 만들었으므로, 실제 서비스에서는 모델 서버의 안정성도 함께 살핍니다. 같은 코드도 실행마다 숫자가 조금씩 다르므로(교안을 만들며 먼저 돌렸을 때는 조각 5개에서 정답 22개, 평균 2.03초), 결정은 여러 번 잰 결과로 합니다.
이 프로젝트가 뜻하는 것
이 프로젝트는 손님이 오래 기다리는 식당을 고쳐, 맛은 거의 그대로 두고 기다리는 시간을 절반으로 줄여 보는 일과 같습니다. 옛 방식의 주방과 고친 주방을 함께 준비합니다(1단계). 같은 주문 25개를 두 주방에 내어 시간과 맛(정답)을 견줍니다(2단계). 고친 주방에 단골 주문의 답을 적어 둔 수첩을 붙여 실제 주문 기록 80개를 처리합니다(3단계). 끝으로 어느 단계에서 시간이 줄었는지 나눠 봅니다(4단계). 여기서 옛 주방이 slow_rag, 고친 주방이 fast_rag, 수첩이 다듬은 질문을 열쇠로 한 답 캐시입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 준비 | 준비 끝 · 조항 30 · 질문 25 · 기록 80 | 규정 조항, 시험 질문, 질문 기록(모두 지어낸 연습용 데이터)과 두 RAG 를 준비했습니다 |
| 2. 기준 | 평균 4.80초 · p50 4.65초 · p95 6.06초 · 정답 24 | 고치기 전의 느린 RAG 는 질문 하나에 5초 가까이 걸리고 25개 가운데 24개를 맞혔습니다 |
| 2. 고친 RAG | 평균 2.42초 · p50 2.00초 · p95 7.37초 · 정답 23 | 가운데 값(p50)은 절반 아래로 줄었고 정답은 하나만 잃었습니다. 느린 쪽(p95)은 다시 물은 시간이 섞여 오히려 길어졌습니다 |
| 2. 비율 | 평균 시간 비율 0.50 · 정답 차이 -1 · 다시 물음 2 | 반올림해 0.50 이지만, 실제로는 2.42 ÷ 4.80 = 0.504 로 절반을 아주 조금 넘어 조건을 아슬아슬하게 못 맞췄습니다 |
| 3. 답 캐시 | 48.1초 · 캐시 맞힘 59/80 | 질문 기록 80개 가운데 59개를 수첩에서 바로 답해, 80개를 48초에 끝냈습니다 |
| 3. 견주기 | 기준 RAG 로 어림 384초 → 8.0배 빠름 | 384초는 실제로 돌린 값이 아니라 기준의 평균 4.80초에 80을 곱한 어림입니다 |
| 4. 줄어든 몫 | 생성 1.396초(0.587) · 재정렬 0.551초(0.232) · 색인 0.428초(0.180) | 줄어든 약 2.38초 가운데 절반 넘게가 생성에서 나왔습니다. 병목부터 고친 효과가 가장 컸습니다 |
한 줄로 줄이면, 병목인 생성부터 줄이고 하지 않아도 될 색인과 재정렬을 덜어 내면 정답을 거의 잃지 않고 시간을 절반 가까이 줄일 수 있고, 되풀이되는 질문에는 답 캐시가 그보다 큰 효과를 낸다는 것을 보인 프로젝트입니다. 다만 한 번 실행한 결과이고, 교안을 만들며 먼저 돌렸을 때는 숫자가 달랐으므로 여러 번 재어 결정해야 합니다. p95 가 늘어난 것처럼 모델 서버가 다시 묻는 일이 느린 꼬리를 만들 수 있으므로, 실제 서비스에서는 서버의 안정성도 함께 살핍니다. 모델을 API(Application Programming Interface, 프로그램끼리 부르는 약속)로 감싸 여러 사람이 부르는 서비스로 내놓는 일은 17장에서 합니다.