학습 자료

고객을 벡터로 — 정답이 5위, 1·2위는 이미 산 물건


Article

앞 편에서 상품 200개를 벡터로 만들어 저장했다. 이제 고객 차례다.

1. 고객을 한 문장으로

뭘 샀고 뭐라고 썼는지를 이어붙여 한 문장으로 만든다.

09_embed_customers.py
import sqlite3
con = sqlite3.connect("shop.db")
cur = con.cursor()

history = cur.execute("""
    SELECT products.name, purchases.rating, purchases.review
    FROM purchases
    JOIN products ON purchases.product_id = products.product_id
    WHERE purchases.customer_id = ?
      AND purchases.is_holdout = 0        -- 정답은 빼고
    ORDER BY purchases.purchased_at
""", ("C007",)).fetchall()

# join 은 목록을 이어붙인다. 앞의 " " 는 "사이에 빈칸을 넣어라" 는 뜻이다
text = " ".join(f"{name} (별점 {r}) {review}" for name, r, review in history)

print("글자 수:", len(text))
print(text[:90], "...")
터미널
  • 글자 수: 147
  • 병풀 링클 클렌징오일 (별점 3) 무난해요. 향이 세지 않아서 좋아요. 히알루론산 탄력 클렌징오일 (별점 4) 두 통째 쓰는 중이에요. ...

300명을 한 번에

customer_ids = [r[0] for r in cur.execute("SELECT customer_id FROM customers").fetchall()]

texts = []
for cid in customer_ids:
    history = cur.execute("""
        SELECT products.name, purchases.rating, purchases.review FROM purchases
        JOIN products ON purchases.product_id = products.product_id
        WHERE purchases.customer_id = ? AND purchases.is_holdout = 0
    """, (cid,)).fetchall()
    texts.append(" ".join(f"{n} (별점 {r}) {v}" for n, r, v in history))

CV = model.encode(texts, normalize_embeddings=True, batch_size=32)
print("모양:", CV.shape)
터미널
  • 300명 5.7초
  • 모양: (300, 384)

이것도 customer_vectors 표에 같은 방식으로 저장한다.

2. 고객에게 가까운 상품을 찾는다

10_recommend_naive.py
# 고객 벡터 하나와 상품 200개를 비교한다
scores = V @ CV[idx]

for i in scores.argsort()[::-1][:5]:
    print(f"  {scores[i]:.4f}  {rows[i][0]} {rows[i][1]}")
터미널 — C007 에게 가까운 상품 (정답은 P140)
  • 0.9230 P057 히알루론산 탄력 클렌징오일 <- 이미 샀다
  • 0.9120 P157 병풀 링클 클렌징오일 <- 이미 샀다
  • 0.9050 P059 히알루론산 포어 선크림
  • 0.9040 P113 약산성 아미노산 탄력 클렌징오일
  • 0.9035 P140 히알루론산 시카 클렌징오일 <- 정답

3. 그래서 지금 몇 점인가

이미 산 것을 뺀 뒤, 300명 전부에 대해 정답이 위에 오는 비율을 세어본다.

벡터 검색만으로 (LLM 없이) · 300명
인기순 추천 hit@53%
벡터 검색 hit@510.3%
벡터 검색 hit@1015%
취향 규칙 hit@521.3%

이번 편에 나온 것

정리
한 것 · 안 것내용
고객 벡터이력·후기를 이어붙여 · 300명 5.7초
" ".join(...)목록을 빈칸으로 이어붙인다
🔴 유출is_holdout = 0 을 빼면 정답이 벡터에 녹아든다
🔴 1·2위이미 산 물건. 빼고 순위를 매겨야 한다
언제 낡는가구매가 하나 생기면 그 고객 벡터는 옛날 값이 된다
지금 점수hit@5 10.3% — 인기순 3%보다 낫고 규칙 21.3%보다 못하다
왜 못한가벡터는 「비슷한 것」을 찾지 「다음에 살 것」을 찾지 않는다

미션

