학습 자료

제대로 들어갔는지 점검한다


Article

이전 수업에서 벡터 3,260개를 만들었습니다. 조각 1,560 · 상품 200 · 고객 300 · 후기 1,200 개입니다.

그런데 제대로 들어갔는지 확인한 적이 없습니다. 개수가 맞는지, 차원이 다 같은지, 길이가 1 로 정규화돼 있는지, 문자로 저장한 대가가 얼마인지 아직 확인을 하지 않았죠.

그래서 이번 시간에는 지금까지 만든 것을 점검하는 파일을 따로 만들어 보겠습니다.


1. 왜 점검을 따로 두나

점검 코드를 03_embed.py 안에 넣으면 편할 것 같습니다. 만든 김에 바로 확인하면 되니까요. 그런데 그러면 점검만 다시 하고 싶을 때 벡터를 30초 들여 다시 만들어야 합니다.

오늘 오전에 02_chunk.py 와 03_embed.py 를 가른 것과 같은 이유입니다. 만드는 파일과 재는 파일은 도는 시점과 횟수가 다릅니다.

pipeline/ ― 지금과 이 교시가 끝났을 때

지금 (4교시가 끝난 상태)

만드는 파일만 있다

  • 01_schema.py — CSV 를 표로
  • 02_chunk.py — 상세를 조각으로
  • 03_embed.py — 글을 벡터로
  • prep/ — options · chunking · storage · embedding
  • 05_lookup_bench.py — 2교시에 만든 실습

5교시가 끝나면

재는 파일이 둘 붙는다

  • 04_verify.py — 새 파일 · 무엇을 어떤 순서로 점검하나
  • prep/verifying.py — 새 파일 · 그 점검을 어떻게 하나
  • 아무것도 만들지 않고 읽기만 한다
  • 몇 번을 돌려도 데이터가 안 바뀐다

번호가 04 인 이유도 여기 있습니다. 01 스키마 → 02 자르기 → 03 벡터 → 04 점검. 번호 순서대로 돌리면 됩니다.


2. 재는 파일도 둘로 가릅니다

파일이 왜 둘인지부터 짚고 가겠습니다. 2교시에 파이프라인을 가를 때 쓴 규칙이 그대로 한 번 더 나옵니다.

같은 규칙이 세 번째로 나온다

조립하는 파일

무엇을 · 어떤 순서로

  • 02_chunk.py → prep/chunking.py
  • 03_embed.py → prep/embedding.py
  • 04_verify.py → prep/verifying.py
  • 짧다. 읽으면 하는 일이 보인다

몸통 파일

어떻게

  • 길다. 읽으면 방법이 보인다
  • DB 연결을 직접 열지 않는다. 인자로 받는다
  • 그래서 부르는 쪽이 연결 수와 트랜잭션을 쥔다

3. app/core/db.py — 벡터를 되살리는 함수를 더합니다

점검하려면 저장한 벡터를 다시 읽어야 합니다. 그런데 벡터는 "[0.018827,-0.024955,...]" 라는 글자로 들어 있습니다. 이걸 숫자 행렬로 되돌리는 함수가 필요합니다.

이 함수를 04_verify.py 안에 두면 안 됩니다. 1일차에 정한 규칙이 있습니다 — DB 에 닿는 코드는 db.py 한 파일에만 둡니다. 벡터를 읽는 것도 DB 에 닿는 일입니다.

맨 위 import 를 둘 더합니다.

고침 · app/core/db.py
- import sqlite3
+ import json          # 벡터가 문자로 들어 있어서 필요하다
+ import sqlite3

그리고 if __name__ 블록 위에 함수 하나를 붙입니다.

app/core/db.py
def load_vectors(table, key, connection=None):
    """문자로 넣어 둔 벡터를 numpy 행렬로 되살린다.

    '[0.018827,-0.024955,...]' 라는 글자를 숫자 목록으로 되돌린다.
    한 번만 하면 된다 ― 그다음부터는 그냥 숫자 행렬이라 BLOB 과 속도가 같다.
    돌려주는 것: (아이디 목록, (행 수 x 차원) 행렬)

    connection 을 안 주면 이 파일의 연결을 쓴다. 
    파이프라인은 자기 연결을 들고 있어서 그것을 넘긴다.  안 넘기면 같은
       DB 에 연결이 하나 더 열린다 (pipeline/04_verify.py 가 그 자리다).
    """
    #    numpy 는 여기서만 쓴다. 파일 맨 위에 두면 SELECT 만 하는 파일까지
    #    (features/profile.py 가 그렇다) numpy 를 끌고 온다. 아래 load_documents 가
    #    LangChain 을 함수 안에서 부르는 것과 같은 이유다
    import numpy as np

    con_in_use = connection if connection is not None else con

    ids, rows = [], []
    for row_id, vector in con_in_use.execute(f"SELECT {key}, vector FROM {table}"):
        ids.append(row_id)
        rows.append(json.loads(vector))
    return ids, np.array(rows, dtype="float32")

