- Published on
pgvector HNSW 튜닝으로 RAG 검색속도 3배
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG에서 체감 성능을 좌우하는 구간은 대개 벡터 검색(Top-K) 입니다. LLM 호출은 캐시·스트리밍·프롬프트 최적화로 어느 정도 가릴 수 있지만, 검색이 느리면 전체 파이프라인이 무너집니다. 특히 Postgres + pgvector 조합은 운영 편의성이 뛰어난 대신, 인덱스와 쿼리 패턴을 제대로 맞추지 않으면 지연이 쉽게 튀고 QPS가 급격히 떨어집니다.
이 글은 pgvector의 HNSW 인덱스를 중심으로, RAG 검색 속도를 실제로 3배 가까이 끌어올릴 때 자주 쓰는 튜닝 포인트를 한 번에 정리합니다. (정확도 손실을 최소화하면서 지연을 줄이는 방향)
이미 IVF/HNSW를 함께 비교하거나 전체 지연 튜닝을 보고 싶다면 아래 글도 같이 참고하면 좋습니다.
전제: HNSW가 RAG에 잘 맞는 이유
HNSW는 그래프 기반 근사 최근접 탐색(ANN)으로, 대략 다음 특성이 있습니다.
- 낮은 지연: 적절히 튜닝하면 Top-K 쿼리가 매우 빠릅니다.
- 온라인 성격: 데이터가 계속 추가되는 워크로드에서 IVF보다 운영이 편한 편입니다(물론 인덱스 유지 비용은 존재).
- 튜닝 레버가 단순: 핵심은
m,ef_construction,ef_search세 개로 요약됩니다.
RAG에서 중요한 건 “정확도 100점”이 아니라 정확도-지연-비용의 균형입니다. HNSW는 ef_search로 런타임에서 정확도와 속도를 쉽게 교환할 수 있어 실전에서 특히 유용합니다.
3배 빨라지는 지점: 병목을 먼저 분리하기
HNSW 튜닝 전에, “진짜 병목이 벡터 검색인지”부터 확인해야 합니다. Postgres에서는 아래 순서로 보면 빠릅니다.
EXPLAIN (ANALYZE, BUFFERS)로 인덱스를 타는지 확인- 벡터 검색 이후 단계(필터/정렬/조인)에서 시간이 새는지 확인
- CPU 바운드인지, IO 바운드인지 확인
예를 들어 아래처럼 확인합니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, doc_id, chunk, embedding <=> $1 AS distance
FROM rag_chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT 10;
여기서 중요한 체크포인트는 다음입니다.
Index Scan using ... hnsw류가 보이는가Sort가 크게 잡히지 않는가(벡터 정렬은 인덱스가 처리해야 함)Rows Removed by Filter가 과도하지 않은가(필터 때문에 인덱스 효율이 깨질 수 있음)
만약 인덱스를 못 타거나, 필터 때문에 많은 후보를 쓸어 담는다면 HNSW 파라미터를 올리는 것보다 쿼리/스키마 패턴을 먼저 고쳐야 3배가 나옵니다.
스키마/인덱스 기본 세팅
아래는 가장 흔한 RAG chunk 테이블 예시입니다.
CREATE TABLE rag_chunks (
id bigserial PRIMARY KEY,
tenant_id bigint NOT NULL,
doc_id bigint NOT NULL,
chunk text NOT NULL,
embedding vector(1536) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX rag_chunks_tenant_doc_idx
ON rag_chunks (tenant_id, doc_id);
그리고 HNSW 인덱스는 거리 연산자에 맞는 opclass를 명확히 선택합니다.
- 코사인 유사도:
vector_cosine_ops - L2:
vector_l2_ops - 내적:
vector_ip_ops
예시(코사인):
CREATE INDEX rag_chunks_embedding_hnsw
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
여기서부터가 튜닝의 출발점입니다.
HNSW 핵심 파라미터 3개: 무엇을, 언제, 얼마나
1) m: 그래프 연결도(메모리/빌드시간/정확도에 영향)
- 의미: 각 노드가 갖는 연결 수(대략적인 degree)
- 효과:
m증가: 정확도 상승 경향, 검색 안정성 증가, 대신 메모리/인덱스 크기/빌드 비용 증가m감소: 인덱스 가벼움, 대신 recall이 흔들리거나ef_search를 더 키워야 함
실전 가이드(경험칙):
- 768
1536 차원 임베딩, RAG Top-20 정도라면k가 5m=12~24범위가 자주 맞습니다. - “속도가 느리다”가 아니라 “recall이 부족하다”면
ef_search보다 먼저m을 올려야 하는 케이스도 있습니다.
2) ef_construction: 인덱스 빌드 품질(생성/삽입 비용)
- 의미: 인덱스 구축 시 후보 탐색 폭
- 효과:
- 증가: 인덱스 품질 상승(검색 recall에 도움), 대신 빌드/삽입이 느려짐
실전 가이드:
- 배치로 인덱스를 만들고, 이후 증분 삽입이 많지 않다면
ef_construction=200~400이 흔합니다. - 실시간 삽입이 많고 write latency가 중요하면
100~200부터 시작하고 모니터링합니다.
3) ef_search: 런타임 정확도-속도 트레이드오프(가장 중요한 레버)
- 의미: 검색 시 후보 탐색 폭
- 효과:
- 증가: recall 상승, 대신 쿼리 CPU 비용 증가
- 감소: 빨라지지만 recall 하락
Postgres 세션 단위로 조절하는 패턴을 추천합니다.
SET LOCAL hnsw.ef_search = 40;
SELECT id, doc_id, chunk
FROM rag_chunks
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 10;
실전에서 3배가 나오는 지점은 보통 이렇습니다.
- 기존:
ef_search를 과하게 크게(예: 200~400) 두고 “정확도 확보”를 하다가 CPU가 터짐 - 개선: 오프라인 평가로 최소 허용 recall을 정하고
ef_search를 40~80 수준으로 내림
RAG는 최종 답변 품질이 Top-1 정확도만으로 결정되지 않습니다. Top-10 내에 “충분히 좋은 근거”가 들어오면 LLM이 잘 풀어주는 경우가 많아, ef_search를 낮춰도 품질이 크게 흔들리지 않는 케이스가 흔합니다.
“필터 + 벡터” 조합이 성능을 망치는 방식과 해결
RAG에서는 거의 항상 tenant_id, doc_id, collection_id 같은 필터가 붙습니다. 문제는 필터가 붙는 순간, 플래너가 다음 중 하나를 선택하면서 성능이 무너질 수 있다는 점입니다.
- 벡터 인덱스로 후보를 찾고 나서 필터 적용(후보가 너무 많으면 낭비)
- 필터를 먼저 적용하고 나서 벡터 정렬(필터 결과가 크면 정렬 비용 폭발)
해결 1) 테넌트/컬렉션 단위 파티셔닝
테넌트별 데이터가 충분히 크고(예: 테넌트당 수십만 chunk 이상) 테넌트 수가 제한적이라면, 파티셔닝이 강력합니다.
CREATE TABLE rag_chunks (
id bigserial,
tenant_id bigint NOT NULL,
doc_id bigint NOT NULL,
chunk text NOT NULL,
embedding vector(1536) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY LIST (tenant_id);
파티션별로 HNSW 인덱스를 만들면, 쿼리 시 스캔 범위가 크게 줄어 ef_search를 크게 올리지 않아도 recall이 안정되는 경우가 많습니다.
해결 2) 컬렉션별 인덱스 분리(운영형 타협)
파티셔닝이 부담이라면, “핵심 컬렉션만 별도 테이블/인덱스”로 분리하는 것도 효과가 큽니다. RAG에서 실제 트래픽은 일부 컬렉션에 몰리는 경우가 많습니다.
튜닝 절차: 정확도(리콜) 기준을 먼저 고정하라
속도를 3배 올리려면, 결국 ef_search를 내리거나 후보군을 줄여야 합니다. 그런데 품질 기준 없이 내리면 장애가 됩니다.
권장 절차:
- 평가셋 준비: 질문
N개와 정답 근거 chunk(또는 doc_id) 라벨 - 기준 모델(현재 설정)로 Top-
k검색 결과 저장 m,ef_construction은 “인덱스 품질”이므로 크게 자주 바꾸지 말고, 우선ef_search를 여러 값으로 스윕- recall@
k, MRR 같은 지표로 “최소 허용”을 정한 뒤, 그 지점의ef_search를 채택
예: ef_search 200에서 recall@10이 0.92이고, 60에서 0.91이라면 60이 더 낫습니다. 이 구간에서 지연이 2~4배 차이 나는 일이 흔합니다.
실제 쿼리 패턴 최적화: 불필요한 계산 줄이기
1) distance 컬럼을 굳이 SELECT 하지 않기
embedding <=> $1을 SELECT에 포함하면 계산이 추가로 들어갈 수 있습니다(플랜/버전에 따라 다르지만, 실전에서는 비용이 체감되는 경우가 있음). 필요할 때만 가져오고, 기본은 id/doc_id만 가져온 뒤 후처리에서 사용하세요.
SELECT id, doc_id, chunk
FROM rag_chunks
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 10;
2) Top-K를 과하게 크게 잡지 않기
RAG에서 Top-k를 50~100으로 크게 잡고 rerank를 돌리면, 검색 단계 자체가 무거워집니다.
- 1차 검색은 Top-10~20
- 필요하면 rerank 단계에서만 후보를 늘리되, rerank는 별도 저장소/캐시/배치 전략 고려
운영에서 자주 놓치는 것: 통계/청소/동시성
HNSW 자체만 튜닝해도 되지만, “왜 갑자기 느려졌지?”는 운영 요인인 경우가 많습니다.
1) ANALYZE로 플래너 통계 최신화
필터가 있는 쿼리는 통계가 오래되면 플래너가 잘못된 선택을 하며 급격히 느려질 수 있습니다.
ANALYZE rag_chunks;
2) VACUUM과 테이블/인덱스 팽창
업데이트/삭제가 있는 워크로드라면 bloat가 성능에 영향을 줍니다.
VACUUM (ANALYZE) rag_chunks;
3) 커넥션 풀에서 SET LOCAL로 ef_search 관리
애플리케이션에서 세션 전역 SET hnsw.ef_search = ...를 해버리면, 풀링 환경에서 다른 요청에 누수될 수 있습니다. 트랜잭션 단위로만 적용하세요.
BEGIN;
SET LOCAL hnsw.ef_search = 60;
SELECT id, doc_id, chunk
FROM rag_chunks
WHERE tenant_id = $1
ORDER BY embedding <=> $2
LIMIT 10;
COMMIT;
“3배”를 만드는 실전 레시피(권장 시작점)
아래 조합은 많은 RAG 서비스에서 무난한 출발점입니다.
- 인덱스:
m=16,ef_construction=200(쓰기 많으면100~200) - 런타임:
ef_search=40~80범위에서 평가 기반 선택 - 쿼리: Top-
k10~20, 불필요한 distance 계산 최소화 - 필터: 테넌트 단위 파티셔닝 또는 핫 컬렉션 분리
- 운영:
ANALYZE/VACUUM주기화, 풀링 환경에서는SET LOCAL
이 중에서 가장 즉시 효과가 큰 건 ef_search 스윕이고, 그 다음이 필터 범위 축소(파티셔닝/분리) 입니다. 이 두 개가 맞물리면 검색 지연이 3배 가까이 줄어드는 경우가 흔합니다.
튜닝 후 검증 체크리스트
마지막으로 “빨라졌는데 품질이 떨어졌다”를 방지하는 체크리스트입니다.
- 평가셋으로 recall@10, MRR을 비교했는가
- 실제 트래픽에서 p50/p95/p99 지연이 모두 개선됐는가(특히 p95/p99)
- CPU 사용률이 안정됐는가(ef_search를 내리면 CPU가 크게 줄어야 정상)
- 테넌트/컬렉션별로 성능 편차가 커지지 않았는가
- 풀링 환경에서
ef_search설정이 요청 간 누수되지 않는가(SET LOCAL사용)
마무리
pgvector HNSW 튜닝은 “인덱스 파라미터 몇 개 조정”처럼 보이지만, 실제로는 RAG의 쿼리 패턴(필터/Top-K/평가 기준) 을 함께 맞춰야 성능이 크게 뜁니다. 특히 ef_search는 런타임에서 조절 가능한 강력한 레버라, 평가셋만 준비되면 가장 안전하게 2~4배 개선을 만들 수 있습니다.
다음 단계로는 (1) 테넌트 파티셔닝 전략, (2) rerank 도입 시 후보 크기 설계, (3) 캐시(질문 임베딩 캐시, 검색 결과 캐시)까지 붙이면 RAG 전체 지연을 더 안정적으로 낮출 수 있습니다.