pgvector 0.8 HNSW 인덱스, 파라미터 세 개로 recall과 latency를 조율하기
프로덕션에서 벡터 검색을 운영하다 보면, 어느 순간 recall이 낮다는 불만과 쿼리가 느리다는 불만이 동시에 들어오는 상황을 만납니다. 저도 예전에 법률 문서 검색 서비스에서 recall 0.85짜리 결과가 나와 팀에서 꽤 곤란했던 적이 있는데, 원인을 파고들어 보니 파라미터 기본값 그대로 인덱스를 만들어 놨던 게 문제였습니다. 그때부터 이 주제를 진지하게 파기 시작했어요.
pgvector의 HNSW 인덱스는 세 개의 파라미터(m, ef_construction, ef_search)로 recall과 latency 사이의 균형점을 결정합니다. 그리고 pgvector 0.8.0(2024년 11월 릴리스, 2026년 기준 안정 버전)에서 추가된 hnsw.iterative_scan은 필터와 벡터 검색을 함께 쓸 때 발생하던 결과 누락 문제를 구조적으로 해결해줍니다. 이 글에서는 파라미터가 실제로 무엇을 제어하는지, 워크로드 유형별로 어떻게 다르게 설정할 수 있는지, 그리고 recall을 자기 데이터로 측정하는 방법을 코드와 함께 살펴봅니다.
HNSW가 실제로 하는 일, 그리고 파라미터가 무엇을 건드리는지
다층 그래프 탐색의 직관적 이해
HNSW(Hierarchical Navigable Small World)는 벡터들을 노드로, 유사한 벡터 사이를 엣지로 연결한 다층 그래프입니다. 쿼리가 들어오면 최상위 레이어에서 시작해 아래 레이어로 내려오면서 탐색 범위를 좁혀갑니다. 덕분에 전체 데이터를 순회하지 않고도 빠르게 근사 최근접 이웃(ANN)을 찾을 수 있습니다.
이 구조에서 파라미터가 어떤 역할을 하는지 각각 살펴보면:
m — 노드당 최대 연결 수 (기본값 16, 범위 2~100)
각 노드가 얼마나 많은 이웃과 연결될지를 결정합니다. 값이 클수록 그래프가 촘촘해져서 recall이 올라가지만, 인덱스 크기와 빌드 시간이 늘어납니다. 그리고 인덱스 생성 후에는 변경할 수 없습니다. m을 바꾸려면 인덱스를 완전히 재빌드해야 하기 때문에, 초기 설계에서 신중하게 잡는 게 중요합니다.
ef_construction — 빌드 시 후보 탐색 수 (기본값 64)
인덱스를 구축할 때 각 노드의 이웃을 얼마나 많이 탐색하며 연결을 결정할지를 제어합니다. 높을수록 인덱스 품질이 좋아지지만 빌드 시간이 늘어납니다. pgvector는 내부적으로 ef_construction >= 2 * m 조건을 강제하므로, m=32로 설정했다면 ef_construction은 최소 64 이상이어야 합니다. 이것도 인덱스 재빌드 없이는 바꿀 수 없습니다.
hnsw.ef_search — 쿼리 시 탐색 후보 수 (기본값 40)
쿼리를 실행할 때 탐색하는 후보의 수입니다. 이 파라미터만 인덱스 재빌드 없이 런타임에 자유롭게 조정할 수 있습니다. recall을 올리고 싶으면 높이고, latency를 줄이고 싶으면 낮추면 됩니다.
recall-latency 곡선은 선형이 아니다
솔직히 처음엔 "ef_search 올리면 recall도 선형으로 오르겠거니" 했는데 실제로 측정해보면 그렇지 않습니다. Crunchy Data 벤치마크의 특정 데이터셋·하드웨어·파라미터 조합에서 보고된 관찰은 이렇습니다(해당 벤치마크 기준, 다른 환경에서는 곡선의 기울기가 달라질 수 있습니다):
- recall 0.80 → 0.95 구간에서는 latency 증가가 상대적으로 완만하다
- recall 0.95 → 0.99 구간에서는 latency가 급격히 상승한다
곡선의 대략적인 형태를 표로 정리하면(어디까지나 개념적 예시입니다):
| recall 목표 구간 | latency 증가 양상 | 실무적 함의 |
|---|---|---|
| ~0.90 | 완만 | 낮은 latency 우선 워크로드에 적합 |
| 0.90 ~ 0.95 | 완만~중간 | 일반 RAG의 균형점 |
| 0.95 ~ 0.99 | 급격 | 정확도 우선 워크로드에서만 감수 |
| 0.99+ | 매우 급격 | pgvector HNSW 단독으로는 비효율적일 수 있음 |
곡선의 정확한 형태는 데이터 분포, 임베딩 차원, 하드웨어, 캐시 상태에 따라 크게 달라지므로 반드시 자기 데이터로 측정해 봐야 합니다. 0.99 이상의 recall이 절대적으로 필요하다면 pgvector HNSW만으로는 무리가 있을 수 있고, IVFFlat + 재정렬 전략이나 외부 전용 벡터 DB를 검토해볼 시점입니다.
워크로드별로 파라미터를 어떻게 잡을 수 있을까
네 가지 대표 시나리오
pgvector 공식 저장소 문서와 커뮤니티 사례를 바탕으로 정리한 워크로드별 출발점입니다. 절대적인 정답이 아니라 최초 세팅의 기준선으로 사용하고, 자기 데이터에서 측정한 뒤 조정해야 합니다.
| 워크로드 유형 | m | ef_construction | ef_search | 목표 recall | 비고 |
|---|---|---|---|---|---|
| 낮은 latency 우선 (추천 시스템) | 8~12 | 32~64 | 20~40 | ~0.90 수용 | 빠른 응답 > 정확도 |
| 균형 (일반 RAG) | 16 (기본) | 64 (기본) | 40~80 | 0.95 목표 | 기본값 검증 후 조정 |
| 높은 recall 우선 (법률·의료 검색) | 32~64 | 128~256 | 100~200 | 0.99 근접 | latency 증가 감수 |
| 대규모 코퍼스 (5M+ 벡터) | 16 + halfvec | 64 | 40 | 0.95 내외 | 메모리 절감 우선 |
정확도가 절대적으로 중요한 도메인이라면 처음부터 m=32~64로 시작하는 편이 낫습니다. m은 나중에 바꾸려면 재빌드가 필요하므로, "기본값으로 시작해서 나중에 올리자"는 접근은 데이터 규모가 커지면 큰 부담이 됩니다.
인덱스 생성 코드
추천 시스템처럼 latency 우선인 경우:
CREATE INDEX ON items
USING hnsw (embedding vector_cosine_ops)
WITH (m = 10, ef_construction = 40);법률 문서 검색처럼 recall 우선인 경우:
CREATE INDEX ON legal_documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 48, ef_construction = 192);런타임에 ef_search를 조정할 때(인덱스 재빌드 불필요):
-- 세션 전체에 적용
SET hnsw.ef_search = 120;
-- 특정 트랜잭션 범위 내에서만 오버라이드
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, content FROM legal_documents
ORDER BY embedding <=> $1
LIMIT 10;
COMMIT;빌드 전에 메모리 설정 확인
HNSW 인덱스 빌드에 필요한 메모리는 원시 벡터 저장분에 대해 대략 N × D × 4 bytes가 하한이고, 여기에 그래프 엣지 오버헤드가 더해집니다. 엣지 오버헤드는 m에 선형 비례하며(노드당 최대 m 개의 이웃 포인터, 상위 레이어에는 추가로 최대 2m개), 빌드 중 후보 큐와 임시 구조를 위한 워킹셋도 필요합니다.
실무에서는 N × D × 4 × 2 정도를 대략적 하한으로 잡고 시작합니다(원시 벡터 + 그래프 및 워킹 오버헤드를 뭉뚱그린 러프한 계수). 다만 m이 크면(예: 48~64) 이 하한을 상당히 초과하므로, m 값에 따라 여유를 더 두어야 합니다. 5백만 개의 1536차원 벡터라면 이 하한만으로도 약 60GB 수준이고, m=48이면 그보다 상당량이 추가된다고 보는 게 안전합니다.
maintenance_work_mem을 충분히 올려두지 않으면 빌드가 디스크로 spill되면서 크게 느려집니다.
SET maintenance_work_mem = '8GB';
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);필터와 벡터 검색을 같이 쓸 때: iterative_scan
결과 과소 반환(overfiltering) 문제
멀티테넌트 RAG 서비스처럼 WHERE tenant_id = 42 같은 필터와 벡터 검색을 함께 쓰는 경우, 기존 pgvector는 인덱스에서 고정 크기 배치를 가져온 뒤 필터를 적용했습니다. 필터 선택도가 높으면(검색 대상이 전체의 아주 일부인 경우) 배치 안에 조건을 만족하는 결과가 거의 없어서 LIMIT 10을 채우지 못하는 결과 과소 반환(overfiltering) 현상이 발생했습니다.
pgvector 0.8.0에서 추가된 iterative_scan은 이 문제를 구조적으로 해결합니다. 결과가 부족하면 인덱스를 계속 더 탐색해서 충분한 결과를 확보합니다.
두 가지 모드 선택
-- strict_order: 정확한 거리 순서 유지 (정확도 우선)
SET hnsw.iterative_scan = strict_order;
SELECT id, content
FROM documents
WHERE tenant_id = 42
ORDER BY embedding <=> $1::vector
LIMIT 10;
-- relaxed_order: 순서 근사, 더 낮은 latency (속도 우선)
SET hnsw.iterative_scan = relaxed_order;필터 선택도가 높을수록(좁은 범위) latency가 늘어나지만, 결과 정확도가 보장되는 트레이드오프입니다. 필터 컬럼에는 반드시 별도 B-tree 인덱스를 함께 만들어두는 게 좋습니다.
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON documents (tenant_id);대규모 코퍼스에서의 메모리 절감: halfvec
Neon 블로그(URL에는 halvec으로 오타가 있지만 실제 타입명은 halfvec입니다)에서는 처음부터 halfvec으로 시작하라는 권고까지 나오고 있습니다. float32 대신 float16을 쓰는 halfvec은 정규화된(normalized) 임베딩에 코사인 유사도를 쓰는 조건에서 recall 손실이 매우 작고 메모리를 약 절반으로 줄일 수 있습니다.
주의할 점은, 이 낮은 손실 특성은 정규화된 임베딩 + 코사인 거리 워크로드에 해당한다는 것입니다. 정규화되지 않은 임베딩이나 유클리드(L2) 거리 기반 검색에서는 상대 오차가 더 크게 나타날 수 있으므로, 도입 전에 자기 데이터로 recall 손실을 실제로 측정해 봐야 합니다.
컬럼을 새로 만들 때:
ALTER TABLE documents ADD COLUMN embedding halfvec(1536);
CREATE INDEX ON documents
USING hnsw (embedding halfvec_cosine_ops)
WITH (m = 16, ef_construction = 64);기존 vector 컬럼이 이미 있는 경우 데이터 이행까지 실제로 수행해야 합니다. 컬럼을 추가만 하고 끝내면 빈 컬럼만 남습니다.
-- 1) 새 halfvec 컬럼 추가
ALTER TABLE documents ADD COLUMN embedding_h halfvec(1536);
-- 2) 기존 float32 데이터를 halfvec으로 캐스팅해 복사
UPDATE documents SET embedding_h = embedding::halfvec(1536);
-- 3) 새 컬럼에 인덱스 생성
CREATE INDEX ON documents
USING hnsw (embedding_h halfvec_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 4) 애플리케이션 코드에서 새 컬럼으로 전환 후 기존 컬럼 정리
-- ALTER TABLE documents DROP COLUMN embedding;
-- ALTER TABLE documents RENAME COLUMN embedding_h TO embedding;또는 한 번에 컬럼 타입을 바꾸려면:
ALTER TABLE documents
ALTER COLUMN embedding TYPE halfvec(1536)
USING embedding::halfvec(1536);대용량 테이블에서는 이 방식이 긴 락을 잡을 수 있으므로, 앞의 4단계 방식이 무중단 배포에는 더 안전합니다.
recall을 실제로 측정하는 방법
"recall 0.95"라는 숫자를 그냥 믿고 쓰는 경우가 많은데, 자기 데이터로 직접 측정해보는 게 훨씬 신뢰할 수 있습니다.
정확한 kNN 기준값을 얻으려면
enable_indexscan = off만 설정해도 PostgreSQL은 bitmap index scan 경로를 선택할 수 있습니다. 순수 sequential scan을 강제하려면 enable_bitmapscan = off도 함께 꺼야 합니다. 그리고 실제로 어떤 플랜이 선택되는지 EXPLAIN으로 반드시 확인해야 합니다.
SET enable_indexscan = off;
SET enable_bitmapscan = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10;플랜에 Seq Scan on documents가 보이면 기준값 산출을 신뢰할 수 있습니다.
하나의 쿼리에서 recall 계산
정확한 kNN 결과와 ANN 결과를 CTE로 각각 얻어 교집합 비율을 바로 계산할 수 있습니다. 쿼리 벡터를 $1::vector로 두 번 쓰면 됩니다.
-- 세션 설정 예시
-- SET enable_indexscan = on;
-- SET enable_bitmapscan = on;
-- SET hnsw.ef_search = 60;
WITH truth AS (
-- 정확한 kNN: seq scan 경로 강제
SELECT id
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10
),
approx AS (
-- ANN 결과: HNSW 인덱스 사용
SELECT id
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10
)
SELECT
COUNT(*) FILTER (WHERE approx.id IS NOT NULL)::float / 10 AS recall_at_10
FROM truth
LEFT JOIN approx USING (id);주의할 점은, CTE 내부에서는 세션 GUC(enable_indexscan 등)가 개별 CTE마다 다르게 적용되지 않는다는 것입니다. 실무에서는 다음 순서로 측정하는 편이 안전합니다.
이를 반영한 파이썬 측정 스크립트의 뼈대는 다음과 같습니다(개념적 예시, 실제 커넥션·에러 처리는 생략).
import psycopg
import numpy as np
def fetch_ids(cur, vec, use_index):
if use_index:
cur.execute("SET enable_indexscan = on")
cur.execute("SET enable_bitmapscan = on")
else:
cur.execute("SET enable_indexscan = off")
cur.execute("SET enable_bitmapscan = off")
cur.execute(
"SELECT id FROM documents "
"ORDER BY embedding <=> %s::vector LIMIT 10",
(vec,),
)
return {row[0] for row in cur.fetchall()}
def measure_recall(conn, query_vectors, ef_search=60):
recalls = []
with conn.cursor() as cur:
cur.execute(f"SET hnsw.ef_search = {ef_search}")
for vec in query_vectors:
truth = fetch_ids(cur, vec, use_index=False)
approx = fetch_ids(cur, vec, use_index=True)
recalls.append(len(truth & approx) / len(truth))
return float(np.mean(recalls))샘플 쿼리 벡터는 실제 트래픽에서 로깅한 것을 쓰는 게 가장 좋습니다. 무작위 벡터로 측정하면 실제 워크로드와 recall 특성이 크게 다를 수 있습니다.
트레이드오프 정리 및 흔한 실수
| 항목 | 장점 | 단점/주의사항 |
|---|---|---|
ef_search 런타임 조정 |
인덱스 재빌드 없이 즉시 적용 | 세션마다 설정이 달라질 수 있음 → 앱 레이어에서 관리 필요 |
m 크게 설정 |
recall 향상, 그래프 품질 개선 | 인덱스 크기·빌드 시간 증가, 변경 불가 |
iterative_scan |
결과 과소 반환 해결 | 선택도 높은 필터에서 latency 증가 |
halfvec 사용 |
메모리 약 50% 절감 (정규화 임베딩 기준 recall 손실 작음) | 정규화되지 않은 임베딩·L2 거리에서는 손실 재측정 필요, 마이그레이션 필요 |
| recall 0.99+ 목표 | 정확도 극대화 | latency 급등, 전용 벡터 DB 검토 권장 |
흔한 실수 세 가지:
-
m을 나중에 바꿀 수 있다고 생각하는 것. 실제로는 인덱스 전체를 재빌드해야 하므로, 데이터 규모가 크면 수 시간이 걸릴 수 있습니다. 처음부터 워크로드 특성을 고려해서 잡는 게 중요합니다. -
코퍼스 크기가 RAM을 초과하는데 캐시를 안 챙기는 것. pgvector HNSW는 여전히 랜덤 페이지 접근이 병목입니다. 데이터가 RAM에 올라가지 않으면 쿼리 성능이 비선형적으로 저하됩니다.
shared_buffers크기와 함께, 콜드 스타트를 없애려면 다음처럼 인덱스를 미리 shared buffer에 적재해두는 게 좋습니다.sqlSELECT pg_prewarm('documents_embedding_idx'); -
ef_search기본값(40)으로 recall 측정 없이 운영하는 것. 워크로드마다 허용 가능한 recall 수준이 다릅니다. 추천 시스템과 의료 검색은 요구사항이 전혀 다른데, 같은 설정으로 운영하면 어느 쪽도 제대로 된 서비스가 아닙니다.
파라미터 선택 의사결정 흐름
마무리
HNSW 파라미터 튜닝의 핵심은 결국 무엇을 포기할 수 있는지를 먼저 정의하는 것입니다. recall을 높이면 latency가 오르고, latency를 줄이면 recall이 떨어집니다. 이 곡선은 선형이 아니고, 특히 recall 0.95를 넘어가면 비용이 급격히 커집니다.
그래서 진입점은 워크로드 요구사항에서 역산해야 합니다. 추천 시스템처럼 recall 0.90도 충분한 도메인이라면 처음부터 m을 낮게, ef_search도 낮게 잡는 편이 낫고, 법률·의료 검색처럼 결과 누락 자체가 사고인 도메인이라면 처음부터 m=32~64로 무겁게 시작해야 합니다. 일반 RAG처럼 요구사항이 애매한 경우에만 기본값(m=16, ef_construction=64, ef_search=40)을 기준선으로 잡고 ef_search를 흔들며 recall을 측정하는 접근이 유효합니다.
어느 진입점을 선택하든, 자기 데이터로 recall을 측정한 숫자가 없으면 튜닝은 감이 됩니다. ef_search는 재빌드 없이 조정 가능하므로 실험 대상으로 삼기 좋고, m과 ef_construction은 초기 설계 결정으로 다뤄야 합니다. 5M+ 규모라면 halfvec 도입을 함께 검토하되(정규화된 임베딩·코사인 거리라는 전제 확인은 필수), WHERE 필터가 들어간다면 iterative_scan은 2026년 기준 사실상 기본 설정으로 봐도 무방합니다.
참고 자료
- pgvector GitHub 공식 저장소
- pgvector 0.8.0 Released — PostgreSQL 공식 뉴스
- Announcing pgvector 0.8.0 on Nile — iterative scan 상세 해설
- HNSW Indexes with Postgres and pgvector — Crunchy Data (벤치마크 출처)
- Accelerate HNSW indexing with pgvector on Amazon Aurora — AWS 공식 블로그
- Don't use vector. Use halfvec instead — Neon 블로그 (URL slug의
halvec은 원문 오타이며 실제 타입명은halfvec) - The Complete Guide to pgvector Tuning — HNSW/IVFFlat/양자화 종합
- Scaling pgvector: Memory, Quantization, and Index Build Strategies