앱이 스스로 찾는다 (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개 — 자
| # | 파일 | 하는 일 |
|---|---|---|
| 0 | pipeline/02_chunk.py | 고침. 지금 안 돌아간다 |
| 1 | pipeline/prep/storage.py | 조각 벡터 표에 읽을거리를 같이 적는다 |
| 2 | app/adapters/stores/base.py | 저장소가 지킬 약속 |
| 3 | app/adapters/stores/sqlite_store.py | 그 약속의 SQLite 판 |
| 4 | app/adapters/stores/__init__.py | 하나만 만들어 돌려쓴다 |
| 5 | app/domain/masking.py | 가리는 규칙만. DB 를 모른다 |
| 6 | app/features/privacy.py | 그 규칙에 고객 사전을 붙인다 |
| 7 | app/domain/safety.py | 금지 문장을 읽는 규칙만 |
| 8 | app/features/safety_filter.py | 그 규칙에 DB 를 붙인다 |
| 9 | app/features/profile.py | 벡터 없이 SQL 로만 답하는 것 |
| 10 | app/features/searching.py | 벡터로 찾는 것 |
| 11 | app/features/retrieve.py | 밖에서 부를 이름을 모은 창구 |
| 12 | pyproject.toml · tests/ | DB 없이 도는 시험 25개 |
| 13 | eval/qa_check.py · qa_golden.json | 1단계의 자 |
0. pipeline/02_chunk.py — 지금 안 돌아갑니다
시작하기 전에 고칩니다. step0 을 그대로 돌리면 여기서 죽습니다.
- sections, chunks = chunking.split_details(details)
- ValueError: too many values to unpack (expected 2)
split_details() 는 값을 셋 돌려줍니다 — (섹션, 조각, 다시 자른 섹션 수). 받는 쪽만 둘이었습니다.
셋으로 받고, 마지막 값을 화면에 찍습니다.
# 3. 자른 결과를 sections, chunks 테이블에 저장한다
storage.save_sections_and_chunks(con, sections, chunks)
+
+ print(f" 섹션 {len(sections):,}개 · 조각 {len(chunks):,}개 · "
+ f"상한을 넘어 다시 자른 섹션 {n_resplit}개")
con.close()
- 섹션 1,560개 · 조각 1,560개 · 상한을 넘어 다시 자른 섹션 0개
1. pipeline/prep/storage.py — 검색 한 번이 조회 한 번으로
앱이 조각을 찾으면 화면에 상품 이름 · 섹션 이름 · 원문을 같이 보여줘야 합니다. 그런데 그 값들은 products · chunks · sections 세 표에 흩어져 있습니다.
찾을 때마다 조인 셋을 따라가는 대신, 벡터를 만들 때 옆에 같이 적어 둡니다. 비정규화입니다.
# 조각 벡터 테이블을 검색에 필요한 값까지 함께 담아서 만드는 함수
# 검색 한 번에 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()
부르는 쪽은 조각만 갈래를 탑니다. 나머지 셋은 그대로입니다.
- 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 지만 나중에 다른 것으로 갈아끼울 자리입니다.
그래서 구현보다 약속을 먼저 적습니다. 함수 이름과 인자만 있고 몸통이 없는 파일입니다.
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 로 맞춰 두었으므로 내적이 곧 코사인 유사도입니다. 나눗셈이 필요 없습니다.
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 할 때 만들면 안 됩니다. 파이프라인이 돌기 전에는 벡터 표 자체가 없습니다.
_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 없이 시험할 수 있게.
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 에 있습니다.
이 파일이 둘을 잇습니다. 앱이 부르는 것은 이쪽입니다.
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 를 모릅니다.
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 와 똑같은 모양입니다. 규칙은 아래에, 사전은 위에.
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 하나만 압니다. 벡터 표가 없어도 이 파일은 돕니다.
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 를 다 압니다. 그래서 위쪽에 둡니다.
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_reviews | search_chunks | |
|---|---|---|
| 몇 개 받나 | k + 60 — 넉넉히 | k — 그대로 |
| 왜 | 중복 692건을 걸러내면 줄어든다 | 걸러낼 것이 없다 |
| 뒤에 뭘 하나 | dicts() 로 DB 조회 | store.documents() 한 번 |
| 가리나 | 🔴 가린다 (후기 원문) | 안 가린다 (상품 설명) |
11. app/features/retrieve.py — 로직이 사라지고 창구만 남습니다
step0 에서 이 파일을 import 하던 곳이 있습니다. 그 코드를 안 고치는 것이 이 파일의 존재 이유입니다.
몸통은 둘로 갔고, 여기 남는 것은 이름뿐입니다.
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 로 죽습니다.
[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 의 자르기를, 나머지 둘이 오늘 만든 규칙을 봅니다. 하나만 통째로 읽습니다.
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개에 정답 섹션을 붙여 둔 파일입니다.
{
"설명": "상품 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개월"]}
]
}
채점기는 두 단입니다. 점수를 재기 전에 자 자신을 먼저 검사합니다.
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단
- 골든셋도 늙는다
- 틀린 자로 잰 점수가 제일 나쁘다
- 숫자보다 숫자가 생긴 것이 성과다