16장. vLLM 서빙 벤치마킹과 RAG 최적화
실습 6 — 조각 수와 답 길이 줄이기
실습 5의 RAG 에서 붙이는 조각 수(k)와 답의 길이를 바꿔 가며, 입력 토큰, 생성 시간, 정답 수가 어떻게 달라지는지 봅니다. 모든 설정이 같은 검색 순위를 쓰도록 질문마다 재정렬 결과를 먼저 만들어 둡니다. 시간은 Ollama 가 재 준 total_duration 을 써서 생성에 든 시간만 봅니다.
1단계. 준비와 검색 순위 만들어 두기
import json, re, 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("|"))
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")]
SYSTEM = "당신은 솔내기술(가상 회사)의 규정 도우미입니다. [조항]에 있는 내용만으로 답하세요."
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
ce = CrossEncoder("cross-encoder/mmarco-mMiniLMv2-L12-H384-v1", device="cpu")
A = emb.encode(["passage: " + a for a in articles], normalize_embeddings=True)
ranked = [] # 질문마다 재정렬한 조항 번호(앞이 더 가깝다)
for x in qa:
qv = emb.encode(["query: " + x["질문"]], normalize_embeddings=True)[0]
cand = np.argsort(-(A @ qv))[:10]
ranked.append(cand[np.argsort(-ce.predict([(x["질문"], articles[i]) for i in cand]))])
unload_all()
chat([{"role": "user", "content": "안녕"}], 5) # 몸풀기
print(len(ranked), "개 질문의 검색 순위 준비")25 개 질문의 검색 순위 준비2단계. 설정 하나를 재는 함수
STYLES = {"한 문장": ("한 문장으로 답하세요.", 80), "자세히": ("자세히 설명해 주세요.", 300)}
def evaluate(k, style):
"""조각 k 개와 답의 꼴(style)로 질문 25개를 묻고 평균 입력·출력 토큰, 평균 생성 시간, 정답 수를 잰다"""
instr, n_pred = STYLES[style]
n_in, n_out, secs, right, answers = [], [], [], 0, []
for x, cand in zip(qa, ranked):
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{x['질문']}\n{instr}"}], num_predict=n_pred)
a = r["message"]["content"].strip()
n_in.append(r["prompt_eval_count"]); n_out.append(r["eval_count"])
secs.append(r["total_duration"] / 1e9) # Ollama 가 잰 시간(나노초 → 초)
right += correct(a, x["정답열쇠"]); answers.append(a)
return {"조각 수": k, "답": style, "입력토큰": round(np.mean(n_in)), "출력토큰": round(np.mean(n_out)),
"생성 평균(초)": round(float(np.mean(secs)), 2), "정답": right}, answers
row, _ = evaluate(3, "한 문장")
# 결과: 조각 3개, 한 문장 답의 평균과 정답 수
print(row){'조각 수': 3, '답': '한 문장', '입력토큰': 248, '출력토큰': 23, '생성 평균(초)': 1.55, '정답': 22}3단계. 조각 수와 답 길이를 바꿔 가며
rows, answers = [], {}
for k, style in [(1, "한 문장"), (3, "한 문장"), (5, "한 문장"), (8, "한 문장"), (3, "자세히"), (8, "자세히")]:
row, ans = evaluate(k, style)
rows.append(row); answers[(k, style)] = ans
table = pd.DataFrame(rows)
# 결과: 설정마다 입력·출력 토큰, 생성 시간, 정답 수
print(table.to_string(index=False))
print("다시 물음", retried["수"]) 조각 수 답 입력토큰 출력토큰 생성 평균(초) 정답
1 한 문장 131 23 0.66 21
3 한 문장 248 24 1.10 22
5 한 문장 360 25 1.36 23
8 한 문장 525 25 2.53 22
3 자세히 249 77 2.26 24
8 자세히 526 70 3.28 24
다시 물음 44단계. 조각이 적어 틀린 질문 보기
for i, x in enumerate(qa):
a1, a3 = answers[(1, "한 문장")][i], answers[(3, "한 문장")][i]
if not correct(a1, x["정답열쇠"]) and correct(a3, x["정답열쇠"]): # 조각 1개로는 틀리고 3개로는 맞힌 질문
top = [articles[j][:articles[j].index(")") + 1] for j in ranked[i][:3]]
print(f"{x['질문']} (근거 {x['근거조항']}) · 순위 1~3: {top}")
print(" k=1:", a1[:70])
print(" k=3:", a3[:70])밤늦게까지 일하면 저녁값이 나오나요? (근거 야근 식대) · 순위 1~3: ['제16조(야근 식대)', '제1조(근무 시간)', '제14조(법인카드)']
k=1: 네, 밤 늦게까지 일하면 저녁 식대가 추가로 나옵니다.
k=3: 네, 밤 늦게까지 일하면 저녁 식대 1만 2천원을 법인카드로 치니다.
영수증은 언제까지 내야 해요? (근거 경비 정산) · 순위 1~3: ['제13조(출장 보고)', '제17조(경비 정산)', '제8조(병가)']
k=1: 영수증은 출장에서 돌아온 뒤 5영업일 이내에 내야 합니다.
k=3: 경비는 쓴 달의 다음 달 5일까지 영수증과 함께 정산합니다.이 실습이 뜻하는 것
이 실습은 시험 문제를 풀 때 참고서를 몇 쪽 펴 놓고, 답을 몇 줄로 쓰느냐에 따라 푸는 시간과 맞는 문제가 어떻게 달라지는지 재는 일과 같습니다. 문제마다 펼 쪽의 순서를 미리 정해 둡니다(1단계). 한 가지 방법으로 25문제를 풀며 시간과 맞힌 수를 재는 법을 만듭니다(2단계). 펴는 쪽 수(1, 3, 5, 8쪽)와 답의 길이(한 문장, 자세히)를 바꿔 가며 견주고(3단계), 한 쪽만 펴서 틀린 문제를 살펴봅니다(4단계). 여기서 펴 놓은 쪽이 조각, 쪽 수가 k, 미리 정한 쪽 순서가 재정렬 순위, 답의 줄 수가 출력 토큰입니다. 사람과 다른 점은, 모델은 펴 놓은 쪽을 매번 처음부터 모두 읽기 때문에 쪽이 늘면 시간도 바로 늘어난다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 순위 준비 | 25 개 질문의 검색 순위 준비 | 질문 25개마다 조항 순위를 미리 만들어, 모든 설정이 같은 검색 결과를 쓰게 했습니다 |
| 2. 조각 3·한 문장 | 입력 248 · 출력 23 · 1.55초 · 정답 22 | 한 가지 설정을 잰 결과입니다. 3단계에서 같은 설정을 다시 재면 1.10초라, 시간은 잴 때마다 흔들립니다 |
| 3. 조각 수 | 조각 1, 3, 5, 8 → 입력 131, 248, 360, 525토큰 | 붙이는 조각이 늘수록 모델이 읽을 입력이 길어집니다. 조각 8개는 1개의 약 네 배입니다 |
| 3. 생성 시간 | 0.66, 1.10, 1.36, 2.53초 | 입력이 길어진 만큼 생성 시간도 늘었습니다 |
| 3. 정답 | 21, 22, 23, 22 | 조각 5개를 넘겨도 정답은 더 늘지 않았습니다. 정답 조항은 대개 순위 위쪽에 있기 때문입니다 |
| 3. 자세히 | 조각 3 → 출력 77 · 2.26초 · 정답 24 / 조각 8 → 출력 70 · 3.28초 · 정답 24 | 답을 길게 쓰게 하면 출력이 세 배쯤 되어 느려지고, 정답은 둘 늘었습니다. 길게 쓴 답에 열쇠 낱말이 걸릴 기회가 많은 몫도 있습니다 |
| 3. 다시 물음 | 다시 물음 4 | 네 번 모델을 내렸다 다시 물었고, 그 시간이 해당 설정의 평균에 섞였습니다 |
| 4. 영수증 질문 | 1위 출장 보고 조항 → k=1 "5영업일" / k=3 "다음 달 5일" | 검색 1위가 틀린 조항이라 한 쪽만 펴면 틀렸고, 2위의 경비 정산 조항까지 펴자 맞혔습니다 |
| 4. 저녁값 질문 | k=1 금액 없음 / k=3 "1만 2천원" | 맞는 조항을 받고도 금액을 빼먹었다가, 조각이 늘자 금액을 적었습니다 |
한 줄로 줄이면, 조각과 답 길이를 줄이면 빨라지지만 어느 선 아래로 줄이면 맞히던 질문을 잃는다는 맞바꿈을 보인 실습입니다. 프로젝트는 이 표에서 정답 수를 지킨 조각 5개와 한 문장 답을 고릅니다. 정답은 열쇠 낱말이 들어 있는지로 센 것이고 질문도 25개뿐이라, 정답 한두 개의 차이는 실행마다 바뀔 수 있는 크기입니다.
코드 설명
- 1단계에서 질문마다 재정렬한 순위를
ranked에 만들어 두어, 모든 설정이 같은 검색 결과를 씁니다. 시간은 Ollama 가 잰total_duration이라 검색과 재정렬은 빠지고 생성만 들어 있습니다. - 한 문장 답에서 조각을 1, 3, 5, 8 개로 늘리자 입력이 131, 248, 360, 525토큰으로 늘고 생성 시간이 0.66, 1.10, 1.36, 2.53초로 늘었습니다. 정답은 21, 22, 23, 22 개로, 조각 5개 이상에서는 더 늘지 않았습니다. 재정렬로 정답 조각이 대개 위쪽에 있기 때문입니다.
- 같은 조각 3개라도 "자세히"는 출력이 77토큰으로 세 배가 되어 2.26초가 걸렸고, 정답은 24개로 둘 많았습니다. 열쇠 낱말로 채점하므로 길게 쓴 답에 낱말이 걸릴 기회가 많은 것도 한몫합니다. 이번 실행에서 다시 물은 일이 4번 있었고, 그 질문은 다시 묻는 시간이 평균에 섞였습니다.
- 4단계는 조각 1개로는 틀리고 3개로는 맞힌 질문입니다. "영수증은 언제까지"는 1위가 출장 보고 조항이라 조각 1개로는 "5영업일"이라 틀렸고, 2위의 경비 정산 조항을 함께 주자 맞혔습니다. "저녁값"은 1위 조항을 받고도 금액을 빼먹었다가 조각이 늘자 금액을 적었습니다. 조각을 줄일 때는 검색 순위가 틀리는 질문이 있다는 것을 기억합니다.