광고
~ / posts / developer

벡터 인덱스 고르기, HNSW·IVF·양자화 메모리와 비용 비교

1536차원 벡터 1,000만 개를 HNSW로 메모리에 올리면 약 59GiB, 서울 리전 128GiB 인스턴스로 월 755달러예요. 같은 데이터로 브루트포스·HNSW·IVF·SQ8·PQ의 재현율과 지연을 직접 재고, 규모별 추천과 pgvector 도입 체크리스트를 정리했어요.

RAG 검색을 붙이려는데 "pgvector에 HNSW 걸면 끝인가?", "벡터가 1,000만 개면 서버가 얼마나 커야 하나?"에서 막히죠. 결론부터 말하면 10만 개 이하는 인덱스 없이 전수 비교로도 충분하고, 그 이상은 HNSW가 기본값이며, 메모리가 모자랄 때만 SQ8·FP16 → IVF-PQ+재정렬 순서로 줄이는 게 무난해요. 비용의 대부분은 그래프가 아니라 벡터 원본이 차지하는 메모리예요.

HNSW 그래프가 어떻게 생겼는지는 알고 있다고 보고, 이 글은 고르는 데 필요한 숫자만 다뤄요. 같은 데이터로 잰 방식별 재현율·지연·빌드 시간, 1,000만 개 기준 메모리와 서울 리전 인스턴스 월 비용, 규모별 추천, pgvector 설정 점검표 순서예요.

광고

먼저 정할 것, 정확도 목표와 규모

인덱스를 고르기 전에 숫자 세 개를 적어 두면 선택지가 거의 정해져요.

여기에 질의량(QPS), 삭제 빈도, 메타데이터 필터의 강도를 더해요. 사용자 한 명의 문서 수백 개처럼 필터가 강하면 "필터로 줄인 뒤 전수 비교"가 더 정확하고 빠를 때가 많아요.

FAISS의 인덱스 선택 안내도 비슷해요. 검색을 1,000~10,000번만 할 거라면 빌드 시간이 회수되지 않으니 Flat(전수 비교)이 낫고, 정확한 결과를 보장하는 것도 Flat뿐이라고 적어요.

벡터 수가 10만 개 이하인지, 필터로 후보가 수천 개 이하로 줄어드는지, 이미 PostgreSQL을 쓰는지, 인덱스가 RAM의 절반 안에 들어가는지를 차례로 물어 전수 비교·pgvector HNSW·FAISS HNSW·SQ8/FP16·IVF-PQ+재정렬로 가지를 나누는 판단 순서도
위에서부터 물어서 처음 '예'가 나오는 칸이 가장 단순한 선택이에요

같은 데이터로 잰 방식별 재현율과 지연

방식마다 숫자를 맞춰 보려고 같은 데이터에 다섯 가지 인덱스를 걸어 재 봤어요. 실제 임베딩처럼 의미 덩어리가 있도록, 주제 100개가 섞인 32차원 잠재 공간을 768차원으로 비선형 투영하고 단위 길이로 맞춘 합성 데이터예요. 저장 벡터 10만 개, 질의 300개, 거리는 내적이에요. 정답은 전수 비교(IndexFlatIP)로 구했어요.

# pip install faiss-cpu numpy   (측정: faiss-cpu 1.15.1, numpy 2.5.4)
import time, numpy as np, faiss

rng = np.random.default_rng(42)
d, n, nq, k = 768, 100_000, 300, 10
C = rng.standard_normal((100, 32)) * 1.5          # 주제 100개
W = rng.standard_normal((32, d)) / np.sqrt(32)    # 32차원 → 768차원
def make(m):
    z = C[rng.integers(0, 100, m)] + rng.standard_normal((m, 32))
    x = (np.tanh(z @ W) + 0.05 * rng.standard_normal((m, d))).astype(np.float32)
    faiss.normalize_L2(x)
    return x
xb, xq = make(n), make(nq)

flat = faiss.IndexFlatIP(d); flat.add(xb)
_, gt = flat.search(xq, k)                         # 정답 top-10