이 함수에서 기억할 것

  • connection=None 이 하는 일. 앱은 load_vectors("product_vectors", "product_id") 라고 부릅니다 — 연결을 안 넘깁니다. 파이프라인은 자기 연결을 이미 열어 뒀으니 그것을 넘깁니다. 기본값 하나로 두 쪽을 다 받습니다.
  • 왜 그게 중요한가. 안 넘기면 같은 DB 파일에 연결이 둘 열립니다. 읽기만 할 때는 티가 안 나지만, 쓰는 쪽이 섞이면 잠금(lock)으로 터집니다. 연결은 부르는 쪽이 쥡니다.
  • import numpy 가 함수 안에 있습니다. 파일 맨 위에 두면 SELECT 만 하는 파일까지 numpy 를 끌고 옵니다. 재 봤더니 import app.core.db 가 106.5ms → 26.4ms 로 줄었습니다. 밑에 있는 load_documents() 가 LangChain 을 함수 안에서 부르는 것과 같은 이유입니다.
  • dtype="float32" 로 못 박습니다. 안 박으면 float64 가 되어 메모리를 두 배 씁니다. 임베딩 값의 정밀도에는 float32 로 충분합니다.

4. pipeline/prep/verifying.py — 새로 만듭니다 (몸통)

점검 넷을 실제로 하는 함수들입니다. 전부 con 을 인자로 받고, 아무것도 만들지 않습니다.

pipeline/prep/verifying.py
"""점검 ― 만든 것이 쓸 수 있는 물건인지 재는 함수들.

    04_verify.py   무엇을 어떤 순서로 점검하나    (verifying.py에 있는 함수를 호출해서 조립하는 위치)
    verifying.py   그 점검을 실제로 어떻게 하나   (실제 04_verify.py에서 활용되는 함수 모음집)

   이 파일은 아무것도 만들지 않는다. `CREATE` 도 `INSERT` 도 `DROP` 도 없다.
   그래서 몇 번을 돌려도 데이터가 안 바뀐다. 재는 파일의 조건이다.

   연결(con)을 **인자로 받는다. 자기가 열지 않는다.** 부르는 쪽이 이미 하나
   열었는데 여기서 또 열면 같은 DB 에 연결이 둘이 된다. storage.py 와 같은 규칙이다.

   검사 결과를 `problems` 목록에 **쌓아서 돌려준다.** 첫 실패에서 멈추지 않는다 ―
   한 번 돌려서 「무엇이 몇 개 틀렸나」를 다 보는 것이 점검표의 값이다.
"""

import numpy as np

#    되살리는 일은 앱도 똑같이 한다. 여기서 한 벌 더 짜면 언젠가 둘이
#    어긋난다 ― 그때 검색은 조용히 이상해진다. 그래서 앱이 쓰는 것을 그대로 쓴다.
#    연결만 우리 것을 넘긴다 (app/core/db.py 의 connection 인자가 그 자리다)
from app.core.db import load_vectors


def check(ok, message, problems):
    """참/거짓을 화면에 한 줄로 찍고, 실패한 것만 problems 에 쌓는다.

       이 함수는 **무엇을 검사하는지 모른다.** 이미 판정된 참/거짓만 받는다.
       그래서 검사가 몇 개로 늘어도 이 함수는 안 바뀐다.

    problems 는 목록이라 append 한 결과가 부른 쪽에도 그대로 남는다.
    그래서 여러 검사 함수가 실패를 **한 목록**에 모을 수 있다.
    """
    print(f"  [{'OK  ' if ok else '문제'}] {message}")
    if not ok:
        problems.append(message)


# ═════════════════════════════ 3일차 5교시 ― 만든 것을 잰다 (1~4번)

# ─────────────────────────────────────────────── 1. 개수

def check_table_data(con, table_names, problems):
    """표마다 몇 행인가. 벡터가 빠진 행은 없는가. 돌려주는 것: {표 이름: 행 수}

    개수가 같다고 열쇠가 하나하나 맞는다는 증명은 아니다. 그래서 FK 검사를 같이 한다.
    """
    counts = {}
    for table in table_names:
        counts[table] = con.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0]
        print(f"  {table:18s} {counts[table]:>7,}")

    print()
    check(counts["chunk_vectors"] == counts["chunks"],
          f"모든 조각에 벡터가 있다 ({counts['chunk_vectors']:,}/{counts['chunks']:,})",
          problems)
    check(counts["product_vectors"] == counts["products"],
          f"모든 상품에 벡터가 있다 ({counts['product_vectors']:,}/{counts['products']:,})",
          problems)

    orphan = con.execute("""
        SELECT COUNT(*) FROM chunks
        WHERE section_id NOT IN (SELECT section_id FROM sections)
    """).fetchone()[0]
    check(orphan == 0,
          f"조각이 전부 원문 섹션에 연결돼 있다 (끊긴 것 {orphan}개) ― small-to-big 의 전제",
          problems)

    fk_errors = con.execute("PRAGMA foreign_key_check").fetchall()
    check(len(fk_errors) == 0, f"FK 위반 없음 (어긴 행 {len(fk_errors)}개)", problems)

    #    채점의 전제다. hit@k 는 「숨겨 둔 정답이 상위 k 안에 오나」를 세는데,
    #    고객에게 숨겨 둔 정답이 없으면 셀 것이 없다. compare_recommendations() 가
    #    answers[customer_id] 로 바로 꺼내 쓰기 때문에 **없으면 그 자리에서 죽는다**
    holdout = con.execute(
        "SELECT COUNT(*) FROM purchases WHERE is_holdout = 1").fetchone()[0]
    check(holdout == counts["customers"],
          f"채점용 정답이 고객당 1건이다 ({holdout}건 / 고객 {counts['customers']}명)",
          problems)

    return counts


