강의용 교안

앱이 스스로 찾는다 (step0 → step1)


Article

어제까지 만든 step0 은 파이프라인 하나입니다. CSV 를 DB 로, 상세를 섹션·조각으로, 조각을 벡터로 만듭니다.

그런데 그 벡터를 쓰는 곳이 pipeline/04_verify.py 하나뿐입니다. 점검 스크립트 안에서 numpy 로 직접 곱합니다. 앱은 아직 없고, 있어도 벡터를 만질 방법이 없습니다.

1단계가 하는 일은 하나입니다 — 그 계산을 앱이 부를 수 있는 자리로 옮깁니다. 그리고 밖으로 나갈 글에서 가릴 것을 가립니다.

git checkout step1
이 교시가 끝나면

step0 — 지금

파이프라인만 있다

  • 벡터 계산이 04_verify.py 안에 있다
  • app/ 에는 config · db · embedder · retrieve 넷뿐
  • 시험이 없다. 자도 없다

step1 — 끝나면

검색기 하나가 남는다

  • 저장소 뒤에서 벡터를 곱한다 (app/adapters/stores/)
  • 규칙만 있는 순수 층이 생긴다 (app/domain/)
  • DB 없이 도는 시험 25개
  • 정답이 붙은 질문 20개 — 자
오늘 손대는 파일 — 이 순서로 끝냅니다
#파일하는 일
0pipeline/02_chunk.py고침. 지금 안 돌아간다
1pipeline/prep/storage.py조각 벡터 표에 읽을거리를 같이 적는다
2app/adapters/stores/base.py저장소가 지킬 약속
3app/adapters/stores/sqlite_store.py그 약속의 SQLite 판
4app/adapters/stores/__init__.py하나만 만들어 돌려쓴다
5app/domain/masking.py가리는 규칙만. DB 를 모른다
6app/features/privacy.py그 규칙에 고객 사전을 붙인다
7app/domain/safety.py금지 문장을 읽는 규칙만
8app/features/safety_filter.py그 규칙에 DB 를 붙인다
9app/features/profile.py벡터 없이 SQL 로만 답하는 것
10app/features/searching.py벡터로 찾는 것
11app/features/retrieve.py밖에서 부를 이름을 모은 창구
12pyproject.toml · tests/DB 없이 도는 시험 25개
13eval/qa_check.py · qa_golden.json1단계의 자

0. pipeline/02_chunk.py — 지금 안 돌아갑니다

시작하기 전에 고칩니다. step0 을 그대로 돌리면 여기서 죽습니다.

터미널
  • sections, chunks = chunking.split_details(details)
  • ValueError: too many values to unpack (expected 2)

split_details() 는 값을 셋 돌려줍니다 — (섹션, 조각, 다시 자른 섹션 수). 받는 쪽만 둘이었습니다.

셋으로 받고, 마지막 값을 화면에 찍습니다.

고침 · pipeline/02_chunk.py
  # 3. 자른 결과를 sections, chunks 테이블에 저장한다
  storage.save_sections_and_chunks(con, sections, chunks)
+
+ print(f"  섹션 {len(sections):,}개 · 조각 {len(chunks):,}개 · "
+       f"상한을 넘어 다시 자른 섹션 {n_resplit}개")
  con.close()
python pipeline/02_chunk.py
  • 섹션 1,560개 · 조각 1,560개 · 상한을 넘어 다시 자른 섹션 0개

1. pipeline/prep/storage.py — 검색 한 번이 조회 한 번으로

앱이 조각을 찾으면 화면에 상품 이름 · 섹션 이름 · 원문을 같이 보여줘야 합니다. 그런데 그 값들은 products · chunks · sections 세 표에 흩어져 있습니다.

찾을 때마다 조인 셋을 따라가는 대신, 벡터를 만들 때 옆에 같이 적어 둡니다. 비정규화입니다.

