점수를 올린다 — 그 전에 자가 얼마나 흔들리는지부터 잰다
Article
앞 편이 불편한 질문 하나를 남겼다. 모델이 벡터가 이미 낸 값을 그대로 낸다.
가운데 막대를 오른쪽 막대 쪽으로 밀어야 한다. 오늘 하는 일이 그것이다.
1. 손잡이가 넷 있다
| 손잡이 | 지금 값 | 바꿔볼 값 |
|---|---|---|
| 후보 개수 | 10개 | 5 · 20 · 50 |
| 시키는 말 | 「좋은 순서대로 5개」 | 「이미 산 것과 비슷한 것 말고 다음에 살 것」 |
| 이유 길이 | 한 문장 | 40자 이내 · 아예 없이 |
| 고객 문장 | 이름 + 별점 + 후기 | 이름만 · 후기만 · 별점 높은 것만 |
2. 실험에는 규칙이 있다
- 01
① 한 번에 하나만 바꾼다
후보를 20개로 늘리면서 프롬프트도 같이 고치면, 좋아져도 무엇 때문인지 모른다. 나빠져도 마찬가지다.
- 02
② hit@5 로 판정한다
「답이 그럴듯해졌다」는 판정이 아니다. 11편에서 만든 자로만 잰다.
- 03
③ 여러 번 돌린다
이게 오늘의 진짜 이야기다. 왜 필요한지는 바로 아래에서 본다.
조합을 표로 만들어둔다
손잡이를 돌릴 때마다 코드를 고치면 무엇을 무엇과 비교했는지 잊어버린다. 조합에 이름을 붙여 표로 둔다.
# 시키는 말은 따로 모아둔다. 프롬프트를 고칠 때 여기만 본다
TASKS = {
"base": "좋은 순서대로 5개를 고른다.",
"next_buy": ("이미 산 것과 비슷한 것을 또 고르지 마라. "
"이력을 보고 다음에 살 만한 것을 좋은 순서대로 5개 고른다."),
}
# 조합에 이름을 붙인다. dict(...) 는 {"n": 10, ...} 과 같은 뜻이다
CONFIGS = {
"base": dict(n=10, task="base", reason="sentence"),
"cand5": dict(n=5, task="base", reason="sentence"),
"cand20": dict(n=20, task="base", reason="sentence"),
"next_buy": dict(n=10, task="next_buy", reason="sentence"),
"short": dict(n=10, task="base", reason="short"),
}
| 이름 | 무슨 뜻인가 | 왜 여기 나오나 |
|---|---|---|
dict(n=10, ...) | 딕셔너리를 만드는 다른 방법 | 따옴표를 안 써도 되어 표처럼 읽힌다 |
| 조합에 이름 | base · cand20 · next_buy | 결과를 적을 때 이름으로 적는다. 나중에 대조된다 |
TASKS 를 따로 | 시키는 말만 모은 표 | 프롬프트를 고칠 때 여기 한 곳만 본다 |
이유 길이는 받을 모양을 바꾸는 것이라 10편의 클래스를 하나 더 만든다.
class ItemWithReason(BaseModel):
product_id: str = Field(description="후보 목록에 있는 상품 번호")
reason: str = Field(description="추천 이유 한 문장")
class ItemShort(BaseModel):
product_id: str = Field(description="후보 목록에 있는 상품 번호")
reason: str = Field(description="추천 이유. 40자 이내")
class ItemOnly(BaseModel): # 이유를 아예 안 받는다
product_id: str = Field(description="후보 목록에 있는 상품 번호")
3. 🔴 조합을 비교하기 전에 — 자가 얼마나 흔들리나
아무것도 안 바꾸고 같은 조합을 네 번 돌렸다.
- 조합 후보 천장 hit@1 hit@3 hit@5
- base 10 30.0% 10.0% 13.3% 23.3%
- base 10 30.0% 10.0% 13.3% 26.7%
- base 10 30.0% 10.0% 16.7% 26.7%
- base 10 30.0% 10.0% 16.7% 20.0%
- hit@5 이 20.0% 에서 26.7% 사이에서 흔들린다
4. 손잡이를 하나씩 돌린다
조합마다 세 번 이상 돌렸다. **범위가 겹치면 「차이 없음」**으로 읽는다.
조합 하나를 세 번씩 돌려 범위를 본다.
import statistics
for name, cfg in CONFIGS.items():
scores = []
for _ in range(3): # 세 번씩. 한 번은 아무 증거가 아니다
scores.append(run(name, cfg)) # 11편의 채점기를 설정만 바꿔 부른다
# 평균만 적으면 안 된다. 최저와 최고를 같이 적는다
print(f"{name:12s} {min(scores):5.1f} ~ {max(scores):5.1f}% "
f"평균 {statistics.mean(scores):5.1f}%")
- base 20.0 ~ 26.7% 평균 24.4%
- cand5 23.3 ~ 23.3% 평균 23.3%
- cand20 16.7 ~ 23.3% 평균 18.7%
- next_buy 23.3 ~ 26.7% 평균 26.0%
후보 개수 — 늘리면 천장은 오르는데 점수가 안 따라온다
| 후보 | 천장 | hit@5 (최저~최고) | 평균 | 회수율 |
|---|---|---|---|---|
| 5개 | 23.3% | 23.3 ~ 23.3% | 23.3% | 100% |
| 10개 | 30.0% | 20.0 ~ 26.7% | 24.4% | 81% |
| 20개 | 33.3% | 16.7 ~ 23.3% | 18.7% | 56% |
| 50개 | 50.0% | 20.0 ~ 23.3% | 21.7% | 43% |
시키는 말 — 여기가 유일하게 움직였다
지금까지
base
- 「좋은 순서대로 5개를 고른다.」
- hit@5 20.0 ~ 26.7% (평균 24.4)
- 회수율 81%
바꾼 뒤
next_buy
- 「이미 산 것과 비슷한 것을 또 고르지 마라.
- 이력을 보고 다음에 살 만한 것을 골라라.」
- hit@5 23.3 ~ 26.7% (평균 26.0)
- 회수율 87%
이유 길이 — 점수는 그대로, 시간과 돈이 준다
| 시킨 것 | hit@5 평균 | 출력 토큰 | 걸린 시간 |
|---|---|---|---|
| 한 문장 (지금) | 24.4% | 4,244 | 75초 |
| 40자 이내 | 24.4% | 3,526 | 69초 |
| 아예 안 씀 | 22.8% | 1,050 | 33초 |
🔴 좋은 것 둘을 합치면 좋아질까
next_buy(26.0%)가 제일 좋았고 후보를 늘리면 천장이 오르니, 둘을 합쳐봤다.
- 조합 후보 천장 hit@5 평균 회수율
- next_buy 10 30.0% 26.0% 87%
- cand20 20 33.3% 18.7% 56%
- next_cand20 20 33.3% 20.0% 60%
5. 고객 문장을 바꾸면 — 여기는 모델이 필요 없다
넷째 손잡이는 고객 벡터를 만드는 방식이다. 이건 후보를 뽑는 단계라 모델을 안 부른다. 그래서 300명을 다 돌려도 몇 초다.
- 고객 문장을 만드는 방식 평균 글자 천장@10 벡터 hit@5
- 이름 + 별점 + 후기 (지금) 260 20.3% 13.7%
- 이름만 49 20.3% 13.0%
- 이름 + 후기 232 19.7% 13.7%
- 별점 4 이상만 167 20.0% 12.3%
- 후기만 182 18.3% 9.3%
6. 오늘 고른 것
드디어 모델이 벡터를 넘었다. 다만 평균으로만 넘었다 — 나쁜 회차에는 23.3% 로 벡터와 같다.
| 손잡이 | 고른 값 | 왜 |
|---|---|---|
| 후보 개수 | 10개 | 5개는 고를 게 없고, 20개부터 회수율이 무너진다 |
| 시키는 말 | 「다음에 살 것」 | 평균 +1.6%p · 바닥이 20.0 → 23.3% |
| 이유 길이 | 40자 이내 | 점수 그대로 · 출력 17% 절약 |
| 고객 문장 | 이름 + 별점 + 후기 유지 | 후기를 빼도 같지만, 차이가 두 명이라 지금은 안 건드린다 |
이번 편에 나온 것
| 한 것 · 안 것 | 내용 |
|---|---|
| 🔴 자의 흔들림 | 아무것도 안 바꿔도 20.0~26.7%. 이 폭보다 작은 차이는 못 믿는다 |
| 실험 규칙 | 하나씩 바꾼다 · hit@5 로 판정한다 · 여러 번 돌린다 |
| 회수율 | 천장 대비 실제로 건진 비율. 후보가 길수록 무너진다 |
| 후보 5개 | 회수율 100% — 모델이 고를 게 없다는 뜻이다 |
| 🔴 후보 50개 | 천장 50% 를 만들어놓고 21.7% 만 건진다 (회수율 43%) |
| 후보 10개 | 모델이 고를 여지가 있으면서 감당되는 길이 |
| 프롬프트 | 「다음에 살 것」 — 평균 +1.6%p · 바닥이 올랐다 |
| 이유 길이 | 점수는 그대로, 시간 절반 · 출력 4분의 1. 그래도 화면에는 필요하다 |
| 🔴 좋은 것 둘을 합치면 | 나쁜 쪽을 따라간다. 합친 것도 따로 재야 한다 |
| 🔴 후기 | 거의 일을 안 하고 있었다. 신호는 상품 이름에 있다 |
| 결론 | 하나만 움직였다. 나머지는 안 나빠지는 선에서 싸게 고른 것이다 |
미션
- 01
[필수] 내 자가 얼마나 흔들리는지 잰다
mission_tune_1.py. 아무것도 안 바꾸고 세 번 돌려 hit@5 의 최저~최고를 적는다. 이 폭이 내 판정 기준이 된다. - 02
[응용] 손잡이 하나를 골라 돌린다
mission_tune_2.py. 후보 개수나 프롬프트 중 하나만 바꿔 세 번씩 돌린다. 범위가 겹치나 안 겹치나로 판정한다. - 03
[도전] 내가 지은 프롬프트로 이겨본다
mission_tune_3.py. 「다음에 살 것」 말고 다른 말을 지어 넣어본다. 26.0% 를 넘는 것을 찾으면 그게 오늘의 성과다.
[도전] 프롬프트를 지을 때 걸리는 것들충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
「더 잘 골라라」류는 거의 안 듣는다. 「신중하게」 「전문가처럼」 같은 말은 모델이 할 일을 바꾸지 않는다. 이미 잘 고르려고 하고 있다.
듣는 것은 우리가 아는 약점을 말로 메울 때다. 「다음에 살 것」이 들은 이유가 그것이다 — 5편에서 벡터가 「비슷한 것」만 찾는다는 것을 이미 재서 알고 있었고, 그 빈자리를 지시로 채웠다.
그래서 프롬프트를 짓기 전에 틀린 것들을 먼저 들여다보는 것이 순서다. 12편에서 아홉 명을 한 줄씩 본 것처럼. 무엇을 틀리는지 모르면 무슨 말을 넣을지도 모른다.
그리고 길게 쓴다고 좋아지지 않는다. 지시가 길어지면 입력 토큰이 늘고, 모델이 지켜야 할 것이 늘어 오히려 흔들린다.
TASKS = {
"base": "좋은 순서대로 5개를 고른다.",
"next_buy": (
"이미 산 것과 비슷한 것을 또 고르지 마라. "
"이력을 보고 다음에 살 만한 것을 좋은 순서대로 5개 고른다."
),
# 여기에 내 것을 하나 더 만든다
"mine": "...",
}
# 조합마다 세 번씩. 범위가 겹치면 차이 없음으로 읽는다
for name in ("base", "next_buy", "mine"):
for _ in range(3):
print(run(name, CONFIGS[name]))26.0% 를 못 넘어도 실패가 아니다. 「이 말은 안 듣는다」를 하나 알아낸 것이고, 그건 다음에 안 해도 되는 일이 하나 준 것이다.
실무에서 프롬프트를 다듬는 일이 대개 이렇다. 열 개를 지어 하나가 걸린다. 그 하나를 찾으려면 아홉 개를 재봐야 한다.
정리하면
오늘 만든 것은 없다. 이미 있는 것의 손잡이를 넷 돌려봤고, 그중 하나만 실제로 움직였다.
제일 먼저 한 일이 조합 비교가 아니라 자가 얼마나 흔들리는지 재는 것이었다. 아무것도 안 바꾸고 네 번 돌렸더니 20.0%에서 26.7%까지 흔들렸다 — 30명 기준으로 두 명이다. 이걸 모르고 조합을 한 번씩 돌려 제일 높은 것을 고르면, 좋아진 것을 고른 게 아니라 운 좋은 회차를 고른 것이 된다.
후보 개수에서 제일 뜻밖의 것이 나왔다. 50개를 주면 천장이 50%까지 오르는데 점수는 21.7%다. 회수율이 43%로 무너진다. 10개일 때가 81%였다. 많이 주는 것과 잘 주는 것은 다르고, 그 사이 어딘가에 답이 있다.
실제로 점수를 움직인 것은 프롬프트 한 줄이었다. 「이미 산 것과 비슷한 것 말고 다음에 살 것」 — 평균이 24.4%에서 26.0%로, 무엇보다 바닥이 20.0%에서 23.3%로 올랐다. 그게 값을 한다. 서비스는 평균으로 도는 게 아니라 매 요청마다 돌기 때문이다.
그리고 이 한 줄이 들은 이유가 있다. 5편에서 「벡터는 비슷한 것을 찾지 다음에 살 것을 찾지 않는다」를 재서 알고 있었다. 그 약점을 말로 메운 것이라 우연이 아니다. 무엇을 틀리는지 모르면 무슨 말을 넣을지도 모른다.
이유 길이에서는 점수가 아니라 시간과 돈을 얻었다. 아예 빼면 시간이 절반 이하인데 점수는 거의 같다 — 그래도 화면에 이유가 없으면 사용자가 안 믿으니 40자로 제한하는 것이 답이었다. 공짜로 얻는 것은 챙긴다.
마지막으로 후기가 거의 일을 안 하고 있었다. 상품 이름만 넣어도 점수가 같다. 8·9편에서 후기의 개인정보를 가리느라 한 편 반을 썼는데, 애초에 안 꺼내면 그 문제가 통째로 사라진다. 재보기 전에는 못 하던 판단이다.
다음 편에서 서버를 세운다. 지금까지 만든 것은 전부 내 컴퓨터에서 파일을 실행하는 것이라 남이 못 쓴다. 브라우저에서 열리는 것으로 만들고, 그 앞에 자물쇠를 하나 단다.