# ─────────────────────────────────────────────── 2. 벡터

def check_vector_data(con, kinds, expected_dim, expected_model, problems):
    """네 벌을 되살려 차원 · 모델 · 정규화를 본다.

    돌려주는 것: {종류: (아이디 목록, (행 수 x 차원) 행렬)}
    """
    vectors = {}
    for kind, key in kinds:
        ids, matrix = load_vectors(f"{kind}_vectors", key, connection=con)
        vectors[kind] = (ids, matrix)

        # matrix.shape 는 (행 수, 차원) 이다. 행 하나가 벡터 하나다
        models = [m for (m,) in
                  con.execute(f"SELECT DISTINCT model FROM {kind}_vectors")]
        print(f"  {kind:9s} {matrix.shape[0]:>6,} x {matrix.shape[1]}  "
              f"모델 {', '.join(models)}")

    print()
    all_dims = {matrix.shape[1] for _, matrix in vectors.values()}
    check(all_dims == {expected_dim},
          f"네 벌 모두 {expected_dim}차원이다 (실제 {all_dims})", problems)

    all_models = {model for kind, _ in kinds
                  for (model,) in
                  con.execute(f"SELECT DISTINCT model FROM {kind}_vectors")}
    check(all_models == {expected_model},
          f"네 벌이 같은 모델로 만들어졌다 ― 다른 모델끼리는 비교 자체가 무의미하다 "
          f"(실제 {all_models})", problems)

    #    아래 4·5번이 통째로 이 한 줄 위에 서 있다. 길이가 1 이면 내적이 곧
    #    코사인 유사도라 나눗셈을 안 해도 된다. 길이가 1 이 아닌데 내적을 쓰면
    #    **긴 벡터가 무조건 이긴다** ― 그래도 오류는 안 난다
    norms = {kind: np.linalg.norm(matrix, axis=1)
             for kind, (_, matrix) in vectors.items()}
    worst = max(abs(n - 1).max() for n in norms.values())
    check(worst < 1e-3,
          f"전부 길이 1 로 정규화돼 있다 -> 내적이 곧 코사인 유사도 "
          f"(제일 어긋난 것도 {worst:.6f})", problems)

    return vectors


# ─────────────────────────────────────────────── 3. 저장 방식

def check_vector_storage(con, kinds, vectors, embed_dim):
    """문자로 넣은 대가를 크기로 잰다. 돌려주는 것: {개수, 문자 바이트, BLOB 바이트}

    지금 벡터는 '[0.018827,-0.024955,...]' 라는 **글자**로 들어 있다. 같은 숫자를
    BLOB(이진)으로 넣으면 float 하나가 딱 4바이트다. 얼마나 차이 나는지 잰다.

       그런데도 문자를 골랐다. `SELECT vector FROM ...` 하면 숫자가 그대로 보이기
       때문이다. 이건 교재라서 그렇게 골랐고, 100만 개가 되면 다시 판단할 일이다.
    """
    vector_count = sum(matrix.shape[0] for _, matrix in vectors.values())

    # LENGTH() 는 글자 수를 센다. 벡터 문자는 숫자와 기호뿐이라 바이트와 사실상 같다
    text_bytes = sum(
        con.execute(f"SELECT COALESCE(SUM(LENGTH(vector)), 0) FROM {kind}_vectors")
           .fetchone()[0]
        for kind, _ in kinds)
    blob_bytes = vector_count * embed_dim * 4      # float32 하나가 4바이트

    print(f"  벡터 {vector_count:,}개 ({embed_dim}차원)")
    print(f"      문자   {text_bytes / 1024 / 1024:>5.1f}MB   (지금 이 방식)")
    print(f"      BLOB   {blob_bytes / 1024 / 1024:>5.1f}MB   (같은 숫자를 이진으로)")
    print(f"      -> 문자가 {text_bytes / blob_bytes:.1f}배 크다. "
          "대신 SELECT 하면 눈에 보인다")

    return {"vector_count": vector_count,
            "text_bytes": text_bytes,
            "blob_bytes": blob_bytes}


