학습 자료

네 벌을 벡터로 만들어 넣는다


Article

이전 단원에서. 고객 정보를 정형(SQL) · 취향(벡터) · 후기(벡터) 세 층으로 가르고, 취향은 상품 벡터에 있는 낱말로만 만들기로 했습니다.

이제 코드로 옮깁니다. 만들 것은 총 4개의 데이터 카테고리입니다.

무엇을 벡터로 만드나
표무엇을몇 개쓰이는 곳
chunk_vectors이전에 자른 제품 설명 조각 (접두어 포함)1,560상품 질문에 근거 대기
product_vectors상품 한 줄 요약200추천 후보 고르기 용
customer_vectors취향 한 줄 (이전 단원에서 만든 정보)300「이 고객」을 한 점으로
review_vectors후기 1건 = 1행1,200「비슷한 불만」 찾기

1. 지금 현재와 추후 변경될 데이터 구조

손대는 파일

지금

자르는 쪽만 있다

  • pipeline/02_chunk.py
  • pipeline/prep/options.py
  • pipeline/prep/chunking.py
  • pipeline/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개를 이어 붙입니다.

app/core/config.py

# ─────────────────────────────────────────────────────────────
# 3일차 ― 임베딩
# ─────────────────────────────────────────────────────────────

# 이 모델이 한 문장을 몇 개의 실수로 바꾸는가. 표를 만들 때와 점검할 때 쓴다
EMBED_DIM = 384

# 벡터를 문자로 적을 때 소수점 몇 자리까지 쓸까.
# 자릿수를 그대로 두면 파일이 두 배가 된다. 얼마나 손해인지는 5교시에 직접 잰다
EMBED_DECIMALS = 6

이 파일에서 기억할 것

  • 값 둘 다 여기 한 곳에만 적습니다. 아래 파일들은 이 이름을 가져다 쓸 뿐입니다.
  • EMBED_DIM 은 6일차에 상용 모델(1536차원)로 갈아끼울 때 같이 바뀝니다. 그때 이 한 줄만 고치면 됩니다.

3. pipeline/prep/embedding.py — 글을 숫자로 벡터화

제일 짧은 파일입니다. 하는 일이 하나뿐입니다.

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() 는 그대로 둡니다.

pipeline/prep/storage.py
"""자료구조를 데이터베이스에 넣는다. **이 파일은 자르는 법을 모른다.**

스플리터도 임베딩기도 부르지 않는다. 이미 만들어진 것을 받아서 넣기만 한다.
"""

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])
pipeline/prep/storage.py


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 인가 문자인가

BLOB (이진 데이터)

숫자로는 이쪽이 낫다

  • 작다 — 벡터 하나에 1,536바이트
  • 읽기가 빠르다
  • SELECT 해도 사람이 못 읽는다

문자

우리가 고른 것

  • '[0.018827,-0.024955,...]' 라고 그냥 보인다
  • 크기는 두 배가 넘는다
  • 실제 문자화해서 백터 정보값을 눈으로 확인하는게 교육측면에서는 더 나음

얼마나 손해인지는 5교시에 직접 잽니다. 지금은 "일부러 느린 쪽을 골랐다"만 알고넘어갑니다. 덤으로, 나중에 Supabase(pgvector) 로 옮길 때 이 모양이 그대로 쓰입니다. pgvector 의 벡터 리터럴이 [0.1,0.2,...] 라 거의 손댈 것이 없습니다.

PK 타입을 손으로 적으면 안 되는 이유

PK 타입이 제각각이다
테이블명PK타입값
chunkschunk_idINTEGER1, 2, 3 …
productsproduct_idTEXT'P001'
customerscustomer_idTEXT'C001'
purchasespurchase_idTEXT'PU0001'

넷 중 하나만 숫자입니다. 이걸 손으로 적다가 chunk_vectors 의 chunk_id 를 TEXT 로 만들면 어떻게 될까요.


5. pipeline/03_embed.py — 새로 만듭니다

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
터미널 — 30초쯤 걸린다
  • 임베딩 ― 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개. 임베딩 비용은 **개수가아니라 토큰 수에 붙습니다. ** 짧은 글이 훨씬 빠릅니다.


이 시간에 한 것

4교시 요점
주제핵심
네 벌조각 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)을 새로 만듭니다.

Share
  • 파이썬
  • 임베딩
  • SQLite
  • LangChain
  • RAG
네 벌을 벡터로 만들어 넣는다 — 디코드랩(DCODELAB)