- Published on
pgvector RAG 인덱스가 느릴 때 IVFFlat·HNSW 튜닝
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG 파이프라인에서 체감 성능을 가장 크게 좌우하는 구간은 대개 “벡터 Top-K 검색”입니다. pgvector를 PostgreSQL에 붙여서 빠르게 시작할 수 있지만, 데이터가 수십만~수백만 chunk로 늘어나면 인덱스가 있어도 느려지거나, 반대로 인덱스를 만들고 나서 쓰기 성능이 무너지는 문제가 자주 발생합니다.
이 글은 pgvector에서 검색이 느릴 때 가장 많이 쓰는 두 인덱스인 IVFFlat과 HNSW를 어떻게 고르고, 어떤 파라미터를 어떤 순서로 튜닝해야 하는지 “RAG 운영 관점”에서 정리합니다. 또한 인덱스 자체가 아니라 쿼리/통계/진공 문제로 느려지는 케이스도 함께 다룹니다.
관련해서 운영 중 PostgreSQL이 비대해져서(=bloat) 디스크와 캐시 효율이 급락하는 문제는 아래 글도 같이 보면 좋습니다.
느린 원인부터 분해: “인덱스가 느린가, 쿼리가 인덱스를 안 타는가”
pgvector 성능 이슈는 크게 4가지로 나뉩니다.
- 아예 인덱스를 안 탐: 연산자/거리 함수가 인덱스 지원 방식과 안 맞거나,
ORDER BY형태가 달라서 플래너가 포기함 - 인덱스는 타는데 튜닝이 안 됨:
IVFFlat의lists가 부적절하거나,HNSW의m,ef_construction,ef_search가 비효율적 - 데이터/운영 문제: autovacuum 지연, bloat, 통계 부정확, 과도한 업데이트로 인덱스 품질 저하
- RAG 쿼리 패턴 문제:
WHERE tenant_id = ...같은 필터를 같이 걸었는데 인덱스 구조가 그 패턴과 안 맞음(특히 멀티테넌트)
가장 먼저 확인할 것은 “정말 벡터 인덱스를 타는지”입니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content
FROM rag_chunks
WHERE tenant_id = 't1'
ORDER BY embedding <-> $1
LIMIT 10;
여기서 핵심은 다음입니다.
- 실행 계획에
Index Scan또는Bitmap Index Scan이 보이는지 ORDER BY embedding <-> $1형태가 맞는지BUFFERS에서 shared hit/read가 과도하게 발생하는지(캐시 미스)
만약 Seq Scan이 뜬다면, 우선 인덱스 튜닝이 아니라 “인덱스가 타도록 쿼리를 정렬”해야 합니다.
IVFFlat vs HNSW: 선택 기준을 먼저 고정
IVFFlat이 맞는 경우
- 데이터가 정적(write 적음)이고, 주기적으로 배치로 인덱스 재구성이 가능
- 메모리 여유가 크지 않고, 디스크 기반으로도 어느 정도 버틸 수 있어야 함
- “정확도/지연시간”을
probes로 비교적 단순하게 조절하고 싶음
HNSW가 맞는 경우
- 온라인 삽입/업데이트가 많고, 인덱스 재빌드 비용을 피하고 싶음
- 낮은 지연시간이 매우 중요하고, 메모리를 더 써도 됨
- 튜닝 파라미터가 많지만, 한번 잡아두면 운영이 편해지는 편
실무적으로는 “RAG chunk가 계속 추가되는가”가 가장 큰 분기점입니다. 문서가 자주 들어오는 서비스라면 HNSW가 운영 난이도를 낮춰주는 경우가 많습니다.
공통 전제: 거리 함수와 연산자부터 정합성 맞추기
pgvector에서 자주 쓰는 거리는 크게 3가지입니다.
- L2 거리:
embedding <-> query - 내적:
embedding <#> query - 코사인 거리:
embedding <=> query
임베딩 모델이 보통 코사인 유사도를 권장하는 경우가 많지만, 실제로는 “정규화 여부”에 따라 내적/코사인이 사실상 동일해지기도 합니다. 중요한 점은 인덱스를 만든 연산자 클래스와 쿼리 연산자가 일치해야 한다는 것입니다.
예를 들어 코사인 기반으로 검색할 거면 인덱스도 코사인용으로 맞춥니다.
-- 예시: 코사인 거리 기반
CREATE INDEX CONCURRENTLY rag_chunks_embedding_hnsw
ON rag_chunks
USING hnsw (embedding vector_cosine_ops);
그리고 쿼리도 ORDER BY embedding <=> $1 형태로 맞춥니다.
SELECT id, content
FROM rag_chunks
ORDER BY embedding <=> $1
LIMIT 10;
여기서 연산자 불일치가 나면, 인덱스가 있어도 플래너가 못 타거나 성능이 급락합니다.
IVFFlat 튜닝: lists와 probes의 균형
IVFFlat은 “클러스터를 lists개로 나누고, 검색 시 probes개 클러스터만 훑는” 구조입니다.
lists: 인덱스 생성 시 결정(재생성 필요)probes: 런타임에 조절 가능(세션/트랜잭션 단위)
1) lists 가이드라인
정답은 없지만 운영에서 많이 쓰는 출발점은 아래입니다.
- 데이터가
N개일 때lists를 대략sqrt(N)또는 그 근처로 시작 N = 1,000,000이면lists를1000전후로 시작해 측정
인덱스 생성 예시는 다음과 같습니다.
CREATE INDEX CONCURRENTLY rag_chunks_embedding_ivfflat
ON rag_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 1000);
주의할 점:
- IVFFlat은 인덱스 생성 시 테이블이 충분히 분석되어야 클러스터가 그럴듯하게 잡힙니다.
- 인덱스 생성 전후로
ANALYZE를 해주는 것이 안전합니다.
ANALYZE rag_chunks;
2) probes 튜닝: 정확도 vs 지연시간 다이얼
probes는 “얼마나 더 찾아볼 것인가”입니다. 올리면 recall이 좋아지고, 지연시간이 늘어납니다.
-- 세션에서만 적용
SET ivfflat.probes = 10;
SELECT id, content
FROM rag_chunks
ORDER BY embedding <=> $1
LIMIT 10;
튜닝 순서는 보통 이렇게 갑니다.
probes를 1, 5, 10, 20… 식으로 올려가며 latency/recall 측정- 목표 recall이 안 나오면
lists를 재조정(대개 증가) 후 재생성
실무 팁:
- RAG에서 Top-K가 5~20인 경우가 많기 때문에,
probes를 과도하게 올리면 “Top-K는 그대로인데 후보 탐색만 늘어” 손해가 커집니다. - 멀티테넌트에서
WHERE tenant_id = ...로 강하게 필터링한다면, 전역 IVFFlat 하나로는 효율이 떨어질 수 있습니다(아래 “필터와 인덱스” 참고).
HNSW 튜닝: m, ef_construction, ef_search
HNSW는 그래프 기반 근사 최근접 탐색입니다.
m: 각 노드가 유지하는 이웃 수(대체로 메모리/인덱스 크기와 연관)ef_construction: 인덱스 구축 품질(클수록 구축 느리지만 품질↑)ef_search: 검색 시 후보 확장 폭(클수록 recall↑, latency↑)
1) 인덱스 생성 파라미터
CREATE INDEX CONCURRENTLY rag_chunks_embedding_hnsw
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
출발점으로 많이 쓰는 조합은 다음입니다.
m = 16또는m = 32ef_construction = 100~400
데이터가 크고 품질이 중요하면 ef_construction을 올리되, 구축 시간이 길어지는 것을 감안해야 합니다.
2) 런타임: ef_search로 검색 품질 조절
ef_search는 세션 단위로 조절합니다.
SET hnsw.ef_search = 50;
SELECT id, content
FROM rag_chunks
ORDER BY embedding <=> $1
LIMIT 10;
튜닝 방법은 IVFFlat의 probes와 유사합니다.
ef_search를 20, 50, 100…으로 올려가며 latency/recall 측정- 목표 recall이 안 나오면
m또는ef_construction을 재조정(재인덱싱 필요)
실무에서 자주 보는 패턴:
- 지연시간이 불안정하게 튄다:
ef_search가 너무 크거나, 캐시 미스가 많거나, 동시성으로 CPU가 튄다 - recall이 낮다:
ef_search만 올려도 해결되는 경우가 많고, 그래도 안 되면m을 올려야 합니다
RAG에서 자주 터지는 함정: 벡터 검색에 필터를 섞는 방식
대부분의 서비스는 다음 중 하나를 합니다.
- 멀티테넌트:
WHERE tenant_id = ... - 권한/스코프:
WHERE doc_id IN (...)또는WHERE workspace_id = ... - 최신 문서 우선:
WHERE created_at >= ...
문제는 “벡터 인덱스가 먼저 후보를 뽑고, 그 뒤 필터링”이 되면 후보가 버려져 Top-K 품질이 떨어지거나, 반대로 “필터가 먼저 적용되어야 하는데” 플래너가 비효율적으로 실행할 수 있다는 점입니다.
해결 전략 1) 테넌트별 파티셔닝 + 로컬 인덱스
테넌트가 크고 분리가 명확하면 파티셔닝이 깔끔합니다.
-- 예시(개념): tenant_id 기준 파티셔닝 후 각 파티션에 벡터 인덱스
이러면 검색 범위 자체가 줄어 벡터 인덱스 효율이 좋아집니다.
해결 전략 2) 2단계 검색(후보 확장 후 재랭킹)
필터가 강하면 “후보를 넉넉히 뽑고 애플리케이션에서 필터링/재랭킹”이 더 안정적일 때가 있습니다.
- 1단계: 벡터로 Top-
K * alpha후보 검색 - 2단계: 필터 적용 + 필요하면 리랭커 적용
리랭커로 환각을 줄이고 검색 품질을 올리는 접근은 아래 글도 참고할 수 있습니다.
운영 체크리스트: 인덱스 튜닝 전에 반드시 볼 것들
1) 통계 갱신과 플래너
대량 적재 후 ANALYZE가 안 되어 있으면 플래너가 잘못된 비용 추정으로 엉뚱한 계획을 선택할 수 있습니다.
VACUUM (ANALYZE) rag_chunks;
2) bloat와 autovacuum
업데이트/삭제가 많은 chunk 테이블은 bloat가 빠르게 쌓여 캐시 효율이 무너지고, 결과적으로 벡터 인덱스도 느려집니다. autovacuum이 밀리면 특히 심각합니다.
- 테이블/인덱스 크기 증가 추이
- dead tuple 증가
- autovacuum 실행 간격
이 주제는 아래 글이 실전 대응에 도움이 됩니다.
3) 동시성과 커넥션 풀
벡터 검색은 CPU를 많이 씁니다. 동시 요청이 늘면 “각 쿼리는 빠른데 p95/p99가 느려지는” 현상이 흔합니다.
- 커넥션 풀에서 동시 쿼리 수 제한
- 애플리케이션 레벨에서 검색 요청 rate limit 또는 큐잉
- 필요한 경우 read replica로 검색 분리
실전 튜닝 플로우(권장 순서)
- 쿼리 형태 고정:
ORDER BY embedding 연산자 $1 LIMIT K형태로, 인덱스 연산자 클래스와 일치 - EXPLAIN으로 인덱스 탑승 확인:
Seq Scan이면 인덱스/연산자/통계부터 - 필터 패턴 정리: 멀티테넌트/권한 필터가 강하면 파티셔닝 또는 2단계 검색 고려
- 인덱스 선택
- 업데이트가 많으면 HNSW 우선 검토
- 배치 위주면 IVFFlat도 충분
- 런타임 파라미터부터 튜닝
- IVFFlat:
ivfflat.probes - HNSW:
hnsw.ef_search
- IVFFlat:
- 인덱스 생성 파라미터 재조정(재인덱싱)
- IVFFlat:
lists - HNSW:
m,ef_construction
- IVFFlat:
- 운영 안정화: autovacuum/통계/캐시/동시성 점검
예시: RAG 테이블 스키마와 인덱스 세트
아래는 흔한 chunk 테이블 예시입니다.
CREATE TABLE rag_chunks (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
doc_id text NOT NULL,
chunk_id int NOT NULL,
content text NOT NULL,
embedding vector(1536) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 필터용 보조 인덱스
CREATE INDEX CONCURRENTLY rag_chunks_tenant_doc
ON rag_chunks (tenant_id, doc_id);
-- 벡터 인덱스: HNSW 예시
CREATE INDEX CONCURRENTLY rag_chunks_embedding_hnsw
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
검색 시에는 세션에서 ef_search를 조절해 품질/지연시간을 맞춥니다.
BEGIN;
SET LOCAL hnsw.ef_search = 50;
SELECT id, doc_id, chunk_id, content
FROM rag_chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT 10;
COMMIT;
SET LOCAL을 쓰면 트랜잭션 범위로만 적용되어, 특정 API 엔드포인트에서만 공격적으로 튜닝하기 쉽습니다.
마무리: “튜닝”은 숫자 맞추기가 아니라 목표를 정하는 일
pgvector에서 인덱스가 느릴 때는 무작정 lists나 ef_search를 올리기보다, 먼저 목표를 수치로 고정하는 것이 중요합니다.
- 목표 p95 지연시간(예: 80ms)
- 목표 recall(예: 오프라인 평가셋 기준 0.95)
- 허용 가능한 인덱스 크기/메모리
- 쓰기 빈도(온라인 삽입 vs 배치)
그 다음에 IVFFlat이면 probes부터, HNSW면 ef_search부터 조절하고, 마지막에 재인덱싱이 필요한 파라미터로 넘어가면 시행착오가 크게 줄어듭니다. RAG 품질 자체를 더 끌어올려야 한다면 “검색 후보 확장 + 리랭킹”까지 포함해 전체 파이프라인을 보는 것이 가장 효과적입니다.