학습 자료

고객을 무엇으로 표현할 것인가


Article

오전에 파이프라인을 갈랐습니다. 오후에는 벡터를 만듭니다.

그런데 그 전에 정할 것이 하나 있습니다. **무엇을 벡터로 만들 것인가. ** 상품 쪽은 답이 쉬웠습니다. 상세가 길어서 잘랐고, 상품 한 줄 요약은 그대로 넣으면 됩니다.

고객은 답이 안 정해져 있습니다.

이 시간에는 파일을 하나도 안 만듭니다. 재 보는 실습 둘로 네 가지를 비교하고, 그 결과로 4교시에 칠 코드의 설계를 정합니다.


1. 고객에 대해 우리가 가진 것

먼저 우리가 가지고 있는 고객에 대한 정보를 확인해 봅니다. 데이터베이스에서 고객 한 명에 대해 꺼낼 수 있는 것은 세 종류입니다.

고객 한 명 = 세 종류의 재료
종류무엇어디에성질
정형나이 58 · 여성 · 청주 · 가입일customers칸이 정해져 있다. 안 늘어난다
집계구매 3건 · 평균 별점 4.67 · 선호 카테고리purchases 를 세면 나온다SQL 로 언제든 다시 계산된다
본문후기 3건 (「무난해요」 …)purchases.review계속 쌓인다. 끝이 없다

세 번째가 오늘의 문제입니다. 앞의 둘은 고객이 늘어도 한 사람당 한 줄이지만, 후기는 서비스 운영이 길어질수록 점점 고객 한명당 점점 늘어나게 되는 정보입니다.


2. 제일 쉬운 방법부터 재 봅니다

고객정보를 벡터로 변환하려면 변환시킬수 있는 글이 있어야 합니다. 제일 쉽게 떠오르는 방법은 후기를 전부 이어 붙이는 것입니다.

예시 코드
def customer_text(purchases):
    """고객 한 명을 구매 이력으로 표현한다."""
    return " ".join(f"{name} (별점 {rating}) {review}" for name, rating, review in purchases)

지금 데이터로는 잘 돌아갈 것 같습니다. 그런데 **얼마나 여유가 있는지 재 본 적이없습니다. ** 루트 경로에 try_customer.py 를 만들어 확인합니다. 해당 파일은 테스트 후 지울 임시 테스트용 파일입니다.

실습용 · try_customer.py (임시 테스트용)
import sqlite3
import statistics

from transformers import AutoTokenizer

tok = AutoTokenizer.from_pretrained("intfloat/multilingual-e5-small")
con = sqlite3.connect("cosmetic.db")

# 고객 한 명 = 후기를 전부 이어 붙인 글
history = {}
for cid, name, rating, review in con.execute("""
    SELECT purchases.customer_id, products.name, purchases.rating, purchases.review
    FROM purchases JOIN products ON products.product_id = purchases.product_id
    WHERE purchases.is_holdout = 0
    ORDER BY purchases.customer_id
"""):
    history.setdefault(cid, []).append(f"{name} (별점 {rating}) {review}")

joined = [" ".join(v) for v in history.values()]
counts = sorted(len(tok.encode(t)) for t in joined)
buys = sorted(len(v) for v in history.values())

print(f"  고객 {len(joined)}명")
print(f"    이어 붙인 글  최소 {counts[0]} · 중앙 {int(statistics.median(counts))} · 최대 {counts[-1]} 토큰")
print(f"    구매 건수      최소 {buys[0]} · 중앙 {int(statistics.median(buys))} · 최대 {buys[-1]}건")
print(f"    상한(512) 초과 {sum(1 for c in counts if c > 512)}명")

per = counts[-1] / buys[-1]
print(f"\n  구매 1건당 약 {per:.0f}토큰 -> 512 를 넘으려면 {512 / per:.0f}건쯤 사야 한다")

one = sorted(len(tok.encode(r)) for (r,) in con.execute(
    "SELECT review FROM purchases WHERE review != ''"))
