상품 200개를 벡터로 — 그리고 「3만원 이하」가 안 잡힌다
Article
앞 편에서 문장 하나를 숫자로 바꿨다. 이제 상품 200개에 건다.
- 상품 설명 200개DB 에서 꺼낸다
- 벡터로 바꿔 저장product_vectors
- 말로 찾아본다「순한 것」
1. 무엇을 벡터로 만들 것인가
상품에는 설명 글이 있다. 그걸 그대로 쓴다.
상품
그대로 쓰면 된다
description칸에 설명이 있다- 「속당김을 잡아주는 히알루론산…」
- 이 글을 벡터로
고객
만들어야 한다
- 나이 · 피부타입만으로는 부족하다
- 뭘 샀고 뭐라고 썼는지가 취향이다
- 이력을 이어붙여 한 문장으로
2. 상품 200개를 벡터로
import sqlite3
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("intfloat/multilingual-e5-small")
con = sqlite3.connect("shop.db")
cur = con.cursor()
# 상품 200개를 통째로 꺼낸다
rows = cur.execute("SELECT product_id, name, description FROM products").fetchall()
# 이름과 설명을 붙여 한 덩어리로 만든다.
# 설명만 넣으면 「히알루론산」 같은 성분 이름이 이름 쪽에만 있을 때 빠진다.
# for 안의 _ 는 "이 값은 안 쓴다"는 표시다 (여기서는 product_id)
docs = [f"{name}. {desc}" for _, name, desc in rows]
# batch_size=32 는 "한 번에 32개씩 묶어서 계산해라" 는 뜻이다.
# 한꺼번에 200개를 올리면 메모리가 모자랄 수 있어 나눠서 처리한다
V = model.encode(docs, normalize_embeddings=True, batch_size=32)
print("모양:", V.shape) # (200, 384) = 상품 200개 x 숫자 384개
print("한 상품의 숫자 개수:", len(V[0]))
- 200개 2.2초 · 개당 11ms
- 모양: (200, 384)
- 한 상품의 숫자 개수: 384
3. DB 에 저장한다
매번 다시 만들면 시간이 아깝다. 한 번 만들어 넣어두고 꺼내 쓴다.
import json
# 벡터를 담을 표를 만든다. IF NOT EXISTS 를 붙이면 이미 있을 때 그냥 넘어간다
cur.execute("""
CREATE TABLE IF NOT EXISTS product_vectors (
product_id TEXT PRIMARY KEY,
vector TEXT -- 숫자 384개를 글자로 눌러 담는다
)
""")
# 다시 만들 때를 대비해 기존 것을 비운다. DELETE 에 WHERE 를 안 쓰면 전부 지운다
cur.execute("DELETE FROM product_vectors")
# zip 은 두 목록을 짝지어 하나씩 꺼내준다. rows[0]와 V[0], rows[1]과 V[1], ...
for (pid, _, _), vec in zip(rows, V):
# tolist() 는 numpy 숫자 묶음을 파이썬 목록으로 바꾼다.
# json.dumps 는 그 목록을 글자로 만든다 -> "[0.0623, -0.0149, ...]"
cur.execute(
"INSERT INTO product_vectors VALUES (?, ?)",
(pid, json.dumps(vec.tolist())),
)
con.commit()
# 잘 들어갔는지 확인한다
print("저장된 줄:", cur.execute("SELECT COUNT(*) FROM product_vectors").fetchone()[0])
raw = cur.execute("SELECT vector FROM product_vectors LIMIT 1").fetchone()[0]
print("한 줄 길이:", len(raw), "글자")
- 저장된 줄: 200
- 한 줄 길이: 8385 글자
꺼낼 때는 되돌린다
def load_vectors():
"""저장해둔 글자를 숫자 묶음으로 되돌린다."""
rows = cur.execute("SELECT product_id, vector FROM product_vectors").fetchall()
ids = [pid for pid, _ in rows]
# json.loads 는 글자를 다시 목록으로 바꾼다 (dumps 의 반대)
# np.array 는 그 목록을 numpy 숫자 묶음으로 만든다 — 계산이 빨라진다
# dtype="float32" 는 "소수를 32비트 크기로 담아라" 는 뜻이다. 기본값(64비트)의 절반이라
# 메모리를 아끼고, 이 정도 정밀도면 검색 결과가 달라지지 않는다
V = np.array([json.loads(v) for _, v in rows], dtype="float32")
return ids, V
| 이름 | 무슨 뜻인가 | 왜 여기 나오나 |
|---|---|---|
IF NOT EXISTS | 이미 있으면 넘어가라 | 두 번 돌려도 에러가 안 난다 |
DELETE FROM 표 | WHERE 없이 쓰면 전부 지운다 | 다시 만들 때 겹치지 않게 |
zip(a, b) | 두 목록을 짝지어 꺼낸다 | 상품과 벡터를 나란히 |
.tolist() | numpy 묶음 → 파이썬 목록 | 글자로 바꾸기 전 단계 |
json.dumps / loads | 목록 → 글자 / 글자 → 목록 | TEXT 칸에 넣고 빼려고 |
dtype="float32" | 소수를 32비트로 | 메모리 절반, 결과는 같다 |
4. 말로 상품을 찾아본다
이제 앞 편에서 못 하던 것을 해본다.
query = "건조한 피부에 순한 것"
# 질문도 똑같이 벡터로 바꾼다. 목록으로 넣었으니 결과도 목록이라 [0] 으로 첫 개를 꺼낸다
q = model.encode([query], normalize_embeddings=True)[0]
# V 는 (200, 384), q 는 (384,) 다. 곱하면 (200,) — 상품마다 점수 하나씩 나온다.
# 200번 반복문을 도는 대신 한 줄로 끝난다
scores = V @ q
# argsort 는 "정렬했을 때의 순서(번호)"를 돌려준다. 작은 것부터라서
# [::-1] 로 뒤집어 큰 것부터로 만들고, [:3] 으로 위에서 셋만 가져온다
top = scores.argsort()[::-1][:3]
for i in top:
pid, name, _ = rows[i]
print(f" {scores[i]:.4f} {pid} {name}")
- 0.8699 P199 약산성 아미노산 탄력 아이크림
- 0.8684 P173 약산성 아미노산 트러블 크림
- 0.8665 P081 판테놀 탄력 크림
이게 SQL 로는 안 되던 일이다. LIKE '%순한%' 로 찾았으면 아무것도 안 나온다.
프로젝트 테스트예제 (오류버전)
import sqlite3
import json
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("intfloat/multilingual-e5-small")
con = sqlite3.connect("shop.db")
cur = con.cursor()
# 상품 200개를 통째로 꺼낸다
rows = cur.execute("SELECT product_id, name, description FROM products").fetchall()
# 이름과 설명을 붙여 한 덩어리로 만든다.
# 설명만 넣으면 「히알루론산」 같은 성분 이름이 이름 쪽에만 있을 때 빠진다.
# for 안의 _ 는 "이 값은 안 쓴다"는 표시다 (여기서는 product_id)
docs = [f"{name}. {desc}" for _, name, desc in rows]
# batch_size=32 는 "한 번에 32개씩 묶어서 계산해라" 는 뜻이다.
# 한꺼번에 200개를 올리면 메모리가 모자랄 수 있어 나눠서 처리한다
V = model.encode(docs, normalize_embeddings=True, batch_size=32)
# 벡터를 담을 표를 만든다. IF NOT EXISTS 를 붙이면 이미 있을 때 그냥 넘어간다
cur.execute("""
CREATE TABLE IF NOT EXISTS product_vectors (
product_id TEXT PRIMARY KEY,
vector TEXT -- 숫자 384개를 글자로 눌러 담는다
)
""")
# 다시 만들 때를 대비해 기존 것을 비운다. DELETE 에 WHERE 를 안 쓰면 전부 지운다
cur.execute("DELETE FROM product_vectors")
# zip 은 두 목록을 짝지어 하나씩 꺼내준다. rows[0]와 V[0], rows[1]과 V[1], ...
for (pid, _, _), vec in zip(rows, V):
# tolist() 는 numpy 숫자 묶음을 파이썬 목록으로 바꾼다.
# json.dumps 는 그 목록을 글자로 만든다 -> "[0.0623, -0.0149, ...]"
cur.execute(
"INSERT INTO product_vectors VALUES (?, ?)",
(pid, json.dumps(vec.tolist())),
)
con.commit()
# 잘 들어갔는지 확인한다
print("저장된 줄:", cur.execute("SELECT COUNT(*) FROM product_vectors").fetchone()[0])
raw = cur.execute("SELECT vector FROM product_vectors LIMIT 1").fetchone()[0]
print("한 줄 길이:", len(raw), "글자")
def load_vectors():
"""저장해둔 글자를 숫자 묶음으로 되돌린다."""
rows = cur.execute("SELECT product_id, vector FROM product_vectors").fetchall()
ids = [pid for pid, _ in rows]
# json.loads 는 글자를 다시 목록으로 바꾼다 (dumps 의 반대)
# np.array 는 그 목록을 numpy 숫자 묶음으로 만든다 — 계산이 빨라진다
# dtype="float32" 는 "소수를 32비트 크기로 담아라" 는 뜻이다. 기본값(64비트)의 절반이라
# 메모리를 아끼고, 이 정도 정밀도면 검색 결과가 달라지지 않는다
V = np.array([json.loads(v) for _, v in rows], dtype="float32")
return ids, V
# print(load_vectors())
# ---- 고객이 우리 사이트에 ai 질문하기 전에 서비스사에서 준비해야 하는 것들 -----
query = "건조한 피부에 순한 것"
# 질문도 똑같이 벡터로 바꾼다. 목록으로 넣었으니 결과도 목록이라 [0] 으로 첫 개를 꺼낸다
q = model.encode([query], normalize_embeddings=True)[0]
# V 는 (200, 384), q 는 (384,) 다. 곱하면 (200,) — 상품마다 점수 하나씩 나온다.
# 200번 반복문을 도는 대신 한 줄로 끝난다
scores = V @ q
# argsort 는 "정렬했을 때의 순서(번호)"를 돌려준다. 작은 것부터라서
# [::-1] 로 뒤집어 큰 것부터로 만들고, [:3] 으로 위에서 셋만 가져온다
top = scores.argsort()[::-1][:3]
for i in top:
pid, name, _ = rows[i]
print(f" {scores[i]:.4f} {pid} {name}")
query = "건조한 피부에 순한 것"
# 질문도 똑같이 벡터로 바꾼다. 목록으로 넣었으니 결과도 목록이라 [0] 으로 첫 개를 꺼낸다
q = model.encode([query], normalize_embeddings=True)[0]
# V 는 (200, 384), q 는 (384,) 다. 곱하면 (200,) — 상품마다 점수 하나씩 나온다.
# 200번 반복문을 도는 대신 한 줄로 끝난다
scores = V @ q
# argsort 는 "정렬했을 때의 순서(번호)"를 돌려준다. 작은 것부터라서
# [::-1] 로 뒤집어 큰 것부터로 만들고, [:3] 으로 위에서 셋만 가져온다
top = scores.argsort()[::-1][:3]
for i in top:
pid, name, _ = rows[i]
print(f" {scores[i]:.4f} {pid} {name}")
프로젝트 테스트 (개선버전)
import sqlite3
import json
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("intfloat/multilingual-e5-small")
con = sqlite3.connect("shop.db")
cur = con.cursor()
# 상품 200개를 통째로 꺼낸다.
# 이름/설명만이 아니라 피부타입·성분·고민·태그까지 같이 꺼낸다.
# 「지성피부」 같은 말은 skin_type 칸에 들어있어서, 그 칸을 안 꺼내면 검색에 안 걸린다
rows = cur.execute("""
SELECT product_id, name, brand, category, price,
skin_type, ingredient, concern, tags, description
FROM products
""").fetchall()
# 컬럼 순서를 숫자로 외우면 헷갈리니 이름을 붙여둔다
PID, NAME, BRAND, CATEGORY, PRICE, SKIN, INGR, CONCERN, TAGS, DESC = range(10)
def to_doc(r):
"""상품 한 줄을 검색용 문장 한 덩어리로 만든다."""
# e5 모델은 저장할 문서 앞에 "passage: " 를 붙이라고 정해져 있다.
# 이 표시를 빼면 모델이 학습한 방식과 달라져서 점수가 엉뚱하게 나온다
#
# 가격도 일부러 글자로 같이 넣어둔다. 넣어도 「5만원 이하」가 안 걸린다는 걸
# 아래에서 직접 확인하기 위해서다
return (
f"passage: {r[NAME]}. "
f"브랜드 {r[BRAND]}. 종류 {r[CATEGORY]}. "
f"{r[SKIN]} 피부용. 성분 {r[INGR]}. 고민 {r[CONCERN]}. "
f"{r[TAGS].replace(',', ' ')}. "
f"{r[PRICE]}원. "
f"{r[DESC]}"
)
docs = [to_doc(r) for r in rows]
# batch_size=32 는 "한 번에 32개씩 묶어서 계산해라" 는 뜻이다.
# 한꺼번에 200개를 올리면 메모리가 모자랄 수 있어 나눠서 처리한다
V = model.encode(docs, normalize_embeddings=True, batch_size=32)
# 벡터를 담을 표를 만든다. IF NOT EXISTS 를 붙이면 이미 있을 때 그냥 넘어간다
cur.execute("""
CREATE TABLE IF NOT EXISTS product_vectors (
product_id TEXT PRIMARY KEY,
vector TEXT -- 숫자 384개를 글자로 눌러 담는다
)
""")
# 다시 만들 때를 대비해 기존 것을 비운다. DELETE 에 WHERE 를 안 쓰면 전부 지운다
cur.execute("DELETE FROM product_vectors")
# zip 은 두 목록을 짝지어 하나씩 꺼내준다. rows[0]와 V[0], rows[1]과 V[1], ...
for r, vec in zip(rows, V):
# tolist() 는 numpy 숫자 묶음을 파이썬 목록으로 바꾼다.
# json.dumps 는 그 목록을 글자로 만든다 -> "[0.0623, -0.0149, ...]"
cur.execute(
"INSERT INTO product_vectors VALUES (?, ?)",
(r[PID], json.dumps(vec.tolist())),
)
con.commit()
# 잘 들어갔는지 확인한다
print("저장된 줄:", cur.execute("SELECT COUNT(*) FROM product_vectors").fetchone()[0])
raw = cur.execute("SELECT vector FROM product_vectors LIMIT 1").fetchone()[0]
print("한 줄 길이:", len(raw), "글자")
def load_vectors():
"""저장해둔 글자를 숫자 묶음으로 되돌린다."""
saved = cur.execute("SELECT product_id, vector FROM product_vectors").fetchall()
ids = [pid for pid, _ in saved]
# json.loads 는 글자를 다시 목록으로 바꾼다 (dumps 의 반대)
# np.array 는 그 목록을 numpy 숫자 묶음으로 만든다 — 계산이 빨라진다
# dtype="float32" 는 "소수를 32비트 크기로 담아라" 는 뜻이다. 기본값(64비트)의 절반이라
# 메모리를 아끼고, 이 정도 정밀도면 검색 결과가 달라지지 않는다
mat = np.array([json.loads(v) for _, v in saved], dtype="float32")
return ids, mat
# ---- 고객이 우리 사이트에 ai 질문하기 전에 서비스사에서 준비해야 하는 것들 -----
query = "5만원 이하의 지성피부 제품 소개해줘"
# 질문도 똑같이 벡터로 바꾼다. 질문 쪽에는 "query: " 를 붙인다 (문서는 "passage: ").
# 목록으로 넣었으니 결과도 목록이라 [0] 으로 첫 개를 꺼낸다
q = model.encode([f"query: {query}"], normalize_embeddings=True)[0]
# V 는 (200, 384), q 는 (384,) 다. 곱하면 (200,) — 상품마다 점수 하나씩 나온다.
# 200번 반복문을 도는 대신 한 줄로 끝난다
scores = V @ q
# argsort 는 "정렬했을 때의 순서(번호)"를 돌려준다. 작은 것부터라서
# [::-1] 로 뒤집어 큰 것부터로 만들고, [:3] 으로 위에서 셋만 가져온다
top = scores.argsort()[::-1][:3]
# 여기가 이 예제의 핵심이다.
# 가격을 글자로 넣어뒀는데도 「5만원 이하」라는 조건은 지켜지지 않는다.
# 벡터는 "비슷한 말"을 찾을 뿐, 숫자의 크기를 비교하지 못하기 때문이다
print(f"\n질문: {query}")
for i in top:
r = rows[i]
print(f" {scores[i]:.4f} {r[PID]} {r[NAME]} / {r[SKIN]} / {r[PRICE]}원")
con.close()
5. 벡터 DB 를 안 쓰는 이유
여기까지 오면 한 가지가 걸린다. 「벡터 검색이면 벡터 데이터베이스라는 걸 쓴다던데?」
맞다. 크로마(Chroma) · Pinecone · Qdrant 같은 이름을 들어봤을 것이다. 그런데 그게 하는 일을 뜯어보면 셋이다.
| 하는 일 | 우리는? |
|---|---|
| 벡터를 저장한다 | 직접 했다 — product_vectors 표 |
| 가까운 것을 찾는다 | 직접 했다 — V @ q 한 줄 |
| 다 안 훑고 빠르게 찾는다 (색인) | 안 했다. 200개엔 필요가 없어서 |
얼마나 커져야 필요해지나
우리 방식은 200개를 전부 훑는다. 무식해 보이는데 재보면 이렇다.
import time
started = time.time()
for _ in range(100):
scores = V @ q # 200개를 전부 훑는다
print(f"검색 한 번 {(time.time() - started) / 100 * 1000:.3f}ms")
- 검색 한 번 0.005ms
크로마 코드는 어떻게 생겼나
설치하지 않고 눈으로만 본다. 하는 일이 같다는 것만 확인하면 된다.
# 우리 방식
scores = V @ q # 200개와 한 번에 비교
top = scores.argsort()[::-1][:5] # 큰 것부터 다섯 개
# 크로마 방식 — 하는 일은 같다
results = collection.query(query_embeddings=[q], n_results=5)
| 이름 | 어떤 것 | 언제 만나나 |
|---|---|---|
pgvector | PostgreSQL 에 벡터 칸을 더한 확장 | 우리가 갈 곳. 이미 DB 를 쓴다면 이게 제일 자연스럽다 |
| Chroma | 로컬에서 쓰는 가벼운 벡터 DB | 혼자 실험할 때. 설치가 필요하다 |
sqlite-vec | SQLite 에 벡터 색인을 더한 확장 | 지금 구조를 그대로 두고 색인만 넣고 싶을 때 |
| Pinecone · Qdrant · Weaviate | 남이 운영해주는 벡터 DB | 규모가 커져 직접 운영하기 싫을 때 |
6. 🔴 「3만원 이하 선물용」이 안 잡힌다
같은 방식으로 하나 더 물어본다.
- 0.8221 P177 판테놀 필링 로션 42900원
- 0.8189 P001 비타민C 필링 로션 25400원
- 0.8185 P035 히알루론산 포어 로션 32100원
SQL 이 잘하는 것
딱 떨어지는 조건
- 가격 · 카테고리 · 재고 · 날짜
- 틀리는 법이 없다
- 「비슷한 것」은 못 찾는다
벡터가 잘하는 것
말의 뜻
- 「순한」 「진정되는」 「선물하기 좋은」
- 낱말이 안 겹쳐도 찾는다
- 숫자 크기를 못 읽는다
이번 편에 나온 것
| 한 것 · 안 것 | 내용 |
|---|---|
| 상품 벡터 | 설명을 벡터로 · 200개 2.2초 |
| 저장 | SQLite 엔 벡터 칸이 없어 글자로 눌러 담는다 |
json.dumps / loads | 목록 ↔ 글자 |
dtype=float32 | 메모리 절반, 결과는 같다 |
V @ q | 200개 점수를 한 줄로 |
argsort()[::-1][:3] | 큰 것부터 세 개 |
| 된 것 | 「순한 것」이 낱말 없이 찾아진다 |
| 🔴 안 된 것 | 「3만원 이하」에 42,900원이 1위 |
| 벡터 DB | 200개엔 필요 없다. 전부 훑어도 0.005ms · 100만 개도 23ms |
| 결론 | SQL 로 먼저 자르고, 남은 것 중에서 뜻으로 고른다 |
미션
- 01
[필수] 내 말로 상품을 찾아본다
mission_pv_1.py. 질문 다섯 개를 지어 상위 세 개씩 뽑는다. 엉뚱한 게 나온 질문을 하나 골라 왜 그랬을지 한 줄로 적는다. - 02
[응용] 설명만 넣었을 때와 비교한다
mission_pv_2.py. 지금은 이름과 설명을 붙여 벡터로 만들었다. 설명만 넣으면 결과가 어떻게 달라지나. - 03
[도전] 글자 대신 숫자 그대로 저장한다
mission_pv_4.py. 지금은 JSON 글자로 저장해 1,639KB 를 쓴다.vec.tobytes()로 담고np.frombuffer로 꺼내면 300KB 다. 얼마나 줄고 얼마나 빨라지나. - 04
[도전] SQL 로 먼저 자르고 찾아본다
mission_pv_3.py.WHERE price <= 30000으로 거른 상품만 벡터에서 골라 검색한다. 「3만원 이하 선물용」이 이제 제대로 나오나.
[도전] 자르고 나서 고르면충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
조건을 어기는 상품이 아예 후보에 없다. 42,900원짜리가 1위로 올 수가 없다.
핵심은 자르는 것을 먼저 한다는 점이다. 뜻으로 먼저 고르고 나중에 거르면, 상위 다섯 개가 전부 조건을 어겨서 남는 게 없을 수도 있다.
그래서 순서가 중요하다 — 틀리는 법이 없는 조건으로 먼저 좁히고, 남은 것 중에서 뜻으로 고른다.
실무에서 이 구조를 아주 흔하게 쓴다. 조건은 데이터베이스가, 뜻은 벡터가 맡는 식이다.
# 1) SQL 로 먼저 자른다
picked = cur.execute("""
SELECT product_id FROM products
WHERE price <= 30000 AND tags LIKE '%선물용%'
""").fetchall()
allowed = {r[0] for r in picked}
print("조건을 통과한 상품:", len(allowed), "개")
# 2) 그중에서만 뜻으로 고른다
q = model.encode(["선물하기 좋은 순한 것"], normalize_embeddings=True)[0]
scores = V @ q
for i in scores.argsort()[::-1]:
pid, name, _ = rows[i]
if pid not in allowed: # 조건을 어긴 것은 건너뛴다
continue
print(f" {scores[i]:.4f} {pid} {name}")51개까지 좁혀진다. 200개 중에서 고르는 것보다 훨씬 쉬운 문제가 된다.
그리고 후보가 줄면 뒤에서 모델에게 줄 목록도 짧아진다 — 입력이 짧아지니 빨라지고 싸진다. 좋은 일이 한꺼번에 온다.
[도전] 글자 대신 바이트로 담으면충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
크기가 5.5배 줄고 되돌리는 속도가 40배 빨라진다. 1,639KB 가 300KB 가 되고, 23.7ms 걸리던 것이 0.6ms 가 된다.
이유는 단순하다. 0.0623 이라는 숫자 하나를 글자로 적으면 여섯 자가 필요한데, 바이트로 담으면 4바이트면 끝난다. 게다가 꺼낼 때 글자를 숫자로 해석하는 일(json.loads)을 안 해도 된다.
그러면 왜 처음부터 이렇게 안 했나 — 글자로 담으면 SELECT 로 꺼내 눈으로 볼 수 있다. 바이트는 열어봐도 알아볼 수 없는 덩어리다. 처음 배울 때는 보이는 쪽이 낫다고 봤다.
실무에서는 바이트로 담는다. 그리고 더 커지면 벡터 전용 칸이 있는 데이터베이스로 간다 — 뒤에서 그걸 한다.
import numpy as np
# 담을 때 — tobytes() 는 숫자 384개를 바이트 덩어리로 만든다.
# 칸 종류를 BLOB 으로 잡아야 바이트를 그대로 받아준다
cur.execute("""
CREATE TABLE IF NOT EXISTS product_vectors_blob (
product_id TEXT PRIMARY KEY,
vector BLOB
)
""")
for (pid, name, desc), vec in zip(rows, V):
cur.execute("INSERT INTO product_vectors_blob VALUES (?, ?)", (pid, vec.tobytes()))
con.commit()
# 꺼낼 때 — frombuffer 는 바이트를 다시 숫자로 읽는다.
# dtype 을 담을 때와 똑같이 적어야 한다. 다르면 엉뚱한 숫자가 나온다
V2 = np.array([
np.frombuffer(v, dtype="float32")
for _, v in cur.execute("SELECT product_id, vector FROM product_vectors_blob")
])
print("모양:", V2.shape)
print("원본과 같은가:", np.abs(V - V2).max() == 0)dtype 을 담을 때와 다르게 적으면 에러도 안 나고 엉뚱한 숫자가 나온다. 바이트에는 「이게 어떤 숫자였는지」가 안 적혀 있기 때문이다.
글자로 담을 때는 이런 사고가 안 났다. 편한 것에는 대개 이유가 있고, 빠른 것에는 대개 조건이 붙는다.
정리하면
상품 200개를 숫자로 바꿔 DB 에 넣었다. 2.2초 걸렸고, 한 줄이 8,385자짜리 글자로 저장된다.
그리고 뜻으로 찾는 것이 실제로 됐다. 「건조한 피부에 순한 것」에 약산성·판테놀 성분이 올라왔다 — 상품 설명에 「순한」이라는 낱말은 한 번도 안 나온다. LIKE 로는 아무것도 못 찾는 질문이다.
그리고 벡터 DB 없이 여기까지 왔다. 200개를 전부 훑는 검색이 0.005ms 다 — 100만 개가 돼도 23ms 라, 색인이 필요해지는 지점은 생각보다 훨씬 멀다. 벡터 DB 가 하는 일 셋 중 둘은 이미 손으로 만들었고, 나머지 하나는 뒤에서 진짜 데이터베이스로 배운다.
그런데 「3만원 이하 선물용」에 42,900원이 1위로 왔다. 임베딩은 가격을 뜻으로 읽지 숫자로 안 읽는다. 이건 모델이 부족해서가 아니라 뜻을 재는 도구에게 크기 비교를 시킨 것이 잘못이다.
답은 2편에 있었다 — WHERE price <= 30000 한 줄이다. 틀리는 법이 없는 조건으로 먼저 자르고, 남은 것 중에서 뜻으로 고른다.
다음 편에서 고객을 벡터로 만든다. 고객에게는 설명 글이 없어서 이력과 후기를 이어붙여야 하는데, 그 과정에서 정답이 슬그머니 새어드는 자리가 하나 나온다.