고객을 무엇으로 표현할 것인가
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 를 만들어 확인합니다. 해당 파일은 테스트 후 지울 임시 테스트용 파일입니다.
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 안에 들어오는지 셉니다.
정답을 미리 아는 문제를 만들어 두고 푸는 셈입니다. 추후 해당 정보값을 이용해서 채점표를 만들예정입니다.
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
- 무엇 평균 글자 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@5 가 7.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')]"
- 에센스 38건
- 토너 28건
- 아이크림 27건
- 클렌징폼 26건
- 클렌징오일 24건
정확하고, 빠르고, 근거를 댈 수 있습니다. 이걸 벡터로 하려고 하면 더 나쁜 답을 얻습니다.
7. 그래서 고객은 세 층이 됩니다
- 고객 한 명
- 정형 · 집계 — SQL
- 벡터로 안 만든다
- 취향 — customer_vectors
- 「건성 피부 · 선호 카테고리 앰플 · 자주 쓴 성분 어성초 · 관심 고민 트러블 · 평균 별점 3.0」
- 본문 — review_vectors
- 후기 1건 = 1행 · 1,200개
- 정형 · 집계 — SQL
그리고 이렇게 하면 상품 쪽과 모양이 같아집니다.
| 정형 (SQL) | 요약 벡터 | 본문 벡터 | |
|---|---|---|---|
| 상품 | products | product_vectors 200 | chunk_vectors 1,560 |
| 고객 | customers + 집계 | customer_vectors 300 | review_vectors 1,200 |
후기를 벡터로 만들어서 어디에 쓰나충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
지금 당장은 추천에 안 씁니다. 추천은 취향 벡터로 합니다.
쓰이는 곳은 「이 고객이 무엇을 불만스러워했나」 같은 질문입니다. 6일차 5교시 화면에 「비슷한 불만을 쓴 다른 후기」 패널로 붙습니다 ― 낱말이 안 겹쳐도 같은 불만을 찾아옵니다.
그리고 이 표가 회사가 AI 를 도입하려는 진짜 이유에 가장 가깝습니다. 몇 년 치 쌓인 후기에서 「같은 불만이 반복되는가」를 찾는 일은 사람이 못 합니다. 낱말이 제각각이라 검색으로도 안 잡힙니다.
이 시간에 한 것
| 주제 | 핵심 |
|---|---|
| 문제를 먼저 쟀다 | 고객 글 최대 344토큰. 구매 13건이면 상한을 넘고 뒤가 조용히 잘린다 |
| 후기 한 건 | 길어야 49토큰. 후기가 긴 게 아니라 여러 건을 뭉친 것이 문제였다 |
| 끝이 없는 데이터 | 한 칸에 밀어 넣으면 언젠가 넘친다. 「지금 안 넘친다」는 「아직」이라는 뜻 |
| 네 가지를 쟀다 | hit@5 — ① 10.7% · ② 9.7% · ③ 12.3% · ④ 7.7% |
| ④ 가 나쁜 이유 | 「무난해요」는 어느 상품에도 어울린다. 신호가 묽어진다 |
| ①②③ 은 못 가른다 | 차이 2.6%p. 흔들림을 안 재 봤으니 순위를 못 믿는다 |
| 그럼 무엇으로 고르나 | 짧은 것 · 설명되는 것 · 안전한 것. 셋 다 ② 를 가리켰다 |
| 나이·지역이 안 움직인 이유 | 상품 벡터에 대응하는 낱말이 없다. 비교 상대에 없는 말은 신호가 되기 어렵다 |
| 그럼 어디에 쓰나 | SQL. 집계·필터·정렬은 데이터베이스가 훨씬 잘한다 |
| 재는 코드의 조건 | 같은 값이 다시 나와야 한다. ORDER BY 를 끝까지 적는 이유가 그것이다 |
| 결론 | 고객도 상품처럼 세 층으로. 정형(SQL) · 취향(벡터) · 본문(벡터) |
다음 시간에 할 것
테스트를 통해 설계 방향이 정해졌으니 이제 실제 임베딩에 필요한 함수를 등록하고 호출해보겠습니다.
다음 단원에서 pipeline/prep/embedding.py 와 pipeline/03_embed.py 를 만들어서 네 벌을 한 번에 벡터로 바꿉니다.
조각 1,560 · 상품 200 · 고객 300 · 후기 1,200 개입니다.