LLM 엔지니어링16장 · vLLM 서빙 벤치마킹과 RAG 최적화18 / 22← 이전목차다음 →MetaContents

16장. vLLM 서빙 벤치마킹과 RAG 최적화

실습 5 — 캐시로 같은 일 줄이기

실습 4의 RAG 에서 색인을 한 번만 만들고, 직원들이 실제로 물은 질문 기록 80줄에 답 캐시를 붙여 봅니다. 글자 그대로 같은 질문만 맞히는 캐시와, 다듬은 질문으로 맞히는 캐시를 견줍니다. 마지막으로 Ollama 의 프리픽스 캐시가 프리필을 얼마나 줄이는지 봅니다. 질문 기록(rag_query_log.csv)은 연습용 가상 데이터입니다.

1단계. 준비(실습 4와 같은 준비, 색인은 한 번만)

Python
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단계. 같은 질문이 얼마나 되풀이되나

Python
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
필수 교육은 언제까지 들어야 해요         6

3단계. 캐시 없이, 그대로 캐시, 다듬은 캐시

Python
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
다시 물음 3

4단계. 프리픽스 캐시: 앞부분이 같은 프롬프트

Python
# 조항 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초 · 캐시 맞힘 4380줄 가운데 처음 나온 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 의 프롬프트 캐시는 최근 요청 몇 개만 기억하므로, 다른 사용자의 요청이 사이에 끼면 맞지 않을 수 있습니다.
← 이전 절16장 · 실습 4 — 느린 RAG 의 병목 찾기다음 절 →16장 · 실습 6 — 조각 수와 답 길이 줄이기