pipeline/prep/storage.py (아래에 덧붙인다)
# 조각 벡터 테이블을 검색에 필요한 값까지 함께 담아서 만드는 함수
# 검색 한 번에 products, chunks, sections 조인이 필요했던 것을 미리 베껴 둔다 (비정규화)
def build_chunk_vectors(con, ids, vectors, dim):
  con.execute("DROP TABLE IF EXISTS chunk_vectors")
  con.execute("""
      CREATE TABLE chunk_vectors (
          chunk_id     INTEGER PRIMARY KEY,
          section_id   INTEGER NOT NULL,   -- 원본으로 돌아가는 실
          product_id   TEXT    NOT NULL,   -- 상품으로 좁힐 때 이 표 안에서 끝난다
          section      TEXT    NOT NULL,   -- '주의사항'
          product_name TEXT    NOT NULL,   -- products 조인 제거
          text         TEXT    NOT NULL,   -- 임베딩에 넣은 조각, chunks 조인 제거
          section_text TEXT    NOT NULL,   -- LLM 에 주는 원문 섹션, sections 조인 제거
          dim          INTEGER NOT NULL,
          model        TEXT    NOT NULL,
          vector       TEXT    NOT NULL,
          FOREIGN KEY (chunk_id)   REFERENCES chunks(chunk_id),
          FOREIGN KEY (section_id) REFERENCES sections(section_id),
          FOREIGN KEY (product_id) REFERENCES products(product_id)
      )
  """)
  con.execute("CREATE INDEX idx_chunk_vectors_product_id ON chunk_vectors(product_id)")

  # 베낄 값은 손으로 만들지 않고 원본에서 읽어 온다
  source = {row[0]: row[1:] for row in con.execute("""
      SELECT chunks.chunk_id, chunks.section_id, chunks.product_id, chunks.section,
             products.name, chunks.text, sections.text
      FROM chunks
      JOIN products ON products.product_id = chunks.product_id
      JOIN sections ON sections.section_id = chunks.section_id
  """)}

  con.executemany("""
      INSERT INTO chunk_vectors (chunk_id, section_id, product_id, section,
                                 product_name, text, section_text, dim, model, vector)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  """, [(chunk_id, *source[chunk_id], dim, EMBED_MODEL, vector_to_text(vector))
        for chunk_id, vector in zip(ids, vectors)])
  con.commit()

부르는 쪽은 조각만 갈래를 탑니다. 나머지 셋은 그대로입니다.

고침 · pipeline/03_embed.py
- storage.save_vectors(con, kind, key, parent, ids, vectors, dim)
+ # 조각만 다르다. 검색 한 번에 필요한 글을 벡터 옆에 같이 적어 둔다
+ if kind == "chunk":
+   storage.build_chunk_vectors(con, ids, vectors, dim)
+ else:
+   storage.save_vectors(con, kind, key, parent, ids, vectors, dim)

여기까지 고쳤으면 벡터를 다시 만들어야 합니다 — python pipeline/02_chunk.py → python pipeline/03_embed.py


2. app/adapters/stores/base.py — 약속만 적습니다

앱은 「벡터를 어디에 어떻게 담았는지」를 몰라야 합니다. 지금은 SQLite 지만 나중에 다른 것으로 갈아끼울 자리입니다.

그래서 구현보다 약속을 먼저 적습니다. 함수 이름과 인자만 있고 몸통이 없는 파일입니다.

app/adapters/stores/base.py
from typing import Protocol


# 벡터 저장소가 지켜야 할 약속(포트). 구현은 갈아끼운다.
# kind 는 'chunk' · 'product' · 'customer' · 'review' 중 하나다
class VectorStore(Protocol):

    # 그 항목의 벡터 하나를 꺼낸다. 없으면 None
    def get_vector(self, kind: str, item_id: str):
        ...

    # 가까운 것 k 개를 [(아이디, 점수), ...] 로 돌려준다. 점수는 클수록 가깝다.
    # only_ids 는 이 아이디들 중에서만, product_ids 는 이 상품에 딸린 것만 본다
    def search(self, kind: str, query_vector, k: int, *, only_ids=None, product_ids=None):
        ...

    # 그 항목의 벡터가 있는지 확인한다
    def has(self, kind: str, item_id: str) -> bool:
        ...

    # 그 종류로 벡터가 있는 아이디 전체를 돌려준다
    def all_ids(self, kind: str) -> list:
        ...

    # 아이디로 사람이 읽을 내용을 꺼낸다. {아이디: {컬럼: 값}}
    def documents(self, kind: str, ids) -> dict:
        ...

3. app/adapters/stores/sqlite_store.py — 그 약속의 SQLite 판

04_verify.py 안에서 numpy 로 하던 계산이 여기로 옮겨 옵니다. 벡터를 한 번 읽어 메모리에 올려 두고, 질문이 올 때마다 행렬을 곱합니다.

임베딩 길이를 1 로 맞춰 두었으므로 내적이 곧 코사인 유사도입니다. 나눗셈이 필요 없습니다.

app/adapters/stores/sqlite_store.py
import numpy as np

from app.core.db import load_vectors, query

