Published on

pgvector HNSW 튜닝으로 RAG 검색속도 3배

Authors

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에서는 아래 순서로 보면 빠릅니다.

  1. EXPLAIN (ANALYZE, BUFFERS)인덱스를 타는지 확인
  2. 벡터 검색 이후 단계(필터/정렬/조인)에서 시간이 새는지 확인
  3. 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를 더 키워야 함

실전 가이드(경험칙):

  • 7681536 차원 임베딩, RAG Top-k가 520 정도라면 m=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를 내리거나 후보군을 줄여야 합니다. 그런데 품질 기준 없이 내리면 장애가 됩니다.

권장 절차:

  1. 평가셋 준비: 질문 N개와 정답 근거 chunk(또는 doc_id) 라벨
  2. 기준 모델(현재 설정)로 Top-k 검색 결과 저장
  3. m, ef_construction은 “인덱스 품질”이므로 크게 자주 바꾸지 말고, 우선 ef_search를 여러 값으로 스윕
  4. recall@k, MRR 같은 지표로 “최소 허용”을 정한 뒤, 그 지점의 ef_search를 채택

예: ef_search 200에서 recall@10이 0.92이고, 60에서 0.91이라면 60이 더 낫습니다. 이 구간에서 지연이 2~4배 차이 나는 일이 흔합니다.


실제 쿼리 패턴 최적화: 불필요한 계산 줄이기

1) distance 컬럼을 굳이 SELECT 하지 않기

embedding <=> $1SELECT에 포함하면 계산이 추가로 들어갈 수 있습니다(플랜/버전에 따라 다르지만, 실전에서는 비용이 체감되는 경우가 있음). 필요할 때만 가져오고, 기본은 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 LOCALef_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-k 10~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 전체 지연을 더 안정적으로 낮출 수 있습니다.