벡터 검색(vector search)이 어떻게 동작하는지 원리부터 이해하고 싶은 개발자를 위한 튜토리얼이다. Pinecone, Weaviate 같은 외부 벡터 데이터베이스 없이 NumPy만으로 임베딩 인덱스 구축, 코사인 유사도 기반 검색, PCA 시각화까지 약 50줄의 코드로 직접 구현해본다. rag 시스템을 이해하거나 RAG 파이프라인 내부를 직접 제어하고 싶은 ML 엔지니어와 파이썬 개발자에게 적합하다.
사전 준비
- Python 3.8 이상
- NumPy, Matplotlib 설치 필요
pip install numpy matplotlib- (선택) 실제 임베딩 생성 시
sentence-transformers추가 설치 - 소스 코드: balapriyac/python-basics — vector-search-from-scratch
벡터 검색이란?
전통적인 키워드 검색(keyword search)은 정확한 단어 일치를 찾는다. 벡터 검색(vector search)은 다르다. 문서와 쿼리를 고차원 공간의 숫자 벡터(임베딩)로 변환한 뒤, 벡터 간 거리가 가까울수록 의미가 유사하다고 판단한다.
핵심 거리 측정 지표는 코사인 유사도(cosine similarity)다. 두 벡터 사이의 각도를 측정하므로 벡터의 절대적 크기가 아닌 방향(의미)에 집중한다. 정규화된 벡터에서는 코사인 유사도가 단순한 내적(dot product) 계산으로 환원되어 연산이 매우 빠르다.
1단계: 데이터셋 설정
실습에는 가상 전자상거래 카탈로그의 상품 설명 15개를 사용한다. 전자제품·의류·가구 3개 클러스터로 구성되며, 각 상품을 8차원 임베딩 벡터로 표현한다. 실제 시스템이라면 sentence-transformers 같은 모델로 임베딩을 생성하겠지만, 여기서는 클러스터 구조가 명확한 시뮬레이션 데이터를 사용한다.
import numpy as np
np.random.seed(42)
# 상품 카탈로그 — 전자제품, 의류, 가구 3개 클러스터
products = [
"Wireless noise-cancelling headphones with 30-hour battery",
"Bluetooth speaker with waterproof design",
"USB-C hub with 7 ports and power delivery",
"4K HDMI cable 6ft braided",
"Mechanical keyboard with RGB backlight",
"Men's slim-fit chino pants navy blue",
"Women's merino wool turtleneck sweater",
"Unisex running jacket lightweight windbreaker",
"Leather chelsea boots for men",
"Organic cotton crew neck t-shirt",
"Solid oak dining table seats 6",
"Ergonomic mesh office chair lumbar support",
"Linen sofa 3-seater natural beige",
"Bamboo bookshelf 5-tier adjustable",
"Memory foam mattress queen size medium firm",
]
# 8차원 공간의 클러스터 중심
electronics_center = np.array([0.9, 0.1, 0.2, 0.8, 0.1, 0.3, 0.7, 0.2])
clothing_center = np.array([0.1, 0.8, 0.7, 0.1, 0.9, 0.2, 0.1, 0.8])
furniture_center = np.array([0.2, 0.3, 0.9, 0.2, 0.1, 0.9, 0.3, 0.1])
n_per_cluster = 5
noise = 0.08
embeddings = np.vstack([
electronics_center + np.random.randn(n_per_cluster, 8) * noise,
clothing_center + np.random.randn(n_per_cluster, 8) * noise,
furniture_center + np.random.randn(n_per_cluster, 8) * noise,
])
print(f"Embeddings shape: {embeddings.shape}")
# 출력: Embeddings shape: (15, 8)2단계: 벡터 인덱스 구축
인덱스(index)의 핵심은 정규화된 임베딩을 저장하는 것이다. L2 정규화를 적용하면 코사인 유사도 계산이 내적 연산으로 단순화된다.
def normalize(vectors: np.ndarray) -> np.ndarray:
"""각 행 벡터를 L2 정규화한다."""
norms = np.linalg.norm(vectors, axis=1, keepdims=True)
norms = np.where(norms == 0, 1e-10, norms)
return vectors / norms
class VectorIndex:
def __init__(self):
self.vectors = None
self.labels = None
def add(self, vectors: np.ndarray, labels: list):
self.vectors = normalize(vectors)
self.labels = labels
print(f"Indexed {len(labels)} items with {vectors.shape[1]}-dimensional embeddings.")
def search(self, query_vector: np.ndarray, top_k: int = 3):
query_norm = normalize(query_vector.reshape(1, -1))
# 정규화된 벡터의 내적 = 코사인 유사도
scores = self.vectors @ query_norm.T # shape: (n_items, 1)
scores = scores.flatten()
top_indices = np.argsort(scores)[::-1][:top_k]
return [(self.labels[i], float(scores[i])) for i in top_indices]
index = VectorIndex()
index.add(embeddings, products)
# 출력: Indexed 15 items with 8-dimensional embeddings.search 메서드는 세 가지 작업을 수행한다. 쿼리를 정규화하고, 저장된 모든 벡터와 내적을 계산하고(@ 행렬 곱셈), 점수 기준 상위 k개를 반환한다. 이 행렬 곱셈 한 줄이 전체 검색 연산의 핵심이다.
3단계: 검색 실행
쿼리 벡터는 클러스터 중심에 약간의 노이즈를 더해 시뮬레이션한다. 실제 시스템에서는 사용자 쿼리 텍스트를 임베딩 모델에 통과시켜 얻는다.
def make_query(center: np.ndarray, noise_scale: float = 0.05) -> np.ndarray:
return center + np.random.randn(8) * noise_scale
queries = {
"audio equipment": make_query(electronics_center),
"casual wear": make_query(clothing_center),
"home furniture": make_query(furniture_center),
}
for query_name, q_vec in queries.items():
print(f"\nQuery: '{query_name}'")
results = index.search(q_vec, top_k=3)
for rank, (label, score) in enumerate(results, 1):
print(f" {rank}. [{score:.4f}] {label}")출력 결과:
Query: 'audio equipment'
1. [0.9856] Wireless noise-cancelling headphones with 30-hour battery
2. [0.9840] USB-C hub with 7 ports and power delivery
3. [0.9829] Mechanical keyboard with RGB backlight
Query: 'casual wear'
1. [0.9960] Men's slim-fit chino pants navy blue
2. [0.9958] Leather chelsea boots for men
3. [0.9916] Women's merino wool turtleneck sweater
Query: 'home furniture'
1. [0.9929] Bamboo bookshelf 5-tier adjustable
2. [0.9902] Linen sofa 3-seater natural beige
3. [0.9881] Solid oak dining table seats 6점수가 1.0에 가까울수록 임베딩 공간에서 방향이 거의 일치한다는 의미다. 각 쿼리가 정확하게 해당 클러스터 상품을 찾아낸다.
4단계: 임베딩 공간 시각화
8차원 데이터는 직접 눈으로 확인하기 어렵다. 주성분 분석(PCA, Principal Component Analysis)으로 2D로 투영하면 클러스터 구조를 시각적으로 확인할 수 있다.
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
def pca_2d(data):
"""NumPy만으로 2D PCA 투영을 구현한다."""
centered = data - data.mean(axis=0)
cov = np.cov(centered.T)
eigenvalues, eigenvectors = np.linalg.eigh(cov)
# 분산이 큰 상위 2개 주성분 선택
idx = np.argsort(eigenvalues)[::-1][:2]
return centered @ eigenvectors[:, idx]
projected = pca_2d(embeddings)
cluster_colors = (
["#4A90D9"] * 5 + # 전자제품 — 파란색
["#E8734A"] * 5 + # 의류 — 주황색
["#5BAD72"] * 5 # 가구 — 초록색
)
fig, ax = plt.subplots(figsize=(6, 4))
ax.scatter(projected[:, 0], projected[:, 1],
c=cluster_colors, s=100, edgecolors="white", linewidths=0.7, zorder=3)
# 쿼리 벡터 투영 및 시각화
q_projected = pca_2d(
np.vstack(list(queries.values())) - embeddings.mean(axis=0)
)
for (qname, _), (qx, qy) in zip(queries.items(), q_projected):
ax.scatter(qx, qy, marker="*", s=200, color="gold",
edgecolors="#333", linewidths=0.6, zorder=4)
ax.annotate(f"← query: {qname}", (qx, qy),
textcoords="offset points", xytext=(6, -8),
fontsize=7, color="#555555", style="italic")
legend_patches = [
mpatches.Patch(color="#4A90D9", label="Electronics"),
mpatches.Patch(color="#E8734A", label="Clothing"),
mpatches.Patch(color="#5BAD72", label="Furniture"),
mpatches.Patch(color="gold", label="Query vectors"),
]
ax.legend(handles=legend_patches, loc="upper left", fontsize=6)
ax.set_title("Vector Search — Embedding Space (PCA projection)", fontsize=10, pad=10)
ax.set_xlabel("PC 1"); ax.set_ylabel("PC 2")
ax.grid(True, linestyle="--", alpha=0.4)
plt.tight_layout()
plt.show()PCA 투영 결과에서 3개 클러스터가 명확히 분리되고, 각 쿼리 벡터(별 모양)가 해당 클러스터 안에 위치하는 것을 확인할 수 있다. 벡터 검색이 활용하는 기하학적 구조 그 자체다.
5단계: 유사도 점수 분포 확인
특정 쿼리에 대해 상위 k개만 보는 것이 아니라 전체 인덱스에 걸친 점수 분포를 확인하면, 1위 결과가 명확한 승자인지 아니면 나머지와 거의 차이가 없는지 판단할 수 있다.
q_vec_furniture = queries["home furniture"]
q_norm_furniture = normalize(q_vec_furniture.reshape(1, -1))
all_scores_furniture = (index.vectors @ q_norm_furniture.T).flatten()
sorted_idx_furniture = np.argsort(all_scores_furniture)[::-1]
sorted_scores_furniture = all_scores_furniture[sorted_idx_furniture]
sorted_labels_furniture = [
products[i][:30] + "…" if len(products[i]) > 30 else products[i]
for i in sorted_idx_furniture
]
# 가구 항목은 초록, 나머지는 회색
bar_colors_furniture = [
"#5BAD72" if 10 <= i <= 14 else "#cccccc"
for i in sorted_idx_furniture
]
fig, ax = plt.subplots(figsize=(10, 5))
ax.barh(sorted_labels_furniture[::-1], sorted_scores_furniture[::-1],
color=bar_colors_furniture[::-1], edgecolor="white", height=0.65)
ax.axvline(sorted_scores_furniture[2], color="#5BAD72", linestyle="--",
linewidth=1.2, label="Top-3 cutoff")
ax.set_xlabel("Cosine Similarity Score")
ax.set_title("Query: 'home furniture' — Similarity Across All Products", fontsize=11, pad=12)
ax.legend(fontsize=8)
ax.grid(axis="x", linestyle="--", alpha=0.4)
plt.tight_layout()
plt.show()가구 클러스터 상위 5개 항목과 나머지 사이에 명확한 점수 간격(gap)이 보인다. 실제 시스템에서는 이 간격을 기준으로 유사도 임계값(threshold)을 설정해 관련 없는 결과를 완전히 제거할 수 있다.
마치며
NumPy 약 50줄로 벡터 검색 엔진의 핵심 구성 요소를 완성했다. 임베딩을 정규화해 저장하는 인덱스, 행렬 곱셈으로 코사인 유사도를 계산하는 검색 메서드, PCA 기반 시각화까지 직접 구현했다.
다음 단계는 시뮬레이션 임베딩 대신 실제 임베딩을 사용하는 것이다. sentence-transformers를 설치해 직접 텍스트를 임베딩하면 여기서 만든 인덱스 코드를 그대로 활용할 수 있다. 더 나아가 대규모 데이터셋에서의 성능이 필요하다면 FAISS나 Annoy 같은 근사 최근접 이웃(ANN, Approximate Nearest Neighbor) 라이브러리로 확장하면 된다.
rag 시스템에서 벡터 검색은 외부 지식을 LLM에 주입하는 핵심 메커니즘이다. 이 원리를 직접 구현해보면 RAG 파이프라인 전체의 동작 방식을 훨씬 명확히 이해할 수 있다.
참고 자료
- How to Build Vector Search from Scratch in Python — KDnuggets (2026-05-10)