print(f"  후기 한 건       최소 {one[0]} · 중앙 {int(statistics.median(one))} · 최대 {one[-1]} 토큰")
python try_customer.py
터미널
  • 고객 300명
  • 이어 붙인 글 최소 31 · 중앙 146 · 최대 344 토큰
  • 구매 건수 최소 1 · 중앙 4 · 최대 9건
  • 상한(512) 초과 0명
  • 구매 1건당 약 38토큰 -> 512 를 넘으려면 13건쯤 사야 한다
  • 후기 한 건 최소 15 · 중앙 26 · 최대 49 토큰

지금은 안 잘립니다. 최대 344토큰이니 상한(512)의 67% 입니다.

문제는 13건이라는 숫자입니다. 지금 당장은 아직 고객이 입력한 후기가 9건 밖에 없지만, 점점 시간이 지나면서 각 고객당 후기가 13개를 넘어가면 임베딩 처리할 수 있는 최대 토큰수를 넘어서게 됩니다. 그리고 그렇게 최대 토큰수를 넘어선 후기 내용은 뒤가 조용히 잘립니다. 오류도 경고도 없습니다.

한편 마지막 줄도 봐 둘 만합니다. 후기 한 건은 길어야 49토큰입니다. 상한의 10% 라 후기 안을 자를 일은 없습니다. 문제는 후기가 긴 게 아니라 여러 건을 하나로 뭉친 것입니다.

del try_customer.py

3. 상품 쪽과 견줘 보면 답이 보입니다

상품 정보는 이전 시간에 상품 정보 카테고리 별로 이미 데이터를 분리했습니다.

상품 — 이미 층이 갈려 있다
무엇지금 상태
정형이름 · 가격 · 카테고리 · 성분products 표에 있다
요약한 줄로 이어 붙인 글오늘 오후에 벡터로 만든다
본문상세를 자른 조각 1,560개chunks 표에 있다

그런데 지금 우리가 가지고 있는 고객 정보는 아래와 같습니다.

고객 — 두 층뿐이고, 그나마 하나는 통으로 뭉쳐 있다
무엇문제
정형나이 · 성별 · 지역괜찮다
요약 + 본문후기를 전부 이어 붙인 글두 가지를 한 칸에 넣었다

상품 상세는 쪼개 뒀는데 고객 후기만 뭉쳐 놓은 셈입니다. 대칭이 안 맞습니다.

그러면 고객도 상품처럼 세 층으로 가르면 될 것 같습니다. 그런데 그전에 확인할 것이 있습니다. 정말 고객 정보를 분리하는게 답변 품질 향상에 도움이 되는가 입니다. 실제 실측해보겠습니다.


4. 네 가지를 직접 재서 확인해보겠습니다.

고객을 표현하는 방법 네 가지를 놓고, 어느 쪽이 추천을 잘하는지 채점합니다.

후보 넷
무엇을 넣나생각예상 길이
후기를 전부 이어 붙인다위에서 본 방식260자
취향만 — 피부타입 · 카테고리 · 성분 · 고민상품 벡터에 있는 낱말로만85자
② + 나이대 · 성별 · 지역인구통계도 신호 아닐까102자
② + 후기많이 넣으면 낫지 않을까241자

채점 방법은 이렇습니다. 고객마다 추천도가 제일 높은 정답 상품 정보를 일부러 숨겨 두고(is_holdout = 1), 그 상품이 추천 상위 k 안에 들어오는지 셉니다. 정답을 미리 아는 문제를 만들어 두고 푸는 셈입니다. 추후 해당 정보값을 이용해서 채점표를 만들예정입니다.

실습용 · try_profile.py (임시 테스트용)
import sqlite3
from collections import Counter

import numpy as np
from langchain_huggingface import HuggingFaceEmbeddings

con = sqlite3.connect("cosmetic.db")
model = HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-small",
    model_kwargs={"device": "cpu"},
    encode_kwargs={"normalize_embeddings": True, "batch_size": 64})

# 상품 벡터 ― 비교 대상
rows = con.execute("""
    SELECT product_id, name, brand, category, price, skin_type,
           ingredient, concern, tags, description FROM products ORDER BY product_id
""").fetchall()
pids = [r[0] for r in rows]
P = np.array(model.embed_documents(
    [" · ".join(str(x) for x in r[1:]) for r in rows]), dtype="float32")

