16장. vLLM 서빙 벤치마킹과 RAG 최적화
실습 5 — 캐시로 같은 일 줄이기
실습 4의 RAG 에서 색인을 한 번만 만들고, 직원들이 실제로 물은 질문 기록 80줄에 답 캐시를 붙여 봅니다. 글자 그대로 같은 질문만 맞히는 캐시와, 다듬은 질문으로 맞히는 캐시를 견줍니다. 마지막으로 Ollama 의 프리픽스 캐시가 프리필을 얼마나 줄이는지 봅니다. 질문 기록(rag_query_log.csv)은 연습용 가상 데이터입니다.
1단계. 준비(실습 4와 같은 준비, 색인은 한 번만)
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
articles = [l.strip() for l in open("company_rules.txt", encoding="utf-8") if re.match(r"^제\d+조\(", l)]
SYSTEM = "당신은 솔내기술(가상 회사)의 규정 도우미입니다. [조항]에 있는 내용만으로 답하세요."
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
ce = CrossEncoder("cross-encoder/mmarco-mMiniLMv2-L12-H384-v1", device="cpu")
# 색인은 여기서 한 번만 만든다(실습 4는 질문마다 다시 만들었다)
A = emb.encode(["passage: " + a for a in articles], normalize_embeddings=True)
def rag(q):
"""색인을 다시 쓰고, 재정렬은 상위 10개만, 조각 3개로 한 문장 답을 만든다"""
qv = emb.encode(["query: " + q], normalize_embeddings=True)[0]
cand = np.argsort(-(A @ qv))[:10]
cand = cand[np.argsort(-ce.predict([(q, articles[i]) for i in cand]))]
ctx = "\n".join(articles[i] for i in cand[:3])
r = chat([{"role": "system", "content": SYSTEM},
{"role": "user", "content": f"[조항]\n{ctx}\n\n[질문]\n{q}\n한 문장으로 답하세요."}], num_predict=80)
return r["message"]["content"].strip()
unload_all()
rag("안녕하세요") # 몸풀기
log = pd.read_csv("rag_query_log.csv")
# 결과: 질문 기록의 앞부분
print(log.head(5).to_string(index=False)) 순번 직원 질문
1 E039 사원증을 잃어버렸어요.
2 E016 아내가 아기를 낳으면 며칠 쉴 수 있나요?
3 E005 법인카드로 70만원을 결제하려면 어떻게 해요?
4 E010 회사 주차장을 쓸 수 있나요?
5 E027 영수증은 언제까지 내야 해요?2단계. 같은 질문이 얼마나 되풀이되나
def normalize(q):
"""빈칸을 하나로 줄이고 앞뒤 빈칸과 끝의 물음표·마침표를 지운다"""
return re.sub(r"\s+", " ", q).strip().rstrip("?.").strip()
raw_unique = log["질문"].nunique()
norm_unique = log["질문"].map(normalize).nunique()
# 결과: 글자 그대로 다른 질문 수와, 다듬은 뒤 다른 질문 수
print(f"질문 {len(log)}줄 · 글자 그대로 {raw_unique}가지 · 다듬으면 {norm_unique}가지")
# 가장 많이 물은 질문 셋
print(log["질문"].map(normalize).value_counts().head(3).to_string())질문 80줄 · 글자 그대로 37가지 · 다듬으면 21가지
질문
사원증을 잃어버렸어요 20
아내가 아기를 낳으면 며칠 쉴 수 있나요 18
필수 교육은 언제까지 들어야 해요 63단계. 캐시 없이, 그대로 캐시, 다듬은 캐시
def run_log(key_fn=None):
"""질문 기록을 차례로 처리한다. key_fn 이 있으면 그 열쇠로 답 캐시를 쓴다"""
cache, hits = {}, 0
start = time.perf_counter()
for q in log["질문"]:
if key_fn is None:
rag(q)
continue
key = key_fn(q)
if key in cache:
hits += 1 # 캐시에 있으면 검색도 생성도 하지 않는다
else:
cache[key] = rag(q)
return round(time.perf_counter() - start, 1), hits
rows = []
for label, fn in [("캐시 없음", None), ("글자 그대로", lambda q: q), ("다듬은 질문", normalize)]:
sec, hits = run_log(fn)
rows.append({"방법": label, "걸린 시간(초)": sec, "캐시 맞힘": hits, "질문 하나 평균(초)": round(sec / len(log), 2)})
# 결과: 80줄을 처리한 시간과 캐시에서 바로 답한 수
print(pd.DataFrame(rows).to_string(index=False))
print("다시 물음", retried["수"]) 방법 걸린 시간(초) 캐시 맞힘 질문 하나 평균(초)
캐시 없음 96.7 0 1.21
글자 그대로 58.5 43 0.73
다듬은 질문 27.7 59 0.35
다시 물음 34단계. 프리픽스 캐시: 앞부분이 같은 프롬프트
# 조항 30개 전체를 시스템 프롬프트에 넣는다. 질문마다 바뀌는 것은 맨 뒤의 질문뿐이다
long_system = SYSTEM + "\n[조항]\n" + "\n".join(articles)
unload_all() # 모델을 내려 Ollama 의 프롬프트 캐시도 비운다
rows = []
for q in ["재택 근무는 며칠까지 되나요?", "반차를 쓰면 몇 시에 출근하나요?", "사원증을 잃어버렸어요."]:
r = chat([{"role": "system", "content": long_system},
{"role": "user", "content": q + " 한 문장으로 답하세요."}], num_predict=60)
# prompt_eval_cached_count: 지난 요청과 앞부분이 같아 다시 계산하지 않은 입력 토큰 수
rows.append({"질문": q, "입력토큰": r["prompt_eval_count"], "캐시토큰": r.get("prompt_eval_cached_count", 0),
"프리필(초)": round(r["prompt_eval_duration"] / 1e9, 2), "전체(초)": round(r["total_duration"] / 1e9, 2)})
# 결과: 첫 질문은 긴 앞부분을 모두 계산하고, 다음 질문부터는 앞부분을 캐시에서 읽는다
print(pd.DataFrame(rows).to_string(index=False)) 질문 입력토큰 캐시토큰 프리필(초) 전체(초)
재택 근무는 며칠까지 되나요? 1665 0 7.16 11.15
반차를 쓰면 몇 시에 출근하나요? 1669 1639 0.16 0.55
사원증을 잃어버렸어요. 1662 1639 0.13 0.98이 실습이 뜻하는 것
이 실습은 같은 질문을 자꾸 받는 안내 데스크가 답을 수첩에 적어 두고 다시 꺼내 쓰는 일과 같습니다. 먼저 안내원이 볼 자료를 한 번만 정리해 두고 지난 질문 기록을 펼칩니다(1단계). 같은 질문이 몇 번이나 되풀이됐는지 셉니다(2단계). 수첩 없이 답할 때, 글자가 똑같은 질문만 수첩에서 찾을 때, 띄어쓰기와 물음표를 다듬어서 찾을 때를 견줍니다(3단계). 끝으로 긴 안내문을 앞에 둔 질문에서 이미 읽은 앞부분을 다시 읽지 않는 효과를 봅니다(4단계). 여기서 수첩이 답 캐시, 다듬기가 normalize(), 이미 읽은 앞부분을 기억하는 일이 프리픽스 캐시입니다. 사람 안내원은 말이 조금 달라도 같은 질문인 줄 알아듣지만, 이 답 캐시는 다듬은 글자가 똑같아야만 맞힙니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 질문 기록 | 순번, 직원, 질문의 앞 5줄 | 직원들이 물은 질문 기록(지어낸 연습용 데이터)을 읽었습니다 |
| 2. 되풀이 | 80줄 · 글자 그대로 37가지 · 다듬으면 21가지 | 80번 물었지만 실제로 다른 질문은 21가지뿐입니다. 띄어쓰기와 물음표만 다른 질문이 많았습니다 |
| 2. 많이 물은 질문 | 사원증 20 · 아기 18 · 필수 교육 6 | 두 질문만으로 80줄 가운데 38줄, 절반 가까이를 차지합니다 |
| 3. 캐시 없음 | 96.7초 · 질문 하나 1.21초 | 80줄을 모두 새로 검색하고 생성했습니다 |
| 3. 글자 그대로 | 58.5초 · 캐시 맞힘 43 | 80줄 가운데 처음 나온 37가지를 뺀 43번은 수첩에서 바로 답했습니다 |
| 3. 다듬은 질문 | 27.7초 · 캐시 맞힘 59 | 다듬은 21가지를 뺀 59번을 수첩에서 답해, 캐시가 없을 때보다 약 3.5배 빨랐습니다 |
| 3. 다시 물음 | 다시 물음 3 | 깨진 답이나 서버 오류로 세 번 모델을 내렸다 다시 물었습니다. 그 몇 초가 시간에 섞여 배수가 조금 흔들립니다 |
| 4. 첫 질문 | 입력 1665 · 캐시 0 · 프리필 7.16초 · 전체 11.15초 | 조항 30개가 든 긴 앞부분을 처음부터 모두 읽었습니다(모델 올리는 시간 포함) |
| 4. 둘째·셋째 질문 | 캐시 1639 · 프리필 0.16초, 0.13초 | 앞부분 1,639토큰을 다시 읽지 않고 꺼내 써서 입력 읽기가 거의 사라졌습니다 |
한 줄로 줄이면, 같은 일을 두 번 하지 않게 하는 캐시가 시간을 크게 줄이고, 질문을 다듬는 한 줄로 캐시가 더 많이 맞는다는 것을 보인 실습입니다. 다듬은 열쇠의 답 캐시는 프로젝트에서 그대로 씁니다. 이 시간들은 다시 물은 일이 섞인 한 번의 실행이라 배수보다 캐시 맞힘 수를 함께 봐야 합니다. 또 답 캐시는 규정이 바뀌면 옛 답을 돌려주고, 프리픽스 캐시는 최근 요청 몇 개만 기억합니다.
코드 설명
- 색인(
A)은 1단계에서 한 번만 만들고,rag()는 재정렬 후보를 10개로, 조각을 3개로 줄이고 한 문장으로 답하게 했습니다. 실습 4의 낭비를 고친 꼴로, 질문 하나가 약 1.2초 걸립니다(캐시 없음 96.7초 ÷ 80). - 질문 기록 80줄은 글자 그대로는 37가지였지만, 빈칸과 물음표를 다듬으면 21가지뿐이었습니다. "사원증을 잃어버렸어요"가 20번, "아내가 아기를 낳으면…"이 18번으로, 자주 묻는 몇 질문이 기록의 절반 가까이를 차지했습니다. 실제 문의 창구에서도 흔한 모습입니다.
- 캐시 없이는 80줄에 96.7초, 글자 그대로의 캐시는 43번 맞혀 58.5초, 다듬은 질문의 캐시는 59번 맞혀 27.7초로 3.5배 빨랐습니다. 다듬기 한 줄로 16번을 더 맞힌 것입니다. 실행 중에 깨진 답이나 서버 오류로 3번 다시 물었고, 그때마다 모델을 다시 올리는 몇 초가 들어 "글자 그대로"의 시간이 커졌습니다. 그래서 세 숫자의 차이를 정확한 배수로 보기보다 캐시 맞힘 수와 함께 봅니다.
- 조항 30개를 시스템 프롬프트에 둔 첫 질문은 입력 1,665토큰을 모두 계산해 프리필 7.16초, 전체 11.15초가 걸렸습니다(모델 올리기 포함). 둘째, 셋째 질문은 앞부분 1,639토큰을 캐시에서 읽어 프리필이 0.16초, 0.13초로 줄었습니다. 바뀌는 질문을 프롬프트 맨 뒤에 둔 덕입니다. 다만 Ollama 의 프롬프트 캐시는 최근 요청 몇 개만 기억하므로, 다른 사용자의 요청이 사이에 끼면 맞지 않을 수 있습니다.