본문 바로가기
C.W.K.
Stream
Lesson 06 of 06 · published

시맨틱 검색 손으로 짜기

~28 min · practice, numpy

Level 0Scout
0 XP0/41 lessons0/10 achievements
0/120 XP to next level120 XP to go0% complete

벡터 DB보다 먼저 직접 만들어보는 이유

모든 벡터 데이터베이스는 화려한 API 뒤에 똑같은 세 동작을 숨겨놨어. 텍스트를 임베딩하고, 벡터를 저장하고, 쿼리와 가장 가까운 벡터를 찾는 일이야. 이 셋을 NumPy 30줄로 직접 만들 수 있으면 벡터 DB 문서를 훨씬 빨리 읽고, 엉뚱한 결과도 더 빨리 고치며, 기본값을 무턱대고 믿지 않게 돼.

검색 흐름 전체를 한 화면에 놓자

할 일은 단순해:

  1. 작은 문서 모음을 로컬 모델로 임베딩해.
  2. 벡터는 NumPy 행렬에, 원문은 같은 순서의 목록에 저장해.
  3. 쿼리를 임베딩한 뒤 모든 행과 cosine 유사도를 계산하고 상위 k개를 돌려줘.

Chroma와 pgvector가 안에서 하는 일도 본질은 같아. 여기에 영속성, 메타데이터 필터, 규모를 감당할 인덱스를 더했을 뿐이지. 검색 원리는 바뀌지 않아.

Code

30줄짜리 시맨틱 검색·python
import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer('BAAI/bge-small-en-v1.5')

docs = [
    'Cancel your annual subscription from Account → Billing.',
    'Refunds are processed within 5 business days.',
    'Pippa is an AI daughter built on Claude Code.',
    'The moon rises tonight at 8:42 PM in Seoul.',
    'Vector search measures meaning, not keyword overlap.',
]

M = model.encode(docs, normalize_embeddings=True)   # (5, 384)

def search(query: str, k: int = 3):
    q = model.encode([query], normalize_embeddings=True)[0]
    scores = M @ q                       # 단위 벡터 cosine == dot product
    top = np.argsort(-scores)[:k]
    return [(scores[i], docs[i]) for i in top]

for score, doc in search('how do I get my money back'):
    print(f'{score:.3f}  {doc}')
메타데이터 추가·python
records = [
    {'text': docs[0], 'category': 'billing'},
    {'text': docs[1], 'category': 'billing'},
    {'text': docs[2], 'category': 'about'},
    {'text': docs[3], 'category': 'weather'},
    {'text': docs[4], 'category': 'about'},
]

def search_filtered(query: str, where: dict, k: int = 3):
    q = model.encode([query], normalize_embeddings=True)[0]
    mask = np.array([all(r.get(k) == v for k, v in where.items()) for r in records])
    if not mask.any():
        return []
    scores = (M @ q)[mask]
    indices = np.where(mask)[0]
    top = indices[np.argsort(-scores)[:k]]
    return [(float((M[i] @ q)), records[i]) for i in top]

print(search_filtered('refund', where={'category': 'billing'}))

External links

Exercise

위의 30줄짜리 의미 검색을 문서 100개로 만들고 쿼리 한 번에 걸리는 시간을 재. 문서를 10,000개로 늘려 다시 재본 뒤 어디서 느려지기 시작하는지 확인해. 그 경계가 인덱스가 필요한 자리이고 다음 트랙의 출발점이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.