# ─────────────────────────────────────────────── 4. 토큰

def check_token_sizes(con, max_tokens, problems):
    """상한을 넘어 조용히 잘리는 조각이 있는가.

    돌려주는 것: {조각 토큰 목록, 섹션 토큰 목록, 넘은 개수, 조각 평균 토큰}

    토큰은 글자 수도 낱말 수도 아니다. 모델이 글을 나누는 단위다. 상한을 넘으면
    **뒤가 잘린 채로 벡터가 된다** ― 그래도 오류는 안 난다.
    """
    chunk_tokens = sorted(n for (n,) in con.execute("SELECT n_tokens FROM chunks"))
    section_tokens = [n for (n,) in con.execute("SELECT n_tokens FROM sections")]

    over = sum(n > max_tokens for n in chunk_tokens)
    average_chunk_tokens = sum(chunk_tokens) / len(chunk_tokens)

    check(over == 0,
          f"상한({max_tokens}) 을 넘는 조각 {over}개 ― 넘으면 뒤가 조용히 잘린다",
          problems)
    print(f"  조각 평균 {average_chunk_tokens:.1f}토큰 · 제일 긴 것 {max(chunk_tokens)}토큰")
    print(f"  상한 대비 여유가 {(1 - max(chunk_tokens) / max_tokens) * 100:.0f}% 남았다")

    return {"chunk_tokens": chunk_tokens,
            "section_tokens": section_tokens,
            "over": over,
            "average_chunk_tokens": average_chunk_tokens}


# ─────────────────────────────────────────────── 마무리

def print_final_result(problems):
    """쌓아 둔 문제를 한 번에 요약한다. 새로 검사하지 않는다."""
    print()
    print("=" * 74)
    if problems:
        print(f"문제 {len(problems)}건 ― 앱을 붙이기 전에 고친다")
        for message in problems:
            print(f"  - {message}")
    else:
        print("전부 통과. 1단계(파이프라인) 끝. 다음은 2단계 ― uvicorn app.main:app --reload")
    print("=" * 74)

이 파일에서 기억할 것

  • check() 한 함수가 화면을 점검표로 바꿉니다. 「검사했다」를 말로 적으면 아무도 안 봅니다. 참/거짓을 찍는 함수를 하나 두고, 실패한 것을 problems 에 쌓아 마지막에 요약합니다. 테스트 도구(pytest)를 쓰기 전 단계이고, 오늘 9교시에 진짜 시험을 만듭니다.
  • check() 는 무엇을 검사하는지 모릅니다. 이미 판정이 끝난 참/거짓만 받습니다. 그래서 검사가 넷에서 여섯으로 늘어도 이 함수는 한 글자도 안 바뀝니다.
  • problems 를 인자로 받아 append 합니다. 파이썬에서 목록은 넘겨도 같은 물건이라, 여기서 담은 것이 부른 쪽에도 그대로 남습니다. 함수 넷이 각자 담아도 목록은 하나입니다.
  • 정규화 검사가 제일 중요합니다. 「전부 길이 1」이 깨지면 그 아래 모든 비교가 뜻을 잃습니다 — 그런데 오류는 안 납니다. 긴 벡터가 무조건 이기는 검색이 될 뿐입니다.
  • 이 파일은 아무것도 만들지 않습니다. 그래서 몇 번을 돌려도 안전합니다.

5. pipeline/04_verify.py — 새로 만듭니다 (순서)

이제 순서를 정하는 파일입니다. 짧습니다. 그게 요점입니다.

pipeline/04_verify.py
"""점검 ― 파이프라인이 만든 것이 쓸 수 있는 물건인지 확인한다.

    1. 개수      표마다 몇 행인가. 벡터가 빠진 행은 없는가
    2. 벡터      차원 · 모델 · 정규화가 맞는가
    3. 저장      문자로 넣은 대가가 얼마인가 (BLOB 과 크기를 견준다)
    4. 토큰      조각 분포. 상한을 넘어 잘리는 것은 없는가

   **이 파일은 순서만 정한다.** 재는 일은 전부 prep/verifying.py 안에 있다.
   02_chunk.py 가 chunking.py 를, 03_embed.py 가 embedding.py 를 부르는 것과
   같은 모양이다 ― 조립하는 파일을 읽으면 **무엇을 하는지**가 보이고,
   몸통 파일을 열면 **어떻게 하는지**가 보인다.

   이 파일은 아무것도 만들지 않는다. 몇 번을 돌려도 데이터가 안 바뀐다.
   여기서 [문제] 가 하나라도 나오면 앱을 붙이기 전에 고친다.

앞:    python pipeline/03_embed.py
실행:  python pipeline/04_verify.py
"""

