pgvector 성능 한계 실측: 100만·1,000만 벡터에서 HNSW가 무너지는 시점
RAG 파이프라인을 처음 만들 때 저도 "어차피 PostgreSQL 쓰고 있으니까 pgvector면 충분하지"라며 별다른 고민 없이 도입했습니다. 벤치마크 수치는 분명 좋았거든요. 그런데 데이터가 500만 벡터를 넘어가던 시점, 카테고리 필터 하나를 추가했더니 P99 레이턴시가 평균의 10배로 튀었습니다. 새벽 2시에 슬랙 알림을 보며 "아, 내가 뭔가를 놓쳤구나"를 깨달은 그 순간이 아직도 생생합니다.
벤치마크와 프로덕션이 다른 이유는 단 하나입니다. pgvector의 진짜 한계는 벡터 수·차원·동시 접속·메타데이터 필터 복잡도가 함께 증가하는 현실 조건에서 드러나거든요. 단일 스레드, 낮은 동시성, 필터 없는 순수 ANN 검색 환경에서 나온 벤치마크는 실제 서비스와 전혀 다른 이야기입니다.
이 글을 읽고 나면 "내 환경에서 pgvector가 언제 한계에 도달하는지"를 수치로 예측하고, 그 전에 무엇을 준비해야 하는지 판단할 수 있게 됩니다. pgvector를 이제 막 도입했거나, "지금은 괜찮은 것 같은데 나중에 문제가 생기지 않을까" 걱정하는 분들께 특히 도움이 될 거라 생각합니다.
핵심 개념
pgvector가 제공하는 두 가지 인덱스, 그리고 그 트레이드오프
pgvector의 동작 방식을 이해하려면 두 인덱스 타입을 먼저 파악하는 게 좋습니다. 어떤 인덱스를 선택하느냐에 따라 메모리 사용량과 검색 정확도가 완전히 달라지거든요.
**HNSW (Hierarchical Navigable Small World)**는 검색 속도가 빠르고 리콜(recall)이 높습니다. 대신 인덱스 전체를 메모리에 올려야 한다는 조건이 붙죠. 데이터가 작을 땐 아무 문제 없지만, 1,000만 벡터를 넘어가면 인덱스 크기만 80120GB를 요구합니다. RAM이 모자라면 디스크 기반 빌드로 자동 전환되는데, 이때 빌드 시간이 1050배 늘어납니다. 저도 처음에 이걸 모르고 maintenance_work_mem을 기본값으로 둔 채 대규모 인덱스를 빌드하다가 새벽 내내 서버가 돌아간 경험이 있습니다.
maintenance_work_mem이란? PostgreSQL이 인덱스 빌드·VACUUM 같은 유지보수 작업에 사용할 수 있는 메모리 한도입니다. 기본값이 64~256MB로 낮게 설정되어 있어, 대규모 HNSW 빌드 시에는 디스크 기반으로 전환되어 빌드 시간이 급격히 늘어납니다.
IVFFlat은 메모리 사용량이 적지만 검색 정확도가 HNSW보다 낮고, 인덱스를 만들기 전에 클러스터 수(lists 파라미터)를 미리 결정해야 합니다. 데이터 분포를 잘 모르는 초기 단계에서 이 값을 잘못 잡으면 나중에 인덱스를 통째로 재빌드해야 하죠.
-- HNSW 인덱스: m=16, ef_construction=64가 일반적인 시작점
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 검색 시 ef_search로 정확도·속도 균형 조절
-- 값이 클수록 정확하지만 느려짐
SET hnsw.ef_search = 100;
-- IVFFlat: lists는 sqrt(row_count) 정도가 흔히 쓰이는 기준
CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);리콜(Recall)이란? ANN(Approximate Nearest Neighbor) 검색에서 "진짜 가장 가까운 벡터 Top-K" 중 실제로 반환된 비율입니다. 리콜 0.95는 정확한 결과의 95%를 찾아냈다는 뜻으로, 속도와 정확도 사이의 트레이드오프 지표로 쓰입니다.
메모리를 아끼고 싶다면: 양자화(Quantization) 옵션
HNSW 인덱스의 메모리 문제를 다루면서 빠뜨릴 수 없는 게 양자화입니다. pgvector 0.7+부터 지원하는 기능인데, 의외로 잘 알려지지 않은 편이에요.
기본 vector 타입은 float32(4바이트)로 각 차원을 저장하는데, halfvec(float16, 2바이트)으로 바꾸면 인덱스 크기를 절반으로 줄일 수 있습니다. 정확도 손실은 대부분의 워크로드에서 무시할 수 있는 수준이고요.
-- float16 기반 halfvec 타입으로 컬럼 생성 (메모리 ~50% 절감)
ALTER TABLE documents ADD COLUMN embedding halfvec(1536);
-- halfvec 기반 HNSW 인덱스
CREATE INDEX ON documents USING hnsw (embedding halfvec_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 인덱스 실제 크기 직접 확인
SELECT pg_size_pretty(pg_relation_size('documents_embedding_idx'));이 쿼리로 나온 값을 실전 적용 섹션의 "규모별 예상 크기 표"와 비교해보시면 자신의 환경이 어느 구간에 있는지 직접 검증할 수 있습니다.
인덱스 크기를 미리 추산하는 방법
대략적인 인덱스 크기를 미리 계산하고 싶다면 이 공식을 활용해볼 수 있습니다:
HNSW 인덱스 크기 ≈ 벡터 수 × 차원 × 4바이트 × 인덱스 오버헤드 배수(1.3~2.0)예를 들어 100만 벡터, 1,536차원이라면: 1,000,000 × 1,536 × 4 × 1.5 ≈ 9.2 GB. halfvec을 사용하면 4바이트 자리에 2바이트를 넣으면 됩니다. 절반이 되죠.
벤치마크와 실측값이 다른 이유
공식 벤치마크 대부분은 단일 스레드·낮은 동시성 조건에서 측정됩니다. 하지만 프로덕션 환경은 다릅니다. 동시 접속자가 늘어날수록 P50(중앙값) 레이턴시보다 P95, P99 레이턴시가 훨씬 빠르게 치솟거든요.
Reddit 엔지니어링 팀이 3억 4천만 벡터, 높은 동시 접속 환경에서 확인한 결과, 메타데이터 필터 해석에 CPU가 집중되면서 P99 레이턴시가 평균 대비 10배 급등하는 현상이 나타났습니다. 단일 스레드 벤치마크 그래프에서는 절대 보이지 않는 숫자입니다.
tail latency(P95, P99): 전체 요청 중 가장 느린 5%, 1% 구간의 응답 시간입니다. 평균 레이턴시가 낮아도 tail latency가 높으면 사용자 일부는 느린 응답을 경험합니다. 동시 접속이 늘어날수록 tail latency는 평균보다 훨씬 빠르게 악화됩니다.
HNSW 인덱스와 VACUUM: 프로덕션에서 놓치기 쉬운 함정
하나 더 짚고 싶은 게 있습니다. HNSW 인덱스는 벡터가 자주 업데이트되거나 삭제되는 환경에서 인덱스 bloat이 발생할 수 있어요. PostgreSQL의 autovacuum이 일반 테이블은 잘 처리하지만, 인덱스 bloat은 별도로 신경 써야 합니다. bloat이 쌓이면 검색 성능이 서서히 저하되는데, 이게 갑자기 드러나지 않고 조금씩 나빠지다 보니 잡기가 더 까다롭습니다.
REINDEX를 쓰면 서비스를 멈춰야 하니, 프로덕션에서는 REINDEX CONCURRENTLY를 활용하는 것이 좋습니다:
-- 서비스 중단 없이 인덱스 재빌드 (빌드 시간은 길어짐)
REINDEX INDEX CONCURRENTLY documents_embedding_idx;
-- 인덱스 크기 추이 모니터링
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE tablename = 'documents'
AND indexname LIKE '%embedding%';실전 적용
예시 1: 소~중규모 SaaS 시맨틱 검색 (500만 벡터 이하)
벡터 수가 500만 이하라면 예시 1이 해당됩니다. 솔직히 이 구간에서는 pgvector가 꽤 잘 작동합니다. 1,024차원 벡터 기준 단일 PostgreSQL 인스턴스에서 5K15K QPS, P95 레이턴시 550ms 수준은 대부분의 SaaS 프로덕션 요건을 충족하거든요. 기존 PostgreSQL 스택에 확장 하나만 추가하면 되니 운영 복잡도도 낮고요. "처음엔 이걸로 다 되겠는데?" 싶은 구간이고, 실제로 맞습니다.
-- pgvector 확장 설치
CREATE EXTENSION IF NOT EXISTS vector;
-- 임베딩 컬럼 추가 (1536차원: OpenAI text-embedding-3-small 기준)
ALTER TABLE documents ADD COLUMN embedding vector(1536);
-- HNSW 인덱스 빌드 전 메모리 충분히 확보
-- maintenance_work_mem: PostgreSQL이 인덱스 빌드에 쓸 수 있는 메모리 한도
SET maintenance_work_mem = '4GB';
CREATE INDEX documents_embedding_idx ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 시맨틱 검색 쿼리
SELECT id, content, embedding <=> $1 AS distance
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;인덱스를 만들고 나서 꼭 확인해볼 것이 있습니다:
-- 인덱스가 제대로 타는지 확인
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content, embedding <=> $1 AS distance
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;출력 결과에서 Index Scan using documents_embedding_idx on documents가 보이면 정상입니다. Seq Scan on documents가 보인다면 인덱스를 타지 않는 것이니 hnsw.ef_search 값과 enable_indexscan 설정을 함께 점검해볼 가치가 있습니다.
| 파라미터 | 역할 | 권장 시작값 |
|---|---|---|
m |
그래프 연결 수 (클수록 정확하지만 메모리↑) | 16 |
ef_construction |
빌드 시 탐색 깊이 (클수록 정확한 인덱스) | 64 |
ef_search |
검색 시 탐색 깊이 (클수록 정확하지만 느림) | 40~100 |
maintenance_work_mem |
인덱스 빌드에 할당할 메모리 | 4~8GB |
예시 2: 메타데이터 필터 + 벡터 검색 조합 (100만 벡터 이상, 필터 포함 쿼리)
벡터 수가 100만을 넘어서면서 "이 사용자의 문서 중에서만" 또는 "이 카테고리 중에서만" 같은 조건 필터를 벡터 검색에 함께 쓰기 시작하면 예시 2가 해당됩니다. 실무에서 가장 자주 맞닥뜨리는 상황이기도 하죠.
저도 카테고리 필터 하나 추가했다가 쿼리 시간이 3배로 늘어난 것을 로그로 확인했을 때의 당혹감이 아직도 생생합니다. 인덱스를 분명 만들었는데 왜 느린지 한참을 헤맸거든요. pgvector 0.7.x까지는 이 조합이 까다로웠습니다. 필터가 매우 선택적이면(결과 후보가 적으면) ANN 인덱스 스캔으로 충분한 결과를 못 찾고 시퀀셜 스캔으로 폴백하거나, 리콜이 뚝 떨어지는 문제가 있었거든요.
-- 0.8.0 이전: 이런 쿼리가 인덱스를 제대로 못 쓰는 경우가 많았음
SELECT id, content
FROM documents
WHERE user_id = $1 -- 선택적 필터
AND category = ANY($2) -- 또 다른 필터
ORDER BY embedding <=> $3
LIMIT 10;
-- 0.8.0+: 반복적 인덱스 스캔 활성화 (세션 레벨)
SET hnsw.iterative_scan = relaxed_order;
-- 재접속하면 초기화되므로, 영구 적용이 필요하다면:
ALTER DATABASE mydb SET hnsw.iterative_scan = relaxed_order;
-- EXPLAIN으로 인덱스 스캔 여부 확인
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content
FROM documents
WHERE user_id = $1 AND category = ANY($2)
ORDER BY embedding <=> $3
LIMIT 10;출력에서 Index Scan using ... (iterative)가 보이면 0.8.0의 반복적 스캔이 동작하는 것입니다. 여전히 Seq Scan이 보인다면 필터 선택도가 너무 높아서 쿼리 플래너가 인덱스를 포기한 경우일 수 있으니, hnsw.ef_search를 높이거나 필터 인덱스와의 조합을 검토해볼 가치가 있습니다.
relaxed_ordervsstrict_order:relaxed_order는 결과가 거리 순서와 완전히 일치하지 않아도 충분한 수의 결과를 반환하는 방식입니다.strict_order는 거리 순서를 보장하지만 더 많은 스캔이 필요합니다. 대부분의 추천·검색 시스템에서는relaxed_order가 실용적입니다.
예시 3: 대규모 (500만~5,000만 벡터) — pgvectorscale 조합
500만 벡터를 넘어가면 예시 3이 해당됩니다. 이 구간부터는 HNSW 인덱스를 RAM에 통째로 올리는 것 자체가 부담이 됩니다.
Timescale의 pgvectorscale은 StreamingDiskANN이라는 디스크 친화적 인덱스를 추가해 이 문제를 완화합니다. HNSW는 그래프 전체를 RAM에 올려야 하는 반면, StreamingDiskANN(DiskANN 알고리즘 기반)은 자주 접근하는 부분만 메모리에 두고 나머지는 NVMe SSD에서 스트리밍 방식으로 읽어오는 구조입니다. RAM이 아닌 디스크 I/O를 활용하는 트레이드오프죠. 5,000만 벡터(Cohere 임베딩, 768차원) 기준으로 99% 리콜에서 471 QPS를 달성했다는 2025년 5월 벤치마크가 있습니다.
-- pgvectorscale 설치 후
CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE;
-- StreamingDiskANN 인덱스 생성
CREATE INDEX documents_diskann_idx ON documents USING diskann (embedding);
-- 검색 쿼리는 pgvector와 동일한 문법 그대로 사용
SELECT id, content, embedding <=> $1 AS distance
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;
-- HNSW vs DiskANN 인덱스 크기 비교
SELECT
indexname,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE tablename = 'documents'
AND indexname IN ('documents_embedding_idx', 'documents_diskann_idx');같은 벡터 수에서 HNSW 대비 DiskANN 인덱스 크기가 훨씬 작게 나오고, RAM 압박이 눈에 띄게 줄어드는 걸 확인할 수 있습니다. 다만 디스크 I/O 성능에 의존하므로 NVMe SSD 환경이 아니라면 기대만큼의 효과가 나오지 않을 수도 있어요.
1억 벡터 이상에서는?
pgvectorscale도 한계가 있습니다. 1억 벡터를 초과하는 구간에서는 Qdrant나 Milvus 같은 전용 벡터 DB를 고려해볼 만합니다. pgvector(+pgvectorscale)가 단일 노드 기반인 것과 달리, 이들은 분산 인덱싱과 온라인 인덱스 업데이트를 기본으로 지원합니다. 벡터가 실시간으로 대량 추가되는 환경에서 pgvector는 HNSW 인덱스를 재빌드하는 동안 검색 품질이 저하될 수 있는 반면, Qdrant 같은 전용 DB는 인덱스를 실시간으로 갱신하는 구조를 갖고 있습니다. GPU 가속 여부도 차이가 있고요.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 기존 인프라 통합 | 별도 벡터 DB 없이 PostgreSQL 하나로 벡터+관계형 데이터 통합 관리 |
| 비용 효율 | 자체 호스팅 시 전용 벡터 DB 대비 최대 75% 비용 절감 (Instacart 사례: 80% 절감) |
| SQL 친화성 | JOIN, 트랜잭션, 기존 비즈니스 로직과 자연스럽게 통합 |
| 생태계 | RDS, Aurora, Supabase, Neon 등 모든 PostgreSQL 관리형 서비스에서 사용 가능 |
| 중규모 충분 | 500만 벡터 이하에서 전용 DB 수준의 성능 달성 가능 |
pgvector를 선택하면 "벡터 DB 팀 따로, DB 팀 따로"가 아니라 하나의 PostgreSQL 운영 팀으로 모든 것을 처리할 수 있다는 게 실무에서 가장 큰 장점이더라고요. 규모가 커지기 전까지는 이 단순함 자체가 엄청난 가치입니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| HNSW 메모리 요구량 | 100만 벡터(1536차원)에 8 |
halfvec 타입으로 절반 절감, 또는 pgvectorscale의 StreamingDiskANN |
| 메타데이터 필터 + ANN 조합 | 선택적 필터 조건에서 P99 레이턴시 급증 가능 | 0.8.0 이상으로 업그레이드 후 iterative_scan 활성화 |
| 수평 샤딩 미지원 | PostgreSQL 기본 기능으로 수평 샤딩 불가 | Citus 또는 애플리케이션 레벨 샤딩 구성 |
| GPU 가속 없음 | Milvus·Qdrant 등 전용 DB 대비 인덱스 빌드·검색에 GPU 활용 불가 | 1억 벡터 초과 시 전용 벡터 DB 전환 검토 |
| 벤치마크·실측 괴리 | 단일 스레드 벤치마크는 동시 접속 환경과 크게 다름 | 실제 동시성 조건에서 직접 부하 테스트 필수 |
| HNSW 인덱스 bloat | 잦은 업데이트·삭제 시 검색 성능 서서히 저하 | 주기적 REINDEX CONCURRENTLY 또는 autovacuum 튜닝 |
장점이 "PostgreSQL이기 때문에 좋다"로 수렴된다면, 단점은 대부분 "PostgreSQL이기 때문에 어렵다"로 수렴됩니다. 기존 스택 통합이라는 장점이 동시에 단일 노드 한계라는 약점이기도 하죠. 이 트레이드오프를 명확하게 이해하고 있어야 나중에 당황하지 않습니다.
규모별 HNSW 인덱스 예상 크기 (참고용)
| 벡터 수 | 차원 | float32 기본 | float16 (halfvec) |
|---|---|---|---|
| 100만 | 1,536 | 8~16 GB | 4~8 GB |
| 1,000만 | 1,536 | 80~120 GB | 40~60 GB |
| 5,000만 | 768 | 150 GB 이상 | 75 GB 이상 |
실무에서 가장 흔한 실수
-
maintenance_work_mem을 기본값으로 두고 인덱스 빌드: 기본값(64~256MB)으로는 대규모 HNSW 빌드 시 디스크 기반으로 전환되어 빌드 시간이 수십 배 늘어납니다. 저도 이걸 몰랐을 때 새벽 내내 빌드가 돌아가는 걸 본 적이 있는데, 인덱스 빌드 전에SET maintenance_work_mem = '4GB'이상을 설정하는 것을 권장합니다. -
EXPLAIN ANALYZE없이 성능 문제 추측: 필터+벡터 검색 쿼리가 느릴 때 인덱스 스캔을 타는지 시퀀셜 스캔으로 폴백하는지 확인하지 않으면 엉뚱한 곳을 최적화하게 됩니다.EXPLAIN (ANALYZE, BUFFERS)출력에서Index Scan이 보이면 정상,Seq Scan이 보이면hnsw.ef_search와 필터 조건부터 점검해볼 가치가 있습니다. -
세션 레벨
SET이 재접속 후 초기화되는 것을 모르는 경우:SET hnsw.iterative_scan = relaxed_order는 세션 레벨 설정입니다. 재접속하면 초기화되기 때문에, 영구 적용이 필요하다면ALTER DATABASE mydb SET hnsw.iterative_scan = relaxed_order를 사용해야 합니다. -
규모 증가를 고려하지 않은 인덱스 전략 고정: 초기에 IVFFlat의
lists값을 잘못 잡거나, 데이터가 10배 늘었을 때 HNSW 메모리 요구량을 고려하지 않으면 나중에 서비스 중단 없이 재빌드하기 어려운 상황이 됩니다. 분기마다 현재 규모에 맞는 인덱스 전략을 재검토하는 것이 좋습니다.
마치며
이 글을 읽기 전과 후의 가장 큰 차이는 이겁니다. pgvector를 "좋다" 또는 "대규모에서 느리다"로 단편적으로 보는 대신, 어떤 조건에서 어떤 이유로 한계가 나타나는지 수치와 패턴으로 판단할 수 있게 된 것입니다. 500만 벡터 이하에서는 충분히 강력한 선택지이고, 메타데이터 필터 조합은 0.8.0으로 크게 개선됐으며, 그 이상의 규모에서는 halfvec 양자화·pgvectorscale·전용 벡터 DB라는 선택지가 순서대로 열려 있습니다.
지금 바로 해볼 수 있는 3단계:
-
현재 환경 진단: 주요 벡터 검색 쿼리에
EXPLAIN (ANALYZE, BUFFERS)를 붙여서Index Scan이 보이는지Seq Scan이 보이는지 확인해볼 수 있습니다.Seq Scan이 나온다면hnsw.ef_search값과 필터 조건을 함께 점검해볼 가치가 있습니다. -
0.8.0 업그레이드 및 반복 스캔 테스트: 메타데이터 필터와 ANN 검색을 조합해서 쓰고 있다면
ALTER DATABASE mydb SET hnsw.iterative_scan = relaxed_order를 스테이징 환경에 적용하고 P95/P99 레이턴시 변화를 측정해보시면 좋습니다. -
규모 임계점 시뮬레이션: 현재 벡터 수가 100만 미만이라도 12개월 후 예상 규모를 공식으로 추산해볼 수 있습니다.
예상 벡터 수 × 차원 × 4바이트 × 1.5— 이 값이 서버 RAM의 70%를 넘는다면halfvec타입이나 pgvectorscale을 미리 검토해두는 것을 권장합니다.