# 고객 재료
info = {c: r for c, *r in con.execute(
    "SELECT customer_id, age, gender, skin_type, city FROM customers")}
hist = {}
for cid, name, cat, ing, concern, rating, review in con.execute("""
    SELECT purchases.customer_id, products.name, products.category, products.ingredient,
           products.concern, purchases.rating, purchases.review
    FROM purchases JOIN products ON products.product_id = purchases.product_id
    WHERE purchases.is_holdout = 0
    ORDER BY purchases.customer_id, purchases.purchase_id
"""):
    #  purchase_id 까지 정렬한다. 고객까지만 정렬하면 같은 고객 안의 순서가 실행할때마다 달라짐
    #  결국 아래 most_common 의 동점 순서가 바뀌어 테스트할때마다 달라지는 순서로 점수가 흔들림
    #  테스트할때마다 동일한 점수가 나와야 함
    hist.setdefault(cid, []).append((name, cat, ing, concern, rating, review))

answer = dict(con.execute(
    "SELECT customer_id, product_id FROM purchases WHERE is_holdout = 1"))
bought = {}
for cid, pid in con.execute(
        "SELECT customer_id, product_id FROM purchases WHERE is_holdout = 0"):
    bought.setdefault(cid, set()).add(pid)
cids = [c for c in sorted(hist) if c in answer]


def top(items, n=3):
    return " · ".join(x for x, _ in Counter(items).most_common(n))


def taste(cid):
    h = hist[cid]
    avg = sum(x[4] for x in h) / len(h)
    return (f"{info[cid][2]} 피부 · 선호 카테고리 {top([x[1] for x in h])} · "
            f"자주 쓴 성분 {top([x[2] for x in h])} · "
            f"관심 고민 {top([x[3] for x in h])} · 평균 별점 {avg:.1f}")


VARIANTS = [
    ("① 후기 이어붙이기", lambda c: " ".join(f"{x[0]} (별점 {x[4]}) {x[5]}" for x in hist[c])),
    ("② 취향만", taste),
    ("③ 취향 + 나이·성별·지역", lambda c:
        f"{info[c][0] // 10 * 10}{'여성' if info[c][1] == 'F' else '남성'} · "
        f"{info[c][3]} 거주 · " + taste(c)),
    ("④ 취향 + 후기", lambda c: taste(c) + " · " + " ".join(x[5] for x in hist[c])[:200]),
]

print(f"  {'무엇':26s} {'평균 글자':>8s} {'hit@1':>7s} {'hit@3':>7s} {'hit@5':>7s}")
for label, make in VARIANTS:
    texts = [make(c) for c in cids]
    C = np.array(model.embed_documents(texts), dtype="float32")
    hits = [0, 0, 0]
    for i, cid in enumerate(cids):
        order = np.argsort(-(P @ C[i]))
        ranked = [pids[j] for j in order if pids[j] not in bought.get(cid, ())]
        for slot, k in enumerate((1, 3, 5)):
            hits[slot] += answer[cid] in ranked[:k]
    n = len(cids)
    print(f"  {label:26s} {sum(len(t) for t in texts) / n:>8.0f} "
          f"{hits[0] / n * 100:>6.1f}% {hits[1] / n * 100:>6.1f}% {hits[2] / n * 100:>6.1f}%")
python try_profile.py
터미널 (고객 300명 · 상품 200개 · 이미 산 것은 후보에서 뺐다 · 30초쯤 걸린다)
  • 무엇 평균 글자 hit@1 hit@3 hit@5
  • ① 후기 이어붙이기 260 4.0% 8.0% 10.7%
  • ② 취향만 85 3.0% 5.0% 9.7%
  • ③ 취향 + 나이·성별·지역 102 2.7% 6.0% 12.3%
  • ④ 취향 + 후기 241 2.0% 4.0% 7.7%
del try_profile.py

5. 이 표를 읽는 법

④ 는 확실히 나쁩니다