import sqlite3
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.stdout.reconfigure(errors="replace")

from app.core.config import DB_PATH, EMBED_DIM, EMBED_MAX_TOKENS, EMBED_MODEL
from pipeline.prep import verifying

con = sqlite3.connect(DB_PATH)
problems = []          # 여기 쌓인 게 있으면 마지막에 빨갛게 요약한다

# 벡터 네 벌과 각 표의 열쇠 컬럼. 여기서 한 줄을 빠뜨리면 점검이 세 벌만 보고
# 「이상 없음」이라고 말한다 ― 검사가 조용히 거짓말을 하는 자리다
KINDS = (("chunk", "chunk_id"), ("product", "product_id"),
         ("customer", "customer_id"), ("review", "purchase_id"))

TABLES = ("customers", "products", "purchases", "product_details",
          "sections", "chunks", "chunk_vectors", "product_vectors",
          "customer_vectors", "review_vectors")


def banner(title):
    print()
    print("=" * 74)
    print(title)
    print("=" * 74)


# ═══════════════════════════════════════════════ 1. 개수
banner("1. 개수")
counts = verifying.check_table_data(con, TABLES, problems)

# ═══════════════════════════════════════════════ 2. 벡터
banner("2. 벡터")
vectors = verifying.check_vector_data(con, KINDS, EMBED_DIM, EMBED_MODEL, problems)

# ═══════════════════════════════════════════════ 3. 저장 방식
banner("3. 저장 방식 ― 문자로 넣은 대가를 잰다")
verifying.check_vector_storage(con, KINDS, vectors, EMBED_DIM)

# ═══════════════════════════════════════════════ 4. 토큰
banner("4. 토큰 ― 잘리는 것이 있는가")
token_result = verifying.check_token_sizes(con, EMBED_MAX_TOKENS, problems)

verifying.print_final_result(problems)
con.close()

이 파일에서 기억할 것

  • 읽으면 목록이 보입니다. check_table_data → check_vector_data → check_vector_storage → check_token_sizes. 방법이 궁금하면 그때 verifying.py 를 엽니다.
  • KINDS 한 줄이 네 벌을 묶습니다. 표 이름과 열쇠 컬럼만 다르고 나머지는 같으니, 4교시의 save_vectors() 와 같은 생각입니다. 여기에 후기를 빠뜨리면 점검이 세 벌만 보고 「이상 없음」이라고 말합니다.
  • vectors 를 받아서 다음 단계에 넘깁니다. 2번에서 한 번 되살린 행렬을 3번이 그대로 씁니다 — 3,260개를 두 번 읽을 이유가 없습니다.
  • problems 를 계속 같이 넘깁니다. 검사 함수들이 각자 여기에 담고, 맨 끝에서 한 번에 요약합니다.

6. 돌립니다

python pipeline/04_verify.py
터미널
  • 1. 개수
  • customers 300
  • products 200
  • purchases 1,500
  • product_details 200
  • sections 1,560
  • chunks 1,560
  • chunk_vectors 1,560
  • product_vectors 200
  • customer_vectors 300
  • review_vectors 1,200
  • [OK ] 모든 조각에 벡터가 있다 (1,560/1,560)
  • [OK ] 모든 상품에 벡터가 있다 (200/200)
  • [OK ] 조각이 전부 원문 섹션에 연결돼 있다 (끊긴 것 0개) ― small-to-big 의 전제
  • [OK ] FK 위반 없음 (어긴 행 0개)
  • [OK ] 채점용 정답이 고객당 1건이다 (300건 / 고객 300명)
  • 2. 벡터
  • chunk 1,560 x 384 모델 intfloat/multilingual-e5-small
  • product 200 x 384 모델 intfloat/multilingual-e5-small
  • customer 300 x 384 모델 intfloat/multilingual-e5-small
  • review 1,200 x 384 모델 intfloat/multilingual-e5-small
  • [OK ] 네 벌 모두 384차원이다 (실제 {384})
  • [OK ] 네 벌이 같은 모델로 만들어졌다 ― 다른 모델끼리는 비교 자체가 무의미하다
  • [OK ] 전부 길이 1 로 정규화돼 있다 -> 내적이 곧 코사인 유사도 (제일 어긋난 것도 0.000001)
  • 3. 저장 방식 ― 문자로 넣은 대가를 잰다
  • 벡터 3,260개 (384차원)
  • 문자 12.4MB (지금 이 방식)
  • BLOB 4.8MB (같은 숫자를 이진으로)
  • -> 문자가 2.6배 크다. 대신 SELECT 하면 눈에 보인다
  • 4. 토큰 ― 잘리는 것이 있는가
  • [OK ] 상한(512) 을 넘는 조각 0개 ― 넘으면 뒤가 조용히 잘린다
  • 조각 평균 105.3토큰 · 제일 긴 것 338토큰
  • 상한 대비 여유가 34% 남았다
  • 전부 통과. 1단계(파이프라인) 끝. 다음은 2단계 ― uvicorn app.main:app --reload

