왜 인덱스 선택이 문제가 되는가

벡터 검색에서 정확한 최근접 이웃(exact k-NN)은 전체 벡터를 스캔하므로 수백만 건이 넘어가면 지연이 선형으로 커진다. 그래서 근사 최근접 이웃(ANN) 인덱스를 쓰는데, 여기서 리콜(정확도)·지연·메모리·인덱싱 시간이 서로 맞물린 트레이드오프에 들어간다. 실무에서 흔한 실패는 "일단 기본값으로 HNSW"를 깔았다가 데이터가 1억 건으로 늘면서 메모리가 폭발하거나, 반대로 IVF를 골랐는데 리콜이 낮아 검색 품질 불만이 쏟아지는 경우다. 인덱스는 나중에 갈아끼우기 비싸므로 초반 선택이 중요하다.

HNSW의 동작과 특성

HNSW는 계층형 그래프를 만들어 상위 레이어에서 대략적인 위치를 잡고 하위로 내려가며 이웃을 좁힌다. 그래프 탐색이라 리콜 대비 지연이 매우 우수하고, 단건 조회에서 강하다. 대신 각 노드가 이웃 링크를 들고 있어 메모리를 많이 먹고, 인덱스 빌드도 느리다. 핵심 파라미터는 M(노드당 이웃 수), ef_construction(빌드 시 탐색 폭), ef_search(질의 시 탐색 폭)다. ef_search는 질의 시점에 올릴 수 있어 리콜·지연을 런타임에 조절할 수 있다.

# FAISS HNSW 구성 예시
import faiss, numpy as np

d = 768
index = faiss.IndexHNSWFlat(d, 32)      # M=32
index.hnsw.efConstruction = 200         # 빌드 품질
index.add(np.random.rand(100000, d).astype('float32'))

index.hnsw.efSearch = 64                # 질의 시 리콜/지연 조절
D, I = index.search(query.astype('float32'), k=10)

IVF의 동작과 특성

IVF는 벡터 공간을 nlist개 클러스터로 나누고, 질의 시 가까운 nprobe개 클러스터만 스캔한다. 그래프가 없어 메모리가 가볍고, PQ(Product Quantization)와 결합하면 벡터를 압축해 수억 건도 다룰 수 있다. 대신 클러스터 경계에 걸친 벡터를 놓쳐 리콜이 흔들리므로 nprobe를 올려 보완하는데, 이는 지연 증가로 이어진다. 학습(train) 단계가 필요해 대표 표본으로 클러스터 중심을 먼저 잡아야 한다.

# FAISS IVF+PQ 구성 예시
nlist = 4096
quantizer = faiss.IndexFlatL2(d)
index = faiss.IndexIVFPQ(quantizer, d, nlist, 64, 8)  # 64개 서브벡터, 8bit

index.train(train_vectors)     # 학습 필수
index.add(base_vectors)
index.nprobe = 32              # 스캔할 클러스터 수 → 리콜/지연 조절
D, I = index.search(query, k=10)

정면 비교

항목HNSWIVF(+PQ)
리콜 대비 지연우수보통(nprobe 의존)
메모리높음낮음(PQ 시 매우 낮음)
인덱스 빌드느림학습 필요, 비교적 빠름
데이터 규모수백만~수천만수천만~수억
업데이트삽입 용이재학습 부담
런타임 튜닝ef_searchnprobe

선택 기준 정리

데이터가 수천만 이하이고 리콜·지연이 최우선이며 메모리 예산이 있으면 HNSW가 무난하다. 반대로 수억 건 규모이거나 메모리·비용을 강하게 눌러야 하면 IVF+PQ가 현실적이다. 삽입·삭제가 잦은 온라인 서비스는 HNSW가 편하고, 배치로 재구축하는 대규모 코퍼스는 IVF가 맞는다. 하이브리드로 IVF_HNSW(클러스터 내부 그래프)를 쓰면 대규모에서 리콜을 끌어올릴 수 있으나 튜닝 복잡도가 올라간다.

실무 튜닝과 검증

파라미터는 감이 아니라 측정으로 정한다. 홀드아웃 질의로 exact k-NN 정답을 만들어 두고 리콜@k를 실제로 재야 한다. HNSW는 ef_search, IVF는 nprobe를 격자 탐색하며 리콜-지연 곡선을 그려 SLO를 만족하는 최소값을 고른다.

# 리콜@k 측정 스케치
def recall_at_k(gt, pred, k):
    hit = sum(len(set(g[:k]) & set(p[:k])) for g, p in zip(gt, pred))
    return hit / (len(gt) * k)

for probe in [8, 16, 32, 64, 128]:
    index.nprobe = probe
    _, pred = index.search(queries, 10)
    print(probe, recall_at_k(ground_truth, pred, 10))

주의점

몇 가지 함정이 있다. 첫째, 거리 척도 불일치다. 임베딩이 코사인 기준인데 인덱스를 L2로 만들면 리콜이 무너지므로 정규화 후 내적을 쓰거나 척도를 맞춰야 한다. 둘째, IVF의 nlist는 대략 데이터 수의 제곱근 근방에서 시작해 조정하고, 학습 표본은 클러스터당 최소 수십 개는 확보해야 한다. 셋째, PQ 압축은 메모리를 크게 줄이지만 정밀도를 깎으므로 재정렬(rerank)로 원본 벡터 소수를 다시 정확히 계산해 상위 결과를 보정하면 품질을 회복할 수 있다. 넷째, 벤치마크는 반드시 운영과 같은 하드웨어·동시성에서 재야 한다. 단건 지연과 부하 상태의 p99는 크게 다르다.