- Published on
PostgreSQL+pgvector RAG 인덱스 튜닝 - HNSW vs IVF
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG(Retrieval-Augmented Generation)를 PostgreSQL 위에서 운영하면, 애플리케이션/데이터 파이프라인이 단순해지고 트랜잭션 일관성도 얻을 수 있습니다. 하지만 임베딩 벡터 검색은 전통적인 B-Tree 튜닝과 결이 다릅니다. 특히 pgvector의 근사 최근접(ANN) 인덱스인 HNSW와 IVF(IVFFlat) 중 무엇을 선택하고, 어떤 파라미터를 어떻게 튜닝하느냐에 따라 지연시간, 정확도(recall), 비용(메모리/디스크), 운영 난이도가 크게 갈립니다.
이 글은 다음을 목표로 합니다.
- RAG에서 자주 쓰는 쿼리 패턴(Top-K + 필터) 기준으로
HNSW와IVF를 비교 - 실무에서 흔히 겪는 “느린데 왜 느린지 모름”을 줄이기 위한 측정/추적 루틴 제시
- 인덱스 생성/튜닝 SQL과 운영 팁을 코드로 제공
운영 중 쿼리 병목을 먼저 잡고 싶다면, 벡터 검색도 결국 SQL이므로 통합 관측이 중요합니다. 병목 추적 루틴은 PostgreSQL 쿼리 폭주? pg_stat_statements로 병목 추적 글의 방식이 그대로 적용됩니다.
RAG 벡터 검색의 전형적인 쿼리 형태
RAG 검색은 보통 아래 형태를 가집니다.
- 입력 쿼리를 임베딩
q로 변환 q와 문서 청크 임베딩 사이의 거리(코사인/내적/L2)를 기준으로 Top-K를 찾음- 동시에 테넌트, 문서 타입, 권한, 시간 범위 같은 메타데이터 필터가 붙음
예시 스키마와 쿼리입니다.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE rag_chunk (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
doc_id bigint NOT NULL,
chunk_no int NOT NULL,
content text NOT NULL,
embedding vector(1536) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 코사인 거리 기준 Top-K (필터 포함)
SELECT id, doc_id, chunk_no, content
FROM rag_chunk
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 10;
여기서 embedding <=> $2는 코사인 거리 연산자(설정/버전에 따라 다를 수 있음)로, 인덱스 타입에 따라 실행 계획이 크게 달라집니다.
HNSW vs IVF: 한 장으로 보는 선택 기준
HNSW의 특징
- 장점
- 높은 recall을 비교적 낮은 튜닝 비용으로 얻기 쉬움
- 검색 지연시간이 안정적인 편(특히 Top-K가 작을 때)
- “일단 빠르게 만들고 운영하면서 조금씩 조정”이 가능
- 단점
- 메모리 사용량이 커질 수 있음(그래프 구조)
- 대량 삽입/갱신이 많은 워크로드에서는 인덱스 빌드/유지 비용이 부담
- 필터가 강하게 걸리면(테넌트별 데이터가 작아지는 경우) 효율이 떨어질 수 있음
IVF(IVFFlat)의 특징
- 장점
- 메모리 압박이 상대적으로 덜하고 디스크 친화적
- 데이터가 매우 크고(수천만 이상), 필터로 후보군이 줄어드는 패턴에서 설계가 잘 맞으면 비용 효율적
lists와probes로 성능/정확도 곡선을 명확히 컨트롤 가능
- 단점
- 인덱스 품질이
ANALYZE/학습(클러스터링)과 파라미터에 민감 - 잘못 튜닝하면 recall이 급격히 떨어지거나, 반대로 probes를 올리면 지연이 급격히 증가
- 운영 중 데이터 분포가 바뀌면 재빌드가 필요해질 수 있음
- 인덱스 품질이
정리하면,
- “높은 recall이 중요하고, 운영 단순성이 최우선”이면
HNSW가 출발점으로 좋습니다. - “데이터가 매우 크고 비용을 정교하게 통제해야 하며, 재빌드/튜닝을 감수할 수 있다”면
IVF가 강력합니다.
인덱스 생성 SQL: HNSW와 IVF
HNSW 인덱스 생성
-- 코사인 거리 기준 예시
CREATE INDEX rag_chunk_embedding_hnsw
ON rag_chunk
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
ANALYZE rag_chunk;
m: 그래프에서 각 노드가 가지는 연결 수(대략 메모리/정확도/빌드 비용에 영향)ef_construction: 빌드 시 탐색 폭(클수록 빌드 느리지만 품질↑)
IVF(IVFFlat) 인덱스 생성
CREATE INDEX rag_chunk_embedding_ivf
ON rag_chunk
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 2000);
ANALYZE rag_chunk;
lists: 클러스터(버킷) 수. 일반적으로 데이터가 클수록 늘립니다.- IVF는
ANALYZE가 특히 중요합니다. 통계가 부정확하면 계획/품질이 흔들릴 수 있습니다.
런타임 튜닝: ef_search vs probes
HNSW 런타임: ef_search
HNSW는 쿼리 시 탐색 폭을 ef_search로 조절합니다.
SET LOCAL hnsw.ef_search = 80;
SELECT id, doc_id
FROM rag_chunk
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 10;
ef_search를 올리면 recall이 올라가지만 지연시간도 증가합니다.- 운영에서는 “기본값 + 특정 요청만 상향” 전략이 유용합니다. 예를 들어, 재랭킹 전 후보를 넉넉히 뽑는 요청만
ef_search를 올립니다.
IVF 런타임: probes
IVF는 쿼리 시 몇 개의 리스트를 탐색할지 probes로 제어합니다.
SET LOCAL ivfflat.probes = 10;
SELECT id, doc_id
FROM rag_chunk
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 10;
probes가 낮으면 빠르지만 recall이 떨어질 수 있습니다.lists가 큰데probes가 너무 낮으면 “거의 랜덤”처럼 동작할 때가 있습니다.
필터(tenant_id 등)와 ANN의 충돌: 실무에서 제일 많이 터지는 지점
RAG 운영에서 흔한 패턴은 tenant_id 같은 강한 필터입니다. 문제는 ANN 인덱스가 “전체 공간에서 가까운 벡터”를 찾는 구조라서, 필터로 후보가 크게 줄면 인덱스가 기대만큼 효율적이지 않을 수 있다는 점입니다.
대응 전략 1: 파티셔닝(테넌트/기간)
테넌트별 데이터가 충분히 크고 테넌트 수가 제한적이면, 테이블 파티셔닝이 강력합니다.
CREATE TABLE rag_chunk (
id bigserial,
tenant_id text NOT NULL,
doc_id bigint NOT NULL,
chunk_no int NOT NULL,
content text NOT NULL,
embedding vector(1536) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY LIST (tenant_id);
CREATE TABLE rag_chunk_tenant_a PARTITION OF rag_chunk FOR VALUES IN ('tenant-a');
CREATE INDEX rag_chunk_tenant_a_hnsw
ON rag_chunk_tenant_a
USING hnsw (embedding vector_cosine_ops);
- 파티션 프루닝으로 검색 범위를 줄이면서 ANN 인덱스 효율을 지킬 수 있습니다.
- 단, 파티션 수가 과도하면 DDL/오토베큠/통계 관리가 복잡해집니다.
대응 전략 2: 2단계 검색(후보 확대 + 재랭킹)
- 1단계: ANN으로 Top-N(예: 50~200) 후보를 빠르게 확보
- 2단계: 필터/정확한 거리 계산/추가 스코어링으로 Top-K 결정
이때 1단계에서 ef_search 또는 probes를 상황에 따라 올리는 식으로 제어합니다.
성능 측정: EXPLAIN (ANALYZE, BUFFERS)로 확인할 것
튜닝은 반드시 측정 루프가 있어야 합니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM rag_chunk
WHERE tenant_id = 'tenant-a'
ORDER BY embedding <=> $1
LIMIT 10;
체크 포인트:
- 인덱스를 타는지(플랜에서
Index Scan/Bitmap/Seq Scan여부) BUFFERS에서 shared hit vs read 비율(캐시 의존도)- 실행 시간이 “가끔만 튀는지” 또는 “항상 느린지”(p95/p99를 봐야 함)
운영에서는 개별 쿼리의 EXPLAIN만으로 부족합니다. 특정 기간 동안 어떤 쿼리가 가장 시간을 먹는지, 호출 수가 폭증했는지를 pg_stat_statements로 같이 봐야 합니다. 이 루틴은 앞서 언급한 내부 글(PostgreSQL 쿼리 폭주? pg_stat_statements로 병목 추적)을 참고해 그대로 적용하면 됩니다.
튜닝 가이드: 현실적인 시작점
HNSW 추천 시작점
m = 16또는m = 24ef_construction = 100~400- 런타임
hnsw.ef_search = 40~120범위에서 p95 지연과 recall을 같이 보고 결정
운영 팁:
- 데이터가 커질수록 인덱스 빌드 시간이 길어집니다. 배치 적재 후 인덱스 생성(또는
CONCURRENTLY) 전략을 검토하세요. - 대량 업데이트가 잦다면, “새 테이블에 적재 후 스왑” 같은 운영 패턴이 더 단순할 수 있습니다.
IVF 추천 시작점
lists는 데이터 건수N에 대해 대략sqrt(N)근처를 출발점으로 두는 경험칙이 자주 쓰입니다(정답은 아니며 분포에 좌우됨).- 런타임
ivfflat.probes는1~30사이에서 단계적으로 올리며 측정
운영 팁:
- IVF는
ANALYZE가 중요합니다. 적재 직후 통계가 없으면 성능이 흔들릴 수 있으니, 적재 파이프라인에ANALYZE를 포함하세요. - 데이터 분포가 바뀌면 IVF 품질이 악화될 수 있습니다. 분기별/월별로 재빌드가 필요한지 지표(지연, recall)를 잡아두는 편이 안전합니다.
메모리/캐시 관점: “DB가 느린 게 아니라 디스크를 읽고 있다”
벡터 검색은 랜덤 액세스 성격이 강해서 캐시 적중률이 성능을 좌우하는 경우가 많습니다.
- HNSW는 구조적으로 메모리를 더 쓰는 편이라, 워킹셋이 RAM에 올라오면 매우 빠르지만 RAM이 부족하면 급격히 흔들릴 수 있습니다.
- IVF는 디스크 친화적일 수 있지만,
probes를 올려 많은 리스트를 읽기 시작하면 결국 I/O가 늘어납니다.
따라서 튜닝은 파라미터만이 아니라 “인스턴스 메모리”와 “shared_buffers/OS 캐시”까지 포함한 총체적 문제입니다.
운영 체크리스트: 장애를 줄이는 습관
- 인덱스 변경 전후로 같은 쿼리를 고정된 조건에서 비교(동일
LIMIT, 동일 필터, 동일 임베딩 샘플) - p50이 아니라 p95/p99를 기준으로 판단
- recall을 측정할 샘플셋을 준비(정답 Top-K를 brute force로 계산해 비교)
- 쿼리 폭주/병목은
pg_stat_statements로 상시 관측 - 배포 중 커넥션 고갈이 동반되면 애플리케이션 스레딩 모델도 점검(예: Spring Boot 3 가상스레드로 DB 커넥션 고갈 막기)
결론: 어떤 선택이 “정답”인가
- 빠르게 안정적인 RAG 검색을 만들고, 높은 recall을 비교적 쉽게 얻고 싶다면
HNSW가 좋은 기본값입니다. - 데이터가 매우 크고 비용/성능을 정교하게 통제해야 하며, 재빌드와 튜닝을 운영 프로세스에 포함할 수 있다면
IVF가 강력한 선택지입니다.
실무에서는 “HNSW로 먼저 안정화 → 비용 압박/규모 확장 시 IVF를 검토” 흐름이 가장 실패 확률이 낮습니다. 무엇을 선택하든, EXPLAIN (ANALYZE, BUFFERS) + pg_stat_statements 기반의 측정 루프를 먼저 만들면 튜닝이 감이 아니라 엔지니어링이 됩니다.