7. 미뤄 둔 질문 둘에 답합니다

① 문자로 저장한 대가 — 2.6배 크다

점검이 방금 찍어 줬습니다. 문자 12.4MB, BLOB 4.8MB. 벡터를 문자로 적어 둔 것만 12.4MB 인데, 원본 CSV 넷을 합쳐도 1MB 가 안 됩니다.

상품 200개짜리 연습용 데이터가 이렇습니다. 실제 서비스 규모가 되면 이 비율이 그대로 커집니다. 이게 벡터 검색의 진짜 비용이고, 「BLOB 으로 하면 4.8MB」라는 선택지가 중요해지는 지점입니다.

그런데 크기만 보고 정하면 안 됩니다. 읽는 시간도 봐야 합니다. 그건 한 번 재 봤습니다.

서버가 뜰 때 드는 시간 (한 번 재 본 값 · 실측)
임베딩 모델 올리기11900ms
문자로 읽기164ms
BLOB 으로 읽기7ms

BLOB 이 숫자로는 압승입니다. 2.6배 작고 25배 빠릅니다. 그런데도 문자를 골랐습니다.

25배 느린데도 문자를 고른 이유

BLOB 이 이기는 것

숫자로는 압승

  • 2.6배 작다 (12.4MB → 4.8MB)
  • 25배 빠르다 (163.8ms → 6.7ms)
  • 100만 개가 되면 이 차이가 진짜로 아프다

문자를 고른 이유

이건 교재다

  • 164ms 는 서버 시작에 한 번이다
  • 그 옆에서 모델 올리는 데 11.9초가 든다. 안 보인다
  • SELECT vector FROM ... 하면 숫자가 그대로 보인다

② 6자리로 자른 손실 — 코사인 0.99999964

config.py 의 EMBED_DECIMALS = 6 이 얼마나 손해였는지도 한 번 재 봤습니다.

자릿수를 줄인 손실: 길이 오차 0.00000016 · 벡터끼리 코사인 0.99999964

원본과 되읽은 벡터가 사실상 같은 방향입니다. 이 숫자를 어떻게 읽어야 할까요.

2일차 4교시에 본 진짜 차이를 떠올려 보십시오. 정답 문장과 3등 문장의 차이가 0.045 였습니다. 반올림으로 잃은 것은 0.00000036 입니다. 10만 배 작습니다.

그러니 6자리는 안전합니다. 그런데 이건 재서 아는 것이지 짐작이 아닙니다. 3자리로 줄이면 어떻게 되는지는 또 재 봐야 압니다.

그런데 이 둘은 점검 파일에 없습니다

눈치채셨을 겁니다. 방금 본 두 값 — 읽기 시간과 반올림 손실 — 은 verifying.py 가 안 잽니다. 크기만 잽니다. 일부러 그렇게 뒀습니다.

한 번 재고 끝낼 것 · 매번 재야 하는 것

한 번 재고 끝낸다

결정의 근거

  • 문자 vs BLOB 읽기 시간
  • 6자리로 자른 반올림 손실
  • 재서 정했으면 그걸로 끝이다
  • 코드에 남겨 두면 점검할 때마다 시간을 더 쓴다

매번 잰다

깨질 수 있는 것

  • 개수 · 차원 · 모델 · 정규화 · 토큰 상한
  • 데이터가 바뀌면 깨진다
  • 그래서 돌릴 때마다 확인해야 한다
  • 크기도 여기 ― 데이터가 늘면 같이 는다

재 본 코드는 이렇게 생겼습니다. 붙여넣지 마십시오 — 어떻게 쟀는지만 보시면 됩니다.

발췌 · 읽기 시간과 반올림 손실을 재 본 코드 (한 번 돌리고 지웠다)
import json
import sqlite3
import time

import numpy as np

# ① 같은 조건에서 재려고 BLOB 판을 임시로 만든다 (메모리 DB · 끝나면 사라진다)
flat = np.vstack([matrix for _, matrix in vectors.values()])
tmp = sqlite3.connect(":memory:")
tmp.execute("CREATE TABLE v (id INTEGER PRIMARY KEY, vec BLOB)")
tmp.executemany("INSERT INTO v VALUES (?, ?)",
                [(i, row.tobytes()) for i, row in enumerate(flat)])


def timed(fn, repeat=3):
    """세 번 재서 제일 빠른 값을 쓴다 ― 다른 프로그램에 밀린 판을 버린다."""
    best = None
    for _ in range(repeat):
        started = time.perf_counter()
        fn()
        elapsed = time.perf_counter() - started
        best = elapsed if best is None else min(best, elapsed)
    return best


def read_text():
    return np.array([json.loads(v) for kind, _ in KINDS
                     for (v,) in con.execute(f"SELECT vector FROM {kind}_vectors")],
                    dtype="float32")


