학습 자료

찾아본다, 그리고 「환불」을 물어본다


Article

벡터 3,260개가 DB 에 있습니다. 점검도 끝났습니다. 이제 찾습니다.

찾는 일 자체는 30줄이면 끝납니다. 이 시간에 진짜로 볼 것은 둘입니다 — 벡터스토어의 속과, 검색이 틀리는 모양입니다.


1. 오늘 손대는 파일

파이프라인은 오후 4·5교시로 끝났습니다. 이번 시간에는 앱 쪽이 자랍니다.

cosmetic-admin/

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

앱은 대시보드만 안다

  • app/core/db.py — query · one · dicts · load_vectors
  • app/features/retrieve.py — customer_list · dashboard
  • pipeline/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__ 블록 위에 붙입니다.

app/core/db.py
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초가 날아갑니다. 그래서 처음 부를 때 올리고, 그다음부터는 갖고 있던 걸 줍니다.

app/features/retrieve.py
"""꺼내오는 자리 ― 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 한 칸을 열면 이렇게 생겼습니다.

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교시에 표를 둘로 나눈 이유가 여기서 쓰입니다.

찾기 좋은 것과 답하기 좋은 것이 다르다
  1. 질문「민감성인데 써도 되나요」
  2. chunks 에서 찾는다짧아서 초점이 선명하다
  3. section_id 로 되찾는다그 조각이 속한 원문
  4. 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. 을 붙입니다.

고침 · pipeline/03_embed.py
- 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 가 받습니다. 파일 맨 아래에 붙입니다.

pipeline/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 를 보강합니다 — 시간을 재야 하고, 토큰을 세야 하고, 방금 옮긴 문장 함수를 불러야 합니다.

고침 · pipeline/prep/verifying.py
- 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

그리고 파일 맨 아래에 붙입니다.

pipeline/prep/verifying.py
# ═════════════════════════ 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 — 부르는 줄 둘을 더합니다

몸통이 늘었으니 순서 파일도 늘어납니다. 딱 그만큼만 늘어납니다.

고침 · pipeline/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
터미널 — 5. max vs mean (300명 전수)
  • 고객 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. 결과를 읽습니다 — 되는 것 하나, 안 되는 것 둘

숫자로 재는 것과 눈으로 보는 것은 다른 일입니다. 표는 「몇 퍼센트」를 말하고, 이건 **「무엇을 틀리나」**를 말합니다.

되는 것 — 「배송은 얼마나 걸리나요」

Q. 배송은 얼마나 걸리나요
  • 0.867 [P091 > 배송 및 교환] 평일 오후 2시 이전 결제 건은 당일 출고됩니다...
  • 0.867 [P006 > 배송 및 교환] 평일 오후 2시 이전 결제 건은 당일 출고됩니다...
  • 0.865 [P077 > 배송 및 교환] 평일 오후 2시 이전 결제 건은 당일 출고됩니다...

정확합니다. 1·2·3위가 전부 「배송 및 교환」입니다.

안 되는 것 ① — 「환불」

Q. 환불하고 싶은데 어떻게 하나요
  • 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일차에 자를 먼저 만들고 나서 로컬과 상용을 나란히 잽니다. 지금 바꿔 보면 「좋아진 것 같다」밖에 말할 수 없습니다.

재는 도구 없이 도구를 바꾸는 것이 이 일에서 제일 흔한 헛수고입니다.

이 문제를 어떻게 메우나 — 남은 사흘의 설계가 여기서 나온다
  1. 01

    오늘 남은 시간 — 골라 읽는다

    metadata 의 section 으로 주의사항만 봅니다. 검색에 안 맡기고 조건으로 집습니다. 10교시 안전 필터가 하는 일이 정확히 이것입니다

  2. 02

    4일차 — 근거를 같이 낸다 · SQL 과 섞는다

    찾은 조각을 답변과 같이 보여 줍니다. 카테고리 · 가격 같은 확실한 조건은 벡터에 안 맡깁니다

  3. 03

    5일차 — 화면에 띄운다

    답변 밑에 출처를 붙입니다. 오늘 같은 실패가 나도 사람이 알아챕니다

  4. 04

    6일차 — 잰다

    골든셋으로 hit@k 를 재고, 자가 무엇을 못 재는지까지 봅니다. 그다음에 모델을 바꿔 봅니다


9. eval/ — 질문 스무 개를 적어 둡니다

오늘 물어본 질문이 넷입니다. 되는 것 둘, 안 되는 것 둘. 그걸로 뭘 판단할 수 있나요?

아무것도 못 합니다. 내일 구분자를 바꾸거나 조각 크기를 바꾸면 좋아졌는지 나빠졌는지 알 방법이 없습니다. 그래서 질문 목록을 파일로 적어 둡니다.

eval/qa_golden.json
{
  "설명": "상품 Q&A 골든셋. 청킹 방식을 판정한다 ― 정답 섹션이 검색 상위에 오나",
  "만든 법": "전부 DB 의 실제 문장에서 뽑았다",
  "items": [
    {"id": 1, "question": "비타민C 필링 로션은 민감성 피부가 써도 되나요?",
     "product_id": "P001", "section": "주의사항",
     "keywords": ["민감성", "권하지 않습니다"]}
  ]
}

문항 하나에 「질문 · 정답이 있는 자리 · 그 자리에 있어야 할 낱말」 셋이 들어갑니다. 스무 개를 적었습니다.

eval/qa_check.py
"""상품 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일차 정리

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 mean11.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교시부터는 내보내도 되는 것만 남기는 필터를 만듭니다.

Share
  • 파이썬
  • LangChain
  • RAG
  • 벡터검색