미션
  1. 01

    [필수] 내 고객 하나로 검색해본다

    mission_cv_1.py. 아무 고객이나 골라 벡터를 만들고 가까운 상품 다섯 개를 뽑는다. 이미 산 것을 빼고 뽑아서 정답이 몇 위인지 적는다.

  2. 02

    [응용] 고객 문장을 다르게 만들어본다

    mission_cv_2.py. 후기를 빼고 상품 이름만으로 만들면 어떻게 되나. 별점 5점짜리만 넣으면 어떻게 되나. 무엇을 넣는 게 나은지 재본다.

  3. 03

    [도전] 유출을 일부러 만들어본다

    mission_cv_3.py. is_holdout = 0 을 빼고 고객 벡터를 만들어 점수를 재본다. 얼마나 좋아지나. 이 숫자를 보고 나면 앞으로 조건을 안 빠뜨리게 된다.

[도전] 정답을 넣고 재면 몇 점이 나오나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

점수가 크게 뛴다. 고객 문장 안에 정답 상품의 이름이 통째로 들어 있으니, 그 상품이 위로 안 올 수가 없다.

무서운 것은 에러가 하나도 안 난다는 점이다. 코드는 잘 돌고 점수만 좋아진다. 그래서 좋아진 줄 알고 그대로 발표한다.

알아채는 방법은 하나다 — 너무 좋으면 의심한다. 갑자기 두 배가 되면 축하할 게 아니라 뭐가 새어들었는지 찾아본다.

그리고 이건 검색만의 문제가 아니다. 모델에게 주는 프롬프트에 정답이 섞여도 똑같은 일이 벌어진다.

def customer_text(cid, with_answer=False):
    cond = "" if with_answer else "AND purchases.is_holdout = 0"
    history = cur.execute(f"""
        SELECT products.name, purchases.rating, purchases.review FROM purchases
        JOIN products ON purchases.product_id = products.product_id
        WHERE purchases.customer_id = ? {cond}
    """, (cid,)).fetchall()
    return " ".join(f"{n} (별점 {r}) {v}" for n, r, v in history)


for with_answer in (False, True):
    texts = [customer_text(c, with_answer) for c in customer_ids]
    CV = model.encode(texts, normalize_embeddings=True, batch_size=32)

    hit = 0
    for j, cid in enumerate(customer_ids):
        bought = {r[0] for r in cur.execute(
            "SELECT product_id FROM purchases WHERE customer_id=? AND is_holdout=0", (cid,))}
        scores = V @ CV[j]
        picks = [ids[i] for i in scores.argsort()[::-1] if ids[i] not in bought][:5]
        if answers[cid] in picks:
            hit += 1

    print(f"정답포함={with_answer}  hit@5 {hit / len(customer_ids) * 100:.1f}%")

이미 산 것을 빼는 조건은 그대로 뒀다. 그런데도 점수가 오른다 — 정답 상품은 is_holdout=1 이라 「산 것」 목록에 없기 때문이다.

이렇게 막았다고 생각한 자리를 비켜서 새어든다. 유출은 대개 이런 모양이다.

정리하면

고객 300명을 숫자로 바꿨다. 5.7초 걸렸다. 고객에게는 설명 글이 없어서 이력과 후기를 이어붙여 한 문장으로 만들었다.

그리고 함정 두 개를 만났다.

벡터를 만들 때 is_holdout 을 빠뜨리면 정답이 벡터에 녹아든다. 에러도 안 나고 점수만 좋아져서, 알아채는 방법은 「너무 좋으면 의심한다」뿐이다.

검색해보니 1·2위가 이미 산 물건이었다. 고객 벡터를 산 것들로 만들었으니 당연한데, 추천으로는 못 쓴다. 돌려보기 전에는 안 보이는 종류의 문제다.

지금 점수는 hit@5 10.3% 다. 아무 정보도 안 쓰는 인기순(3%)보다 세 배 낫지만, 카테고리·성분을 세는 단순한 규칙(21.3%)보다는 못하다. 벡터는 「비슷한 것」을 찾지 「다음에 살 것」을 찾지 않기 때문이다.

다음 편에서 모델을 얹는다. 지금까지 만든 후보를 모델에게 주고 골라달라고 시킨다. 그때 이 10.3% 가 어떻게 움직이는지 보게 된다.

Share
  • 파이썬
  • AI
  • 임베딩
  • 추천
  • 측정
고객을 벡터로 — 정답이 5위, 1·2위는 이미 산 물건 — 디코드랩(DCODELAB)