def read_blob():
    return np.vstack([np.frombuffer(v, dtype="float32", count=EMBED_DIM)
                      for (v,) in tmp.execute("SELECT vec FROM v")])


print(f"문자 {timed(read_text) * 1000:.1f}ms · BLOB {timed(read_blob) * 1000:.1f}ms")
tmp.close()

# ② 자릿수를 줄이면서 얼마나 손해 봤나
exact = np.array(json.loads(con.execute(
    "SELECT vector FROM product_vectors LIMIT 1").fetchone()[0]), dtype="float64")
first = vectors["product"][1][0]
print(f"길이 오차 {abs(np.linalg.norm(exact) - 1):.8f} · "
      f"벡터끼리 코사인 {float(first @ first):.8f}")

8. 다시 만들면 얼마나 드나

지금까지 잰 것은 만들어 둔 것입니다. 그런데 실무에서 계속 드는 비용은 다시 만드는 것입니다. 후기 한 건이 새로 올라오면 무슨 일이 벌어지는지 재 봅니다.

try_rebuild_cost.py 를 뿌리에 만듭니다. 다 보고 지울 파일입니다.

실습용 · try_rebuild_cost.py (다 보고 지운다)
import time

from app.core.db import query
from pipeline.prep import chunking, embedding

chunks = [t for (t,) in query("SELECT text FROM chunks ORDER BY chunk_id")]
reviews = [t for (t,) in query(
    "SELECT review FROM purchases WHERE is_holdout = 0 AND review != '' ORDER BY purchase_id")]

total_tokens = sum(chunking.count_tokens(t) for t in chunks + reviews)
print(f"  조각 {len(chunks):,}건 + 후기 {len(reviews):,}건 = {total_tokens:,} 토큰")

started = time.perf_counter()
embedding.get_embeddings()
print(f"  모델 올리기 {time.perf_counter() - started:.1f}초\n")

# ① 후기 한 건이 새로 들어왔다면 ― 그것만 임베딩한다
one = reviews[0]
best = None
for _ in range(5):
    started = time.perf_counter()
    embedding.embed_documents([one])
    elapsed = time.perf_counter() - started
    best = elapsed if best is None else min(best, elapsed)
print(f"  ① 새 후기 1건만        {best * 1000:>8.1f}ms  ({chunking.count_tokens(one)} 토큰)")

# ② 지금 방식 ― 표를 DROP 하고 전부 다시 만든다
started = time.perf_counter()
embedding.embed_documents(chunks)
full = time.perf_counter() - started
print(f"  ② 조각 전량 다시       {full:>8.1f}초   ({sum(chunking.count_tokens(t) for t in chunks):,} 토큰)")

print(f"\n  같은 일을 하는 데 {full / best:,.0f}배")
python try_rebuild_cost.py
터미널
  • 조각 1,560건 + 후기 1,200건 = 197,246 토큰
  • 모델 올리기 7.0초
  • ① 새 후기 1건만 9.5ms (21 토큰)
  • ② 조각 전량 다시 20.9초 (164,269 토큰)
  • 같은 일을 하는 데 2,201배

로컬에서 20.9초는 참을 만합니다. 그런데 6일차에 상용 API 를 켜면 저 197,246 토큰이 후기 한 건 올라올 때마다 그대로 요금이 됩니다.

바뀐 것만 다시 만드는 것을 증분 임베딩이라고 합니다. 운영 중인 시스템에서는 이쪽이 보통입니다 — 문서 하나 올라올 때마다 전량 재색인하는 서비스는 없습니다.

그런데 지금 스키마로는 못 합니다

증분을 하려면 두 가지가 있어야 합니다. 둘 다 없습니다.

증분에 필요한 것 두 가지
필요한 것왜지금 상태
안 바뀌는 정체성「이 행이 지난번 그 행인가」를 물어야 한다chunk_id 는 삽입 순서대로 붙는 위치 번호다
변경 감지 재료「원문이 바뀌었나」를 알아야 한다chunk_vectors 에 원문 해시가 없다

직접 확인해 봅니다.

python -c "import sqlite3;con=sqlite3.connect('cosmetic.db');print([r[1] for r in con.execute('PRAGMA table_info(chunk_vectors)')]);[print(r) for r in con.execute('SELECT chunk_id, product_id, section, chunk_index FROM chunks LIMIT 3')]"
터미널
  • ['chunk_id', 'dim', 'model', 'vector'] <- 원문이 뭐였는지가 없다
  • (1, 'P001', '제품 소개', 0)
  • (2, 'P001', '주요 성분', 0)
  • (3, 'P001', '이런 분께 권합니다', 0)

그럼 무엇을 갖추면 되나

