네 벌을 벡터로 만들어 넣는다
Article
이전 단원에서. 고객 정보를 정형(SQL) · 취향(벡터) · 후기(벡터) 세 층으로 가르고, 취향은 상품 벡터에 있는 낱말로만 만들기로 했습니다.
이제 코드로 옮깁니다. 만들 것은 총 4개의 데이터 카테고리입니다.
| 표 | 무엇을 | 몇 개 | 쓰이는 곳 |
|---|---|---|---|
chunk_vectors | 이전에 자른 제품 설명 조각 (접두어 포함) | 1,560 | 상품 질문에 근거 대기 |
product_vectors | 상품 한 줄 요약 | 200 | 추천 후보 고르기 용 |
customer_vectors | 취향 한 줄 (이전 단원에서 만든 정보) | 300 | 「이 고객」을 한 점으로 |
review_vectors | 후기 1건 = 1행 | 1,200 | 「비슷한 불만」 찾기 |
1. 지금 현재와 추후 변경될 데이터 구조
지금
자르는 쪽만 있다
pipeline/02_chunk.pypipeline/prep/options.pypipeline/prep/chunking.pypipeline/prep/storage.py— 표 둘을 만든다app/core/config.py
이후
벡터 쪽이 붙는다
app/core/config.py— 값 두 줄이 는다prep/embedding.py— 새 파일 · 글을 숫자로prep/storage.py— 함수 둘이 더 붙는다pipeline/03_embed.py— 새 파일 · 순서를 정한다02_chunk.py·chunking.py·options.py는 안 건드린다
이전 단원에서 정한 규칙을 그대로 씁니다.
embedding.py 는 순수 함수 형태이고 (해당 함수는 DB 정보를 모릅니다), 실제 DB에 데이터를 넣는 일은 전부 storage.py 가 합니다.
2. app/core/config.py — 값 두 줄을 더합니다
「추후 필요할때 추가한다고」고 미뤄 둔 값 두개를 추가합니다. 파일 맨 아래에 아래 정보 2개를 이어 붙입니다.
# ─────────────────────────────────────────────────────────────
# 3일차 ― 임베딩
# ─────────────────────────────────────────────────────────────
# 이 모델이 한 문장을 몇 개의 실수로 바꾸는가. 표를 만들 때와 점검할 때 쓴다
EMBED_DIM = 384
# 벡터를 문자로 적을 때 소수점 몇 자리까지 쓸까.
# 자릿수를 그대로 두면 파일이 두 배가 된다. 얼마나 손해인지는 5교시에 직접 잰다
EMBED_DECIMALS = 6
이 파일에서 기억할 것
- 값 둘 다 여기 한 곳에만 적습니다. 아래 파일들은 이 이름을 가져다 쓸 뿐입니다.
EMBED_DIM은 6일차에 상용 모델(1536차원)로 갈아끼울 때 같이 바뀝니다. 그때 이 한 줄만 고치면 됩니다.
3. pipeline/prep/embedding.py — 글을 숫자로 벡터화
제일 짧은 파일입니다. 하는 일이 하나뿐입니다.
"""뜻을 숫자로 바꾼다. **이 파일도 DB 정보를 모른다.**
들어오는 것 ["[비타민C 필링 로션 > 주의사항] 고농도...", ...] ― 그냥 글 목록
나가는 것 [[0.018827, -0.024955, ...], ...] ― 그냥 숫자 목록
임베딩기를 만드는 자리가 이 파일 하나뿐이다.
부르는 쪽(03_embed.py)은 모델이 무엇인지 모른다. 6일차에 상용 API 로 갈아끼울 때 고치는 곳이 여기 한 군데뿐입니다.
숫자를 어떻게 **저장**할지는 이 파일이 정하지 않는다. 그건 storage.py에서 정해질 값입니다.
"""
import time
from app.core.config import EMBED_MODEL
_embeddings = None
def get_embeddings():
"""임베딩기를 한 번만 만들어 두고 계속 쓴다. 모델을 올리느라 몇 초 걸린다."""
global _embeddings
if _embeddings is not None:
return _embeddings
from langchain_huggingface import HuggingFaceEmbeddings
_embeddings = HuggingFaceEmbeddings(
model_name=EMBED_MODEL,
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True, "batch_size": 64},
)
return _embeddings
def embed_documents(texts):
"""글 목록을 벡터 목록으로. 돌려주는 것: (벡터 목록, 걸린 초)"""
started = time.perf_counter()
vectors = get_embeddings().embed_documents(texts)
return vectors, time.perf_counter() - started
이 파일에서 기억할 것
-
하는 일은 두 줄인데도 파일을 따로 뗐습니다. 모델 이름이 바뀌는 자리이기 때문입니다. 6일차에 상용 API 로 갈아끼울 때 고칠 곳이
get_embeddings()안쪽뿐이고,03_embed.py도chunking.py도 한 글자도 안 바뀝니다. 셋 다embed_documents라는 규칙만 알기 때문입니다. 1교시에 말한 포트와 어댑터를 설정하는 파일이 바로 이곳입니다. -
규칙을 새로 만들지 않았습니다. LangChain 이 이미
Embeddings규격을 갖고 있어서 그걸 따르는 객체를 돌려주기만 하면 됩니다. 만약 해당 파일에서 Langchain을 활용한 어뎁터를 정해두지 않으면 추후 상용 API를 갈아끼울때마다 모든 로직의 함수를 일일이 수정해야합니다.** -
_embeddings = None과global은 모델을 한 번만 올리기 위해서입니다. 올리는 데 10초 넘게 걸려서, 데이터덩어리를 임베딩할때마다 호출하면 4배에 해당하는 40초를 매번 임베딩할때마다 낭비해야합니다. 이름 앞 밑줄은 "이 파일 안에서만 쓴다"는 파이썬의 관습입니다. -
encode_kwargs의normalize_embeddings=True는 길이를 1 로 맞춥니다. 그러면 내적이 곧 코사인 유사도가 되어 나눗셈이 필요 없습니다.device="cpu"는 교육장 PC 의 그래픽카드가 제각각이라 그래픽카드 드라이버 충돌을 방지하기 위해 못 박아 둔 것입니다.
4. pipeline/prep/storage.py — 함수 둘을 더합니다
2교시에 만든 파일 맨 위와 맨 아래에 붙입니다.
위에는 import 두 줄과 vector_to_text(), 아래에는 save_vectors() 입니다.
가운데의 save_sections_and_chunks() 는 그대로 둡니다.
"""자료구조를 데이터베이스에 넣는다. **이 파일은 자르는 법을 모른다.**
스플리터도 임베딩기도 부르지 않는다. 이미 만들어진 것을 받아서 넣기만 한다.
"""
import json
from app.core.config import EMBED_DECIMALS, EMBED_MODEL
def vector_to_text(vector):
"""벡터를 '[0.018827,-0.024955,...]' 라는 **글자**로 바꾼다.
소수점 6자리로 줄여 적는다 (config 의 EMBED_DECIMALS).
"""
return json.dumps([round(x, EMBED_DECIMALS) for x in vector])
def save_vectors(con, kind, key, parent, ids, vectors, dim):
"""벡터 정보가 저장될 테이블을 하나를 만들고 저장한다. 앞으로 만들 4개의 데이터셋이 모두 이 함수로 벡터화되어 저장된다."""
# PK타입을 수동으로 입력하지 않고 부모 테이블에서 읽어 온다.
# chunks.chunk_id 는 INTEGER 고 products.product_id 는 TEXT 다.
# 어긋나면 조인이 오류 없이 0건이 출력된다
key_type = next(x[2] for x in con.execute(f"PRAGMA table_info({parent})") if x[1] == key)
con.execute(f"DROP TABLE IF EXISTS {kind}_vectors")
con.execute(f"""
CREATE TABLE {kind}_vectors (
{key} {key_type} PRIMARY KEY,
dim INTEGER NOT NULL,
model TEXT NOT NULL,
vector TEXT NOT NULL,
FOREIGN KEY ({key}) REFERENCES {parent}({key})
)
""")
con.executemany(
f"INSERT INTO {kind}_vectors ({key}, dim, model, vector) VALUES (?, ?, ?, ?)",
[(item_id, dim, EMBED_MODEL, vector_to_text(vector))
for item_id, vector in zip(ids, vectors)],
)
con.commit()
이 파일에서 기억할 것
- 테이블 이름도 열쇠 PK 컬럼도 f-string 으로 갈아 끼웁니다. 그래서
chunk·product·customer·review넷을 같은 함수 하나로 처리합니다. dim과model컬럼을 왜 두나. 벡터만 있으면 어느 모델로 만든 건지 알 수가 없습니다. 384개짜리 숫자는 그냥 384개짜리 숫자입니다. 다른 모델로 만든 벡터끼리 비교하면 숫자는 나오고 오류도 안 납니다. 그 값이 아무 뜻이 없을 뿐입니다. 6일차에 상용 1536차원으로 바꿀 때 이 두 컬럼이 안전장치가 됩니다.PRAGMA table_info가 이번 단원에서 제일 주의해서 확인해야할 정보입니다. 아래에서 따로 봅니다.
왜 BLOB 이 아니라 글자인가
SQLite 에는 실수 배열을 담는 타입이 없습니다. 숫자 384개를 어딘가에 넣어야 하는데 방법이 둘입니다.
BLOB (이진 데이터)
숫자로는 이쪽이 낫다
- 작다 — 벡터 하나에 1,536바이트
- 읽기가 빠르다
SELECT해도 사람이 못 읽는다
문자
우리가 고른 것
'[0.018827,-0.024955,...]'라고 그냥 보인다- 크기는 두 배가 넘는다
- 실제 문자화해서 백터 정보값을 눈으로 확인하는게 교육측면에서는 더 나음
얼마나 손해인지는 5교시에 직접 잽니다.
지금은 "일부러 느린 쪽을 골랐다"만 알고넘어갑니다. 덤으로, 나중에 Supabase(pgvector) 로 옮길 때 이 모양이 그대로 쓰입니다.
pgvector 의 벡터 리터럴이 [0.1,0.2,...] 라 거의 손댈 것이 없습니다.
PK 타입을 손으로 적으면 안 되는 이유
| 테이블명 | PK | 타입 | 값 |
|---|---|---|---|
chunks | chunk_id | INTEGER | 1, 2, 3 … |
products | product_id | TEXT | 'P001' |
customers | customer_id | TEXT | 'C001' |
purchases | purchase_id | TEXT | 'PU0001' |
넷 중 하나만 숫자입니다. 이걸 손으로 적다가 chunk_vectors 의 chunk_id 를 TEXT 로 만들면 어떻게 될까요.
5. pipeline/03_embed.py — 새로 만듭니다
"""글을 벡터로 만들어 넣는다 ― 총 4개의 카테고리 정보로 구성.
이 파일이 하는 일은 순서를 정하는 것뿐이다. 실제 일은 prep/ 안에서 한다.
고객은 세 층으로 나눠 다룬다 .
정형 나이 · 성별 · 지역 -> SQL 이 답한다. 벡터로 안 만든다
취향 피부타입 · 카테고리 -> customer_vectors (한 명 = 한 점)
본문 후기 1,200건 -> review_vectors (1건 = 1행)
이 파일은 비싸다. 로컬에서 30초쯤 걸리고 상용 API 를 켜면 해당 정보를 임베딩하는 모든 토큰비용이 곧 요금이다.
그래서 02_chunk.py 와 갈라 뒀다. 조각을 안 바꿨으면 다시 돌릴 이유가 없다.
앞: python pipeline/02_chunk.py
실행: python pipeline/03_embed.py
"""
import sqlite3
import sys
import time
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.stdout.reconfigure(errors="replace")
from huggingface_hub.utils import disable_progress_bars
from huggingface_hub.utils import logging as hub_logging
from transformers import logging as hf_logging
hf_logging.set_verbosity_error()
hf_logging.disable_progress_bar()
hub_logging.set_verbosity_error()
disable_progress_bars()
from app.core.config import DB_PATH, EMBED_DIM, EMBED_MODEL
from pipeline.prep import embedding, storage
con = sqlite3.connect(DB_PATH)
con.execute("PRAGMA foreign_keys = ON")
# ═══════════════════════════════════════════════ ① 무엇을 벡터로 만드나
def product_text(row):
"""상품 한 줄을 검색용 문장으로. 상세(detail)는 일부러 뺀다."""
name, brand, category, price, skin_type, ingredient, concern, tags, desc = row
return (f"{name} · {brand} · {category} · {price}원 · {skin_type} · "
f"{ingredient} · {concern} · {tags} · {desc}")
def top(values, n=3):
"""제일 자주 나온 것 n 개를 가운뎃점으로 잇는다."""
return " · ".join(value for value, _ in Counter(values).most_common(n))
def customer_text(skin_type, purchases):
"""고객 한 명을 **취향 한 줄**로 만든다.
후기를 안 넣는다. 상품 벡터에 있는 낱말로만 만든다 ― 3교시에 재서 정했다.
"""
ratings = [rating for *_, rating in purchases]
return (f"{skin_type} 피부 · 선호 카테고리 {top([p[1] for p in purchases])} · "
f"자주 쓴 성분 {top([p[2] for p in purchases])} · "
f"관심 고민 {top([p[3] for p in purchases])} · "
f"평균 별점 {sum(ratings) / len(ratings):.1f}")
targets = {}
# ─ 상세 상품 정보 조각 ─ 이전 시간에 미리 벡터화 한 정보들
r = con.execute("SELECT chunk_id, text FROM chunks ORDER BY chunk_id").fetchall()
targets["chunk"] = ("chunk_id", "chunks", [x[0] for x in r], [x[1] for x in r])
# ─ 상품 정보 요약 한줄
r = con.execute("""
SELECT product_id, name, brand, category, price, skin_type,
ingredient, concern, tags, description
FROM products ORDER BY product_id
""").fetchall()
targets["product"] = ("product_id", "products", [x[0] for x in r],
[product_text(x[1:]) for x in r])
# ─ 고객 정보 is_holdout = 1 은 뺀다. AI에게 일부러 정답을 제외해서 확증편향 방지 목적
history = {}
for cid, category, ingredient, concern, rating in con.execute("""
SELECT purchases.customer_id, products.category, products.ingredient,
products.concern, purchases.rating
FROM purchases
JOIN products ON products.product_id = purchases.product_id
WHERE purchases.is_holdout = 0
ORDER BY purchases.customer_id, purchases.purchase_id
"""):
history.setdefault(cid, []).append((None, category, ingredient, concern, rating))
skin_of = dict(con.execute("SELECT customer_id, skin_type FROM customers"))
cids = [c for (c,) in con.execute("SELECT customer_id FROM customers ORDER BY customer_id")
if c in history]
targets["customer"] = ("customer_id", "customers", cids,
[customer_text(skin_of[c], history[c]) for c in cids])
# ─ 고객후기만 분리 : 고객후기 한 건을 한 행으로 저장 (이어 붙이지 않는다)
r = con.execute("""
SELECT purchase_id, review FROM purchases
WHERE is_holdout = 0 AND review IS NOT NULL AND review != ''
ORDER BY purchase_id
""").fetchall()
targets["review"] = ("purchase_id", "purchases", [x[0] for x in r], [x[1] for x in r])
# ═══════════════════════════════════════════════ ② 벡터로 만들어 넣는다
print(f"임베딩 ― {EMBED_MODEL}")
started = time.perf_counter()
embedding.get_embeddings()
print(f" 임베딩기 준비 {time.perf_counter() - started:.1f}초\n")
print(f" {'무엇':10s} {'개수':>7s} {'평균 글자':>9s} {'걸린 시간':>10s} {'초당':>9s}")
for kind, (key, parent, ids, texts) in targets.items():
vectors, elapsed = embedding.embed_documents(texts)
dim = len(vectors[0])
if dim != EMBED_DIM:
print(f" 경고: config 의 EMBED_DIM({EMBED_DIM}) 과 실제 차원({dim}) 이 다르다")
# 네 개의 데이터 카테고리를 같은 구조로 활용. 테이블 이름도 PK도 f-string 으로 갈아 끼우기 편하게 데이터 구조 통일
storage.save_vectors(con, kind, key, parent, ids, vectors, dim)
print(f" {kind:10s} {len(ids):>7,} {sum(len(t) for t in texts) / len(texts):>9.0f} "
f"{elapsed:>9.1f}초 {len(ids) / elapsed:>8.0f}개")
peek = con.execute("SELECT vector FROM product_vectors LIMIT 1").fetchone()[0]
print(f"\n 실제로 이렇게 들어 있다:\n {peek[:60]}...")
sample = con.execute("SELECT customer_id FROM customer_vectors LIMIT 1").fetchone()[0]
print(f"\n 고객 한 명을 이렇게 적었다 ({sample}):")
print(f" {targets['customer'][3][targets['customer'][2].index(sample)]}")
con.close()
이 파일에서 기억할 것
is_holdout = 0이 두 SQL 에 다 붙어 있습니다. 채점용으로 숨겨 둔 정답 상품을 숨기이 위함입니다. 이걸 빠뜨리면 AI가 답을 보고 시험을 치는 셈이 됩니다 — 정답 상품의 카테고리와 성분이 고객 프로필에 들어가게되니 AI는 이미 정해진 답을 확증 편향으로 답변할뿐 제대로된 추천 검증을 할 수 없습니다.- 후기는 이어 붙이는 코드가 아예 없습니다.
purchase_id하나에 후기 하나입니다. 3교시에 잰 대로 후기 한 건은 길어야 49토큰이라 안을 자를 일이 없습니다.
customer_text() 에 들어가는 낱말이 전부 상품 벡터에도 있는 말이라는 점을 유의하세요.
카테고리 · 성분 · 고민 · 피부타입입니다.
이전 단원에서 확인한 결과가 이 선택의 근거입니다
— hit@5 로 ① 후기 이어붙이기 10.7% · ② 취향만 9.7% · ③ 취향+나이지역 12.3% · ④ 취향+후기 7.7% 였고,
앞 셋은 못 가르니 제일 짧고 사람이 읽을 수 있는 데이터 셋을 골랐습니다.
6. 실제 임베딩처리합니다. (각 데이터 카테고리별 벡터화)
python pipeline/03_embed.py
- 임베딩 ― intfloat/multilingual-e5-small
- 임베딩기 준비 11.9초
- 무엇 개수 평균 글자 걸린 시간 초당
- chunk 1,560 188 21.1초 74개
- product 200 197 2.8초 71개
- customer 300 85 1.7초 180개
- review 1,200 45 3.8초 312개
- 실제로 이렇게 들어 있다:
- [0.018827, -0.024955, -0.038822, -0.058947, 0.071216, -0.016...
- 고객 한 명을 이렇게 적었다 (C001):
- 건성 피부 · 선호 카테고리 앰플 · 자주 쓴 성분 어성초 · 관심 고민 트러블 · 평균 별점 3.0
이 화면에서 볼 것 셋
첫째, 고객 한 명의 정보를 확인합니다 건성 피부 · 선호 카테고리 앰플 · 자주 쓴 성분 어성초 · 관심 고민 트러블 · 평균 별점 3.0」. 후기를 이어 붙였다면 여러 후기 덩어리가 한 덩어리로 뭉쳐서 정확한 정보를 추출하기 어렵습니다.
둘째, 평균 글자가 갈립니다. 조각 188자 · 상품 197자 · 고객 85자 · 후기 45자. 고객이 제일 짧습니다. 상용 API 는 글자 수가 곧 요금이라, 이 차이에 따라 나중에 AI 사용 요금의 편차가 커지게 됩니다.
셋째, 초당 처리량이 다릅니다. 조각 74개 · 후기 312개. 임베딩 비용은 **개수가아니라 토큰 수에 붙습니다. ** 짧은 글이 훨씬 빠릅니다.
이 시간에 한 것
| 주제 | 핵심 |
|---|---|
| 네 벌 | 조각 1,560 · 상품 200 · 고객 300 · 후기 1,200 |
config.py | 값은 쓸 자리가 생기는 날 넣는다 (EMBED_DIM · EMBED_DECIMALS) |
embedding.py | 모델을 만드는 자리가 한 군데. 6일차에 여기만 고친다 |
| 이미 있는 약속을 쓴다 | LangChain 의 Embeddings 규격을 또 감싸지 않는다 |
global 로 한 번만 | 모델 올리는 데 10초 넘게 걸린다. 부를 때마다 만들면 안 된다 |
| 문자로 저장 | BLOB 이 작고 빠르지만 `SELECT` 하면 눈에 보이는 쪽을 골랐다 (5교시에 잰다) |
dim · model 컬럼 | 다른 모델 벡터끼리 비교해도 오류가 안 난다. 그래서 적어 둔다 |
PRAGMA table_info | 열쇠 타입을 손으로 적으면 조인이 오류 없이 0건이 된다 |
is_holdout = 0 | 채점용 정답을 빼야 한다. 안 빼면 답을 보고 시험을 치는 셈 |
| 고객이 읽힌다 | 「건성 피부 · 선호 카테고리 앰플 …」 85자. 화면에 그대로 쓸 수 있다 |
| 후기는 행으로 | 이어 붙이는 코드가 아예 없다. 쌓여도 행이 늘 뿐이다 |
다음 시간에 할 것
벡터 3,260개가 데이터베이스에 들어갔습니다. 그런데 제대로 들어갔는지 확인한 적이 없습니다.
개수가 맞나? 차원이 다 같나? 길이가 1 로 정규화돼 있나? 6자리로 줄인 손실은 얼마나 되나? 문자로 넣은 대가는?
다음 시간에 위의 내용을 직접 점검하는 점검 파일을(pipeline/04_verify.py)을 새로 만듭니다.