조각을 몇 개 넣을 것인가 — 오늘의 마지막 결정
Article
오늘 마지막 시간이다. 남은 결정이 하나 있다 — 조각을 몇 개 넣을 것인가.
- ①②③ RAG 를 만든다끝남
- ④ 갈래를 나눈다끝남
- ⑤ 프롬프트를 고친다끝남 · 13/18
- ⑥ 조각 수를 정한다지금 여기
search(question, k=3) 의 그 k 다. 3은 그냥 적어둔 값이고 아직 아무도 재보지 않았다.
직관은 「많이 넣으면 근거가 늘어나니 답이 좋아진다, 대신 느려진다」고 말한다. 절반만 맞다. 느려지는 것은 맞고 좋아지는 것은 아니다.
1. 시간부터 잰다
같은 질문에 k 만 바꿔가며 잰다. 출력은 120토큰으로 고정한다 — 답 길이가 들쭉날쭉하면 비교가 안 된다.
# (24_ask.py 의 위쪽을 그대로 복사해 온다 — data, model, search)
import json
import time
import urllib.request
SOFT = """너는 사내 문서 안내원이다. 주어진 자료만 근거로 답한다.
자료에 없는 내용은 절대 쓰지 않는다. 자료로 답할 수 없으면 「자료에 없다」고만 답한다.
반드시 한국어로만 답한다. 세 문장 이내로 답한다."""
def timed(prompt, num_gpu=None):
"""네이티브 창구로 부른다. 걸린 시간을 잘게 돌려주기 때문이다."""
options = {"num_predict": 120}
if num_gpu is not None:
options["num_gpu"] = num_gpu # 0 이면 CPU 로만 돌린다
body = json.dumps({
"model": "qwen2.5:3b",
"messages": [
{"role": "system", "content": SOFT},
{"role": "user", "content": prompt},
],
"stream": False,
"options": options,
}).encode()
req = urllib.request.Request(
"http://localhost:11434/api/chat", data=body,
headers={"Content-Type": "application/json"},
)
r = json.loads(urllib.request.urlopen(req, timeout=900).read().decode())
# 나노초로 온다. 1e9 로 나누면 초가 된다
return {
"in_tok": r["prompt_eval_count"],
"prefill": r["prompt_eval_duration"] / 1e9,
"total": r["total_duration"] / 1e9,
}
question = "조각을 겹치게 자르는 이유가 뭔가"
for k in [1, 3, 5, 10, 20, 30]:
context = "\n\n".join(text for score, slug, text in search(question, k))
prompt = f"[자료]\n{context}\n\n[질문]\n{question}\n"
g = timed(prompt) # 되는 대로 (우리 기준 GPU)
c = timed(prompt, num_gpu=0) # CPU 강제 (학원 노트북 흉내)
print(f"k={k:<3} {len(context):>5}자 {g['in_tok']:>5}tok | "
f"GPU {g['prefill']:>4.1f}/{g['total']:>4.1f} | "
f"CPU {c['prefill']:>4.1f}/{c['total']:>4.1f}")
여기 함정이 하나 있다. Ollama 는 이미 올려둔 모델을 재사용해서, num_gpu 를 바꿔 보내도 앞 요청의 배치가 그대로 쓰인다. 처음 쟀을 때 CPU 와 GPU 가 똑같이 나왔는데 알고 보니 둘 다 CPU 로 돌고 있었다. 재기 전에 모델을 내려야 한다.
def unload():
"""모델을 메모리에서 내린다. 재기 전에 반드시 부른다."""
body = json.dumps({"model": "qwen2.5:3b", "messages": [], "keep_alive": 0}).encode()
req = urllib.request.Request(
"http://localhost:11434/api/chat", data=body,
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req).read()
time.sleep(2)
| k | 자료 글자 | 입력 토큰 | GPU 프리필 | GPU 총 | CPU 프리필 | CPU 총 |
|---|---|---|---|---|---|---|
| 1 | 184 | 263 | 0.1초 | 3.7초 | 2.9초 | 9.4초 |
| 3 | 468 | 459 | 0.1초 | 4.4초 | 4.7초 | 11.1초 |
| 5 | 858 | 718 | 0.2초 | 4.0초 | 7.3초 | 14.6초 |
| 10 | 1,678 | 1,278 | 0.3초 | 4.2초 | 14.1초 | 21.4초 |
| 20 | 3,167 | 2,264 | 0.6초 | 5.1초 | 27.0초 | 36.3초 |
| 30 | 4,494 | 3,155 | 0.9초 | 5.5초 | 39.0초 | 47.5초 |
늘어난 것은 전부 프리필이다. CPU 프리필이 2.9초에서 39.0초로 열세 배가 됐는데, 답을 만드는 시간은 4.0초에서 5.9초로 거의 그대로다. GPU 쪽은 0.1초에서 0.9초다 — 같은 코드인데 한쪽은 신경 쓸 일이 없고 한쪽은 수업이 멈춘다.
2. 그러면 품질은 얼마나 좋아지나
앞 편에서 만든 채점기를 그대로 쓴다. k 만 바꿔가며 18점 만점을 매긴다.
# (28_grade.py 의 CASES · judge · grade 를 그대로 가져온다)
for k in [1, 3, 5, 10, 20, 30]:
total = 0
for question, kind, good, bad in CASES:
context = "\n\n".join(t for s, sl, t in search(question, k)) # 여기만 바뀐다
prompt = f"[자료]\n{context}\n\n[질문]\n{question}\n"
ok = 0
for _ in range(N):
res = client.chat.completions.create(
model="qwen2.5:3b",
messages=[
{"role": "system", "content": SOFT},
{"role": "user", "content": prompt},
],
)
if judge(res.choices[0].message.content, good, bad):
ok += 1
total += ok
print(f"k={k:<3} {total}/18")
앞 편에서 「한 번 돌려보고 좋아졌다고 말하지 않는다」고 했다. 그래서 세 번 돌렸다.
| k | 1회 | 2회 | 3회 | 평균 |
|---|---|---|---|---|
| 1 | 10 | 9 | 11 | 10.0 |
| 3 | 11 | 11 | 11 | 11.0 |
| 5 | 6 | 7 | 7 | 6.7 |
| 10 | 9 | 8 | 8 | 8.3 |
| 20 | 6 | 7 | 9 | 7.3 |
| 30 | 9 | 9 | 10 | 9.3 |
3. 🔴 조각을 열 배 넣고 시간을 다섯 배 쓰고 점수는 떨어졌다
k=3 은 자료 468자 · 입력 459토큰 · CPU 11.1초에 평균 11.0점이다. k=30 은 자료 4,494자 · 입력 3,155토큰 · CPU 47.5초에 평균 9.3점이다. 시간을 네 배 넘게 쓰고 점수를 잃었다.
답과 상관없는 조각이 같이 들어가기 때문이다. k=30 이면 27개는 대개 헛것이다. 앞 편에서 「오늘 점심 뭐 먹지」가 0.881 을 받는 것을 봤는데, 점수가 낮다고 안 들어오는 게 아니라 순서만 뒤일 뿐이라 k 를 늘리면 그런 조각이 그대로 딸려 들어온다.
작은 모델일수록 긴 자료에서 필요한 대목을 못 골라낸다. 실제로 정상 문항이 무너졌다 — k=3 에서 세 번 다 2/3 였는데 k=10 과 k=30 에서는 0/3 이 나왔다.
4. 결정적 실험 — 정답 조각을 넣어줬는데도
여기까지는 「방해가 되나 보다」로 넘길 수도 있다. 그래서 한 가지를 더 확인했다.
앞 편의 갈래 ② 문항을 기억할 것이다. 「우리가 자른 조각의 크기는 몇 자인가」 · 정답은 200자 · 정답이 든 조각이 23위였다. k=30 이면 23위는 들어온다.
question = "우리가 자른 조각의 크기는 몇 자인가"
for k in [3, 10, 30]:
hits = search(question, k)
inside = any("size=200" in text for score, slug, text in hits)
context = "\n\n".join(text for score, slug, text in hits)
print(f"--- k={k} 정답 조각 들어있나: {inside}")
for _ in range(3):
answer, sources = ask_with(context, question)
print(" >", answer[:60])
- --- k=3 정답 조각 들어있나: False
- > 69자입니다.
- > 6자부터 69자까지 다양한 크기로 자른다.
- > 57자
- --- k=10 정답 조각 들어있나: False
- > 자료에 없다.
- > 자료에 없다.
- > 69자짜리 조각이 점수가 가장 높다.
- --- k=30 정답 조각 들어있나: True
- > 69자 이내입니다.
- > 우리가 자른 조각의 크기는 69자다.
- > 69자에서 70자까지만 자르는 것이 적당하다.
k=30 에서 정답 조각은 분명히 들어갔는데 답은 여전히 69자다. 옆에 있던 6강의 길이 표에 끌려간 것이다. 검색이 못 데려온 문제를 k 로 밀어붙이면 시간만 다섯 배 쓰고 답은 그대로다. 필요한 것은 많이가 아니라 정확히다.
여기서 하나 더 보인다. 앞 편에서 갈래 ② 라고 이름 붙인 문항이 k=30 에서는 갈래 ③ 이 됐다.
- k=3근거가 안 들어왔다 → 갈래 ②
- k=30근거가 들어왔는데 못 읽는다 → 갈래 ③
- 다음 주 월요일모델을 바꾸면 풀리는지 본다
5. 그래서 k=3 으로 간다
내일부터 이 값으로 시작한다
- k
- 3
- 세 라운드 다 최고점 · CPU 11초
- model
- qwen2.5:3b
- 17강에서 정함
- system
- 역할 + 자료밖 금지 + 한국어 + 세 문장
- 앞 편 C · 13/18
- chunk_size
- 200자
- 9강에서 정하고 17강에서 검증
k=1 도 나쁘지 않았다는 것이 중요하다. 평균 10.0 으로 k=3 의 11.0 과 큰 차이가 없고 CPU 에서 9.4초로 제일 빠르다. 노트북이 유난히 느린 사람은 k=1 로 내려도 손해가 거의 없다. 이게 오늘의 진짜 수확이다 — 줄여도 된다는 것을 알고 줄이는 것. 재보지 않았으면 무서워서 못 줄인다.
이번 편에 나온 것
| 쓴 것 · 안 것 | 내용 |
|---|---|
options.num_predict | 출력 길이를 묶는다. 묶어야 비교가 된다 |
options.num_gpu = 0 | CPU 로만 돌린다. 학원 노트북을 흉내 낸다 |
keep_alive: 0 | 모델을 내린다. 안 내리면 앞 설정이 재사용된다 |
prompt_eval_duration | 입력을 읽은 시간(프리필). 나노초라 1e9 로 나눈다 |
| k 와 시간 | CPU 9.4초 → 47.5초. 늘어난 건 전부 프리필 |
| k 와 품질 | 우상향하지 않는다. k=3 이 세 번 다 최고 |
| 많이 넣기의 값 | 시간 네 배 · 점수 하락. 사면 안 되는 거래 |
| k=30 실험 | 정답 조각을 넣어줘도 못 읽는다 |
| 갈래는 움직인다 | k 를 바꾸면 ② 가 ③ 이 된다 |
오늘 하루를 정리하면
아침에는 노트북에 모델을 얹는 것부터 했고, 지금은 질문을 던지면 우리 문서 264편에서 근거를 찾아 답하는 것이 손에 있다. 그런데 오늘 배운 것을 한 줄로 줄이면 만드는 법이 아니라 재지 않으면 모른다는 것이다.
「자료에 없는 건 쓰지 마라」가 좋아 보였는데 멀쩡한 답을 죽이고 있었고, 「많이 넣으면 좋아진다」가 당연해 보였는데 시간만 네 배 쓰고 점수는 떨어졌고, 점수 0.88 이 높아 보였는데 「오늘 점심 뭐 먹지」도 0.881 을 받는다. 셋 다 눈으로 봐서는 반대로 판단했을 것들이다.
그리고 18점 중 여섯 점은 오늘 손댈 수 없었다. 검색이 못 데려온 세 점은 수요일에, 모델이 못 읽는 세 점은 다음 주 월요일에 열린다.
다음 편에서 답의 모양을 잡는다.