자를 만든다 — 골든셋 30명으로 채점한다
Article
앞 편까지 추천이 나오게 만들었다. 그런데 그게 좋은 추천인지는 한 번도 안 세어봤다.
눈으로 본다
지금까지 해온 것
- 「그럴듯한데?」
- 고칠 때마다 매번 다시 읽는다
- 두 방법 중 어느 쪽이 나은지 말할 수 없다
- 본 사람이 바뀌면 판정도 바뀐다
센다
오늘 만들 것
- 「30명 중 4명 맞혔다 — 13.3%」
- 고치고 다시 돌리면 숫자가 움직인다
- 어느 쪽이 나은지 말할 수 있다
- 누가 돌려도 같은 값이 나온다
1. 숨겨둔 300건에 이름을 붙인다
2편에서 is_holdout 이라는 낯선 칸을 봤다. 그 300건에 이제 이름을 붙인다.
- 무엇
- 정답이 붙어 있는 묶음
- is_holdout = 1 · 300건
- 규칙
- 학습·검색에 안 쓴다
- 쓰면 유출이다
- 쓰임
- 점수를 잴 때만 꺼낸다
- 자에 해당한다
답안지를 꺼내본다
import sqlite3
con = sqlite3.connect("shop.db")
cur = con.cursor()
print("골든셋:", cur.execute("SELECT COUNT(*) FROM purchases WHERE is_holdout = 1").fetchone()[0], "건")
print("이력 :", cur.execute("SELECT COUNT(*) FROM purchases WHERE is_holdout = 0").fetchone()[0], "건")
# dict 로 감싸면 [(고객, 상품), ...] 이 {고객: 상품} 이 된다.
# 고객 번호로 바로 정답을 꺼낼 수 있어 채점할 때 편하다
answers = dict(cur.execute(
"SELECT customer_id, product_id FROM purchases WHERE is_holdout = 1").fetchall())
print("고객 수:", len(answers))
print("C001 의 정답:", answers["C001"])
# 정답 상품이 몇 종류인가. 한 상품에 몰려 있으면 그것만 찍어도 점수가 나온다
print("정답 상품 종류:", cur.execute(
"SELECT COUNT(DISTINCT product_id) FROM purchases WHERE is_holdout = 1").fetchone()[0])
- 골든셋: 300 건
- 이력 : 1200 건
- 고객 수: 300
- C001 의 정답: P106
- 정답 상품 종류: 153
자를 쓰기 전에 자를 검사한다
정답이 이력에도 들어 있으면 채점이 헐거워진다. 이미 산 것을 또 추천해도 맞은 것이 되기 때문이다.
duplicated = 0
for customer_id, answer in answers.items():
bought = {r[0] for r in cur.execute(
"SELECT product_id FROM purchases WHERE customer_id = ? AND is_holdout = 0",
(customer_id,))}
if answer in bought:
duplicated += 1
print("이력에도 있는 정답:", duplicated, "건")
- 이력에도 있는 정답: 0 건
재는 도구가 틀리면 그 뒤에 하는 일이 전부 무의미해진다. 그런데 재는 도구가 틀렸다는 것은 아무 에러도 안 낸다 — 숫자는 멀쩡하게 나온다. 그래서 이 편의 마지막 미션이 채점기가 맞는지 채점하는 법이다.
2. db.py — 흩어진 연결을 한 곳으로 모은다
채점기를 만들기 전에 정리를 한 번 한다. 지금 만든 파일들을 세어보면 이렇다.
- ai-eng-svc
- 02_load_db.py
- 03_query.py
- 07_embed_products.py
- 09_embed_customers.py
- 10_ask_json.py
- config.py
같은 두 줄이 다섯 군데에 있다. 지금은 아무 문제가 없다. 문제는 다음 주에 생긴다.
"""데이터베이스에 닿는 자리를 여기 하나로 모은다.
다른 파일은 전부 이렇게 쓴다 ─
from db import query, one
"""
import sqlite3
DB_PATH = "shop.db"
con = sqlite3.connect(DB_PATH)
def query(sql, params=()):
"""여러 줄을 꺼낸다."""
return con.execute(sql, params).fetchall()
def one(sql, params=()):
"""한 줄만 꺼낸다. 없으면 None 이 온다."""
return con.execute(sql, params).fetchone()
| 이름 | 무슨 뜻인가 | 왜 여기 나오나 |
|---|---|---|
DB_PATH | 파일 이름을 담아둔 값 | 대문자는 「바꾸지 않는 값」이라는 표시다 |
params=() | 안 넘기면 빈 묶음 | 물음표가 없는 SQL 도 그냥 부를 수 있다 |
con.execute(...) | cur 없이 바로 실행 | sqlite 는 손을 알아서 만들어준다 |
query / one | 여러 줄 / 한 줄 | fetchall · fetchone 을 매번 안 적으려고 |
이제 다른 파일에서는 이렇게 쓴다.
from db import query, one
customer = one("SELECT age, gender, skin_type FROM customers WHERE customer_id = ?", ("C007",))
history = query("SELECT product_id, rating FROM purchases WHERE customer_id = ?", ("C007",))
지금 안 모으면
파일 다섯 개
- 다섯 곳을 똑같이 고친다
- 하나 빠뜨리면 에러 없이 옛날 데이터를 본다
- 앞으로 파일이 더 늘어난다
모아두면
db.py 한 줄
connect한 줄만 갈아끼운다- SQL 문장은 그대로 간다
- 물음표(
?)가%s로 바뀌는 정도
3. 채점기 — 맞았는지 세는 한 줄
맞았다는 것을 먼저 정한다. 추천 다섯 개 안에 정답이 있으면 맞은 것이다.
| 이름 | 무슨 뜻인가 | 무엇을 말해주나 |
|---|---|---|
| hit@1 | 첫 번째 추천이 정답이었다 | 순위까지 잘 매겼나 |
| hit@3 | 위 셋 안에 정답이 있었다 | 화면 첫 줄에 뜨나 |
| hit@5 | 위 다섯 안에 정답이 있었다 | 이번 시리즈의 기준 자 |
@ | at — 「몇 개까지 봤을 때」 | k 를 안 적으면 아무 뜻이 없다 |
왜 30명인가
채점기 코드
import json
import time
import numpy as np
from config import client, MODEL
from db import query, one # 이제 sqlite3 를 여기서 안 부른다
N = 30 # 채점할 고객 수
N_CANDIDATES = 10 # 모델에게 줄 후보 개수
N_PICK = 5 # 그중 고르게 할 개수
# 저장해둔 벡터를 꺼낸다 (4·5편)
products = query("SELECT product_id, vector FROM product_vectors")
product_ids = [p for p, _ in products]
V = np.array([json.loads(v) for _, v in products], dtype="float32")
customers = query("SELECT customer_id, vector FROM customer_vectors")
customer_ids = [c for c, _ in customers]
CV = np.array([json.loads(v) for _, v in customers], dtype="float32")
# 답안지 (골든셋)
answers = dict(query("SELECT customer_id, product_id FROM purchases WHERE is_holdout = 1"))
def candidates_for(customer_id, index):
"""이미 산 것을 빼고, 뜻이 가까운 상품 열 개를 고른다. 5편에서 만든 것이다."""
bought = {r[0] for r in query(
"SELECT product_id FROM purchases WHERE customer_id = ? AND is_holdout = 0",
(customer_id,))}
scores = V @ CV[index]
picked = [product_ids[i] for i in scores.argsort()[::-1]
if product_ids[i] not in bought][:N_CANDIDATES]
# 번호만으로는 모델에게 줄 수 없다. 이름·브랜드·가격을 붙여 온다.
# 물음표를 개수만큼 만들어 IN 에 넣는다 — 1편에서 한 것과 같은 방법이다
marks = ",".join("?" * len(picked))
rows = query(
f"SELECT product_id, name, brand, price FROM products WHERE product_id IN ({marks})",
tuple(picked))
# IN 으로 꺼내면 순서가 뒤섞여 온다. 가까운 순서대로 되돌려 놓는다
order = {p: i for i, p in enumerate(picked)}
return sorted(rows, key=lambda row: order[row[0]])
모델에게 물어보는 부분은 앞 편에서 만든 것을 함수 둘로 묶기만 하면 된다.
def prompt_for(customer_id, candidates):
"""9편의 08_safe_prompt.py 그대로다. 이름·전화·메일은 SELECT 에 아예 없고,
후기에만 mask_text 를 건다."""
age, gender, skin_type = one(
"SELECT age, gender, skin_type FROM customers WHERE customer_id = ?", (customer_id,))
history = query("""
SELECT products.name, products.price, purchases.rating, purchases.review
FROM purchases JOIN products ON purchases.product_id = products.product_id
WHERE purchases.customer_id = ? AND purchases.is_holdout = 0
""", (customer_id,))
lines = "\n".join(f"- {name} {price}원 (별점 {rating}) {mask_text(review)}"
for name, price, rating, review in history)
catalog = "\n".join(f"{i} | {n} | {b} | {p}원" for i, n, b, p in candidates)
return f"""아래 고객에게 어울릴 상품 {N_PICK}개를 후보에서 골라라.
[고객] {age}세 {gender} · {skin_type}
[구매 이력]
{lines}
[후보]
{catalog}
좋은 순서대로 {N_PICK}개를 고른다."""
def ask_model(customer_id, candidates):
"""10편의 12_schema.py 를 함수로 묶었다.
추천만 꺼내(res.choices[0].message.parsed) 돌려주면 깔끔해 보이는데,
그러면 토큰 수(usage)가 같이 버려진다. 재려고 만드는 코드에서
잴 것을 버리면 안 되므로 응답을 통째로 돌려준다.
"""
return client.chat.completions.parse(
model=MODEL,
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": prompt_for(customer_id, candidates)},
],
response_format=Recommend, # 10편에서 만든 pydantic 클래스
)
이제 30명을 도는 부분이다.
hits = {1: 0, 3: 0, 5: 0} # hit@1 · hit@3 · hit@5 를 한 번에 센다
in_candidates = 0 # 후보 안에 정답이 있었던 횟수
started_all = time.time()
log = open("runs.jsonl", "a", encoding="utf-8") # a = 이어 붙이기
for index in range(N):
customer_id = customer_ids[index]
candidates = candidates_for(customer_id, index)
candidate_ids = [c[0] for c in candidates]
answer = answers[customer_id]
# 정답이 후보에 아예 없으면 모델이 무엇을 하든 못 맞힌다. 그걸 따로 센다
in_candidates += answer in candidate_ids
started = time.time()
res = ask_model(customer_id, candidates) # 10편에서 만든 것. 응답을 그대로 돌려준다
took = time.time() - started
recommend = res.choices[0].message.parsed
# 후보 밖 번호는 버린다 (10편). 남은 것이 진짜 추천이다
picks = [it.product_id for it in recommend.items if it.product_id in candidate_ids]
for k in hits:
hits[k] += answer in picks[:k]
# 관측 기록 — 한 줄이 한 번의 호출이다
log.write(json.dumps({
"customer_id": customer_id,
"took": round(took, 2),
"in_tokens": res.usage.prompt_tokens,
"out_tokens": res.usage.completion_tokens,
"picks": picks,
"answer": answer,
"hit": answer in picks[:5],
}, ensure_ascii=False) + "\n")
log.flush() # 바로 파일에 쓴다. 중간에 멈춰도 여기까지는 남는다
print(f"{index + 1:2d}/{N} {customer_id} {took:5.1f}초 "
f"{'HIT' if answer in picks[:5] else ''}", flush=True)
log.close()
elapsed = time.time() - started_all
print(f"\n{N}명 {elapsed / 60:.1f}분 (한 명당 {elapsed / N:.1f}초)")
for k in (1, 3, 5):
print(f"hit@{k} {hits[k] / N * 100:.1f}%")
print(f"후보 안에 정답이 있던 비율 {in_candidates / N * 100:.1f}%")
| 이름 | 무슨 뜻인가 | 왜 여기 나오나 |
|---|---|---|
hits = {1: 0, 3: 0, 5: 0} | 세 개의 세는 통 | 세 숫자를 한 번에 센다 |
answer in picks[:k] | 앞 k 개 안에 있나 | 이 한 줄이 채점기의 전부다 |
in_candidates | 후보에 정답이 있었나 | 오늘의 핵심 숫자다. 뒤에서 본다 |
open(..., "a") | 이어 붙이기 | 돌릴 때마다 밑에 쌓인다 |
flush() | 당장 파일에 쓴다 | 중간에 멈춰도 기록이 남는다 |
ensure_ascii=False | 한글을 한글로 적는다 | 빼면 알아볼 수 없게 저장된다 |
4. 돌려본다
- 1/30 C001 9.4초
- 6/30 C006 11.9초 HIT
- 7/30 C007 18.6초 HIT
- …
- 30/30 C030 13.4초
- 30명 7.6분 (한 명당 15.3초)
- hit@1 10.0%
- hit@3 13.3%
- hit@5 13.3%
- 후보 안에 정답이 있던 비율 20.0%
5편에서 모델 없이 잰 값이 있으니 나란히 놓아본다.
| hit@1 | hit@3 | hit@5 | 맞힌 사람 | |
|---|---|---|---|---|
| 벡터검색 상위 5개 · 5편 방식 | 6.7% | 13.3% | 13.3% | 4명 |
| 모델이 후보 10개 중 5개 | 10.0% | 13.3% | 13.3% | 4명 |
상용 모델로 바꾸면 어떻게 되나
7편의 길을 골랐다면 config.py 세 줄만 바꿔 같은 채점기를 그대로 돌릴 수 있다. 돌려봤다.
| 내 노트북 (3B) | 상용 (gpt-4o-mini) | |
|---|---|---|
| 걸린 시간 | 7.6분 | 1.5분 |
| hit@1 | 10.0% | 6.7% |
| hit@3 | 13.3% | 10.0% |
| hit@5 | 13.3% | 13.3% |
| 5개를 다 낸 사람 | 17/30 | 29/30 |
| 후보 밖 번호 | 5개 | 1개 |
| 비용 | 0원 | 약 8원 |
🔴 다섯 개를 시켰는데 다섯 개가 안 온다
기록을 열어보면 하나가 더 보인다.
- 5개 온 사람 17명
- 4개 온 사람 11명
- 2개 온 사람 1명
- 0개 온 사람 1명 (C030)
- 같은 번호를 두 번 낸 사람 2명 (C014 · C017)
- 후보 밖 번호 136개 중 5개
5. 🔴 점수보다 먼저 봐야 할 숫자
후보 안에 정답이 있던 비율이 20%다.
- 상품 200개정답이 여기 있다
- 벡터로 후보 10개여기서 80%가 탈락
- 모델이 5개 고른다없는 것은 못 고른다
- hit@5 13.3%천장이 20%다
30명을 셋으로 갈라보면
| 어떤 사람 | 몇 명 | 무슨 뜻인가 |
|---|---|---|
| 후보 상위 5개 안에 정답이 있었다 | 4명 | 모델이 없어도 맞히는 사람 |
| 후보 6~10위에 정답이 있었다 | 2명 | 모델이 끌어올렸어야 할 사람 |
| 후보 10개에 정답이 아예 없었다 | 24명 | 모델이 무엇을 하든 못 맞히는 사람 |
제일 큰 덩어리는 셋째 줄이다. 24명은 후보를 만드는 단계에서 이미 진 사람들이라, 프롬프트를 아무리 다듬어도 안 움직인다. 프롬프트로 건드릴 수 있는 것은 가운데 두 명이고, 거기에 하루를 써서 얻을 수 있는 최대치가 6.7%다.
어디에 시간을 쓸지를 숫자가 정해줬다 — 후보를 잘 고르는 일이 먼저고, 프롬프트를 다듬는 일이 그다음이다.
후보를 늘리면 천장이 올라간다
6. 관측 — 재는 김에 남긴다
채점하면서 runs.jsonl 에 한 줄씩 쌓아뒀다. 한 줄이 한 번의 호출이다.
- {"customer_id": "C007", "took": 18.64, "in_tokens": 625,
- "out_tokens": 336, "picks": ["P140", "P059", "P188", "P154", "P058"],
- "answer": "P140", "hit": true}
이번 편에 나온 것
| 한 것 · 안 것 | 내용 |
|---|---|
| 골든셋 | 정답이 붙어 있고 점수를 잴 때만 꺼내는 묶음. is_holdout = 1 300건 |
| 자를 먼저 검사 | 정답이 이력에도 있나 — 0건이라 그대로 쓴다 |
db.py | 흩어진 connect 를 한 곳으로. 다음 주에 고칠 파일이 하나가 된다 |
| hit@k | 위 k 개 안에 정답이 있나. k 와 표본 수를 반드시 같이 적는다 |
| 채점 코드 | answer in picks[:k] 한 줄. 10편에서 모양을 고정한 값이다 |
| 30명인 이유 | 300명은 65분. 30명이면 7~8분 · 한 명이 3.3% |
🔴 IN 의 순서 | 적어 넣은 순서대로 안 온다. 다시 정렬한다 |
| 오늘 점수 | hit@1 10.0% · hit@3 13.3% · hit@5 13.3% |
| 모델을 얹은 값 | hit@5 가 안 늘었다. 순서만 다시 매겼다 |
| 🔴 개수도 부탁 | 5개를 시켰는데 다 낸 사람이 17/30. 0개도 하나 있었다 |
| 🔴 천장 | 후보 안에 정답이 있던 비율 20%. 모델이 완벽해도 못 넘는다 |
| 관측 | runs.jsonl 에 시간·토큰·결과를 쌓아만 둔다. 읽는 것은 뒤에서 |
| 상용으로 바꿔도 | 형식 사고는 사라지는데 hit@5 는 그대로. 천장에 갇힌다 |
| 결론 | 점수가 안 오르면 모델이 아니라 병목을 본다 |
미션
- 01
[필수] 30명을 채점하고 숫자를 적는다
mission_grade_1.py.21_grade.py를 돌려 hit@1 · hit@3 · hit@5 와 걸린 시간을 적는다. 「후보 안에 정답이 있던 비율」도 같이 적는다. - 02
[응용] 후보 개수를 바꿔 다시 잰다
mission_grade_2.py.N_CANDIDATES를 5 와 20 으로 바꿔 다시 돌린다. 천장과 점수가 같이 움직이나, 따로 움직이나. 걸린 시간도 같이 본다. - 03
[도전] 채점기가 맞는지 채점한다
mission_grade_3.py. 모델을 아예 떼고 후보 앞 다섯 개를 그대로 답으로 내보낸다. 이때 나오는 hit@5 는 5편에서 한 벡터검색을 같은 30명에 돌린 값과 정확히 같아야 한다. 다르면 어딘가 틀린 것이다.
[도전] 모델을 떼면 채점기를 검산할 수 있다충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
13.3% 가 나온다. 5편의 벡터검색을 같은 30명에 돌린 값과 똑같다. 당연하다 — 후보를 가까운 순서로 뽑아 앞에서 다섯 개를 그대로 냈으니, 그건 벡터검색 상위 5개와 정확히 같은 목록이다.
여기서 확인되는 것이 셋이다. 후보를 제대로 뽑고 있고, 이미 산 것을 제대로 빼고 있고, 정답 대조가 제대로 돌고 있다.
만약 두 값이 달랐다면 점수를 보기 전에 그것부터 고쳐야 한다. 채점기가 틀린 상태에서 프롬프트를 고치면, 좋아졌는지 나빠졌는지를 틀린 자로 재게 된다.
이 방식에 이름이 있다 — 알고 있는 답이 나오는 입력을 넣어보는 것이다. 재는 도구를 만들 때마다 한 번씩 해두면 크게 헤매지 않는다.
# 모델을 부르는 줄만 빼고 나머지는 21_grade.py 와 똑같다
hit = 0
for index in range(N):
customer_id = customer_ids[index]
candidates = candidates_for(customer_id, index)
picks = [c[0] for c in candidates][:5] # 모델 대신 — 그냥 앞에서 다섯 개
hit += answers[customer_id] in picks
print(f"가짜 추천기 hit@5 {hit / N * 100:.1f}%")7분이 0.1초로 끝난다. 모델을 안 부르니 그렇다. 채점기를 고칠 때는 이 가짜 추천기로 먼저 돌려보면 훨씬 빠르게 다듬을 수 있다.
그리고 이 값은 다음 편부터 계속 쓸 기준선이다. 무엇을 고치든 13.3% 보다 나은지를 먼저 묻는다. 못 넘으면 모델을 부를 이유가 없다.
정리하면
오늘 만든 것은 추천이 아니라 자다. 숨겨둔 300건에 골든셋이라는 이름을 붙였고, 30명을 돌려 점수를 냈다.
자를 쓰기 전에 자부터 검사했다. 정답이 이력에도 들어 있으면 채점이 헐거워지는데, 세어보니 0건이라 그대로 썼다. 재는 도구가 틀렸다는 것은 아무 에러도 안 낸다.
그리고 db.py 를 만들었다. 같은 connect 두 줄이 파일 다섯 군데에 흩어져 있었다. 다음 주에 Supabase 로 옮길 때 고칠 자리가 한 곳이 된다 — SQL 문장은 거의 그대로 간다.
점수는 hit@5 13.3% 가 나왔다. 벡터검색만 했을 때와 똑같다. 7분을 들여 모델을 서른 번 불렀는데 맞힌 사람은 그대로 4명이다. 달라진 것은 hit@1 하나 — 한 명이 3위에서 1위로 올라왔다. 모델은 순서를 다시 매겼을 뿐, 새로운 답을 데려오지 못했다.
그리고 진짜 발견은 그 아래 줄에 있었다. 후보 열 개 안에 정답이 있던 비율이 20%다. 열 명 중 여덟 명에게는 애초에 정답이 배달되지 않았다. 모델을 아무리 좋은 것으로 바꿔도 20%를 못 넘는다.
30명을 갈라보면 더 분명해진다. 모델이 없어도 맞히는 사람이 4명, 모델이 끌어올렸어야 할 사람이 2명, 무엇을 해도 못 맞히는 사람이 24명이다. 프롬프트를 다듬어 얻을 수 있는 최대치가 두 명이라는 뜻이다.
점수 하나만 봤으면 「모델이 시원찮네」로 끝났을 것이다. 실제로 상용 모델로 바꿔 돌려봤더니 시간은 5분의 1이 되고 형식 사고는 사라졌는데 hit@5 는 13.3% 그대로였다 — 천장이 20%라 그 아래에 갇힌 것이다. 병목이 어디인지 알려주는 숫자를 같이 재는 것 — 그게 채점기를 직접 만드는 이유다.
다음 편에서 후보를 다시 고른다. 개수를 늘리는 대신 더 잘 고른다. 4편에서 「3만원 이하 선물용」에 42,900원이 1위로 왔던 그 장면을 기억할 것 — 조건은 SQL 이 자르고, 뜻은 벡터가 고른다. 그 순서를 지키면 이 20% 가 어디까지 올라가는지 본다.