hit@57.7% 로, 넷 중 제일 낮습니다. ③ 과는 4.6%p 차이입니다.

이유는 후기의 내용에 있습니다. 「무난해요」 · 「재구매합니다」 · 「잘 썼어요」 같은 말은 어느 상품에도 어울립니다. 그래서 벡터가 어느 쪽으로도 안 기울고, 취향을 가리키던 신호가 묽어집니다.

"많이 넣으면 좋아진다"는 가설이지 사실이 아닙니다."

여기서 흔히 하는 실수가 **"③ 이 제일 높으니 ③ 으로 하자"**입니다. 그런데 그 2.6%p 가 믿을 만한 차이인지 직접 테스트해보기 전까지는 우리는 **아직 모릅니다.

그럼 무엇으로 고르나

성능이 안 갈리면 다른 기준으로 고릅니다. 실무에서는 대개 이 셋입니다.

성능이 같을 때 쓰는 기준
기준무슨 뜻누가 이기나
짧은 것상용 API 는 토큰 수가 곧 요금이다② 85자 (① 은 260자)
설명되는 것「왜 이걸 추천했나」를 사람이 읽을 수 있나② — 「건성 피부 · 선호 카테고리 앰플」
안전한 것개인정보가 벡터로 새 나가지 않나② — 후기가 안 들어간다 (오늘 오후에 다룬다)

세 기준이 모두 ② 를 가리킵니다. ② 로 정합니다.

그런데 나이와 지역은요?

③ 이 ② 와 크게 안 갈린 것이 뜻밖일 수 있습니다. 「30대 여성」이나 「청주 거주」는 분명 의미 있는 정보인데 왜 그럴까요.

고객 벡터가 무엇과 비교되는지를 보면 답이 나옵니다.

고객 벡터는 상품 벡터와 같은 공간에서 비교된다

상품 벡터에 있는 말

비교 상대

  • 카테고리 — 선크림 · 앰플 · 토너
  • 성분 — 콜라겐 · 레티놀 · 어성초
  • 피부타입 — 지성 · 건성 · 민감성
  • 고민 — 각질 · 트러블 · 미백

「30대」 · 「청주」

대응하는 말이 없다

  • 상품 설명에 나이가 안 적혀 있다
  • 지역도 안 적혀 있다
  • 그래서 유사도에 거의 기여하지 못한다
  • 길이만 늘리고 신호는 적다

비교할 상대에 없는 낱말은 신호가 되기 어렵습니다. 이것이 벡터 검색의 성질입니다.


6. 그럼 나이와 지역은 어디에 쓰나 — SQL 입니다

여기서 오해하면 안 됩니다. 나이·성별·지역이 **쓸모없다는 말이 아닙니다. ** 오히려 회사가 제일 원하는 정보입니다. 다만 묻는 방법이 다릅니다.

같은 데이터, 다른 질문

SQL 이 이기는 질문

정확한 답이 필요하다

  • 「30대 여성이 뭘 제일 많이 샀나」
  • 「청주에서 잘 팔리는 카테고리는」
  • 「민감성 고객의 평균 별점은」
  • 「38건」이라고 숫자로 답한다

벡터가 이기는 질문

낱말이 안 겹친다

  • 「환불하고 싶은데」 -> 「교환·반품」 찾기
  • 「비슷한 불만을 쓴 후기」
  • 「이 고객과 취향이 닮은 상품」
  • 「대충 이쪽」까지만 한다

실제로 SQL 로 물어보면 이렇습니다.

python -c "import sqlite3;con=sqlite3.connect('cosmetic.db');[print(f'  {r[0]:10s} {r[1]:>3}건') for r in con.execute('SELECT products.category, COUNT(*) n FROM purchases JOIN customers ON customers.customer_id=purchases.customer_id JOIN products ON products.product_id=purchases.product_id WHERE customers.age BETWEEN 30 AND 39 AND customers.gender=\'F\' GROUP BY products.category ORDER BY n DESC LIMIT 5')]"
터미널 — 30대 여성이 많이 산 카테고리
  • 에센스 38건
  • 토너 28건
  • 아이크림 27건
  • 클렌징폼 26건
  • 클렌징오일 24건