# kind -> (표 이름, 열쇠 컬럼, 같이 적어 둔 읽을거리)
# 마지막 칸이 documents() 가 돌려주는 컬럼이다. 벡터만 있는 종류는 비어 있다
TABLES = {
    "chunk": ("chunk_vectors", "chunk_id",
              ("product_id", "product_name", "section", "section_text")),
    "product": ("product_vectors", "product_id", ()),
    "customer": ("customer_vectors", "customer_id", ()),
    "review": ("review_vectors", "purchase_id", ()),
}


# base.VectorStore 의 SQLite 구현. 벡터를 메모리에 올리고 앱이 계산한다.
# 임베딩 길이를 1 로 맞춰 두었으므로 내적이 곧 코사인 유사도다
class SqliteVectorStore:

    def __init__(self):
        self._cache = {}      # {kind: (아이디 목록, 행렬, {아이디: 줄번호})}

    # 그 종류의 벡터를 한 번만 읽어 캐시에 담아 두는 함수
    def _get(self, kind):
        if kind not in self._cache:
            table, key, _columns = TABLES[kind]
            ids, matrix = load_vectors(table, key)
            # chunk_id 는 INTEGER 고 product_id 는 TEXT 라 아이디를 전부 글자로 맞춘다
            ids = [str(v) for v in ids]
            # 목록에서 .index() 로 찾으면 항목 수에 비례해 느려지므로 줄 번호를 같이 만든다
            self._cache[kind] = (ids, matrix, {v: i for i, v in enumerate(ids)})
        return self._cache[kind]

    # 그 항목의 벡터가 있는지 확인하는 함수
    def has(self, kind, item_id):
        _, _, row_of = self._get(kind)
        return str(item_id) in row_of

    # 그 항목의 벡터 하나를 꺼내는 함수
    def get_vector(self, kind, item_id):
        _, matrix, row_of = self._get(kind)
        i = row_of.get(str(item_id))
        return None if i is None else matrix[i]

    # 그 종류로 벡터가 있는 아이디 전체를 돌려주는 함수
    def all_ids(self, kind):
        ids, _, _ = self._get(kind)
        return list(ids)

    # 그 상품들에 딸린 아이디를 찾는 함수. 상품 자신이면 아이디가 곧 상품 번호다
    def _ids_of_products(self, kind, product_ids):
        table, key, _columns = TABLES[kind]
        if key == "product_id":
            return [str(p) for p in product_ids]
        marks = ",".join("?" * len(product_ids))
        return [str(row[0]) for row in query(
            f"SELECT {key} FROM {table} WHERE product_id IN ({marks})",
            tuple(product_ids))]

    # 아이디로 벡터 옆에 적어 둔 읽을거리를 꺼내는 함수
    def documents(self, kind, ids):
        table, key, columns = TABLES[kind]
        ids = [str(i) for i in ids]
        if not ids or not columns:
            return {}
        marks = ",".join("?" * len(ids))
        picked = ", ".join((key,) + columns)
        rows = query(f"SELECT {picked} FROM {table} WHERE {key} IN ({marks})", tuple(ids))
        # 열쇠를 글자로 통일해야 search() 가 준 아이디로 바로 찾을 수 있다
        return {str(row[0]): dict(zip(columns, row[1:])) for row in rows}

    # 질문 벡터와 가까운 것 k 개를 [(아이디, 점수), ...] 로 돌려주는 함수
    def search(self, kind, query_vector, k, *, only_ids=None, product_ids=None):
        ids, matrix, row_of = self._get(kind)

        # 두 가지 좁히기를 여기서 합친다. 둘 다 주면 교집합이다
        wanted = None
        if only_ids is not None:
            wanted = {str(i) for i in only_ids}
        if product_ids is not None:
            by_product = set(self._ids_of_products(kind, product_ids))
            wanted = by_product if wanted is None else (wanted & by_product)

        if wanted is None:
            target_ids, rows = ids, matrix
        else:
            # 집합은 순서가 없다. 정렬하지 않으면 실행할 때마다 순위가 흔들린다
            target_ids = sorted(i for i in wanted if i in row_of)
            if not target_ids:
                return []
            rows = matrix[[row_of[i] for i in target_ids]]

        scores = rows @ query_vector          # 길이가 1 이라 내적이 곧 코사인이다
        order = np.argsort(-scores)[:k]       # 음수를 붙이면 큰 값부터
        return [(target_ids[i], float(scores[i])) for i in order]

4. app/adapters/stores/__init__.py — 하나만 만들어 돌려씁니다

저장소는 벡터를 통째로 메모리에 올립니다. 요청마다 새로 만들면 그때마다 다시 읽습니다.

그런데 import 할 때 만들면 안 됩니다. 파이프라인이 돌기 전에는 벡터 표 자체가 없습니다.

app/adapters/stores/__init__.py
_store = None


