RAG 검색을 붙이려는데 "pgvector에 HNSW 걸면 끝인가?", "벡터가 1,000만 개면 서버가 얼마나 커야 하나?"에서 막히죠. 결론부터 말하면 10만 개 이하는 인덱스 없이 전수 비교로도 충분하고, 그 이상은 HNSW가 기본값이며, 메모리가 모자랄 때만 SQ8·FP16 → IVF-PQ+재정렬 순서로 줄이는 게 무난해요. 비용의 대부분은 그래프가 아니라 벡터 원본이 차지하는 메모리예요.
HNSW 그래프가 어떻게 생겼는지는 알고 있다고 보고, 이 글은 고르는 데 필요한 숫자만 다뤄요. 같은 데이터로 잰 방식별 재현율·지연·빌드 시간, 1,000만 개 기준 메모리와 서울 리전 인스턴스 월 비용, 규모별 추천, pgvector 설정 점검표 순서예요.
먼저 정할 것, 정확도 목표와 규모
인덱스를 고르기 전에 숫자 세 개를 적어 두면 선택지가 거의 정해져요.
- 벡터 수 N과 1년 뒤 예상치: 메모리와 빌드 시간이 N에 비례해요.
- 차원 d: 임베딩 모델이 정해요. float32 벡터 하나가
4 × d바이트라 1536차원이면 6,144바이트예요. - 필요한 재현율: 진짜 상위 10개 중 몇 개를 찾을지예요. 리랭커나 LLM이 한 번 더 고르는 RAG는 0.95 안팎이면 충분한 경우가 많고, 중복 탐지처럼 놓치면 안 되는 일은 0.99 이상이나 정확 검색이 필요해요.
여기에 질의량(QPS), 삭제 빈도, 메타데이터 필터의 강도를 더해요. 사용자 한 명의 문서 수백 개처럼 필터가 강하면 "필터로 줄인 뒤 전수 비교"가 더 정확하고 빠를 때가 많아요.
FAISS의 인덱스 선택 안내도 비슷해요. 검색을 1,000~10,000번만 할 거라면 빌드 시간이 회수되지 않으니 Flat(전수 비교)이 낫고, 정확한 결과를 보장하는 것도 Flat뿐이라고 적어요.
같은 데이터로 잰 방식별 재현율과 지연
방식마다 숫자를 맞춰 보려고 같은 데이터에 다섯 가지 인덱스를 걸어 재 봤어요. 실제 임베딩처럼 의미 덩어리가 있도록, 주제 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.000 | 32.86ms | 없음 | 3,072B |
| HNSW M=16 | efSearch 32 / 64 / 128 | 0.938 / 0.975 / 0.984 | 0.27 / 0.45 / 0.71ms | 11.8초 | 3,216B |
| HNSW M=32 | efSearch 32 / 64 / 128 | 0.974 / 0.985 / 0.988 | 0.41 / 0.55 / 0.59ms | 16.6초 | 3,344B |
| IVFFlat 316목록 | nprobe 2 / 4 / 8 | 0.862 / 0.986 / 1.000 | 0.40 / 0.57 / 1.05ms | 7.5초 | 3,090B |
| HNSW + SQ8 M=32 | efSearch 64 / 128 | 0.979 / 0.983 | 1.20 / 1.60ms | 32.4초 | 1,040B |
| IVFPQ 96바이트 | nprobe 8 | 0.750 | 1.36ms | 72.3초 | 122B |
| IVFPQ + 원본 재정렬 ×10 | nprobe 8 | 1.000 | 1.62ms | (위와 같음) | 122B + 원본 |
읽을 점은 네 가지예요.
- HNSW는 M보다 efSearch가 먼저예요. M=16에서 efSearch를 32 → 64로 올리니 재현율이 0.938 → 0.975, 지연은 0.27 → 0.45ms였어요. M=32는 같은 재현율에 도달하는 efSearch가 작은 대신 빌드가 40% 길고 메모리가 벡터당 128바이트 늘어요.
- IVF는 이 데이터에서 HNSW와 비슷했어요. 주제 100개로 뚜렷이 나뉜 합성 데이터라 IVF에 유리한 조건이에요. 경계에 걸친 문서가 많은 실제 임베딩에서는 nprobe가 더 필요할 수 있어요.
- SQ8은 메모리를 3분의 1로 줄이고 재현율은 0.006 잃었어요(M=32, efSearch 64 기준 0.985 → 0.979). 대신 거리 계산 때 복원 비용이 들어 지연은 0.55 → 1.20ms로 늘었어요.
- PQ는 재정렬이 사실상 필수예요. 96바이트로 누르면 가까운 후보끼리 순서가 뭉개져 nprobe를 32로 올려도 0.750에서 멈췄어요. 후보 100개를 원본으로 다시 정렬하니 1.000이 됐어요. 원본을 어디 둘지가 설계의 핵심이에요.
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=16 | 6,144 + 144 = 6,288 | 58.6GiB | 기준 |
| FP16(halfvec) + HNSW | 3,072 + 144 = 3,216 | 30.0GiB | 보통 작음 (자기 데이터로 확인) |
| SQ8 + HNSW M=32 | 1,536 + 272 = 1,808 | 16.8GiB | 0.985 → 0.979 |
| 1비트 이진화 | 1,536 ÷ 8 + 8 = 200 | 1.9GiB | 재정렬 없이는 크게 떨어짐 |
| PQ 96바이트 + IVF id | 96 + 8 = 104 | 1.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.large | 16GiB | 0.1292달러 | 94.3달러 |
| r7g.xlarge | 32GiB | 0.2584달러 | 188.6달러 |
| r7g.2xlarge | 64GiB | 0.5168달러 | 377.3달러 |
| r7g.4xlarge | 128GiB | 1.0336달러 | 754.5달러 |
| r7g.8xlarge | 256GiB | 2.0672달러 | 1,509.1달러 |
인덱스가 메모리의 절반 정도만 쓰게 잡았어요(나머지는 OS·질의 버퍼·재빌드 여유). 이 가정이면 1536차원 1,000만 개는 이렇게 배치돼요.
- float32 그대로면 128GiB(월 754.5달러)가 필요해요. 64GiB에 58.6GiB를 넣으면 여유가 9%뿐이라 재빌드나 질의 급증에 버티지 못해요.
- FP16으로만 바꿔도 절반(377.3달러)이에요. 대부분의 RAG에서 가장 먼저 해 볼 만한 절약이에요.
- SQ8은 4분의 1(188.6달러)이고 위 측정의 재현율 손실은 0.006이었어요. 대신 지연이 두 배 남짓 늘었어요.
- PQ+재정렬은 메모리 비용이 가장 싸지만 원본을 디스크에서 읽는 지연과 저장 비용, 학습(k-means) 시간이 붙어요. 위 측정에서 빌드가 72초로 가장 길었어요.
- 복제본을 두면 대수를 곱하고, 약정·스팟 할인은 넣지 않았어요.
벡터를 만드는 임베딩 비용은 토큰 수에 비례해요. 한국어 토큰 수 세는 법은 한국어 LLM 토큰 비용 비교에 있어요.
파라미터 시작값, efSearch·nprobe·M 정하는 법
ef 64 이후로는 재현율이 1% 안쪽으로만 오르고 지연은 계속 늘어요
표로 보기
| 구분 | M | M |
|---|---|---|
| ef16 | 0 | 0 |
| ef32 | 0.938 | 0.974 |
| ef64 | 0.975 | 0.985 |
| ef128 | 0.984 | 0.988 |
| ef256 | 0.991 | 0.996 |
- HNSW efSearch: 기본값은 구현마다 달라요(pgvector
hnsw.ef_search40, FAISSefSearch16). 목표 재현율을 처음 넘기는 값을 찾아요. 위 측정에서 0.97 목표면 M=16에서 64, M=32에서 32였어요. 반드시 k(LIMIT)보다 크게 둬요. - HNSW M: 16에서 시작해요. efSearch를 256까지 올려도 목표에 못 미칠 때만 32로 올려요. 메모리는 벡터당
M × 8바이트 정도씩 늘어요. - ef_construction: pgvector 기본 64, FAISS 40이에요. 빌드 시간을 감당할 수 있으면 올려 그래프 품질을 높여요.
- IVF 목록 수: pgvector README는 100만 행까지
rows / 1000, 그 이상은sqrt(rows)로 시작하고,probes는sqrt(lists)에서 시작하라고 해요. 10만 행이면 lists 100, probes 10이에요. FAISS 안내는 100만 개 미만에서 K를4·√N ~ 16·√N으로 잡아요.
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바이트 안팎, 원본은 디스크 |
| 재빌드가 잦고 데이터가 이미 다 있음 | IVFFlat | pgvector README 기준 빌드가 빠르고 메모리를 덜 써요(속도-재현율은 HNSW보다 낮음) |
| 삭제·수정이 잦음 | HNSW + 주기적 재빌드 계획, 또는 IVF | FAISS의 HNSW는 삭제를 지원하지 않아요 |
pgvector에서 근사 인덱스는 후보를 뽑은 뒤 필터를 걸어요. 필터 결과가 수천 행 이하라면 그 행들만 정확히 비교하는 편이 단순하고, 테넌트가 몇 개뿐이면 부분 인덱스, 많으면 파티셔닝을 README가 권해요.
GPU 서빙을 같이 운영한다면 메모리 계산 방식이 비슷해요. 요청당 메모리로 동시 처리 수를 정하는 vLLM KV 캐시 용량 계산과 함께 보면 RAG 전체 비용을 한 표로 만들 수 있어요.
도입 체크리스트, pgvector와 FAISS 공통
- 정답 세트부터 만들어요. 실제 질의 수백 개의 전수 비교 top-10을 저장해 두고 인덱스를 바꿀 때마다 recall@10을 재요.
- 거리와 연산자 클래스를 맞춰요. 코사인으로 학습된 임베딩이면 pgvector는
vector_cosine_ops(질의<=>)로 만들어요. 연산자가 다르면 인덱스를 타지 않으니EXPLAIN에Index Scan using <인덱스 이름>이 보이는지 확인해요. - 빌드 메모리를 잡아요. pgvector README는 그래프가
maintenance_work_mem에 들어가면 빌드가 크게 빨라진다고 하고, 넘치면hnsw graph no longer fits into maintenance_work_mem알림이 떠요. 서버 메모리를 다 쓰지 않는 선에서 올려요. - p99 지연을 재요. 필터 질의나 efSearch를 높인 질의가 꼬리를 만드니 동시 질의를 걸고 기록해요.
- 삭제와 재색인을 계획해요. 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달러). 관리형은 저장량·읽기·쓰기 단위 과금이라, 공식 가격표에 같은 규모를 넣고 복제본·운영 인력까지 더해 비교하세요.
정리
- 정확도 목표·벡터 수·차원부터 적고, 10만 개 이하나 강한 필터는 전수 비교로 시작하세요.
- 그 이상은 HNSW(M=16)에서 efSearch만 올려 목표 재현율을 맞춰요(위 측정 0.97 이상, 0.45ms).
- 메모리는 원본 벡터가 대부분이라, 줄일 땐 FP16 → SQ8 → PQ+재정렬 순서로 가요. 1536차원 1,000만 개면 서울 r7g 기준 월 754.5 → 377.3 → 188.6달러예요.
- pgvector는 표와 인덱스에 벡터가 두 번 들어가니 halfvec 인덱스를 먼저 검토하세요. GiB·GB 단위는 데이터 용량 변환기로 맞춘 뒤 계산하면 실수가 줄어요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 HNSW 원리 — 벡터 DB 근사 검색과 M·ef 튜닝에서 도식과 코드로 단계별로 풀었어요.