Published on

pgvector HNSW 튜닝으로 RAG 검색 2배 빠르게

Authors

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는 대표적으로 ivfflathnsw 를 제공합니다.

  • 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 성능은 크게 두 축입니다.

  • 인덱스 품질(그래프 품질): m, ef_construction
  • 검색 시 탐색량(지연시간 vs recall): ef_search

각각을 RAG 관점으로 해석하면 다음과 같습니다.

m: 노드당 연결 수(그래프 밀도)

  • 값이 클수록 그래프가 촘촘해져 recall이 올라가고 탐색이 쉬워질 수 있지만, 인덱스가 커지고 빌드가 느려집니다.
  • 너무 작으면 탐색이 자주 막혀 ef_search 를 올려도 recall이 잘 안 나옵니다.

실무 출발점으로는 보통 m16 또는 24 정도로 시작합니다. 데이터가 크고(수백만) recall 목표가 높다면 32도 고려합니다.

ef_construction: 인덱스 빌드 시 탐색량

  • 빌드 시간과 인덱스 품질에 영향
  • 온라인 쿼리 지연시간에는 직접 영향이 적지만, 품질이 좋아지면 같은 recall을 더 낮은 ef_search 로 달성할 수 있습니다.

대개 ef_construction64~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 품질이 무너집니다. 아래 순서를 권합니다.

  1. 오프라인 평가셋을 만든다(질문 100~500개, 정답 문서/청크 라벨링)
  2. 목표 recall@k 를 정한다(예: recall@80.95 이상)
  3. 그 recall을 만족하는 최소 ef_search 를 찾는다
  4. 그때의 지연시간을 비교한다(튜닝 전/후)

평가셋이 없다면 최소한 “대표 쿼리 로그”를 샘플링해서 사람이 빠르게 확인하는 방식이라도 필요합니다.


벤치마크 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 를 낮추기 위해 mef_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) 테이블/인덱스가 부풀었는가


결론: “빠른 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 를 요청 유형별로 가변 적용해 “품질과 비용”을 동시에 최적화하는 운영 전략까지 가져가면 성숙도가 크게 올라갑니다.