찾아본다, 그리고 「환불」을 물어본다
Article
벡터 3,260개가 DB 에 있습니다. 점검도 끝났습니다. 이제 찾습니다.
찾는 일 자체는 30줄이면 끝납니다. 이 시간에 진짜로 볼 것은 둘입니다 — 벡터스토어의 속과, 검색이 틀리는 모양입니다.
1. 오늘 손대는 파일
파이프라인은 오후 4·5교시로 끝났습니다. 이번 시간에는 앱 쪽이 자랍니다.
지금 (5교시가 끝난 상태)
앱은 대시보드만 안다
app/core/db.py—query·one·dicts·load_vectorsapp/features/retrieve.py—customer_list·dashboardpipeline/04_verify.py+prep/verifying.py— 점검 넷eval/— 아직 없다
6교시가 끝나면
찾는 일이 붙는다
app/core/db.py— `load_documents` 하나가 는다app/features/retrieve.py— 검색·추천 넷이 는다prep/embedding.py— 문장 함수 셋이 이사 온다prep/verifying.py— 재는 함수 넷이 는다 (04_verify 는 부르는 줄만)eval/qa_golden.json·eval/qa_check.py— 새 파일 둘
🔴 1일차에 만든 customer_list() · dashboard() 는 한 줄도 안 건드립니다.
그 위아래로만 늘어납니다.
2. 손으로 짜면 이렇습니다
먼저 도구 없이 해 봅니다. 어려운 일이 아닙니다.
import numpy as np
# 1. DB 에서 벡터를 읽는다 (문자 -> 숫자)
ids, matrix = load_vectors("chunk_vectors", "chunk_id") # (1560, 384) 행렬
# 2. 질문도 벡터로
q = embeddings.embed_query("민감성인데 써도 되나요") # 384개
# 3. 전부와 비교한다. 길이가 1 이라 행렬 곱 한 번이면 끝난다
scores = matrix @ np.array(q) # 1,560개 점수
# 4. 큰 것부터 k 개
top = np.argsort(-scores)[:4]
네 줄입니다. 그런데 우리는 InMemoryVectorStore 를 씁니다. 이유는 뒤에서 봅니다
— 먼저 이 도구가 마법이 아니라는 걸 확인합니다.
3. app/core/db.py — LangChain 문서로 바꾸는 함수가 하나 늘어납니다
벡터를 되살리는 load_vectors() 는 5교시에 이미 만들었습니다. 점검할 때 필요했죠.
오늘은 하나만 더 붙입니다 — 조회 결과를 LangChain 이 아는 모양(Document)으로 바꾸는 함수입니다.
if __name__ 블록 위에 붙입니다.
def load_documents(sql, params=()):
"""SQL 결과를 LangChain `Document` 목록으로 바꾼다.
약속: 첫 컬럼이 id, 둘째가 본문, 셋째가 벡터 문자, 나머지는 metadata 로 들어간다.
"""
from langchain_core.documents import Document
cur = con.execute(sql, params)
columns = [c[0] for c in cur.description]
docs, vectors = [], []
for row in cur.fetchall():
record = dict(zip(columns, row))
doc_id = str(record.pop(columns[0]))
content = record.pop(columns[1])
vectors.append(json.loads(record.pop(columns[2])))
docs.append(Document(id=doc_id, page_content=content, metadata=record))
return docs, vectors
이 함수에서 기억할 것
- 왜
retrieve.py가 아니라 여기인가. 1일차에 정한 규칙이 있습니다 — DB 에 닿는 코드는db.py한 파일에만 둡니다. 5교시에load_vectors()를 여기 둔 것과 같은 이유입니다. - 그래서 나중에 pgvector 로 갈아끼울 때 고칠 파일이 이것 하나입니다. 5일차에 이 약속을 한 번 더 조입니다.
load_documents()의 약속을 눈여겨보십시오. 첫 컬럼이 id, 둘째가 본문, 셋째가 벡터. 이 약속 하나로 조각·상품·고객을 같은 코드로 다룹니다.- 🔴
from langchain_core.documents import Document가 함수 안에 있습니다. LangChain 은 무겁습니다 —query()만 쓰는 파일이 이 파일을import할 때까지 끌고 올 이유가 없습니다.
4. app/features/retrieve.py — 검색이 붙습니다
질문을 벡터로 바꿔야 찾을 수 있으니 임베딩기가 이 파일에도 필요합니다. 그런데
모델 올리는 데 12초가 듭니다 — 파일 맨 위에 그냥 두면 이 파일을 import 하는
것만으로 12초가 날아갑니다. 그래서 처음 부를 때 올리고, 그다음부터는 갖고 있던 걸
줍니다.
"""꺼내오는 자리 ― SQL 로 집계하고, 벡터로 찾는다.
오늘 오후에 세 가지가 늘었다.
get_embeddings() 질문을 숫자로 바꾸는 모델 (처음 부를 때 올린다)
search_chunks() 질문 -> 조각 검색 -> 원문 섹션 (small-to-big)
rank_products() 고객 벡터와 가까운 상품
🔴 벡터를 **여기서 직접 읽는다.** 파일 맨 위에서 표를 통째로 메모리에 올린다.
지금은 이게 제일 단순하다. 5일차 5교시에 저장소 뒤로 숨기면서 고친다.
"""
import numpy as np
from langchain_core.vectorstores import InMemoryVectorStore
from app.core.config import EMBED_MODEL
from app.core.db import dicts, load_documents, load_vectors, one
# 🔴 import 하는 순간 벡터 표가 있어야 한다 ― 03_embed.py 를 돌리기 전에는 여기서 터진다
PRODUCT_IDS, PRODUCT_MATRIX = load_vectors("product_vectors", "product_id")
CUSTOMER_IDS, CUSTOMER_MATRIX = load_vectors("customer_vectors", "customer_id")
_embeddings = None # 임베딩기는 무겁다. 첫 질문이 들어올 때 올린다
_chunk_store = None # 조각 벡터스토어도 그때 같이 만든다
def customer_list(limit=None):
"""고객 목록 + 각자 구매 건수."""
rows = dicts("""
SELECT customers.customer_id, customers.name, customers.age, customers.gender,
customers.skin_type, customers.city,
COUNT(purchases.purchase_id) AS n_purchases
FROM customers
LEFT JOIN purchases ON purchases.customer_id = customers.customer_id
AND purchases.is_holdout = 0
GROUP BY customers.customer_id
ORDER BY customers.customer_id
""")
return rows[:limit] if limit else rows
def dashboard(customer_id):
"""한 고객에 대해 아는 것을 전부 모아서 돌려준다. 없는 고객이면 None."""
profile = dicts("""
SELECT customer_id, name, age, gender, skin_type, city
FROM customers WHERE customer_id = ?
""", (customer_id,))
if not profile:
return None
purchases = dicts("""
SELECT products.product_id, products.name, products.category, products.price,
purchases.purchased_at, purchases.rating, purchases.review
FROM purchases
JOIN products ON purchases.product_id = products.product_id
WHERE purchases.customer_id = ? AND purchases.is_holdout = 0
ORDER BY purchases.purchased_at DESC
""", (customer_id,))
by_category = {}
for row in purchases:
by_category[row["category"]] = by_category.get(row["category"], 0) + 1
ratings = [row["rating"] for row in purchases if row["rating"] is not None]
return {
"customer": {**profile[0], "n_purchases": len(purchases)},
"avg_rating": round(sum(ratings) / len(ratings), 2) if ratings else None,
"total_spent": sum(row["price"] for row in purchases),
"by_category": by_category,
"purchases": purchases,
}
def get_embeddings():
"""질문을 숫자로 바꾸는 모델. 처음 부를 때 한 번만 올린다."""
global _embeddings
if _embeddings is not None:
return _embeddings
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 langchain_huggingface import HuggingFaceEmbeddings
_embeddings = HuggingFaceEmbeddings(
model_name=EMBED_MODEL,
# 🔴 파이프라인과 **같은 조건**으로 올린다. 다른 자로 재면 검색이 어긋난다
model_kwargs={"device": "cpu"},
encode_kwargs={"normalize_embeddings": True},
)
return _embeddings
def get_chunk_store():
"""조각 벡터스토어. DB 의 벡터를 그대로 넣는다 (다시 임베딩하지 않는다)."""
global _chunk_store
if _chunk_store is not None:
return _chunk_store
docs, vectors = load_documents("""
SELECT chunks.chunk_id, chunks.text, chunk_vectors.vector,
chunks.product_id, chunks.section, chunks.section_id, chunks.body
FROM chunks
JOIN chunk_vectors ON chunks.chunk_id = chunk_vectors.chunk_id
ORDER BY chunks.chunk_id
""")
store = InMemoryVectorStore(get_embeddings())
# 🔴 속을 직접 채운다. 벡터스토어의 속은 그냥 이렇게 생긴 딕셔너리다
store.store = {
doc.id: {"id": doc.id, "vector": vector,
"text": doc.page_content, "metadata": doc.metadata}
for doc, vector in zip(docs, vectors)
}
_chunk_store = store
return _chunk_store
def search_chunks(question, k=4, product_ids_only=None):
"""질문으로 조각을 찾고, 원문 섹션을 같이 들고 온다 (small-to-big)."""
store = get_chunk_store()
keep = set(product_ids_only) if product_ids_only else None
found = store.similarity_search_with_score(
question, k=k,
# filter 는 조각마다 이 함수를 물어본다. metadata 만 본다 ― 본문은 안 본다
filter=(lambda doc: doc.metadata["product_id"] in keep) if keep else None,
)
out = []
for doc, score in found:
# 🔴 찾은 건 조각인데, 넘기는 건 그 조각이 속한 원문 섹션이다
section_text = one("SELECT text FROM sections WHERE section_id = ?",
(doc.metadata["section_id"],))[0]
name = one("SELECT name FROM products WHERE product_id = ?",
(doc.metadata["product_id"],))[0]
out.append({"product_id": doc.metadata["product_id"],
"product_name": name,
"section": doc.metadata["section"],
"score": round(float(score), 4),
"text": section_text})
return out
def rank_products(customer_id, n=5):
"""고객 벡터와 가까운 상품 n 개."""
if customer_id not in CUSTOMER_IDS:
return []
me = CUSTOMER_MATRIX[CUSTOMER_IDS.index(customer_id)]
scores = PRODUCT_MATRIX @ me # 내적 = 코사인 (길이가 1 이라서)
order = np.argsort(-scores)[:n]
picked = [PRODUCT_IDS[i] for i in order]
rows = {row["product_id"]: row for row in dicts(
f"SELECT product_id, name, category, price FROM products "
f"WHERE product_id IN ({','.join('?' * len(picked))})", picked)}
return [{**rows[pid], "score": round(float(scores[i]), 4)}
for pid, i in zip(picked, order)]
if __name__ == "__main__":
import time
cid = "C001"
board = dashboard(cid)
print(f"{board['customer']['name']} · {board['customer']['skin_type']} · "
f"구매 {board['customer']['n_purchases']}건")
print("\n가까운 상품 3개:")
for row in rank_products(cid, n=3):
print(f" {row['score']:.3f} {row['name']:26s} {row['price']:>7,}원")
started = time.perf_counter()
store = get_chunk_store()
print(f"\n벡터스토어 준비 {time.perf_counter() - started:.1f}초 "
f"(조각 {len(store.store):,}개 · 다시 임베딩하지 않았다)")
print("\n청크 검색 ― 민감성인데 써도 되나요")
for source in search_chunks("민감성인데 써도 되나요"):
print(f" {source['score']:.3f} [{source['product_name']} > {source['section']}]")
이 파일에서 기억할 것
global _embeddings는 게으른 초기화(lazy init)입니다. 「처음 부를 때 만들고 그다음부터는 만들어 둔 걸 준다」. 12초짜리 일을 필요할 때로 미루는 것이 목적입니다 — 대시보드만 보는 사람은 임베딩기를 안 올리고 끝납니다. 🔴 함정도 있습니다: 동시에 두 요청이 들어오면 모델을 두 번 올릴 수 있습니다. 지금은 혼자 쓰니 안 보이고, 5일차에 웹 서버를 붙일 때 다시 얘기합니다.store.store를 직접 채웁니다. 정석은store.add_documents(docs)인데, 그러면 1,560개를 그 자리에서 다시 임베딩합니다. 4교시에 21.1초 들여 만든 걸 또 만드는 셈입니다. 우리는 이미 계산해 둔 벡터가 있습니다.filter는metadata만 봅니다. 본문 글자는 안 봅니다. 2일차 3교시에product_id·section·section_id를 조각마다 붙여 둔 게 이걸 위해서였습니다.
벡터스토어의 속은 딕셔너리입니다
store.store 한 칸을 열면 이렇게 생겼습니다.
- id 1
- vector [-0.0091, -0.0386, -0.0604] ... 384개
- text [비타민C 필링 로션 > 제품 소개] 비타민C 필링 로션은 각질 고민을 겪는 분들을
- metadata {'product_id': 'P001', 'section': '제품 소개', 'section_id': 1, 'body': '...'}
「벡터 데이터베이스」라는 말이 주는 인상보다 훨씬 단순합니다충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
{id: {vector, text, metadata}} 짜리 파이썬 딕셔너리입니다. 그게 전부입니다.
검색은 그 딕셔너리를 전부 훑으면서 코사인을 계산하고 큰 것부터 세웁니다. 2절에서 손으로 짠 것과 같은 일입니다.
🔴 1,560개라서 이게 통합니다. 100만 개가 되면 「전부 훑기」가 안 됩니다 — 그때 필요한 게 색인(HNSW 같은 것)이고, 그게 진짜 벡터 DB(pgvector · Qdrant)가 하는 일입니다.
지금 필요 없는 것을 미리 쓰지 않습니다. 대신 5일차에 갈아끼울 자리를 만들어 둡니다.
조각으로 찾고 원문을 준다 (small-to-big)
2일차 3교시에 표를 둘로 나눈 이유가 여기서 쓰입니다.
- 질문「민감성인데 써도 되나요」
- chunks 에서 찾는다짧아서 초점이 선명하다
- section_id 로 되찾는다그 조각이 속한 원문
- sections 를 넘긴다맥락이 있는 통글 (4일차에 LLM 에게)
5. 돌려 봅니다
python -m app.features.retrieve
- 박은수 · 건성 · 구매 1건
- 가까운 상품 3개:
- 0.901 어성초 트러블 앰플 32,700원
- 0.900 어성초 트러블 클렌징오일 21,200원
- 0.899 쌀겨 트러블 에센스 26,400원
- 벡터스토어 준비 13.0초 (조각 1,560개 · 다시 임베딩하지 않았다)
- 청크 검색 ― 민감성인데 써도 되나요
- 0.892 [나이아신아마이드 브라이트닝 세럼 > 이런 분께 권합니다]
- 0.892 [알로에 트러블 미스트 > 이런 분께 권합니다]
- 0.891 [약산성 아미노산 포어 토너 > 이런 분께 권합니다]
- 0.888 [판테놀 브라이트닝 로션 > 이런 분께 권합니다]
그리고 청크 검색 결과가 이상합니다. 그건 7절에서 봅니다.
6. 점검에 재는 토막 둘을 더합니다
추천에서 상품 점수를 무엇으로 할지 정해야 합니다. 상품 하나에 조각이 평균 7.8개인데, 그 점수를 합칠까요, 아니면 4교시에 만든 요약 벡터를 쓸까요.
조각을 합친다
max 또는 mean
- max — 하나라도 딱 맞으면 잡는다. 그런데 우연히 걸린 조각 하나로 1위가 되기도 한다
- mean — 우연에 덜 휘둘린다. 그런데 관계없는 조각이 점수를 깎는다
- 「배송 및 교환」 섹션이 모든 상품 점수를 비슷하게 만든다
상품 요약 벡터
애초에 안 합친다
- 4교시에 만든
product_vectors다 - 상품 하나에 벡터 하나. 합치는 문제 자체가 없다
- 계산도 8배 싸다 (200개 vs 1,560개)
재서 정합니다. 그런데 그 전에 준비가 하나 필요합니다.
문장을 만드는 함수를 embedding.py 로 옮깁니다
추천 방식을 견줄 때 hit@k 만 보면 반쪽입니다. **「그 벡터가 평균 몇 토큰짜리 글로 만들어졌나」**를 옆에 놓아야 값을 하는지가 보입니다.
그러려면 점검 쪽에서 product_text() 를 불러야 하는데, 지금 그 함수는 03_embed.py 안에 있습니다. 파일 이름이 숫자로 시작해서 import 자체가 안 됩니다.
🔴 그런데 진짜 이유는 따로 있습니다. 만들 때와 잴 때가 같은 함수여야 합니다. 여기서 문장을 손으로 다시 조립하면, 나중에 03_embed.py 쪽만 고쳐도 아무도 모릅니다 — 점검이 옛날 자로 재고 「이상 없음」이라고 말합니다.
pipeline/
03_embed.py 무엇을 골라 오나 (순서)
- def product_text() <- 여기 있던 셋이
- def top()
- def customer_text()
prep/embedding.py 어떤 글로 적나 · 그 글을 숫자로
+ def product_text() <- 여기로 간다
+ def top()
+ def customer_text()
먼저 03_embed.py 에서 덜어냅니다. import 한 줄과 함수 셋을 지우고, 부르는 자리 둘에 embedding. 을 붙입니다.
- import time
- from collections import Counter
+ import time
- 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}")
+ # 🔴 문장을 만드는 함수 셋은 prep/embedding.py 로 갔다. 「상품 한 줄을 어떤
+ # 문장으로 적나」는 순서를 정하는 일이 아니라 검색 품질을 정하는 결정이다.
+ # 그리고 04_verify.py 가 토큰을 다시 잴 때 **같은 함수**를 부른다
- [product_text(x[1:]) for x in r])
+ [embedding.product_text(x[1:]) for x in r])
- [customer_text(skin_of[c], history[c]) for c in cids])
+ [embedding.customer_text(skin_of[c], history[c]) for c in cids])
이제 prep/embedding.py 가 받습니다. 파일 맨 아래에 붙입니다.
# ═══════════════════════════════════════════════ ① 무엇을 벡터로 만드나
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일차 3교시 · hit@5):
후기 이어붙이기 10.7% · 취향만 9.7% · 취향+나이지역 12.3% · 취향+후기 7.7%
④는 확실히 나쁘고, 앞 셋은 못 가른다 ― 차이 2.6%p 는 흔들림 안이다.
그래서 **제일 짧고 사람이 읽을 수 있는 것**을 골랐다. 「왜 이걸 추천했나」를
화면에 그대로 쓸 수 있다.
"""
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}")
prep/verifying.py — 재는 함수 넷이 늘어납니다
5교시에 만든 파일에 붙입니다. 먼저 맨 위 import 를 보강합니다 — 시간을 재야 하고, 토큰을 세야 하고, 방금 옮긴 문장 함수를 불러야 합니다.
- import numpy as np
+ import time
+
+ import numpy as np
- from app.core.db import load_vectors
+ from app.core.db import load_vectors
+ from pipeline.prep import chunking, embedding
그리고 파일 맨 아래에 붙입니다.
# ═════════════════════════ 3일차 6교시 ― 어느 쪽이 나은가를 잰다 (5~6번)
#
# 🔴 위쪽은 '맞나'를 묻는다 (참/거짓이라 problems 에 쌓인다).
# 여기부터는 '어느 쪽이 나은가'를 묻는다 ― 숫자를 나란히 놓고 사람이 고른다
# ─────────────────────────────────────────────── 5. 합치기 (max vs mean)
def calculate_scores(customer_matrix, product_matrix, chunk_matrix,
chunk_ids, product_ids, product_of):
"""고객 x 상품 점수를 세 가지 방법으로 만든다. 돌려주는 것: {방법 이름: 점수 행렬}
점수 행렬의 모양은 전부 (고객 수, 상품 수) 다. 그래야 같은 자로 잰 셈이 된다.
상품 요약 상품을 한 문장으로 적은 벡터와 곧바로 견준다 (기준선)
조각 max 그 상품 조각 중 **제일 잘 맞는 하나**의 점수를 쓴다
조각 mean 그 상품 조각 **전부의 평균**을 쓴다
max 는 조각 하나가 우연히 튀면 같이 튄다. mean 은 중요한 조각 하나가
상관없는 조각들에 묻힌다. 어느 쪽이 나은지는 재 봐야 안다 ― 그게 아래 hit@k 다.
"""
product_scores = customer_matrix @ product_matrix.T # (고객, 상품)
chunk_scores = customer_matrix @ chunk_matrix.T # (고객, 조각)
# 어느 열이 어느 상품의 조각인지 미리 묶어 둔다. 안 그러면 상품마다 다시 훑는다
columns_of = {}
for column, chunk_id in enumerate(chunk_ids):
columns_of.setdefault(product_of[chunk_id], []).append(column)
max_scores = np.stack(
[chunk_scores[:, columns_of[pid]].max(axis=1) for pid in product_ids], axis=1)
mean_scores = np.stack(
[chunk_scores[:, columns_of[pid]].mean(axis=1) for pid in product_ids], axis=1)
return {"상품 요약 벡터 (기준선)": product_scores,
"조각 벡터 · max 로 합치기": max_scores,
"조각 벡터 · mean 으로 합치기": mean_scores}
def hit_at(scores, customer_ids, product_ids, bought, answers, ks=(1, 3, 5)):
"""숨겨 둔 정답이 상위 k 안에 오나 센다. 돌려주는 것: {k: 맞힌 비율(%)}
이미 산 상품은 후보에서 뺀다 ― 산 것을 또 추천하는 건 맞혀도 의미가 없다.
k 가 커지면 맞힐 기회가 느니 보통 hit@1 < hit@3 < hit@5 다.
"""
hits = {k: 0 for k in ks}
for row, customer_id in enumerate(customer_ids):
ranked = [product_ids[i] for i in np.argsort(-scores[row])
if product_ids[i] not in bought.get(customer_id, ())]
for k in ks:
hits[k] += answers[customer_id] in ranked[:k]
return {k: count / len(customer_ids) * 100 for k, count in hits.items()}
def compare_recommendations(con, vectors, token_result):
"""세 방법을 같은 자로 재서 나란히 찍는다. 돌려주는 것: {방법 이름: {k: 비율}}
평균 토큰을 같이 보는 이유: hit 만 보면 품질만 보인다. 벡터 하나를 만드는 데
글이 얼마나 들어갔는지를 옆에 놓아야 **값을 하는지**가 보인다.
"""
customer_ids, customer_matrix = vectors["customer"]
product_ids, product_matrix = vectors["product"]
chunk_ids, chunk_matrix = vectors["chunk"]
answers = dict(con.execute(
"SELECT customer_id, product_id FROM purchases WHERE is_holdout = 1"))
bought = {}
for customer_id, product_id in con.execute(
"SELECT customer_id, product_id FROM purchases WHERE is_holdout = 0"):
bought.setdefault(customer_id, set()).add(product_id)
product_of = dict(con.execute("SELECT chunk_id, product_id FROM chunks"))
# 🔴 03_embed.py 가 쓴 **바로 그 함수**로 문장을 다시 만들어 토큰을 센다.
# 여기서 문장을 손으로 다시 조립하면, 만들 때와 잴 때가 어긋나도 아무도 모른다
product_rows = con.execute("""
SELECT name, brand, category, price, skin_type,
ingredient, concern, tags, description
FROM products ORDER BY product_id
""").fetchall()
product_texts = [embedding.product_text(row) for row in product_rows]
average_product_tokens = sum(
chunking.count_tokens(text) for text in product_texts) / len(product_texts)
# max 와 mean 은 같은 조각 벡터를 쓴다. 그래서 평균 토큰이 같다
average_chunk_tokens = token_result["average_chunk_tokens"]
average_tokens = {
"상품 요약 벡터 (기준선)": average_product_tokens,
"조각 벡터 · max 로 합치기": average_chunk_tokens,
"조각 벡터 · mean 으로 합치기": average_chunk_tokens,
}
started = time.perf_counter()
score_sets = calculate_scores(customer_matrix, product_matrix, chunk_matrix,
chunk_ids, product_ids, product_of)
elapsed = time.perf_counter() - started
print(f" 고객 {len(customer_ids)}명 · 상품 {len(product_ids)}개 · "
f"조각 {len(chunk_ids):,}개 전부 비교하는 데 {elapsed * 1000:.0f}ms\n")
print(f" {'무엇으로 찾나':28s} {'평균 토큰':>10s} "
f"{'hit@1':>7s} {'hit@3':>7s} {'hit@5':>7s}")
hit_results = {}
for label, scores in score_sets.items():
hits = hit_at(scores, customer_ids, product_ids, bought, answers)
hit_results[label] = hits
print(f" {label:28s} {average_tokens[label]:>10.1f} "
f"{hits[1]:>6.1f}% {hits[3]:>6.1f}% {hits[5]:>6.1f}%")
print()
print(" 읽는 법: 6일차 2교시에 잰 '자의 흔들림'은 30명 표본에서 16.7%p 다.")
print(" 여기는 300명 전수라 그만큼은 아니지만, 1%p 남짓한 차이는 여전히 못 믿는다.")
print(" 그리고 상세 조각은 애초에 추천 신호가 아니다 ― 추천은 상품 요약 벡터로 한다.")
print(" 조각 벡터가 일하는 곳은 추천이 아니라 '상품 질문에 근거 대기'(4일차 2교시) 다")
return hit_results
# ─────────────────────────────────────────────── 6. 눈으로
def inspect_search_results(con, chunk_ids, chunk_matrix, questions, embed_model):
"""실제 질문을 던져 상위 3개를 눈으로 본다. 돌려주는 것: {질문: [섹션 이름, ...]}
숫자만 보면 검색이 사람 눈에 말이 되는지는 모른다. 그래서 두 개를 던진다 ―
되는 것 하나, 안 되는 것 하나.
"""
# 🔴 앱과 **똑같은 임베딩기**를 쓴다. 파이프라인과 앱이 다른 자를 쓰면 그 순간
# 검색이 어긋난다. 여기서 HuggingFaceEmbeddings 를 직접 부르면 나중에
# 상용으로 갈아끼울 때 'text-embedding-3-small' 을 HuggingFace 에서
# 찾다가 죽는다 ― 실제로 당했다. 그래서 앱의 함수를 그대로 부른다.
# 파일 맨 위가 아니라 여기서 부르는 이유: retrieve 는 import 되는 순간
# 벡터 표를 읽는다. 1~5번만 보고 싶을 때까지 그걸 끌고 올 이유가 없다
from app.features.retrieve import get_embeddings
model = get_embeddings()
question_matrix = np.array(model.embed_documents(questions), dtype="float32")
meta = {chunk_id: (product_id, section, body)
for chunk_id, product_id, section, body
in con.execute("SELECT chunk_id, product_id, section, body FROM chunks")}
top_sections = {}
for question, question_vector in zip(questions, question_matrix):
scores = chunk_matrix @ question_vector # 길이가 1 이라 이게 코사인이다
ranks = np.argsort(-scores)[:3]
print(f"\n Q. {question}")
top_sections[question] = [meta[chunk_ids[rank]][1] for rank in ranks]
for rank in ranks:
product_id, section, body = meta[chunk_ids[rank]]
print(f" {scores[rank]:.3f} [{product_id} > {section}] "
f"{body[:48].replace(chr(10), ' ')}...")
# 두 번째 질문이 이 데이터의 급소다. 「환불」이라는 낱말이 문서에 없기 때문이다
print()
for word in ("환불", "반품", "교환"):
n = con.execute("SELECT COUNT(*) FROM chunks WHERE body LIKE ?",
(f"%{word}%",)).fetchone()[0]
print(f" '{word}' 이 들어간 조각: {n:,}개")
print(" 우리 문서는 '환불' 이라는 낱말을 한 번도 안 쓴다. '교환·반품' 으로만 적혀 있다.")
# 🔴 결과를 **보고** 말한다 ― 미리 적어 둔 결론을 찍지 않는다.
# 로컬 e5-small 은 못 이었고, 상용 text-embedding-3-small 은 이었다 (둘 다 실측)
refund_top = top_sections.get("환불하고 싶은데 어떻게 하나요", [])
if refund_top and refund_top[0] == "배송 및 교환":
print(f" 이 임베딩({embed_model})은 '환불'과 '교환·반품'을 이어 냈다 "
"-> 1위 [배송 및 교환]")
print(" 같은 질문을 e5-small 로 던지면 엉뚱한 섹션이 1위로 온다. 모델이 하는 일이 이거다")
elif refund_top:
print(f" 이 임베딩({embed_model})은 그 둘을 못 이었다 -> 1위 [{refund_top[0]}]")
print(" 낱말이 다르면 못 찾는다. 접두어(query:/passage:)를 붙여도 안 이어졌다 (재 봤다)")
print(" -> 어느 쪽이든 벡터 검색만 믿으면 안 된다. 그래서 4일차 4교시에서 SQL 필터와 섞고,")
print(" 6일차 5교시에서 출처를 화면에 띄운다. 근거가 보이면 사람이 틀린 걸 알아챈다")
return top_sections
이 토막에서 기억할 것
- 왜 앱이 아니라 점검 파일인가. 이건 재는 코드지 서비스가 쓰는 코드가 아닙니다. 앱에 넣으면 아무도 안 부르는 함수가 넷 늘 뿐입니다.
vectors를 그대로 씁니다. 2번에서 올려 둔 그 행렬입니다. 그래서 이 토막은 2번 뒤에 와야 합니다.customer_matrix @ product_matrix.T는 고객 300명과 상품 200개를 전부 짝지어 코사인을 냅니다 — 60,000번의 계산이 한 줄입니다. 4교시에 「길이를 1 로 맞춰 두면 곱하고 더하면 끝」이라고 한 게 이걸 위해서였습니다.- 🔴
hit_at()이 이미 산 상품을 뺍니다. 안 빼면 「이미 산 걸 또 추천해서 맞혔다」가 점수에 섞입니다. 맞혀도 의미가 없는 정답입니다. - 🔴
inspect_search_results()는 결론을 미리 안 적습니다.if/else로 결과를 보고 말합니다. 미리 적어 둔 결론을 찍으면 모델을 바꿨을 때 코드가 거짓말을 합니다. - 🔴 앱의
get_embeddings()를 부릅니다. 여기서HuggingFaceEmbeddings를 직접 만들면, 6일차에 상용으로 갈아끼울 때text-embedding-3-small을 HuggingFace 에서 찾다가 죽습니다. 실제로 당했습니다.
04_verify.py — 부르는 줄 둘을 더합니다
몸통이 늘었으니 순서 파일도 늘어납니다. 딱 그만큼만 늘어납니다.
- 4. 토큰 조각 분포. 상한을 넘어 잘리는 것은 없는가
+ 4. 토큰 조각 분포. 상한을 넘어 잘리는 것은 없는가
+ 5. 합치기 한 상품의 조각 점수를 max 로 합칠까 mean 으로 합칠까 (실측)
+ 6. 눈으로 실제 질문 두 개를 던져 본다
- from pathlib import Path
+ from pathlib import Path
+
+ from huggingface_hub.utils import disable_progress_bars
+ from huggingface_hub.utils import logging as hub_logging
+ from transformers import logging as hf_logging
- sys.stdout.reconfigure(errors="replace")
+ sys.stdout.reconfigure(errors="replace")
+
+ hf_logging.set_verbosity_error()
+ hf_logging.disable_progress_bar() # 가중치 로딩 진행바
+ hub_logging.set_verbosity_error()
+ disable_progress_bars()
- verifying.print_final_result(problems)
+ # ═══════════════════════════════════════════════ 5. 합치기
+ banner("5. 한 상품의 조각 점수를 어떻게 합치나 ― max vs mean")
+ verifying.compare_recommendations(con, vectors, token_result)
+
+ # ═══════════════════════════════════════════════ 6. 눈으로
+ banner("6. 눈으로 ― 실제 질문을 던져 본다 (되는 것 하나, 안 되는 것 하나)")
+ chunk_ids, chunk_matrix = vectors["chunk"]
+ verifying.inspect_search_results(
+ con, chunk_ids, chunk_matrix,
+ ["배송은 얼마나 걸리나요", "환불하고 싶은데 어떻게 하나요"], EMBED_MODEL)
+
+ verifying.print_final_result(problems)
python pipeline/04_verify.py
- 고객 300명 · 상품 200개 · 조각 1,560개 전부 비교하는 데 5ms
- 무엇으로 찾나 평균 토큰 hit@1 hit@3 hit@5
- 상품 요약 벡터 (기준선) 121.3 3.0% 6.3% 11.0%
- 조각 벡터 · max 로 합치기 105.3 2.0% 7.0% 10.0%
- 조각 벡터 · mean 으로 합치기 105.3 2.7% 6.0% 10.0%
7. 결과를 읽습니다 — 되는 것 하나, 안 되는 것 둘
숫자로 재는 것과 눈으로 보는 것은 다른 일입니다. 표는 「몇 퍼센트」를 말하고, 이건 **「무엇을 틀리나」**를 말합니다.
되는 것 — 「배송은 얼마나 걸리나요」
- 0.867 [P091 > 배송 및 교환] 평일 오후 2시 이전 결제 건은 당일 출고됩니다...
- 0.867 [P006 > 배송 및 교환] 평일 오후 2시 이전 결제 건은 당일 출고됩니다...
- 0.865 [P077 > 배송 및 교환] 평일 오후 2시 이전 결제 건은 당일 출고됩니다...
정확합니다. 1·2·3위가 전부 「배송 및 교환」입니다.
안 되는 것 ① — 「환불」
- 0.859 [P185 > 이런 분께 권합니다] 민감성이신 분, 트러블 때문에 오래 고민하신 분께...
- 0.855 [P117 > 자주 묻는 질문] Q. 샘플을 받아볼 수 있나요? A. 고객센터로 문의해...
- 0.854 [P047 > 이런 분께 권합니다] 민감성이신 분, 트러블 때문에 오래 고민하신 분께...
- '환불' 이 들어간 조각: 0개
- '반품' 이 들어간 조각: 200개
- '교환' 이 들어간 조각: 200개
- 🔴 이 임베딩은 그 둘을 못 이었다 -> 1위 [이런 분께 권합니다]
왜 실패했을까요. 2일차 4교시로 돌아가 보면 답이 보입니다.
2일차 4교시
성공
- 후보가 3개였다
- 1등(교환·반품 0.8597)과 2등(배송 0.8451) 차이 0.0146
- 간신히 이겼다
지금
실패
- 후보가 1,560개다
- 0.0146 짜리 우위로 1,560개를 이겨야 한다
- 그 사이에 더 가까운 게 하나만 있어도 진다
안 되는 것 ② — 「민감성인데 써도 되나요」
5절에서 본 그 화면입니다. 상위 넷이 **전부 「이런 분께 권합니다」**이고 「주의사항」이 하나도 없습니다.
8. 그럼 모델을 바꾸면 되나
그럴 수도 있습니다. 그런데 오늘은 확인할 수 없습니다충분히 고민해본 뒤 꼭 필요한 경우에만 열어보세요
우리 벡터는 384차원입니다. 다른 모델은 차원이 다릅니다(상용 임베딩은 1536). 384짜리와 1536짜리는 한 표에 못 섞습니다 ― 비교 자체가 성립하지 않습니다.
바꾸려면 벡터 3,260개를 전부 다시 만들어야 합니다. 그리고 그 모델이 상용이면 키가 있어야 하고 돈이 듭니다.
🔴 그래서 오늘은 안 바꿉니다. 대신 6일차에 자를 먼저 만들고 나서 로컬과 상용을 나란히 잽니다. 지금 바꿔 보면 「좋아진 것 같다」밖에 말할 수 없습니다.
재는 도구 없이 도구를 바꾸는 것이 이 일에서 제일 흔한 헛수고입니다.
- 01
오늘 남은 시간 — 골라 읽는다
metadata의section으로 주의사항만 봅니다. 검색에 안 맡기고 조건으로 집습니다. 10교시 안전 필터가 하는 일이 정확히 이것입니다 - 02
4일차 — 근거를 같이 낸다 · SQL 과 섞는다
찾은 조각을 답변과 같이 보여 줍니다. 카테고리 · 가격 같은 확실한 조건은 벡터에 안 맡깁니다
- 03
5일차 — 화면에 띄운다
답변 밑에 출처를 붙입니다. 오늘 같은 실패가 나도 사람이 알아챕니다
- 04
6일차 — 잰다
골든셋으로 hit@k 를 재고, 자가 무엇을 못 재는지까지 봅니다. 그다음에 모델을 바꿔 봅니다
9. eval/ — 질문 스무 개를 적어 둡니다
오늘 물어본 질문이 넷입니다. 되는 것 둘, 안 되는 것 둘. 그걸로 뭘 판단할 수 있나요?
아무것도 못 합니다. 내일 구분자를 바꾸거나 조각 크기를 바꾸면 좋아졌는지 나빠졌는지 알 방법이 없습니다. 그래서 질문 목록을 파일로 적어 둡니다.
{
"설명": "상품 Q&A 골든셋. 청킹 방식을 판정한다 ― 정답 섹션이 검색 상위에 오나",
"만든 법": "전부 DB 의 실제 문장에서 뽑았다",
"items": [
{"id": 1, "question": "비타민C 필링 로션은 민감성 피부가 써도 되나요?",
"product_id": "P001", "section": "주의사항",
"keywords": ["민감성", "권하지 않습니다"]}
]
}
문항 하나에 「질문 · 정답이 있는 자리 · 그 자리에 있어야 할 낱말」 셋이 들어갑니다. 스무 개를 적었습니다.
"""상품 Q&A 골든셋 채점 ― 검색이 제 일을 하는지 본다.
두 가지를 잰다.
1) 골든셋이 정말 맞나 keywords 가 그 섹션 원문에 실제로 있는지 확인한다
2) 검색이 찾아오나 질문을 던져 상위 k 에 정답 섹션이 오는지 센다 (hit@1/3/5)
실행: python eval/qa_check.py
"""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
sys.stdout.reconfigure(errors="replace")
from app.core.db import one
from app.features.retrieve import search_chunks
GOLDEN = json.loads((Path(__file__).parent / "qa_golden.json").read_text(encoding="utf-8"))
ITEMS = GOLDEN["items"]
# ─────────────────────────────────────────── 1. 골든셋 자체를 검사한다
broken = []
for item in ITEMS:
row = one("SELECT text FROM sections WHERE product_id = ? AND section = ?",
(item["product_id"], item["section"]))
if row is None:
broken.append((item["id"], "그런 섹션이 없다"))
continue
missing = [w for w in item["keywords"] if w not in row[0]]
if missing:
broken.append((item["id"], f"원문에 없는 낱말: {missing}"))
print(f" 문항 {len(ITEMS)}개 · 어긋난 것 {len(broken)}개")
for item_id, why in broken:
print(f" {item_id}. {why}")
# ─────────────────────────────────────────── 2. 검색이 정답 섹션을 찾아오나
hits = {1: 0, 3: 0, 5: 0}
missed = []
for item in ITEMS:
found = search_chunks(item["question"], k=5)
# 상품을 안 가리킨 일반 질문(배송 등)은 섹션만 맞으면 맞은 것으로 본다 ―
# 200개 상품의 배송 섹션이 글자까지 똑같아서 상품을 가리는 게 의미가 없다
sections = [s["section"] for s in found]
ranks = [(s["product_id"], s["section"]) for s in found]
for k in hits:
if (item["product_id"], item["section"]) in ranks[:k] or item["section"] in sections[:k]:
hits[k] += 1
if (item["product_id"], item["section"]) not in ranks and item["section"] not in sections:
missed.append((item["id"], item["question"], item["section"], sections[:3]))
n = len(ITEMS)
print(f" hit@1 {hits[1] / n * 100:.0f}% · hit@3 {hits[3] / n * 100:.0f}% "
f"· hit@5 {hits[5] / n * 100:.0f}% ({n}문항)")
if missed:
print(f"\n 못 찾은 것 {len(missed)}개 ― 왜 못 찾았는지가 수업 재료다:")
for item_id, question, section, got in missed:
print(f" {item_id}. {question}")
print(f" 정답 [{section}] · 검색 결과 {got}")
이 파일에서 기억할 것
- 채점하기 전에 골든셋 자체를 검사합니다. 「이 질문의 답은 P001 의 주의사항에 있다」고 적었는데 실제로는 거기 없을 수 있습니다. 기억으로 적으면 반드시 몇 개는 틀립니다. 🔴 자를 만들었으면 그 자부터 재 봐야 합니다.
- 고치기 전에 만듭니다. 고친 뒤에 자를 만들면 「고치기 전 값」이 없습니다. 비교할 것이 없으면 좋아졌는지 알 수 없습니다.
- 이 자는 오늘 만든 것 전부를 지나갑니다 — 자르기 · 접두어 · 벡터 · 검색. 그중 하나만 잘못돼도 이 숫자가 내려갑니다. 파이프라인 전체의 건강검진인 셈입니다.
python eval/qa_check.py
- 문항 20개 · 어긋난 것 0개
- hit@1 80% · hit@3 90% · hit@5 95% (20문항)
- 못 찾은 것 1개 ― 왜 못 찾았는지가 수업 재료다:
- 18. 시험 중에 이상 반응이 있었나요?
- 정답 [사용성 평가] · 검색 결과 ['인증 및 시험', '인증 및 시험', '인증 및 시험']
3일차 정리
| 주제 | 핵심 |
|---|---|
| 폴더를 나눴다 | app/ 을 core · features 로. 기준은 「무엇 없이는 못 도나」 |
| 파이프라인도 갈랐다 | chunking(순수) · storage(바깥) · 02_chunk(조립) |
| 🔴 고객을 세 층으로 | 정형은 SQL · 취향은 벡터 한 줄 · 후기는 1건 = 1행 |
| 재서 골랐다 | 네 가지 중 후기를 넣은 판이 제일 나빴다 (12.3% -> 7.7%) |
| 못 가르면 이유로 고른다 | ①②③ 차이 2.6%p. 짧은 것 · 설명되는 것 · 안전한 것으로 골랐다 |
| 네 벌을 벡터로 | 조각 1,560 · 상품 200 · 고객 300 · 후기 1,200 = 3,260개 |
🔴 PRAGMA table_info | 열쇠 타입을 손으로 적으면 조인이 오류 없이 0건이 된다 |
| 점검을 따로 뒀다 | 04_verify.py. 만드는 파일과 재는 파일은 도는 시점이 다르다 |
| 🔴 재는 파일도 갈랐다 | 04_verify.py 는 순서만, prep/verifying.py 가 방법을. 점검이 넷에서 여섯이 돼도 순서 파일은 다섯 줄만 늘었다 |
| 저장 | 문자 12.4MB vs BLOB 4.8MB (2.6배). 읽기 163.8ms vs 6.7ms 는 한 번 재고 끝냈다 ― 서버 시작에 한 번이라 문자를 골랐다 |
| 반올림 | 6자리로 자른 대가는 코사인 0.99999964. 진짜 차이보다 10만 배 작다 |
| 벡터스토어 | 딕셔너리 하나. 13초 중 거의 전부가 모델 로딩이다 |
| 🔴 재는 코드는 재는 파일에 | prep/verifying.py 에 붙였다. 앱에 넣으면 아무도 안 부르는 함수가 늘 뿐이다 |
| 🔴 만들 때와 잴 때 같은 자 | product_text() 를 embedding.py 로 옮겼다. 점검이 문장을 손으로 다시 조립하면 옛날 자로 재고 이상 없다고 말한다 |
| max vs mean | 11.0 · 10.0 · 10.0%. 🔴 차이를 믿을 수 없어 이유로 골랐다. 조각 쪽은 여덟 배(105토큰 x 7.8개)를 써서 같은 자리다 |
| 🔴 오늘의 실패 ① | 「민감성인데 써도 되나요」 상위가 전부 정반대(「권합니다」) |
| 🔴 오늘의 실패 ② | 「환불」 — 후보 3개에서 되던 게 1,560개에서 깨졌다 |
| 🔴 모델을 바꾸면? | 오늘은 확인할 수 없다. 차원이 달라 3,260개를 다시 만들어야 한다 → 6일차 |
| 자를 하나 만들어 뒀다 | qa_check.py 20문항 · hit@1 80% · hit@3 90% · hit@5 95%. 고치기 전에 만든다 |
다음 시간에 할 것
4교시에 고객을 벡터로 만들 때 후기도 같이 벡터로 만들었습니다. review_vectors
1,200행입니다.
- ... 좋아요 재구매합니다 010-1159-8256 으로 연락주세요 ...
- ... 카톡 skinlover22 로 문의드려요 ...
- ... hong@example.com 으로 샘플 보내주세요 ...
이걸 그대로 벡터로 만들었습니다. 그리고 나중에 상용 API 로 바꾸면 이 글이 통째로 다른 회사 서버로 나갑니다.
7교시부터는 내보내도 되는 것만 남기는 필터를 만듭니다.