def evaluate(index, name):
    faiss.omp_set_num_threads(1)                   # 검색은 한 스레드, 한 건씩
    for i in range(20): index.search(xq[i:i+1], k) # 워밍업
    best = 1e9
    for _ in range(3):                             # 세 번 재서 가장 빠른 값
        t = time.perf_counter()
        I = np.vstack([index.search(xq[i:i+1], k)[1] for i in range(nq)])
        best = min(best, time.perf_counter() - t)
    recall = np.mean([len(set(I[i]) & set(gt[i])) / k for i in range(nq)])
    print(f"{name:<24} recall@10={recall:.3f} {best*1000/nq:.3f} ms")

hnsw = faiss.IndexHNSWFlat(d, 16, faiss.METRIC_INNER_PRODUCT)
faiss.omp_set_num_threads(8); hnsw.add(xb)          # 빌드는 8스레드
for ef in (16, 32, 64, 128, 256):
    hnsw.hnsw.efSearch = ef
    evaluate(hnsw, f"HNSW M=16 ef={ef}")
# IVFFlat(nlist=316), HNSWSQ8(M=32), IVFPQ(m=96) + IndexRefine 도 같은 방식으로

Apple M1(8GB) 노트북에서 돌린 결과를 표로 모았어요. 메모리는 faiss.serialize_index로 직렬화한 크기를 벡터 수로 나눈 값이에요.

인덱스설정recall@10단건 지연빌드(8스레드)벡터당 메모리
전수 비교 Flat—1.00032.86ms없음3,072B
HNSW M=16efSearch 32 / 64 / 1280.938 / 0.975 / 0.9840.27 / 0.45 / 0.71ms11.8초3,216B
HNSW M=32efSearch 32 / 64 / 1280.974 / 0.985 / 0.9880.41 / 0.55 / 0.59ms16.6초3,344B
IVFFlat 316목록nprobe 2 / 4 / 80.862 / 0.986 / 1.0000.40 / 0.57 / 1.05ms7.5초3,090B
HNSW + SQ8 M=32efSearch 64 / 1280.979 / 0.9831.20 / 1.60ms32.4초1,040B
IVFPQ 96바이트nprobe 80.7501.36ms72.3초122B
IVFPQ + 원본 재정렬 ×10nprobe 81.0001.62ms(위와 같음)122B + 원본
recall@10 0.97을 처음 넘긴 설정의 단건 검색 지연 (768차원 10만 개)
HNSW M16 ef64HNSW M16 ef64 · 0.451ms0.451msHNSW M32 ef32HNSW M32 ef32 · 0.412ms0.412msIVFFlat nprobe4IVFFlat nprobe4 · 0.573ms0.573msHNSW+SQ8 ef64HNSW+SQ8 ef64 · 1.2ms1.2msIVFPQ+재정렬 nprobe8IVFPQ+재정렬 nprobe8 · 1.6ms1.6ms전수 비교전수 비교 · 32.9ms32.9ms

전수 비교만 수십 ms이고, 근사 인덱스는 모두 2ms 안쪽이에요

단위: ms · 자료: faiss-cpu 1.15.1 · Apple M1 한 스레드 · 합성 데이터 질의 300개, 세 번 잰 최솟값으로 직접 측정
표로 보기
구분지연
HNSW M16 ef640.451
HNSW M32 ef320.412
IVFFlat nprobe40.573
HNSW+SQ8 ef641.2
IVFPQ+재정렬 nprobe81.6
전수 비교32.9

읽을 점은 네 가지예요.

1,000만 개면 메모리 몇 GiB, 계산식과 예시

벡터 DB의 메모리는 "벡터 원본 + 그래프(또는 목록) 오버헤드"로 거의 정해져요. FAISS 안내 문서는 HNSW의 메모리를 벡터당 d × 4 + M × 2 × 4바이트로 적어요. 위 측정에서도 M=16은 원본보다 144바이트, M=32는 272바이트 컸어요(윗층 링크·레벨 정보 포함). 768차원 float32 원본이 3,072바이트이니 그래프는 메모리의 5% 안팎이고 나머지는 원본이에요. 메모리를 줄이려면 M이 아니라 벡터 표현을 바꿔야 한다는 뜻이에요.

