조건은 SQL 이 자르고, 뜻은 벡터가 고른다
Article
앞 편에서 자를 만들었고, 그 자가 어디를 고쳐야 하는지까지 알려줬다.
- 상품 200개정답이 여기 있다
- 벡터로 후보 10개여기서 정답의 80%가 탈락
- 모델이 5개 고른다없는 것은 못 고른다
- hit@5 13.3%천장이 20%다
1. 먼저 4편의 빚을 갚는다
**「3만원 이하 선물용」**을 벡터로 찾으면 이렇게 나왔다.
- 0.8221 P177 판테놀 필링 로션 42900원 가격
- 0.8189 P001 비타민C 필링 로션 25400원
- 0.8185 P035 히알루론산 포어 로션 32100원 가격
- 0.8179 P185 알로에 트러블 미스트 31700원 가격
- 0.8178 P069 판테놀 포어 로션 56700원 가격
처방은 4편에서 이미 말했다 — 조건은 SQL 이 맡는다.
from db import query
# ① 먼저 자른다. 이 조건은 틀리는 법이 없다
allowed = {r[0] for r in query("""
SELECT product_id FROM products
WHERE price <= 30000 AND tags LIKE '%선물용%'
""")}
print("조건을 통과한 상품:", len(allowed), "개")
# ② 남은 것 중에서만 뜻으로 고른다.
# argsort 로 전체 순위를 받아놓고, 조건 밖은 건너뛴다
scores = V @ q
count = 0
for i in scores.argsort()[::-1]:
if pids[i] not in allowed:
continue
print(f" {scores[i]:.4f} {pids[i]} {names[pids[i]]}")
count += 1
if count == 5:
break
- 조건을 통과한 상품: 51 개
- 0.8189 P001 비타민C 필링 로션 25400원
- 0.8172 P153 판테놀 브라이트닝 로션 28400원
- 0.8124 P189 비타민C 수분 로션 27300원
- 0.8104 P062 세라마이드 브라이트닝 클렌징오일 26800원
- 0.8105 P002 나이아신아마이드 트러블 토너 26200원
순서를 바꾸면 안 되나
된다. 그런데 다르게 된다.
고르고 나서 거른다
벡터 → SQL
- 상위 5개를 뽑고 조건 위반을 뺀다
- 방금 본 경우 하나만 남는다
- 더 뽑으면 되지만 몇 개를 뽑아야 할지 모른다
- 조건이 셀수록 남는 게 없어진다
자르고 나서 고른다
SQL → 벡터
- 51개로 좁힌 뒤 그중 5개
- 항상 5개가 나온다
- 비교할 상품이 200개에서 51개로 준다
- 고를 대상이 4분의 1로 준다
2. 추천에 붙인다 — 어떤 조건을 걸 것인가
「3만원 이하 선물용」은 사람이 말로 준 조건이었다. 추천에는 그런 말이 없다. 고객이 아무 말도 안 했는데 우리가 조건을 정해야 한다.
| 조건 | SQL 로 어떻게 | 왜 말이 되나 |
|---|---|---|
| 피부타입 | products.skin_type 이 고객 타입이거나 모든 피부 | 안 맞는 피부에 권하면 안 된다 |
| 가격대 | 이력의 최저가~최고가 사이 | 쓰던 값에서 크게 벗어나면 안 산다 |
| 이력 카테고리 | 이력에 있는 카테고리만 | 토너를 사던 사람은 또 토너를 산다 |
| 별점 높은 카테고리 | 위와 같되 별점 4 이상만 | 마음에 든 것만 남긴다 |
코드는 두 단계로 갈라 쓴다. 자르는 쪽과 고르는 쪽이다.
def allowed_for(customer_id):
"""① SQL 로 자른다 — 이력에 있는 카테고리만, 이미 산 것은 빼고."""
return [r[0] for r in query("""
SELECT product_id FROM products
WHERE category IN (
SELECT products.category FROM purchases
JOIN products ON purchases.product_id = products.product_id
WHERE purchases.customer_id = ? AND purchases.is_holdout = 0)
AND product_id NOT IN (
SELECT product_id FROM purchases
WHERE customer_id = ? AND is_holdout = 0)
""", (customer_id, customer_id))]
def candidates_for(customer_id, index):
"""② 남은 것 중에서 뜻으로 고른다."""
allowed = allowed_for(customer_id)
if not allowed:
return [] # 조건을 통과한 상품이 하나도 없다
# 통과한 상품의 벡터만 골라 계산한다. 200개 전부를 볼 필요가 없다
rows = [index_of[p] for p in allowed]
scores = V[rows] @ CV[index]
return [allowed[i] for i in scores.argsort()[::-1][:N_CANDIDATES]]
| 이름 | 무슨 뜻인가 | 왜 여기 나오나 |
|---|---|---|
IN (SELECT ...) | 조회 결과 안에 있는 것만 | 이력 카테고리 목록을 그 자리에서 만든다 |
NOT IN (SELECT ...) | 그 안에 없는 것만 | 이미 산 것을 뺀다 |
index_of[p] | 상품 번호 → 벡터 줄 번호 | 통과한 것의 벡터만 꺼내려고 |
V[rows] | 그 줄들만 뽑은 작은 묶음 | 200개가 아니라 마흔 개쯤과 비교한다 |
조건을 바꾸려면 allowed_for 안의 SQL 만 갈아끼운다. 뒤쪽은 한 글자도 안 바뀐다 — 그래서 조건 넷을 만들어 재보는 일이 SQL 네 개를 쓰는 일로 끝난다.
3. 30명으로 재본다
11편에서 만든 자를 그대로 쓴다. 표본도 30명 그대로다.
| 조건 | 통과한 상품 | 천장 | hit@5 |
|---|---|---|---|
| 없음 (11편) | 196개 | 20.0% | 13.3% |
| 피부타입 | 70개 | 33.3% | 20.0% |
| 가격대 | 65개 | 20.0% | 13.3% |
| 이력 카테고리 | 42개 | 30.0% | 23.3% |
| 별점4↑ 카테고리 | 29개 | 23.3% | 20.0% |
| 카테고리+피부 | 15개 | 26.7% | 26.7% |
두 배다. 여기서 끝내고 「카테고리+피부로 간다」고 발표하면 된다.
4. 🔴 300명으로 재면 뒤집힌다
| 조건 | 30명 hit@5 | 300명 hit@5 | 판정 |
|---|---|---|---|
| 없음 (11편) | 13.3% | 10.3% | 기준선 |
| 피부타입 | 20.0% | 8.0% | 🔴 기준선보다 나쁘다 |
| 가격대 | 13.3% | 11.0% | 거의 같다 |
| 이력 카테고리 | 23.3% | 13.7% | ✅ 제일 낫다 |
| 별점4↑ 카테고리 | 20.0% | 12.3% | 카테고리보다 못하다 |
| 카테고리+피부 | 26.7% | 12.7% | 🔴 1등에서 3등으로 |
5. 왜 뒤집혔나 — 조건은 정답도 자른다
「SQL 은 틀리는 법이 없다」고 했다. 맞다. 그런데 조건 자체가 틀릴 수 있다.
카테고리+피부는 정답의 70%를 잘라버린다. 그 사람들은 조건을 거는 순간 이미 졌다. 남은 15개 중에서 고르기가 아무리 쉬워져도 거기에 정답이 없다.
좋아지는 쪽
고르기가 쉬워진다
- 200개 중 고르기 → 45개 중 고르기
- 엉뚱한 것이 위로 올라올 자리가 준다
- 프롬프트가 짧아져 싸고 빨라진다
나빠지는 쪽
정답이 잘려나간다
- 정답이 조건을 안 지킬 수 있다
- 잘리면 그 사람은 무조건 틀린다
- 조건을 겹칠수록 급격히 나빠진다
6. 고른 것에 모델을 붙인다
이력 카테고리 하나만 걸기로 했다. 11편과 똑같은 30명·똑같은 채점기에 후보 뽑는 방법만 바꿔 끝까지 돌려본다.
- 1/30 C001 통과 18개 27.4초
- 6/30 C006 통과 90개 21.7초
- 13/30 C013 통과 41개 17.0초 HIT
- …
- 30명 9.3분 · SQL 통과 평균 42개
- 후보 안에 정답이 있던 비율 30.0%
- hit@1 3.3%
- hit@3 10.0%
- hit@5 13.3%
| 천장 | 벡터 상위 5개 | 모델이 고른 5개 | |
|---|---|---|---|
| 벡터로만 후보 (11편) | 20.0% | 13.3% | 13.3% |
| SQL 로 자르고 벡터로 (12편) | 30.0% | 23.3% | 13.3% |
후보는 확실히 좋아졌다. 천장이 20%에서 30%로 올랐고, 모델 없이 벡터 상위 5개만 내면 13.3%에서 23.3%가 된다. 300명에서도 10.3%에서 13.7%로 올랐다.
그런데 모델을 얹으면 13.3%로 도로 내려온다.
7. 🔴 모델이 벌어놓은 것을 못 받아먹는다
정답이 후보 열 개 안에 있던 사람은 아홉 명이었다. 그 아홉 명을 하나씩 본다. 모델을 바꿔가며 봤다.
- 벡터순위 내 노트북 상용
- C013 1위 O O
- C015 1위 O O
- C008 5위 O O
- C006 3위 X O
- C007 3위 X O
- C016 4위 X O
- C010 5위 X O
- C020 9위 O X
- C026 10위 X X
| 천장 | 벡터 상위 5개 | 모델이 고른 5개 | |
|---|---|---|---|
| 내 노트북 (3B) | 30.0% | 23.3% | 13.3% |
| 상용 (gpt-4o-mini) | 30.0% | 23.3% | 23.3% |
8. 🔴 조건을 통과한 상품이 하나도 없는 사람
조건을 겹쳐보면 새 사고가 나온다.
- 없음 0명
- 이력 카테고리 0명
- 가격대 9명
- 별점4↑ 카테고리 17명
- 카테고리+가격 26명
def allowed_with_fallback(customer_id):
"""조건을 센 것부터 걸어보고, 비면 한 겹씩 떼어낸다."""
# 튜플의 목록이다. 앞이 셀수록 좋고, 뒤로 갈수록 헐거워진다
steps = [
("카테고리+가격", lambda: by_category(customer_id) & by_price(customer_id)),
("카테고리", lambda: by_category(customer_id)),
("전체", lambda: all_product_ids),
]
bought = already_bought(customer_id)
for label, make in steps:
allowed = make() - bought
if allowed:
return list(allowed), label # 어느 단계에서 나왔는지도 돌려준다
return [], "없음"
이번 편에 나온 것
| 한 것 · 안 것 | 내용 |
|---|---|
| 순서 | 자르고 나서 고른다. 고르고 나서 거르면 남는 게 없을 수 있다 |
| 4편의 빚 | 「3만원 이하 선물용」이 51개로 좁혀지고 위반이 사라진다 |
IN (SELECT ...) | 조회 결과 안에 있는 것만. 이력 카테고리를 그 자리에서 만든다 |
V[rows] | 통과한 것의 벡터만 비교한다 |
| 🔴 30명의 함정 | 1등이 3등으로 뒤집힌다. 피부타입은 기준선보다 나빠졌다 |
| 실험 크기 | 비싼 실험은 작게 여러 번, 싼 실험은 크게 한 번 |
| 🔴 조건은 정답도 자른다 | 카테고리+피부는 정답의 70%를 잘라낸다 |
| 생존율 58% | 2편에서 잰 숫자가 반대편에서 돌아왔다 |
| 고른 것 | 이력 카테고리 하나만. 겹칠수록 나빠졌다 |
| 천장 | 20.0% → 30.0% |
| 벡터까지의 점수 | 30명 13.3% → 23.3% · 300명 10.3% → 13.7% |
| 🔴 모델까지의 점수 | 내 노트북 13.3% · 상용 23.3% |
| 진단 | 정답이 후보에 있던 9명 중 벡터는 7명 · 노트북 4명 · 상용 7명 |
| 작은 모델 | 망친다. 차려준 것을 빼버린다 |
| 큰 모델 | 본전이다. 벡터 순위를 그대로 낸다 — 그럴 거면 왜 부르나 |
| 비용 | 입력 토큰이 거의 같다 — 이 개선은 공짜다 |
| 🔴 빈 후보 | 조건을 겹치면 26명에게 추천이 아예 안 나온다. 단계로 푼다 |
| 결론 | 후보를 고친 이득은 진짜인데, 모델이 그걸 살리지 못한다 |
미션
- 01
[필수] 조건 하나를 골라 300명으로 잰다
mission_filter_1.py. 표에 있는 조건 중 하나를 골라 모델 없이 300명의 hit@5 를 잰다. 기준선 10.3% 보다 나은가. 몇 초면 끝난다. - 02
[응용] 내가 지은 조건을 넣어본다
mission_filter_2.py. 성분(ingredient) · 고민(concern) · 태그(tags) 로 조건을 하나 지어 잰다. 생존율과 hit@5 를 같이 적는다 — 둘을 나란히 보면 왜 그 점수가 나왔는지 보인다. - 03
[도전] 조건을 단계로 푼다
mission_filter_3.py.allowed_with_fallback을 만들어 300명을 돌리고, 몇 명이 어느 단계에서 나왔는지 센다. 그리고 그때 hit@5 가 카테고리 하나만 걸었을 때보다 나은지 본다.
[도전] 단계로 풀면 점수가 오르나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
빈 화면이 없어지고 점수도 오른다. 카테고리+가격 단독일 때 12.3% 이던 것이 단계로 풀면 13.7% 가 된다. 후보가 없어 무조건 틀리던 26명이 이제 뭔가를 받기 때문이다.
그런데 13.7% 는 카테고리 하나만 걸었을 때와 똑같은 값이다. 274명에게 추가로 건 가격 조건이 점수에 아무것도 안 보탠 것이다. 즉 이 단계 사다리가 한 일은 점수를 올린 게 아니라 빈 화면을 없앤 것이다.
그래서 재는 값이 둘이라는 것을 여기서 배운다 — 얼마나 맞히나와 얼마나 자주 아무것도 못 내놓나. 앞의 것만 보면 26명이 안 보인다.
실무에서는 뒤쪽이 더 크게 문제가 된다. 틀린 추천은 다음에 고치면 되는데, 빈 화면은 그 자리에서 사용자를 잃는다.
from_step = {}
hit = 0
for index in range(300):
customer_id = customer_ids[index]
allowed, step = allowed_with_fallback(customer_id)
# 어느 단계에서 나왔는지 센다. get 은 없으면 0 을 돌려준다
from_step[step] = from_step.get(step, 0) + 1
if not allowed:
continue
rows = [index_of[p] for p in allowed]
scores = V[rows] @ CV[index]
picks = [allowed[i] for i in scores.argsort()[::-1][:5]]
hit += answers[customer_id] in picks
print(from_step)
print(f"hit@5 {hit / 300 * 100:.1f}%")찍어보면 {'카테고리+가격': 274, '카테고리': 26} 가 나온다. 마지막 「전체」 단계는 한 번도 안 걸렸다 — 있으나 마나인 단계다. 그래도 남겨둔다. 데이터가 바뀌면 걸릴 수 있고, 안 걸리는 안전장치는 값이 싸다.
반대 경우도 재보면 재미있다. 첫 단계를 카테고리+피부로 바꾸면 300명 전원이 1단계에서 나온다. 상품 표에 모든 피부 가 있어서 후보가 절대 안 비기 때문이다. 그때는 사다리 자체가 아무 일도 안 한다.
어느 단계가 실제로 일을 하는지 세어보기 전에는 모른다. 그래서 from_step 을 같이 돌려주는 것이다.
정리하면
4편에서 진 빚을 갚았다. 「3만원 이하 선물용」이 이제 제대로 나온다 — 51개로 먼저 자르고 그 안에서 고르니 조건을 어길 방법이 없다. 순서가 핵심이다. 고르고 나서 거르면 남는 게 없을 수 있고, 자르고 나서 고르면 항상 나온다.
추천에도 같은 구조를 붙였다. 조건 후보 넷을 만들어 30명으로 재봤더니 카테고리+피부가 hit@5 26.7% 로 두 배였다. 여기서 끝냈으면 틀린 것을 골랐을 것이다.
300명으로 다시 재니 뒤집혔다. 1등이던 카테고리+피부가 3등이 되고, 2등이던 피부타입은 아무것도 안 한 것(10.3%)보다 나쁜 8.0% 가 됐다. 재는 데 모델이 필요 없어 몇 초면 되는 실험이었다. 싸게 잴 수 있으면 크게 잰다 — 비싼 실험은 작게 여러 번, 싼 실험은 크게 한 번이다.
뒤집힌 이유도 숫자로 나왔다. 조건은 정답도 자른다. 카테고리+피부는 정답의 70%를 잘라내서, 그 사람들은 조건을 거는 순간 이미 진다. 「SQL 은 틀리는 법이 없다」는 말은 SQL 이 시킨 대로 정확히 한다는 뜻이지 우리가 시킨 것이 옳다는 뜻이 아니다.
그래서 이력 카테고리 하나만 걸었다. 천장이 20%에서 30% 로 올랐고, 모델 없이 벡터 상위 다섯 개만 내면 30명 기준 13.3%에서 23.3%, 300명 기준 10.3%에서 13.7% 가 됐다. 후보를 42개로 좁혔는데 모델에게 주는 토큰은 그대로라, 값이 안 드는 개선이었다.
그런데 모델을 얹으니 13.3% 로 도로 내려왔다. 정답이 후보 안에 있던 아홉 명을 하나씩 뜯어보니 이유가 다 적혀 있었다 — 벡터가 다섯 개 안에 넣어둔 일곱 명 중 넷을 모델이 빼버렸고, 대신 9위에 있던 하나를 끌어올렸다. 하나 건지고 넷 떨어뜨렸다.
같은 채점기를 상용 모델로 돌려봤다. 이번에는 일곱 명을 그대로 지켜 23.3% 가 나왔다. 형식도 30명 전원이 다섯 개를 다 냈고 1.5분 만에 끝났다.
그래서 정직한 결론은 이렇게 갈린다.
작은 모델은 망친다. 차려준 것을 빼버린다. 큰 모델은 본전이다. 벡터가 이미 낸 23.3% 를 그대로 낼 뿐, 천장 30% 로는 못 간다. 6위와 9·10위에 있던 세 명은 아무도 못 끌어올렸다.
그럴 거면 모델을 왜 부르나 — 이게 이 편이 남기는 제일 불편한 질문이다. 지금 우리는 모델에게 이력과 후보를 던져놓고 「좋은 순서대로」라고만 했다. 모델이 값을 더할 자리를 우리가 안 만들어준 것일 수 있다.
다음 편에서 그 자리를 만든다. 후보를 몇 개 줄지, 이유를 짧게 시키면 어떻게 되는지, 프롬프트에 「이미 산 것과 비슷한 것 말고 다음에 살 것」을 넣으면 달라지는지 — 전부 hit@5 로 판정한다. 「좋아 보인다」는 금지다.
판정 기준은 이미 정해져 있다. 벡터만으로 23.3% 가 나온다. 무엇을 고치든 그 숫자를 못 넘으면 모델을 부를 이유가 없고, 넘어야 할 곳은 천장 30% 다.