정확하고, 빠르고, 근거를 댈 수 있습니다. 이걸 벡터로 하려고 하면 더 나쁜 답을 얻습니다.


7. 그래서 고객은 세 층이 됩니다

오후에 만들 모양
  • 고객 한 명
    • 정형 · 집계 — SQL나이 · 성별 · 지역 · 구매횟수 · 평균별점
      • 벡터로 안 만든다SQL 이 더 정확하다. 분석 · 필터 · 화면에 쓴다
    • 취향 — customer_vectors
      • 「건성 피부 · 선호 카테고리 앰플 · 자주 쓴 성분 어성초 · 관심 고민 트러블 · 평균 별점 3.0」고객 1명 = 벡터 1개. 추천에 쓴다
    • 본문 — review_vectors
      • 후기 1건 = 1행 · 1,200개이어 붙이지 않는다. 후기가 쌓이면 행이 늘 뿐이다

그리고 이렇게 하면 상품 쪽과 모양이 같아집니다.

오늘 오후가 끝나면 대칭이 맞는다
정형 (SQL)요약 벡터본문 벡터
상품productsproduct_vectors 200chunk_vectors 1,560
고객customers + 집계customer_vectors 300review_vectors 1,200
후기를 벡터로 만들어서 어디에 쓰나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요

지금 당장은 추천에 안 씁니다. 추천은 취향 벡터로 합니다.

쓰이는 곳은 「이 고객이 무엇을 불만스러워했나」 같은 질문입니다. 6일차 5교시 화면에 「비슷한 불만을 쓴 다른 후기」 패널로 붙습니다 ― 낱말이 안 겹쳐도 같은 불만을 찾아옵니다.

그리고 이 표가 회사가 AI 를 도입하려는 진짜 이유에 가장 가깝습니다. 몇 년 치 쌓인 후기에서 「같은 불만이 반복되는가」를 찾는 일은 사람이 못 합니다. 낱말이 제각각이라 검색으로도 안 잡힙니다.


이 시간에 한 것

3교시 요점
주제핵심
문제를 먼저 쟀다고객 글 최대 344토큰. 구매 13건이면 상한을 넘고 뒤가 조용히 잘린다
후기 한 건길어야 49토큰. 후기가 긴 게 아니라 여러 건을 뭉친 것이 문제였다
끝이 없는 데이터한 칸에 밀어 넣으면 언젠가 넘친다. 「지금 안 넘친다」는 「아직」이라는 뜻
네 가지를 쟀다hit@5 — ① 10.7% · ② 9.7% · ③ 12.3% · ④ 7.7%
④ 가 나쁜 이유「무난해요」는 어느 상품에도 어울린다. 신호가 묽어진다
①②③ 은 못 가른다차이 2.6%p. 흔들림을 안 재 봤으니 순위를 못 믿는다
그럼 무엇으로 고르나짧은 것 · 설명되는 것 · 안전한 것. 셋 다 ② 를 가리켰다
나이·지역이 안 움직인 이유상품 벡터에 대응하는 낱말이 없다. 비교 상대에 없는 말은 신호가 되기 어렵다
그럼 어디에 쓰나SQL. 집계·필터·정렬은 데이터베이스가 훨씬 잘한다
재는 코드의 조건같은 값이 다시 나와야 한다. ORDER BY 를 끝까지 적는 이유가 그것이다
결론고객도 상품처럼 세 층으로. 정형(SQL) · 취향(벡터) · 본문(벡터)

다음 시간에 할 것

테스트를 통해 설계 방향이 정해졌으니 이제 실제 임베딩에 필요한 함수를 등록하고 호출해보겠습니다.

다음 단원에서 pipeline/prep/embedding.pypipeline/03_embed.py 를 만들어서 네 벌을 한 번에 벡터로 바꿉니다. 조각 1,560 · 상품 200 · 고객 300 · 후기 1,200 개입니다.

Share
  • 파이썬
  • 임베딩
  • RAG
  • 데이터 설계
  • SQL