제대로 들어갔는지 점검한다
Article
이전 수업에서 벡터 3,260개를 만들었습니다. 조각 1,560 · 상품 200 · 고객 300 · 후기 1,200 개입니다.
그런데 제대로 들어갔는지 확인한 적이 없습니다. 개수가 맞는지, 차원이 다 같은지, 길이가 1 로 정규화돼 있는지, 문자로 저장한 대가가 얼마인지 아직 확인을 하지 않았죠.
그래서 이번 시간에는 지금까지 만든 것을 점검하는 파일을 따로 만들어 보겠습니다.
1. 왜 점검을 따로 두나
점검 코드를 03_embed.py 안에 넣으면 편할 것 같습니다.
만든 김에 바로 확인하면 되니까요. 그런데 그러면 점검만 다시 하고 싶을 때 벡터를 30초 들여 다시 만들어야 합니다.
오늘 오전에 02_chunk.py 와 03_embed.py 를 가른 것과 같은 이유입니다.
만드는 파일과 재는 파일은 도는 시점과 횟수가 다릅니다.
지금 (4교시가 끝난 상태)
만드는 파일만 있다
01_schema.py— CSV 를 표로02_chunk.py— 상세를 조각으로03_embed.py— 글을 벡터로prep/— options · chunking · storage · embedding05_lookup_bench.py— 2교시에 만든 실습
5교시가 끝나면
재는 파일이 둘 붙는다
04_verify.py— 새 파일 · 무엇을 어떤 순서로 점검하나prep/verifying.py— 새 파일 · 그 점검을 어떻게 하나- 아무것도 만들지 않고 읽기만 한다
- 몇 번을 돌려도 데이터가 안 바뀐다
번호가 04 인 이유도 여기 있습니다.
01 스키마 → 02 자르기 → 03 벡터 → 04 점검. 번호 순서대로 돌리면 됩니다.
2. 재는 파일도 둘로 가릅니다
파일이 왜 둘인지부터 짚고 가겠습니다. 2교시에 파이프라인을 가를 때 쓴 규칙이 그대로 한 번 더 나옵니다.
조립하는 파일
무엇을 · 어떤 순서로
02_chunk.py→prep/chunking.py03_embed.py→prep/embedding.py04_verify.py→prep/verifying.py- 짧다. 읽으면 하는 일이 보인다
몸통 파일
어떻게
- 길다. 읽으면 방법이 보인다
- DB 연결을 직접 열지 않는다. 인자로 받는다
- 그래서 부르는 쪽이 연결 수와 트랜잭션을 쥔다
3. app/core/db.py — 벡터를 되살리는 함수를 더합니다
점검하려면 저장한 벡터를 다시 읽어야 합니다. 그런데 벡터는 "[0.018827,-0.024955,...]" 라는 글자로 들어 있습니다. 이걸 숫자 행렬로 되돌리는 함수가 필요합니다.
이 함수를 04_verify.py 안에 두면 안 됩니다. 1일차에 정한 규칙이 있습니다 — DB 에 닿는 코드는 db.py 한 파일에만 둡니다. 벡터를 읽는 것도 DB 에 닿는 일입니다.
맨 위 import 를 둘 더합니다.
- import sqlite3
+ import json # 벡터가 문자로 들어 있어서 필요하다
+ import sqlite3
그리고 if __name__ 블록 위에 함수 하나를 붙입니다.
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 을 인자로 받고, 아무것도 만들지 않습니다.
"""점검 ― 만든 것이 쓸 수 있는 물건인지 재는 함수들.
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 — 새로 만듭니다 (순서)
이제 순서를 정하는 파일입니다. 짧습니다. 그게 요점입니다.
"""점검 ― 파이프라인이 만든 것이 쓸 수 있는 물건인지 확인한다.
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」라는 선택지가 중요해지는 지점입니다.
그런데 크기만 보고 정하면 안 됩니다. 읽는 시간도 봐야 합니다. 그건 한 번 재 봤습니다.
BLOB 이 숫자로는 압승입니다. 2.6배 작고 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 를 뿌리에 만듭니다. 다 보고 지울 파일입니다.
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)
그럼 무엇을 갖추면 되나
- 01
① 벡터를 자연키에 매단다
chunk_id(위치) 대신(product_id, section, chunk_index). 2교시에 이미 들고 다니던 그 열쇠입니다 — 1,560행 전부 유일합니다 - 02
② 원문 해시를 같이 저장한다
source_hash컬럼 하나. 조각의 글자를 해시해서 넣습니다. 청커가 만들 수 있습니다 ― 글자를 받아 글자를 내는 일이라 DB 를 몰라도 됩니다 - 03
③ 세 갈래로 가른다
해시가 다르면 변경(다시 임베딩) · 새 열쇠면 신규(추가) · 없어진 열쇠면 삭제
그래도 전량이 필요한 날이 옵니다
모델 · 차원 · 청킹 파라미터가 바뀌면 증분이 안 됩니다. 옛 벡터와 새 벡터는 같은 공간에 있지 않기 때문입니다. 그때는 통째로 다시 만들어야 합니다.
2절에서 dim 과 model 을 벡터 표에 같이 적어 둔 이유가 이것입니다 — 섞이면
숫자는 나오는데 뜻이 없는 값이 나오고, 오류는 안 납니다.
변경 감지 장치가 없으면
지금 우리
- 파생 표는 버리고 새로 만든다
- 느리지만 틀리지 않는다
- 3,260개 30초 ― 아직 참을 만하다
해시와 자연키를 갖추면
운영 시스템
- 바뀐 것만 다시 만든다
- 후기 1건 = 9.5ms · 21토큰
- 대신 모델을 바꾸는 날엔 전량이다
del try_rebuild_cost.py
이 시간에 한 것
| 주제 | 핵심 |
|---|---|
| 점검은 따로 둔다 | 만드는 파일과 재는 파일은 도는 시점이 다르다. 재는 쪽만 몇 번이고 돌린다 |
| 재는 파일도 둘로 가른다 | 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)와, 실제 질문을 던져 눈으로 보는 자리입니다.