학습 자료

조각을 몇 개 넣을 것인가 — 오늘의 마지막 결정


Article

오늘 마지막 시간이다. 남은 결정이 하나 있다 — 조각을 몇 개 넣을 것인가.

오늘 여섯 시간 중 지금
  1. ①②③ RAG 를 만든다끝남
  2. ④ 갈래를 나눈다끝남
  3. ⑤ 프롬프트를 고친다끝남 · 13/18
  4. ⑥ 조각 수를 정한다지금 여기

search(question, k=3) 의 그 k 다. 3은 그냥 적어둔 값이고 아직 아무도 재보지 않았다.

직관은 「많이 넣으면 근거가 늘어나니 답이 좋아진다, 대신 느려진다」고 말한다. 절반만 맞다. 느려지는 것은 맞고 좋아지는 것은 아니다.

1. 시간부터 잰다

같은 질문에 k 만 바꿔가며 잰다. 출력은 120토큰으로 고정한다 — 답 길이가 들쭉날쭉하면 비교가 안 된다.

30_measure_k.py
# (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 별 시간 — qwen2.5:3b 실측 (출력 120토큰 고정)
k자료 글자입력 토큰GPU 프리필GPU 총CPU 프리필CPU 총
11842630.1초3.7초2.9초9.4초
34684590.1초4.4초4.7초11.1초
58587180.2초4.0초7.3초14.6초
101,6781,2780.3초4.2초14.1초21.4초
203,1672,2640.6초5.1초27.0초36.3초
304,4943,1550.9초5.5초39.0초47.5초
총 시간에는 모델을 올리는 3초쯤이 매번 섞여 있다. 비교는 프리필 칸으로 한다.
CPU 총 시간 — 학원 노트북이 겪는 것
k=19.4초
k=311.1초
k=514.6초
k=1021.4초
k=2036.3초
k=3047.5초
같은 코드를 GPU 에서 돌리면 3.7초에서 5.5초로 끝난다.

늘어난 것은 전부 프리필이다. CPU 프리필이 2.9초에서 39.0초로 열세 배가 됐는데, 답을 만드는 시간은 4.0초에서 5.9초로 거의 그대로다. GPU 쪽은 0.1초에서 0.9초다 — 같은 코드인데 한쪽은 신경 쓸 일이 없고 한쪽은 수업이 멈춘다.

2. 그러면 품질은 얼마나 좋아지나

앞 편에서 만든 채점기를 그대로 쓴다. k 만 바꿔가며 18점 만점을 매긴다.

31_quality_k.py
# (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 별 품질 — 18점 만점 · 세 라운드
k1회2회3회평균
11091110.0
311111111.0
56776.7
109888.3
206797.3
3099109.3
k=3 은 세 번 다 11 이 나왔다. 나머지는 흔들리는데 어느 것도 11 을 못 넘었다.
평균 점수 — 18점 만점
k=110/18
k=311/18
k=56.7/18
k=108.3/18
k=207.3/18
k=309.3/18
우상향이 아니다. 제일 적게 넣은 축에서 제일 높다.

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=10k=30 에서는 0/3 이 나왔다.

4. 결정적 실험 — 정답 조각을 넣어줬는데도

여기까지는 「방해가 되나 보다」로 넘길 수도 있다. 그래서 한 가지를 더 확인했다.

앞 편의 갈래 ② 문항을 기억할 것이다. 「우리가 자른 조각의 크기는 몇 자인가」 · 정답은 200자 · 정답이 든 조각이 23위였다. k=30 이면 23위는 들어온다.

32_k30_check.py
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 만 달라졌다
  1. k=3근거가 안 들어왔다 → 갈래 ②
  2. k=30근거가 들어왔는데 못 읽는다 → 갈래 ③
  3. 다음 주 월요일모델을 바꾸면 풀리는지 본다
갈래는 질문의 성질이 아니라 지금 설정에서의 상태다. 설정을 바꾸면 옮겨간다.

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 = 0CPU 로만 돌린다. 학원 노트북을 흉내 낸다
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점 중 여섯 점은 오늘 손댈 수 없었다. 검색이 못 데려온 세 점은 수요일에, 모델이 못 읽는 세 점은 다음 주 월요일에 열린다.

다음 편에서 답의 모양을 잡는다.

Share
  • 파이썬
  • AI
  • LLM
  • RAG
  • 성능