- Published on
pgvector HNSW 튜닝으로 RAG 검색 2배 빠르게
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG에서 체감 성능을 좌우하는 구간은 대개 LLM 생성이 아니라 벡터 검색(Top-K) 입니다. 특히 pgvector를 쓰는 경우, 데이터가 수십만~수백만 청크로 늘어나면 ORDER BY embedding <-> $query LIMIT k 의 지연시간이 전체 응답을 지배합니다.
이 글은 pgvector의 HNSW 인덱스를 “그럴듯하게”가 아니라, 재현 가능한 방식으로 튜닝해서 검색을 2배 가깝게 빠르게 만드는 절차를 다룹니다. 핵심은 다음 3가지입니다.
- HNSW의 탐색 비용을 결정하는
ef_search와 그래프 품질을 결정하는m,ef_construction의 균형 - RAG 워크로드(Top-K, 필터 유무, 동시성)에 맞춘 쿼리 형태 및 플래너 유도
EXPLAIN (ANALYZE, BUFFERS)기반으로 “정말 인덱스를 타는지”와 병목을 확인
쿼리가 느린 원인을 SQL 레벨에서 추적하는 방법은 아래 글도 함께 보면 좋습니다.
전제: HNSW가 RAG에서 유리한 이유
pgvector는 대표적으로 ivfflat 과 hnsw 를 제공합니다.
ivfflat: 빌드는 빠르고 메모리 부담이 비교적 적지만, 정확도/지연시간이probes에 민감하고 데이터 분포에 따라 튜닝 난도가 올라갑니다.hnsw: 빌드는 느리고 메모리를 더 쓰지만, 낮은 지연시간과 높은 recall을 얻기 쉽습니다. RAG처럼 “대부분 Top-k만 필요”하고 “온라인 질의가 많음”인 워크로드에 잘 맞습니다.
다만 HNSW는 기본값으로도 잘 동작하지만, 동시성 + 필터 + 큰 테이블이 결합되면 튜닝 여지가 큽니다. 여기서 2배 차이는 흔히 나옵니다.
스키마와 인덱스: 기본 형태부터 정리
먼저 전형적인 RAG 청크 테이블 예시입니다.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE rag_chunks (
id bigserial PRIMARY KEY,
doc_id bigint NOT NULL,
tenant_id bigint NOT NULL,
chunk_idx int NOT NULL,
content text NOT NULL,
embedding vector(1536) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 필터가 자주 들어가는 컬럼은 B-Tree 인덱스도 준비
CREATE INDEX rag_chunks_tenant_id_idx ON rag_chunks (tenant_id);
CREATE INDEX rag_chunks_doc_id_idx ON rag_chunks (doc_id);
HNSW 인덱스는 연산자(class)와 거리(metric)에 따라 달라집니다. OpenAI 계열 임베딩을 코사인 유사도로 쓰는 경우가 많으니 vector_cosine_ops 예시를 들겠습니다.
-- HNSW 인덱스 생성(코사인)
CREATE INDEX rag_chunks_embedding_hnsw_idx
ON rag_chunks
USING hnsw (embedding vector_cosine_ops);
여기까지는 “기본”. 이제부터가 성능을 가르는 구간입니다.
HNSW 튜닝 파라미터 3종: m, ef_construction, ef_search
HNSW 성능은 크게 두 축입니다.
- 인덱스 품질(그래프 품질):
m,ef_construction - 검색 시 탐색량(지연시간 vs recall):
ef_search
각각을 RAG 관점으로 해석하면 다음과 같습니다.
m: 노드당 연결 수(그래프 밀도)
- 값이 클수록 그래프가 촘촘해져 recall이 올라가고 탐색이 쉬워질 수 있지만, 인덱스가 커지고 빌드가 느려집니다.
- 너무 작으면 탐색이 자주 막혀
ef_search를 올려도 recall이 잘 안 나옵니다.
실무 출발점으로는 보통 m 을 16 또는 24 정도로 시작합니다. 데이터가 크고(수백만) recall 목표가 높다면 32도 고려합니다.
ef_construction: 인덱스 빌드 시 탐색량
- 빌드 시간과 인덱스 품질에 영향
- 온라인 쿼리 지연시간에는 직접 영향이 적지만, 품질이 좋아지면 같은 recall을 더 낮은
ef_search로 달성할 수 있습니다.
대개 ef_construction 을 64~200 사이에서 조절합니다. “한 번 구축하고 오래 쓰는” RAG 인덱스라면 빌드 비용을 감수하고 128 또는 200을 추천합니다.
ef_search: 검색 시 후보 탐색량(가장 중요)
- 값이 클수록 recall이 올라가지만 지연시간이 증가
- 값이 작으면 빠르지만 recall이 떨어져 RAG 답변 품질이 흔들립니다.
RAG의 목표는 “정확도 100%”가 아니라 충분한 recall을 확보하면서 지연시간을 최소화하는 것입니다. 그래서 ef_search 는 워크로드별로 튜닝 폭이 큽니다.
인덱스 생성 시 파라미터 적용(핵심)
pgvector는 HNSW 인덱스 생성 시 WITH 옵션으로 파라미터를 줄 수 있습니다(버전에 따라 지원 범위가 다를 수 있으니 사용하는 pgvector 릴리즈 노트를 확인하세요).
DROP INDEX IF EXISTS rag_chunks_embedding_hnsw_idx;
CREATE INDEX rag_chunks_embedding_hnsw_idx
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 24, ef_construction = 128);
그리고 검색 시점의 ef_search 는 세션 단위로 조절하는 패턴이 일반적입니다.
-- 세션/트랜잭션 단위로 설정(예: API 요청 처리 커넥션에서)
SET hnsw.ef_search = 64;
SELECT id, doc_id, content
FROM rag_chunks
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 8;
주의할 점은 연산자입니다.
- 코사인 거리: 보통
embedding <=> $query - L2 거리: 보통
embedding <-> $query
연산자와 인덱스 opclass가 맞지 않으면 인덱스를 제대로 활용하지 못합니다.
“2배 빠르게” 만드는 실전 절차: 정확도 목표부터 고정
튜닝을 지연시간만 보고 하면 RAG 품질이 무너집니다. 아래 순서를 권합니다.
- 오프라인 평가셋을 만든다(질문 100~500개, 정답 문서/청크 라벨링)
- 목표
recall@k를 정한다(예:recall@8이0.95이상) - 그 recall을 만족하는 최소
ef_search를 찾는다 - 그때의 지연시간을 비교한다(튜닝 전/후)
평가셋이 없다면 최소한 “대표 쿼리 로그”를 샘플링해서 사람이 빠르게 확인하는 방식이라도 필요합니다.
벤치마크 SQL: 플랜과 버퍼를 반드시 본다
다음은 튜닝 전후를 비교할 때 최소로 수행할 측정입니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, doc_id
FROM rag_chunks
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 8;
여기서 확인 포인트:
- 플랜에
Index Scan using ... hnsw류가 보이는지 Buffers: shared hit비율이 높은지(캐시 히트)- 실행 시간이
Planning Time이 아니라Execution Time에서 줄었는지
쿼리 병목을 지속적으로 수집하려면 auto_explain 도 유용합니다. 운영에서 “가끔 느린 요청”이 튀는 케이스를 잡는 데 특히 좋습니다.
튜닝 레시피 1: ef_search 를 낮추기 위해 m 과 ef_construction 을 올린다
많은 팀이 ef_search 를 올려서 recall을 맞춥니다. 하지만 이 방식은 동시성에서 바로 병목이 됩니다.
전략은 반대입니다.
- 인덱스 품질을 높여서(
m,ef_construction증가) - 동일 recall을 더 낮은
ef_search로 달성 - 결과적으로 p95 지연시간을 크게 줄임
예시 시나리오(전형적인 개선 패턴):
- 기존:
m = 16,ef_construction = 64,ef_search = 120에서recall@8만족 - 개선:
m = 24,ef_construction = 128로 재구축 후ef_search = 60에서 동일 recall - 효과: 탐색량이 줄어 p95가 거의 절반 수준으로 감소
재구축 비용이 들어가지만, RAG 인덱스는 보통 “자주 갈아엎지 않는” 자산이라 투자 대비 효과가 큽니다.
튜닝 레시피 2: 필터가 있으면 쿼리를 “인덱스 친화적”으로 만든다
RAG에서는 tenant_id, doc_id, collection_id 같은 필터가 거의 항상 붙습니다. 문제는 필터 선택도가 낮거나(거의 모든 row가 같은 tenant), 혹은 반대로 너무 높아서(tenant별 데이터가 작음) 플래너가 애매해질 수 있다는 점입니다.
권장 패턴:
- 필터 컬럼은 B-Tree 인덱스를 둔다
- 벡터 인덱스는 벡터 전용으로 두고, 쿼리에서 필터를 명확히 한다
SET hnsw.ef_search = 64;
SELECT id, doc_id, content
FROM rag_chunks
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 8;
만약 필터가 강해서(예: 특정 doc_id 만) 후보가 매우 적다면, 벡터 인덱스보다 필터 후 정렬이 더 싸질 수도 있습니다. 이때는 아래처럼 2단계로 분리해 실험해볼 가치가 있습니다.
-- 1) 필터로 후보를 줄이고
-- 2) 그 안에서만 벡터 정렬
WITH candidates AS (
SELECT id, doc_id, content, embedding
FROM rag_chunks
WHERE tenant_id = $1
AND doc_id = $2
)
SELECT id, doc_id, content
FROM candidates
ORDER BY embedding <=> $3
LIMIT 8;
데이터 분포에 따라 어느 쪽이 빠른지는 다르므로, 반드시 EXPLAIN (ANALYZE, BUFFERS) 로 확인해야 합니다.
튜닝 레시피 3: 동시성에서 느려지면 커넥션/워크메모리만 보지 말고 “탐색량”을 줄인다
벡터 검색이 느려질 때 흔히 DB 커넥션 풀부터 의심합니다. 물론 커넥션 고갈은 별개의 장애 원인이 될 수 있지만, HNSW 튜닝 관점에서는 동시성 증가가 곧 CPU 탐색량 폭증으로 이어지는 경우가 많습니다.
- 요청 수가 늘면
ef_search만큼의 탐색이 동시 실행 - CPU가 포화되면 p95/p99가 급격히 튐
이때의 처방은 “DB 커넥션을 더 늘리기”가 아니라:
- 목표 recall을 만족하는 최소
ef_search로 내리기 - 필요하면
m,ef_construction을 올려 재구축해서ef_search를 더 내릴 여지를 만들기
커넥션 풀 고갈 자체를 진단하는 글은 아래가 참고가 됩니다(벡터 검색 API도 결국 DB 커넥션을 쓰기 때문에 함께 봐두면 좋습니다).
운영 팁: ef_search 를 요청 단위로 가변 적용하기
모든 질문에 같은 recall이 필요한 건 아닙니다.
- 사용자가 “정확한 근거”를 요구하는 질문(규정, 법무, 의료 등):
ef_search를 높여 recall 우선 - 일반 FAQ/가벼운 질의:
ef_search를 낮춰 지연시간 우선
예: API 레벨에서 mode 에 따라 세션 파라미터를 바꾸는 방식
-- fast 모드
SET LOCAL hnsw.ef_search = 40;
-- accurate 모드
-- SET LOCAL hnsw.ef_search = 120;
SELECT id, doc_id, content
FROM rag_chunks
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 8;
SET LOCAL 을 쓰면 트랜잭션 범위로만 적용되어, 커넥션 풀에서 재사용되는 커넥션에 설정이 “새어 나가는” 사고를 줄일 수 있습니다.
체크리스트: 2배 개선이 안 나올 때 보는 순서
마지막으로, 튜닝했는데도 기대만큼 개선이 없을 때의 점검 순서입니다.
1) 인덱스를 정말 타고 있는가
EXPLAIN에 HNSW 인덱스 스캔이 보이는지- opclass와 연산자가 일치하는지(코사인인데
vector_l2_ops를 쓰는 실수 등)
2) ef_search 를 내릴 수 없는 이유가 인덱스 품질 때문인가
m이 너무 작거나ef_construction이 낮으면,ef_search를 낮출 때 recall이 급락- 이 경우 재구축이 정답인 경우가 많음
3) 필터가 성능을 망치고 있는가
- 필터 선택도가 애매하면 플랜이 흔들릴 수 있음
- 후보가 작으면 2단계 쿼리(필터 후 정렬)가 더 빠를 수 있음
4) 테이블/인덱스가 부풀었는가
업데이트/삭제가 많으면 bloat로 인해 IO가 늘 수 있음
벡터 테이블은 누적되기 쉬우니
autovacuum정책을 점검
결론: “빠른 HNSW”는 ef_search 가 아니라 “낮은 ef_search 로도 되는 인덱스”에서 나온다
pgvector HNSW 튜닝의 핵심은 단순합니다.
ef_search를 무작정 올려 recall을 맞추는 대신m,ef_construction으로 인덱스 품질을 끌어올려- 동일 recall을 더 낮은
ef_search로 달성하고 EXPLAIN (ANALYZE, BUFFERS)로 실제 플랜/버퍼/시간을 확인한다
이 과정을 워크로드(Top-K, 필터, 동시성) 기준으로 반복하면, RAG 검색 지연시간을 체감 2배 수준으로 줄이는 케이스는 충분히 현실적입니다.
다음 단계로는 평가셋 기반으로 recall@k 와 p95를 함께 대시보드화하고, ef_search 를 요청 유형별로 가변 적용해 “품질과 비용”을 동시에 최적화하는 운영 전략까지 가져가면 성숙도가 크게 올라갑니다.