# 앱이 쓸 벡터 저장소를 하나 만들어 두고 계속 재사용하는 함수
# import 할 때가 아니라 처음 부를 때 만든다. 파이프라인이 벡터를 만들기 전에는 표가 없다
def get_store():
    global _store
    if _store is None:
        from app.adapters.stores.sqlite_store import SqliteVectorStore
        _store = SqliteVectorStore()
    return _store

import numpy 도 이 안으로 밀려 들어갑니다. 저장소를 안 쓰는 코드는 numpy 를 안 올립니다.


5. app/domain/masking.py — 규칙만. DB 를 모릅니다

후기 원문에는 전화번호·카톡 아이디·이름·주소가 들어 있습니다. 벡터는 원문으로 만들 수밖에 없지만, 화면에 띄우는 순간 가려야 합니다.

여기서 층이 하나 늘어납니다. core(아래) 와 features(위) 사이가 아니라 옆입니다.

app/domain/masking.py     정규식과 치환 규칙만. import 가 re 하나뿐이다
app/features/privacy.py   그 규칙에 customers 표의 이름·도시 사전을 붙인다

가르는 이유는 chunking.py 와 같습니다 — DB 없이 시험할 수 있게.

app/domain/masking.py
import re

# 010-1234-5678 · 010 1234 5678 · 01012345678 을 다 잡는다.
# 하이픈만 잡는 정규식은 152건 중 42건만 잡았다. 구분자를 선택으로 만든다
PHONE = re.compile(r"01[016-9][-.\s]?\d{3,4}[-.\s]?\d{4}")
EMAIL = re.compile(r"[\w.+-]+@[\w-]+\.[\w.]{2,}")

# 카톡 아이디는 모양이 정해져 있지 않다. 아이디 대신 그 문장을 통째로 버린다
KAKAO = re.compile(r"[^.!?]*카톡[^.!?]*[.!?]?")
CONTACT = re.compile(r"[^.!?]*(문자|연락|공구|디엠|DM)[^.!?]*[.!?]?")


# 도시 목록으로 주소 정규식을 만드는 함수. 목록이 비면 None
def build_address_pattern(cities):
    if not cities:
        return None
    # 긴 도시 이름부터 시도한다. 짧은 게 먼저 먹으면 '천안'이 '천안시'를 조각낸다
    ordered = sorted(set(cities), key=len, reverse=True)
    return re.compile(r"(?:%s)(?:\s?[가-힣]+(?:동|구|읍|면|로|길))?" % "|".join(ordered))


# 가리고 나서 생기는 연속 공백을 정리하는 함수
def _tidy(text):
    return re.sub(r"\s{2,}", " ", text).strip()


# 개인정보를 가리는 함수. 되돌릴 수 없으니 밖으로 나가는 글에만 쓴다
# names 를 안 주면 이름을, address 를 안 주면 주소를 가리지 않는다
def mask_text(text, *, names=(), address=None):
    if not text:
        return text

    # 문장을 통째로 버리는 것부터 하고, 남은 글에서 조각을 가린다
    text = KAKAO.sub(" ", text)
    text = CONTACT.sub(" ", text)
    text = PHONE.sub("[연락처]", text)
    text = EMAIL.sub("[메일]", text)

    if address is not None:
        text = address.sub("[주소]", text)

    # 긴 이름부터 지운다. 짧은 게 먼저 먹으면 조각이 남는다
    for name in sorted(set(names), key=len, reverse=True):
        if name in text:
            text = text.replace(name, "[이름]")

    return _tidy(text)

6. app/features/privacy.py — 규칙에 사전을 붙입니다

masking.py 는 「누구의 이름인지」를 모릅니다. 그 사전은 DB 에 있습니다.

이 파일이 둘을 잇습니다. 앱이 부르는 것은 이쪽입니다.

app/features/privacy.py
from app.core.db import query
from app.domain import masking

_names = None      # None 은 아직 안 읽었다는 표시. 빈 목록([])과 구분해야 한다
_address = None


# 이름·도시 사전을 DB 에서 한 번만 읽어 두는 함수. import 할 때가 아니라 처음 쓸 때 읽는다
def _load():
    global _names, _address
    if _names is not None:
        return
    # 하드코딩하면 고객이 늘 때 조용히 새어 나가므로 고객 표에서 그때그때 만든다
    _names = [name for (name,) in query("SELECT DISTINCT name FROM customers")]
    cities = [city for (city,) in query("SELECT DISTINCT city FROM customers")]
    _address = masking.build_address_pattern(cities)


# 규칙에 사전을 붙여 개인정보를 가리는 함수. 앱이 부르는 것은 이 함수다
def mask_text(text):
    _load()
    return masking.mask_text(text, names=_names, address=_address)