증분으로 가는 세 걸음
  1. 01

    ① 벡터를 자연키에 매단다

    chunk_id(위치) 대신 (product_id, section, chunk_index). 2교시에 이미 들고 다니던 그 열쇠입니다 — 1,560행 전부 유일합니다

  2. 02

    ② 원문 해시를 같이 저장한다

    source_hash 컬럼 하나. 조각의 글자를 해시해서 넣습니다. 청커가 만들 수 있습니다 ― 글자를 받아 글자를 내는 일이라 DB 를 몰라도 됩니다

  3. 03

    ③ 세 갈래로 가른다

    해시가 다르면 변경(다시 임베딩) · 새 열쇠면 신규(추가) · 없어진 열쇠면 삭제

그래도 전량이 필요한 날이 옵니다

모델 · 차원 · 청킹 파라미터가 바뀌면 증분이 안 됩니다. 옛 벡터와 새 벡터는 같은 공간에 있지 않기 때문입니다. 그때는 통째로 다시 만들어야 합니다.

2절에서 dim 과 model 을 벡터 표에 같이 적어 둔 이유가 이것입니다 — 섞이면 숫자는 나오는데 뜻이 없는 값이 나오고, 오류는 안 납니다.

그래서 규칙은 조건부다

변경 감지 장치가 없으면

지금 우리

  • 파생 표는 버리고 새로 만든다
  • 느리지만 틀리지 않는다
  • 3,260개 30초 ― 아직 참을 만하다

해시와 자연키를 갖추면

운영 시스템

  • 바뀐 것만 다시 만든다
  • 후기 1건 = 9.5ms · 21토큰
  • 대신 모델을 바꾸는 날엔 전량이다
del try_rebuild_cost.py

이 시간에 한 것

5교시 요점
주제핵심
점검은 따로 둔다만드는 파일과 재는 파일은 도는 시점이 다르다. 재는 쪽만 몇 번이고 돌린다
재는 파일도 둘로 가른다04_verify.py 가 순서를, prep/verifying.py 가 방법을 안다. 02_chunk↔chunking · 03_embed↔embedding 과 같은 규칙
load_vectors 는 db.py 에벡터를 읽는 것도 DB 에 닿는 일이다. connection=None 기본값 하나로 앱과 파이프라인을 다 받는다
check() 한 함수참/거짓을 찍어 두면 화면이 점검표가 된다. problems 에 쌓아 마지막에 요약
KINDS 한 줄네 벌을 같은 코드로 돈다. 여기서 후기를 빠뜨리면 점검이 거짓말을 한다
개수벡터 3,260개 · 조각 1,560/1,560 · FK 위반 0
차원 · 모델네 벌 모두 384차원 · 같은 모델. 다른 모델끼리 비교해도 오류가 안 난다
정규화전부 길이 1 -> 내적이 곧 코사인 유사도. 깨져도 오류가 안 난다 ― 긴 벡터가 무조건 이길 뿐이다
채점용 정답고객당 1건. 이게 깨지면 6교시의 hit@k 가 그 자리에서 죽는다
문자 vs BLOB점검은 크기만 잰다 ― 문자 12.4MB · BLOB 4.8MB (2.6배). 읽기 163.8ms vs 6.7ms 는 한 번 재고 끝낸 값
왜 느린 쪽을서버가 뜰 때 한 번이다. 모델 올리는 11.9초 옆에서 안 보인다. 그리고 눈에 보인다
반올림 손실6자리로 자른 대가는 코사인 0.99999964. 진짜 차이(0.045)보다 10만 배 작다
토큰상한(512)을 넘는 조각 0개. 여유 34%
다시 만드는 값후기 1건 9.5ms vs 전량 20.9초. 상용이면 197,246 토큰이 매번 요금이다
지금은 증분을 못 한다chunk_id 는 위치 번호고 원문 해시가 없다. 장치가 없으면 DROP 이 안전하다
증분으로 가려면벡터를 자연키에 매달고 source_hash 한 칸. 2교시의 그 열쇠가 다시 쓰인다
재는 코드에도 수명결정을 위한 측정은 결정이 끝나면 지운다. 점검이 무거워지면 사람이 안 돌린다
읽는 법「[문제] 가 하나라도 나오면 앱을 붙이기 전에 고친다」 ― 화면까지 만들고 돌아오면 못 찾는다

다음 시간에 할 것

벡터가 다 들어갔고 점검도 끝났습니다. 이제 찾아봅니다.

찾는 일 자체는 30줄이면 끝납니다. 다음 시간에 진짜로 확인해 볼 것은 두 가지입니다 — 벡터스토어의 구조와, 맥락에 안 맞는 답이 나오는 구조입니다.

그리고 오늘 만든 점검 파일에 재는 토막 둘을 더합니다. 조각 점수를 어떻게 합칠지(max vs mean)와, 실제 질문을 던져 눈으로 보는 자리입니다.

Share
  • 파이썬
  • 임베딩
  • SQLite
  • LangChain
  • RAG
제대로 들어갔는지 점검한다 — 디코드랩(DCODELAB)