학습 자료

상품 200개를 벡터로 — 그리고 「3만원 이하」가 안 잡힌다


Article

앞 편에서 문장 하나를 숫자로 바꿨다. 이제 상품 200개에 건다.

이번 편에서 만들 것
  1. 상품 설명 200개DB 에서 꺼낸다
  2. 벡터로 바꿔 저장product_vectors
  3. 말로 찾아본다「순한 것」
고객 쪽은 다음 편이다. 모델은 여전히 한 번도 안 부른다.

1. 무엇을 벡터로 만들 것인가

상품에는 설명 글이 있다. 그걸 그대로 쓴다.

고객에게는 설명 글이 없다

상품

그대로 쓰면 된다

  • description 칸에 설명이 있다
  • 「속당김을 잡아주는 히알루론산…」
  • 이 글을 벡터로

고객

만들어야 한다

  • 나이 · 피부타입만으로는 부족하다
  • 뭘 샀고 뭐라고 썼는지가 취향이다
  • 이력을 이어붙여 한 문장으로
고객의 취향은 프로필이 아니라 행동에 있다. 산 것과 쓴 후기가 그 사람을 제일 잘 말해준다.

2. 상품 200개를 벡터로

07_embed_products.py
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 에 저장한다

매번 다시 만들면 시간이 아깝다. 한 번 만들어 넣어두고 꺼내 쓴다.

07_embed_products.py — 이어서
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. 말로 상품을 찾아본다

이제 앞 편에서 못 하던 것을 해본다.

08_search.py
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 같은 이름을 들어봤을 것이다. 그런데 그게 하는 일을 뜯어보면 셋이다.

벡터 DB 가 하는 일 셋
하는 일우리는?
벡터를 저장한다직접 했다 — 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
상품이 늘면 — 전부 훑는 방식의 검색 시간
200개0.005ms
1만 개0.23ms
100만 개23ms

크로마 코드는 어떻게 생겼나

설치하지 않고 눈으로만 본다. 하는 일이 같다는 것만 확인하면 된다.

크로마를 쓴다면 — 설치하지 않는다
# 우리 방식
scores = V @ q                                  # 200개와 한 번에 비교
top = scores.argsort()[::-1][:5]                # 큰 것부터 다섯 개

# 크로마 방식 — 하는 일은 같다
results = collection.query(query_embeddings=[q], n_results=5)
이름만 알아두면 되는 것들
이름어떤 것언제 만나나
pgvectorPostgreSQL 에 벡터 칸을 더한 확장우리가 갈 곳. 이미 DB 를 쓴다면 이게 제일 자연스럽다
Chroma로컬에서 쓰는 가벼운 벡터 DB혼자 실험할 때. 설치가 필요하다
sqlite-vecSQLite 에 벡터 색인을 더한 확장지금 구조를 그대로 두고 색인만 넣고 싶을 때
Pinecone · Qdrant · Weaviate남이 운영해주는 벡터 DB규모가 커져 직접 운영하기 싫을 때
도구는 계속 바뀐다. 안 바뀌는 것은 임베딩 · 유사도 · 색인 · 필터링이고, 그건 지금 손으로 만들고 있다.

6. 🔴 「3만원 이하 선물용」이 안 잡힌다

같은 방식으로 하나 더 물어본다.

터미널 — 「3만원 이하 선물용」
  • 0.8221 P177 판테놀 필링 로션 42900원
  • 0.8189 P001 비타민C 필링 로션 25400원
  • 0.8185 P035 히알루론산 포어 로션 32100원
각자 잘하는 것이 다르다

SQL 이 잘하는 것

딱 떨어지는 조건

  • 가격 · 카테고리 · 재고 · 날짜
  • 틀리는 법이 없다
  • 「비슷한 것」은 못 찾는다

벡터가 잘하는 것

말의 뜻

  • 「순한」 「진정되는」 「선물하기 좋은」
  • 낱말이 안 겹쳐도 찾는다
  • 숫자 크기를 못 읽는다
그래서 순서를 정한다 — SQL 로 먼저 자르고, 남은 것 중에서 뜻으로 고른다. 뒤에서 이 순서로 만든다.

이번 편에 나온 것

정리
한 것 · 안 것내용
상품 벡터설명을 벡터로 · 200개 2.2초
저장SQLite 엔 벡터 칸이 없어 글자로 눌러 담는다
json.dumps / loads목록 ↔ 글자
dtype=float32메모리 절반, 결과는 같다
V @ q200개 점수를 한 줄로
argsort()[::-1][:3]큰 것부터 세 개
된 것「순한 것」이 낱말 없이 찾아진다
🔴 안 된 것「3만원 이하」에 42,900원이 1위
벡터 DB200개엔 필요 없다. 전부 훑어도 0.005ms · 100만 개도 23ms
결론SQL 로 먼저 자르고, 남은 것 중에서 뜻으로 고른다

미션

미션
  1. 01

    [필수] 내 말로 상품을 찾아본다

    mission_pv_1.py. 질문 다섯 개를 지어 상위 세 개씩 뽑는다. 엉뚱한 게 나온 질문을 하나 골라 왜 그랬을지 한 줄로 적는다.

  2. 02

    [응용] 설명만 넣었을 때와 비교한다

    mission_pv_2.py. 지금은 이름과 설명을 붙여 벡터로 만들었다. 설명만 넣으면 결과가 어떻게 달라지나.

  3. 03

    [도전] 글자 대신 숫자 그대로 저장한다

    mission_pv_4.py. 지금은 JSON 글자로 저장해 1,639KB 를 쓴다. vec.tobytes() 로 담고 np.frombuffer 로 꺼내면 300KB 다. 얼마나 줄고 얼마나 빨라지나.

  4. 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 한 줄이다. 틀리는 법이 없는 조건으로 먼저 자르고, 남은 것 중에서 뜻으로 고른다.

다음 편에서 고객을 벡터로 만든다. 고객에게는 설명 글이 없어서 이력과 후기를 이어붙여야 하는데, 그 과정에서 정답이 슬그머니 새어드는 자리가 하나 나온다.

Share
  • 파이썬
  • AI
  • 임베딩
  • SQL
  • 검색
상품 200개를 벡터로 — 그리고 「3만원 이하」가 안 잡힌다 — 디코드랩(DCODELAB)