__all__ = ["mask_text"]

7. app/domain/safety.py — 금지 문장을 읽는 규칙

주의사항 섹션에는 「민감성 피부에는 사용을 권하지 않습니다」 같은 문장이 있습니다. 민감성 고객에게 그 상품을 추천하면 사고입니다.

이것도 규칙만 먼저 가릅니다. 이 파일 역시 DB 를 모릅니다.

app/domain/safety.py
import re

# 금지로 읽을 문장. '주의하세요' 와 '쓰지 마세요' 는 다르다. 뒤엣것만 뺀다
BAN = re.compile(r"(사용을 권하지 않|사용하지 마|사용을 피하|사용을 삼가|사용 금지)")

# 이 낱말이 같이 있어야 민감성 고객에게 금지로 본다
SENSITIVE = "민감성"


# (상품id, 주의사항 본문) 목록에서 {상품id: [금지 문장]} 을 만드는 함수
# 조각(chunks)이 아니라 원문 섹션(sections)을 넣어야 한다. 경고가 조각 경계에 걸리면 놓친다
def extract_bans(sections):
    bans = {}
    for product_id, text in sections:
        # (?<=[.!?]) 는 '.!? 뒤' 라는 뜻이고 그 글자 자체는 안 먹는다
        for sentence in re.split(r"(?<=[.!?])\s+|\n", text or ""):
            if BAN.search(sentence) and SENSITIVE in sentence:
                bans.setdefault(product_id, []).append(sentence.strip())
    return bans


# 이 피부 타입 고객에게 추천하면 안 되는 상품 아이디들을 돌려주는 함수
def blocked_for(skin_type, bans):
    if skin_type == SENSITIVE:
        return set(bans)          # 딕셔너리를 set() 에 넣으면 열쇠만 나온다
    return set()


# 왜 막혔는지 근거 문장을 돌려주는 함수. 근거 없는 차단은 사람이 고칠 수 없다
def reason_for(product_id, bans):
    sentences = bans.get(product_id, [])
    return sentences[0] if sentences else ""

8. app/features/safety_filter.py — 여기에 DB 를 붙입니다

privacy.py 와 똑같은 모양입니다. 규칙은 아래에, 사전은 위에.

app/features/safety_filter.py
from app.core.db import query
from app.domain import safety

_bans = None      # {상품id: [금지 문장]}


# 주의사항 섹션을 DB 에서 한 번만 읽어 금지 목록을 만드는 함수
def _load():
    global _bans
    if _bans is not None:
        return
    sections = query(
        "SELECT product_id, text FROM sections WHERE section = '주의사항'")
    _bans = safety.extract_bans(sections)


# 이 피부 타입 고객에게 추천하면 안 되는 상품 아이디들을 돌려주는 함수
def blocked_for(skin_type):
    _load()
    return safety.blocked_for(skin_type, _bans)


# 왜 막혔는지 근거 문장을 돌려주는 함수. 화면에 그대로 띄운다
def reason_for(product_id):
    _load()
    return safety.reason_for(product_id, _bans)

9. app/features/profile.py — 벡터를 안 쓰는 쪽

step0 의 retrieve.py 는 한 파일이었습니다. 이제 몸통을 둘로 가릅니다. 가르는 기준은 줄 수가 아니라 「무엇을 알아야 도는가」입니다.

이 파일은 DB 하나만 압니다. 벡터 표가 없어도 이 파일은 돕니다.

app/features/profile.py
from app.core.db import dicts
from app.features.privacy import mask_text


