학습 자료

조건은 SQL 이 자르고, 뜻은 벡터가 고른다


Article

앞 편에서 자를 만들었고, 그 자가 어디를 고쳐야 하는지까지 알려줬다.

11편이 남긴 숙제
  1. 상품 200개정답이 여기 있다
  2. 벡터로 후보 10개여기서 정답의 80%가 탈락
  3. 모델이 5개 고른다없는 것은 못 고른다
  4. hit@5 13.3%천장이 20%다
고칠 곳은 가운데다. 후보 개수를 늘리는 길은 앞 편에서 이미 막혔다 — 지어내기와 토큰이 같이 늘어난다. 그러니 개수를 늘리지 말고 잘 골라야 한다.

1. 먼저 4편의 빚을 갚는다

**「3만원 이하 선물용」**을 벡터로 찾으면 이렇게 나왔다.

터미널 — 「3만원 이하 선물용」 · 자르지 않고 고른 것
  • 0.8221 P177 판테놀 필링 로션 42900원 가격
  • 0.8189 P001 비타민C 필링 로션 25400원
  • 0.8185 P035 히알루론산 포어 로션 32100원 가격
  • 0.8179 P185 알로에 트러블 미스트 31700원 가격
  • 0.8178 P069 판테놀 포어 로션 56700원 가격

처방은 4편에서 이미 말했다 — 조건은 SQL 이 맡는다.

23_filter_then_search.py
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 이상만마음에 든 것만 남긴다
전부 그럴듯하다. 그럴듯한 것과 점수가 오르는 것은 다르므로, 넷을 다 만들어놓고 재본다.

코드는 두 단계로 갈라 쓴다. 자르는 쪽과 고르는 쪽이다.

24_candidates.py
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개가 아니라 마흔 개쯤과 비교한다
5편에서는 200개 전부와 비교하고 나서 걸렀다. 이번에는 걸러진 것만 비교한다 — 순서가 바뀌면 코드도 바뀐다.

조건을 바꾸려면 allowed_for 안의 SQL 만 갈아끼운다. 뒤쪽은 한 글자도 안 바뀐다 — 그래서 조건 넷을 만들어 재보는 일이 SQL 네 개를 쓰는 일로 끝난다.

3. 30명으로 재본다

11편에서 만든 자를 그대로 쓴다. 표본도 30명 그대로다.

조건별 · 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%
「천장」은 후보 10개 안에 정답이 있던 비율이다. 마지막 줄이 제일 좋아 보인다 — 13.3%에서 26.7%면 두 배다.

두 배다. 여기서 끝내고 「카테고리+피부로 간다」고 발표하면 된다.

4. 🔴 300명으로 재면 뒤집힌다

같은 조건 · 300명
조건30명 hit@5300명 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등으로
30명에서 1등이던 카테고리+피부가 3등이 됐다. 2등이던 피부타입은 아무것도 안 한 것보다 나빠졌다.

5. 왜 뒤집혔나 — 조건은 정답도 자른다

「SQL 은 틀리는 법이 없다」고 했다. 맞다. 그런데 조건 자체가 틀릴 수 있다.

조건을 통과한 상품 안에 정답이 남아 있는 비율 · 300명
없음100%
이력 카테고리58%
피부타입56.7%
가격대54.3%
별점4↑ 카테고리45.3%
카테고리+가격35.7%
카테고리+피부30.3%

카테고리+피부는 정답의 70%를 잘라버린다. 그 사람들은 조건을 거는 순간 이미 졌다. 남은 15개 중에서 고르기가 아무리 쉬워져도 거기에 정답이 없다.

조건을 걸면 두 가지가 동시에 일어난다

좋아지는 쪽

고르기가 쉬워진다

  • 200개 중 고르기 → 45개 중 고르기
  • 엉뚱한 것이 위로 올라올 자리가 준다
  • 프롬프트가 짧아져 싸고 빨라진다

나빠지는 쪽

정답이 잘려나간다

  • 정답이 조건을 안 지킬 수 있다
  • 잘리면 그 사람은 무조건 틀린다
  • 조건을 겹칠수록 급격히 나빠진다
두 힘이 반대로 움직인다. 그래서 조건을 몇 개 걸지는 취향이 아니라 재서 정하는 문제다.

6. 고른 것에 모델을 붙인다

이력 카테고리 하나만 걸기로 했다. 11편과 똑같은 30명·똑같은 채점기에 후보 뽑는 방법만 바꿔 끝까지 돌려본다.

터미널 — 30명 채점 (SQL 로 자르고 벡터로 고른 후보 10개)
  • 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%
11편 → 12편 · 같은 30명 · 같은 채점기
천장벡터 상위 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. 🔴 모델이 벌어놓은 것을 못 받아먹는다

정답이 후보 열 개 안에 있던 사람은 아홉 명이었다. 그 아홉 명을 하나씩 본다. 모델을 바꿔가며 봤다.

정답이 후보에 있던 9명 — 벡터가 몇 위에 뒀나, 모델이 골랐나
  • 벡터순위 내 노트북 상용
  • 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
그래서 점수가 이렇게 갈린다 · 같은 30명
천장벡터 상위 5개모델이 고른 5개
내 노트북 (3B)30.0%23.3%13.3%
상용 (gpt-4o-mini)30.0%23.3%23.3%
상용은 벡터 상위 5개를 정확히 그대로 냈다. 한 명도 안 잃고 한 명도 안 얻었다.

8. 🔴 조건을 통과한 상품이 하나도 없는 사람

조건을 겹쳐보면 새 사고가 나온다.

터미널 — 조건별 · 후보가 0개인 사람 · 300명
  • 없음 0명
  • 이력 카테고리 0명
  • 가격대 9명
  • 별점4↑ 카테고리 17명
  • 카테고리+가격 26명
25_fallback.py
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명에게 추천이 아예 안 나온다. 단계로 푼다
결론후보를 고친 이득은 진짜인데, 모델이 그걸 살리지 못한다

미션

미션
  1. 01

    [필수] 조건 하나를 골라 300명으로 잰다

    mission_filter_1.py. 표에 있는 조건 중 하나를 골라 모델 없이 300명의 hit@5 를 잰다. 기준선 10.3% 보다 나은가. 몇 초면 끝난다.

  2. 02

    [응용] 내가 지은 조건을 넣어본다

    mission_filter_2.py. 성분(ingredient) · 고민(concern) · 태그(tags) 로 조건을 하나 지어 잰다. 생존율과 hit@5 를 같이 적는다 — 둘을 나란히 보면 왜 그 점수가 나왔는지 보인다.

  3. 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% 다.

Share
  • 파이썬
  • AI
  • SQL
  • 검색
  • 측정
조건은 SQL 이 자르고, 뜻은 벡터가 고른다 — 디코드랩(DCODELAB)