1536차원 벡터 1,000만 개를 기준으로 표현별 크기를 계산하면 이래요. GiB는 2³⁰바이트예요.

표현벡터당 바이트1,000만 개재현율 영향(위 측정 기준)
float32 + HNSW M=166,144 + 144 = 6,28858.6GiB기준
FP16(halfvec) + HNSW3,072 + 144 = 3,21630.0GiB보통 작음 (자기 데이터로 확인)
SQ8 + HNSW M=321,536 + 272 = 1,80816.8GiB0.985 → 0.979
1비트 이진화1,536 ÷ 8 + 8 = 2001.9GiB재정렬 없이는 크게 떨어짐
PQ 96바이트 + IVF id96 + 8 = 1041.0GiB재정렬 없으면 0.75, 있으면 1.0

이진화 크기는 pgvector bit 타입의 저장 크기 dimensions / 8 + 8바이트를 썼어요. GB(10⁹)와 GiB(2³⁰)가 섞이면 7% 넘게 차이 나니, 인스턴스 사양(GiB)과 비교하기 전에 데이터 용량 변환기로 단위를 맞추세요.

pgvector는 원본이 두 번 들어가요

pgvector의 HNSW 인덱스는 표와 별도로 벡터를 한 번 더 저장해요. pgvector 소스 hnsw.h의 인덱스 원소 구조체(HnswElementTupleData) 끝에 Vector data가 들어 있어요. 그래서 float32 vector(1536) 1,000만 행이면 표 약 57GiB(행당 4 × d + 8바이트) + 인덱스 57GiB 이상이고, 빠르려면 적어도 인덱스는 메모리에 올라 있어야 해요.

pgvector README의 해법은 표를 halfvec(2 × d + 8바이트)으로 바꾸거나 인덱스만 halfvec으로 거는 거예요. vector 인덱스는 2,000차원까지라 3072차원 임베딩은 halfvec 인덱스(4,000차원)나 이진화(64,000차원)가 필요해요.