# 왼쪽 목록에 쓸 고객 전체와 각자 구매 건수를 돌려주는 함수
def customer_list():
    return 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
    """)


# 한 고객의 프로필·구매 이력·집계를 돌려주는 함수. 없는 고객이면 None
# 전화·메일 컬럼은 아예 SELECT 하지 않는다. 마스킹보다 이게 먼저다
def dashboard(customer_id):
    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,))

    # 후기 원문은 그대로 두고 가린 것을 따로 붙인다. 화면에서 둘을 나란히 보여 준다
    for row in purchases:
        row["review_masked"] = mask_text(row["review"] or "")

    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,
    }

10. app/features/searching.py — 벡터를 쓰는 쪽

여기가 오늘의 목적지입니다. 04_verify.py 안에 있던 계산이 앱이 부르는 함수 두 개가 됩니다.

이 파일은 저장소 · 임베딩기 · DB 를 다 압니다. 그래서 위쪽에 둡니다.

app/features/searching.py
import numpy as np

from app.adapters.stores import get_store
from app.core.db import dicts
from app.core.embedder import get_embeddings
from app.features.privacy import mask_text


# 비슷한 불만을 쓴 다른 후기를 찾는 함수
# '따가웠어요' 와 '자극이 있네요' 는 글자가 안 겹치지만 같은 불만이라 LIKE 로는 못 찾는다
# 후기 벡터는 원문으로 만들 수밖에 없으므로 화면에 띄울 때 반드시 가린다
def search_reviews(text, k=5, exclude_customer_id=None):
    query_vector = np.asarray(get_embeddings().embed_query(text), dtype="float32")

    # 자기 후기와 같은 글을 걸러 내야 하므로 넉넉히 받는다
    found = get_store().search("review", query_vector, k + 60)
    if not found:
        return []

    marks = ",".join("?" * len(found))
    rows = {row["purchase_id"]: row for row in dicts(f"""
        SELECT purchases.purchase_id, purchases.customer_id, purchases.rating,
               purchases.review, products.name AS product_name
        FROM purchases
        JOIN products ON products.product_id = purchases.product_id
        WHERE purchases.purchase_id IN ({marks})
    """, tuple(purchase_id for purchase_id, _ in found))}

    # 후기 1,200건 중 서로 다른 글은 692건뿐이다. 걸러 내지 않으면
    # '비슷한 불만' 이 아니라 '글자까지 같은 문장' 이 상위를 채운다
    out, seen = [], {(text or "").strip()}
    for purchase_id, score in found:
        row = rows.get(purchase_id)
        if row is None or row["customer_id"] == exclude_customer_id:
            continue
        body = (row["review"] or "").strip()
        if body in seen:
            continue
        seen.add(body)
        out.append({"purchase_id": purchase_id,
                    "product_name": row["product_name"],
                    "rating": row["rating"],
                    "score": round(score, 4),
                    "review": mask_text(row["review"] or "")})
        if len(out) == k:
            break
    return out


# 질문으로 조각을 찾고 원문 섹션을 같이 들고 오는 함수 (small-to-big)
# 찾기는 조각이 유리하고 답 쓰기는 원문이 유리해서, 조각으로 찾고 원문 섹션을 LLM 에 준다
def search_chunks(question, k=4, product_ids_only=None):
    query_vector = np.asarray(get_embeddings().embed_query(question), dtype="float32")

    store = get_store()
    found = store.search("chunk", query_vector, k, product_ids=product_ids_only or None)
    if not found:
        return []

    # 조회 한 번으로 끝난다. 검색에 필요한 값을 파이프라인이 미리 벡터 옆에 적어 두었다
    rows = store.documents("chunk", [chunk_id for chunk_id, _ in found])

    out = []
    for chunk_id, score in found:            # 저장소가 준 순서를 그대로 지킨다
        row = rows.get(chunk_id)
        if row is None:                      # 조각이 지워졌는데 벡터만 남은 경우
            continue
        out.append({"product_id": row["product_id"],
                    "product_name": row["product_name"],
                    "section": row["section"],
                    "score": round(score, 4),
                    "text": row["section_text"]})
    return out
이 파일의 두 함수가 서로 다른 이유
search_reviewssearch_chunks
몇 개 받나k + 60 — 넉넉히k — 그대로
왜중복 692건을 걸러내면 줄어든다걸러낼 것이 없다
뒤에 뭘 하나dicts() 로 DB 조회store.documents() 한 번
가리나🔴 가린다 (후기 원문)안 가린다 (상품 설명)

11. app/features/retrieve.py — 로직이 사라지고 창구만 남습니다

step0 에서 이 파일을 import 하던 곳이 있습니다. 그 코드를 안 고치는 것이 이 파일의 존재 이유입니다.

몸통은 둘로 갔고, 여기 남는 것은 이름뿐입니다.

app/features/retrieve.py
from app.core.embedder import get_embeddings
from app.features.profile import customer_list, dashboard
from app.features.searching import search_chunks, search_reviews

# 꺼내 오는 이름을 한곳에 모은 창구. 밖에서는 이 파일 하나만 import 한다.
# 몸통을 다시 쪼개도 부르는 쪽이 안 바뀌도록 여기에는 로직을 두지 않는다
__all__ = ["customer_list", "dashboard", "get_embeddings",
           "search_chunks", "search_reviews"]

12. pyproject.toml · tests/ — DB 없이 도는 시험 25개

domain/ 을 가른 값을 여기서 받습니다. 커넥션 없이 도는 시험이 그 증거입니다.

먼저 시험을 돌릴 수 있게 판을 깝니다. 이게 없으면 tests/ 안에서 import app 이 ModuleNotFoundError 로 죽습니다.

pyproject.toml
[project]
name = "axi-ai"
version = "0.1.0"
description = "화장품 회사 관리자 AI 대시보드 ― 국비 「AI서비스 개발 심화」 실습"
requires-python = ">=3.10"

# ─────────────────────────────────────────────────────────────────
# 왜 이 파일이 있나
#
# 이게 없으면 "이 프로젝트를 돌리려면 뭘 깔아야 하나" 의 답이 사람 머릿속에만
# 있다. 저장소를 처음 여는 사람이 제일 먼저 찾는 파일이다.
#
# 그리고 pytest 가 tests/ 에서 app 을 import 할 수 있어야 한다.
# 아래 pythonpath = ["."] 가 그 일을 한다. 이게 없으면 ModuleNotFoundError 다.
#
# 지금은 단계마다 필요한 것만 적는다. 단계가 오르면 여기도 같이 는다.
# ─────────────────────────────────────────────────────────────────
dependencies = [
    "langchain-core~=1.5",
    "langchain-text-splitters~=1.1",   # 자르기
    "transformers~=5.14",              # 토큰 세는 자
    "huggingface-hub~=1.24",
    "numpy~=2.4",
]

[project.optional-dependencies]
# 로컬 임베딩. 모델 가중치 449MB + torch 를 끌고 오므로 기본에서 뺐다
local = [
    "sentence-transformers~=5.6",
    "langchain-huggingface~=1.2",
]

dev = [
    "pytest~=9.1",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
# 시험이 app · pipeline 을 import 할 수 있게 뿌리를 경로에 넣는다
pythonpath = ["."]
addopts = "-q"

시험은 셋입니다. test_chunking.py 는 step0 의 자르기를, 나머지 둘이 오늘 만든 규칙을 봅니다. 하나만 통째로 읽습니다.

tests/test_safety.py
from app.domain.safety import blocked_for, extract_bans, reason_for

SECTIONS = [
    ("P001", "고농도 비타민C 유도체가 함유되어 있습니다. "
             "민감성 피부에는 따가움이 느껴질 수 있으므로 사용을 권하지 않습니다."),
    ("P002", "민감성 피부도 편안하게 쓰실 수 있습니다."),          # 금지 문장이 아니다
    ("P003", "자극이 있을 수 있으니 주의하세요."),                   # '주의' 는 금지가 아니다
    ("P004", "건성 피부에는 사용을 권하지 않습니다."),               # 민감성 얘기가 아니다
]
BANS = extract_bans(SECTIONS)


# 금지 문장이 있는 상품만 잡는지 확인
def test_금지_문장이_있는_상품만_잡는다():
    assert set(BANS) == {"P001"}


# '주의하세요' 와 '쓰지 마세요' 를 구분하는지 확인
def test_주의하세요_는_금지가_아니다():
    assert "P003" not in BANS


# 금지 문장이어도 '민감성' 이 없으면 안 잡는지 확인
def test_민감성이_아닌_금지는_안_잡는다():
    assert "P004" not in BANS


# 민감성 고객에게만 차단이 걸리는지 확인
def test_민감성_고객만_막힌다():
    assert blocked_for("민감성", BANS) == {"P001"}
    assert blocked_for("건성", BANS) == set()
    assert blocked_for(None, BANS) == set()


# 차단 근거 문장을 돌려주는지 확인. 근거 없는 차단은 사람이 못 고친다
def test_근거_문장을_돌려준다():
    assert "사용을 권하지 않습니다" in reason_for("P001", BANS)
    assert reason_for("P002", BANS) == ""


# 빈 글이나 None 이 섞여도 안 터지는지 확인
def test_빈_글이_섞여도_안_터진다():
    assert extract_bans([("P100", ""), ("P101", None)]) == {}
pytest
터미널
  • 25 passed

13. eval/qa_check.py — 1단계의 자

시험은 「내가 쓴 규칙이 내 뜻대로 도나」를 봅니다. 검색이 잘 되는지는 못 봅니다.

그래서 자를 하나 만듭니다. 질문 20개에 정답 섹션을 붙여 둔 파일입니다.

eval/qa_golden.json (앞부분)
{
  "설명": "상품 Q&A 골든셋. 청킹 방식을 판정하고(정답 섹션이 검색 상위에 오나) 나중에 Ragas 를 붙일 자리다.",
  "만든 법": "전부 DB 의 실제 문장에서 뽑았다. python eval/qa_check.py 가 keywords 가 정말 그 섹션에 있는지 매번 다시 확인한다.",
  "채점": "retrieve.search_chunks(question) 상위 k 에 product_id+section 이 들어오면 맞은 것 (hit@k). keywords 는 답변 자체를 볼 때 쓴다.",
  "items": [
    {"id": 1, "question": "비타민C 필링 로션은 민감성 피부가 써도 되나요?",
     "product_id": "P001", "section": "주의사항",
     "keywords": ["민감성", "권하지 않습니다"]},
    {"id": 2, "question": "비타민C 필링 로션 개봉하고 언제까지 써야 하나요?",
     "product_id": "P001", "section": "주의사항",
     "keywords": ["개봉", "3개월"]}
  ]
}

채점기는 두 단입니다. 점수를 재기 전에 자 자신을 먼저 검사합니다.

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. 골든셋 자체를 검사한다. keywords 가 그 섹션 원문에 정말 있는가
print("=" * 74)
print("1. 골든셋이 데이터와 맞나, keywords 가 그 섹션에 정말 있는가")
print("=" * 74)

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 qid, reason in broken:
    print(f"      {qid}번: {reason}")


# 2. 질문을 던져 정답 섹션이 상위 k 에 오는지 센다 (hit@1/3/5)
print()
print("=" * 74)
print("2. 질문을 던져 정답 섹션이 상위에 오나 (조각 검색)")
print("=" * 74)

hits = {1: 0, 3: 0, 5: 0}
misses = []
for item in ITEMS:
    found = search_chunks(item["question"], k=5)
    ranks = [(s["product_id"], s["section"]) for s in found]
    target = (item["product_id"], item["section"])

    # 상품을 지정하지 않은 일반 질문(배송 등)은 섹션만 맞으면 맞은 것으로 본다.
    # 200개 상품의 배송 섹션이 글자까지 똑같아서 상품을 가리는 건 의미가 없다
    section_only = [s for _, s in ranks]

    for k in hits:
        ok = target in ranks[:k] or item["section"] in section_only[:k]
        if ok:
            hits[k] += 1
    if item["section"] not in section_only[:5]:
        misses.append((item, section_only))

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 misses:
    print(f"\n  못 찾은 것 {len(misses)}개:")
    for item, got in misses:
        print(f"      {item['id']:>2}. {item['question']}")
        print(f"          정답 [{item['section']}] · 검색 결과 {got[:3]}")

14. 돌려 봅니다

python pipeline/01_schema.py     # 표 4개
python pipeline/02_chunk.py      # 섹션·조각
python pipeline/03_embed.py      # 벡터 넷 (chunk 만 다른 길로 간다)
python pipeline/04_verify.py     # 그대로 「전부 통과」
pytest                           # 25개
python eval/qa_check.py          # ← 1단계의 자

이 컴퓨터에서 실제로 나온 값입니다.

실측
  • 02_chunk 섹션 1,560 · 조각 1,560 · 다시 자른 섹션 0
  • 03_embed 임베딩기 준비 14.6초 · chunk 1,560개 21.6초(초당 72) ·
  • product 200개 2.8초 · customer 300명 1.6초 · review 1,200건 3.9초
  • pytest 25개 10.1초
  • qa_check 문항 20개 · 어긋난 것 0 · hit@1 80% · hit@3 90% · hit@5 95%
  • 못 찾은 1개: 18번 「시험 중에 이상 반응」 → 「인증 및 시험」으로 끌려간다

여기서 멈춰도 남는 것

검색기 하나. 질문을 던지면 근거 섹션이 점수와 함께 나오고, 밖으로 나갈 후기에서 전화번호·이름·주소가 사라집니다.

아직 없는 것 — LLM(답을 쓰는 것) · 추천 · API · 화면. 만들어만 둔 safety_filter.py 는 2단계의 candidates() 가 첫 손님입니다.

오늘 배운 가르는 법 셋

무엇을 알아야 도나

층을 가르는 기준

  • domain/ — 아무것도 모른다 (re 뿐)
  • adapters/ — 표 이름을 안다
  • features/ — 둘을 잇는다

이름과 자리를 가른다

retrieve.py 가 창구가 된 이유

  • 부르는 쪽은 이름만 안다
  • 몸통은 몇 번이고 다시 쪼갠다
  • 고칠 파일이 하나면 고쳐진다

재기 전에 자를 잰다

qa_check.py 의 1단

  • 골든셋도 늙는다
  • 틀린 자로 잰 점수가 제일 나쁘다
  • 숫자보다 숫자가 생긴 것이 성과다
Share
  • 파이썬
  • RAG
  • 벡터 검색
  • 리팩터링
  • 개인정보
앱이 스스로 찾는다 (step0 → step1) — 디코드랩(DCODELAB)