-- 표는 float32 그대로, 인덱스만 FP16 (인덱스 크기 약 절반)
CREATE INDEX ON docs USING hnsw ((embedding::halfvec(1536)) halfvec_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- 질의도 같은 식으로 써야 인덱스를 타요
SELECT id FROM docs
ORDER BY embedding::halfvec(1536) <=> $1::halfvec(1536)
LIMIT 10;

서울 리전 인스턴스 월 비용으로 바꾸면

메모리를 돈으로 바꿔 볼게요. AWS EC2 온디맨드 가격표의 서울 리전 Linux 메모리 최적화 r7g 단가(2026-10-26 확인)예요. 한 달은 730시간으로 계산했어요.

인스턴스메모리시간당월(730시간)
r7g.large16GiB0.1292달러94.3달러
r7g.xlarge32GiB0.2584달러188.6달러
r7g.2xlarge64GiB0.5168달러377.3달러
r7g.4xlarge128GiB1.0336달러754.5달러
r7g.8xlarge256GiB2.0672달러1,509.1달러

인덱스가 메모리의 절반 정도만 쓰게 잡았어요(나머지는 OS·질의 버퍼·재빌드 여유). 이 가정이면 1536차원 1,000만 개는 이렇게 배치돼요.

1536차원 1,000만 개를 한 대에 올릴 때 인스턴스 월 비용 (서울 r7g 온디맨드)
float32+HNSW 58.6GiBfloat32+HNSW 58.6GiB · 754달러754달러FP16+HNSW 30.0GiBFP16+HNSW 30.0GiB · 377달러377달러SQ8+HNSW 16.8GiBSQ8+HNSW 16.8GiB · 189달러189달러PQ+재정렬 1.0GiBPQ+재정렬 1.0GiB · 94.3달러94.3달러

PQ는 원본을 디스크에 두는 비용(EBS)과 재정렬 지연이 따로 들어요

단위: 달러 · 자료: AWS EC2 온디맨드 가격표 서울 리전 r7g 단가(2026-10-26 확인) × 730시간, 인덱스가 메모리 절반 안에 드는 가장 작은 크기로 직접 계산
표로 보기
구분월 비용
float32+HNSW 58.6GiB754
FP16+HNSW 30.0GiB377
SQ8+HNSW 16.8GiB189
PQ+재정렬 1.0GiB94.3

벡터를 만드는 임베딩 비용은 토큰 수에 비례해요. 한국어 토큰 수 세는 법은 한국어 LLM 토큰 비용 비교에 있어요.

파라미터 시작값, efSearch·nprobe·M 정하는 법

HNSW efSearch에 따른 recall@10 (768차원 10만 개, M=16과 M=32)
MM
00.250.50.751ef16ef32ef64ef128ef256ef16 · M 0recallef32 · M 0.938recallef64 · M 0.975recallef128 · M 0.984recallef256 · M 0.991recall0.991ef16 · M 0recallef32 · M 0.974recallef64 · M 0.985recallef128 · M 0.988recallef256 · M 0.996recall0.996

ef 64 이후로는 재현율이 1% 안쪽으로만 오르고 지연은 계속 늘어요

단위: recall · 자료: faiss-cpu 1.15.1 IndexHNSWFlat · Apple M1 한 스레드 · 합성 데이터로 직접 측정
표로 보기
구분MM
ef1600
ef320.9380.974
ef640.9750.985
ef1280.9840.988
ef2560.9910.996

pgvector에서 질의별로 값을 바꿀 때는 트랜잭션 안에서 SET LOCAL을 써야 다른 연결에 새지 않아요.

BEGIN;
SET LOCAL hnsw.ef_search = 80;   -- 이 트랜잭션에만 적용
SELECT id FROM docs ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;
광고
광고

상황별 추천, 규모와 조건으로 고르기

상황추천이유
벡터 10만 개 이하, QPS 낮음인덱스 없이 전수 비교위 측정에서 10만 개 단건 33ms, 1만 개면 대략 10분의 1. 정확하고 운영할 게 없어요
사용자·테넌트 필터로 후보가 수천 개 이하필터 + 전수 비교(인덱스는 필터 칼럼에)근사 인덱스는 후보를 먼저 뽑고 필터를 거는 구조라 강한 필터에서 결과가 모자라요
이미 PostgreSQL, 수백만 개, 트랜잭션·조인 필요pgvector HNSW (+ halfvec 인덱스)백업·권한·조인을 그대로 써요. 빈 표에도 만들 수 있어요
메모리 넉넉, 지연·재현율 최우선FAISS·hnswlib HNSW학습이 필요 없고 0.5ms 안팎에 0.97 이상
메모리가 빠듯함HNSW + FP16 또는 SQ8메모리 2~4분의 1, 재현율 손실 작음
수억 개, 메모리에 다 못 올림IVF-PQ + 원본 재정렬벡터당 100바이트 안팎, 원본은 디스크
재빌드가 잦고 데이터가 이미 다 있음IVFFlatpgvector README 기준 빌드가 빠르고 메모리를 덜 써요(속도-재현율은 HNSW보다 낮음)
삭제·수정이 잦음HNSW + 주기적 재빌드 계획, 또는 IVFFAISS의 HNSW는 삭제를 지원하지 않아요

pgvector에서 근사 인덱스는 후보를 뽑은 뒤 필터를 걸어요. 필터 결과가 수천 행 이하라면 그 행들만 정확히 비교하는 편이 단순하고, 테넌트가 몇 개뿐이면 부분 인덱스, 많으면 파티셔닝을 README가 권해요.

GPU 서빙을 같이 운영한다면 메모리 계산 방식이 비슷해요. 요청당 메모리로 동시 처리 수를 정하는 vLLM KV 캐시 용량 계산과 함께 보면 RAG 전체 비용을 한 표로 만들 수 있어요.

도입 체크리스트, pgvector와 FAISS 공통

도입 전 계산(벡터 수·차원·목표 재현율, 메모리 GiB, 필터 선택도), 설정 확인(거리 연산자와 연산자 클래스 일치, ef_search가 LIMIT보다 큼, maintenance_work_mem, halfvec 인덱스), 운영 점검(자기 데이터로 recall 재기, p99 지연, VACUUM 전 REINDEX, 임베딩 모델 교체 시 전체 재색인)을 세 칸으로 나눈 체크리스트
가운데 칸이 어긋나면 인덱스를 타지 않거나 결과가 모자라요
  1. 정답 세트부터 만들어요. 실제 질의 수백 개의 전수 비교 top-10을 저장해 두고 인덱스를 바꿀 때마다 recall@10을 재요.
  2. 거리와 연산자 클래스를 맞춰요. 코사인으로 학습된 임베딩이면 pgvector는 vector_cosine_ops(질의 <=>)로 만들어요. 연산자가 다르면 인덱스를 타지 않으니 EXPLAIN에 Index Scan using <인덱스 이름>이 보이는지 확인해요.
  3. 빌드 메모리를 잡아요. pgvector README는 그래프가 maintenance_work_mem에 들어가면 빌드가 크게 빨라진다고 하고, 넘치면 hnsw graph no longer fits into maintenance_work_mem 알림이 떠요. 서버 메모리를 다 쓰지 않는 선에서 올려요.
  4. p99 지연을 재요. 필터 질의나 efSearch를 높인 질의가 꼬리를 만드니 동시 질의를 걸고 기록해요.
  5. 삭제와 재색인을 계획해요. pgvector README는 HNSW의 VACUUM이 오래 걸리면 REINDEX INDEX CONCURRENTLY를 먼저 하라고 안내해요. 임베딩 모델을 바꾸면 새 칼럼에 채우고 인덱스를 만든 뒤 바꿔 끼워요.
광고
광고

자주 묻는 질문

벡터가 몇 개부터 인덱스가 필요한가요?

위 측정에서 768차원 10만 개 전수 비교가 단건 33ms였어요. 지연 목표가 수십 ms이고 QPS가 낮으면 10만 개까지는 인덱스 없이도 돼요. 그 이상이거나 동시 질의가 많으면 HNSW부터 걸어 보세요.

pgvector HNSW와 IVFFlat 중 무엇을 고르나요?

대부분 HNSW예요. pgvector README도 IVFFlat이 빌드가 빠르고 메모리를 덜 쓰는 대신 속도-재현율 균형은 HNSW보다 낮다고 설명해요. IVFFlat은 데이터가 다 들어온 뒤 만들어야 군집이 제대로 잡히니, 데이터가 계속 늘어나는 표라면 HNSW가 편해요.

메모리를 줄이려면 M을 낮추면 되나요?

효과가 작아요. M=16 그래프는 벡터당 144바이트로 1536차원 원본의 2%예요. FP16이나 SQ8처럼 벡터 표현을 바꿔야 크게 줄어요.

양자화하면 검색 품질이 얼마나 떨어지나요?

같은 측정에서 SQ8은 recall@10이 0.985에서 0.979로 0.006 떨어졌어요. PQ(96바이트)는 0.75까지 떨어졌지만 원본으로 후보 100개를 다시 정렬하니 1.0으로 돌아왔어요. 데이터마다 다르니 자기 정답 세트로 꼭 재세요.

관리형 벡터 DB와 직접 운영 중 무엇이 싼가요?

이 글은 직접 운영 비용만 계산했어요(FP16 HNSW 1,000만 개면 r7g.2xlarge 월 377.3달러). 관리형은 저장량·읽기·쓰기 단위 과금이라, 공식 가격표에 같은 규모를 넣고 복제본·운영 인력까지 더해 비교하세요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 HNSW 원리 — 벡터 DB 근사 검색과 M·ef 튜닝에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#벡터 인덱스 선택#HNSW IVF 비교#pgvector 인덱스 설정#FAISS 인덱스 고르기#벡터DB 메모리 계산#임베딩 양자화 SQ8 PQ#RAG 검색 비용#efSearch nprobe 튜닝
← 이전 글투 포인터 언제 쓰나, 음수·0 입력 판단표와 슬라이딩 윈도우 버그 점검다음 글 →wordexp 쓰면 안 되는 이유, 물결표 경로 안전하게 펼치는 법
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →