Published on

pgvector HNSW 튜닝으로 RAG 검색지연 50% 줄이기

Authors

RAG 파이프라인에서 사용자가 체감하는 속도는 대부분 벡터 검색 단계에서 결정됩니다. 특히 PostgreSQL + pgvector 조합은 운영 편의성이 뛰어나지만, 기본 설정만으로는 HNSW 인덱스 성능을 끝까지 끌어내기 어렵습니다.

이 글은 pgvector HNSW 튜닝으로 검색 지연을 50퍼센트 수준까지 낮추기 위해, 어떤 파라미터를 어떤 순서로 조정해야 하는지와 측정 방법을 정리합니다. 단순히 ef_search 를 올리는 식의 감각 튜닝이 아니라, 지연 시간과 리콜의 균형을 데이터로 잡는 접근을 목표로 합니다.

또한 DB 레벨 튜닝만으로 해결이 안 될 때, 애플리케이션 레벨에서 병목을 줄이는 방법도 함께 다룹니다. RAG가 웹 서비스에 붙어 있다면 서버 렌더링의 워터폴을 끊는 최적화도 같이 고려해야 하므로, 필요하면 Next.js 14 RSC fetch waterfall 끊는 캐시·prefetch 최적화도 함께 참고하세요.

RAG에서 HNSW가 지연을 만드는 지점

RAG 검색 단계는 대략 아래 흐름입니다.

  1. 쿼리 임베딩 생성
  2. 벡터 인덱스에서 top K 후보 검색
  3. 후보 문서 추가 필터링 및 재정렬
  4. LLM 컨텍스트 구성

이 중 2번이 느려지면 전체가 무너집니다. HNSW는 근사 최근접 탐색이라 빠르지만, 인덱스 구축 파라미터검색 파라미터가 워크로드에 맞지 않으면 다음 문제가 생깁니다.

  • 지연이 튀는 tail latency 증가
  • 리콜이 낮아져 RAG 답변 품질 하락
  • 인덱스가 커져 캐시 미스 증가
  • 동시성에서 CPU가 과도하게 소모

핵심은 인덱스는 한 번 만들면 오래 쓰고, 검색 파라미터는 트래픽과 품질 요구에 따라 런타임에서 조절한다는 점입니다.

준비: pgvector HNSW 구성과 전제

아래 예시는 다음을 가정합니다.

  • PostgreSQL 14 이상
  • pgvector 최신 계열
  • 임베딩 차원 1536 또는 768 같이 일반적인 크기
  • 거리 함수는 cosine 또는 l2 사용

테이블과 인덱스 기본 형태는 다음과 같습니다.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id bigserial PRIMARY KEY,
  content text NOT NULL,
  embedding vector(1536) NOT NULL,
  created_at timestamptz DEFAULT now()
);

-- HNSW 인덱스 생성 예시
CREATE INDEX documents_embedding_hnsw
  ON documents
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 128);

여기서 튜닝 대상은 크게 3가지입니다.

  • 인덱스 구축 파라미터: m, ef_construction
  • 검색 파라미터: hnsw.ef_search
  • 쿼리 형태와 운영 파라미터: work_mem, 동시성, 필터 조건, VACUUM 상태 등

1단계: 지표부터 잡기 (리콜과 지연을 같이 본다)

튜닝은 반드시 측정 기반으로 진행해야 합니다. 특히 HNSW는 빠른 대신 근사 검색이라, 지연만 낮추면 품질이 망가질 수 있습니다.

최소 측정 세트

  • p50, p95, p99 검색 지연
  • top K 리콜
  • CPU 사용률과 컨텍스트 스위칭
  • 버퍼 캐시 히트율

리콜을 측정하려면 샘플 쿼리에 대해 정확 검색 결과를 정답으로 두고 비교합니다. pgvector에서는 정확 검색은 보통 ivfflat 없이 순차 스캔이므로 비용이 큽니다. 대신 샘플 수를 제한해 오프라인으로 돌립니다.

정확 검색 기준 예시입니다.

-- 샘플 쿼리 벡터를 바인딩한다고 가정
-- 정확 검색(ground truth) top 20
SELECT id
FROM documents
ORDER BY embedding <=> $1
LIMIT 20;

HNSW 검색 결과와 비교해 top K 교집합 비율을 리콜로 잡습니다.

2단계: HNSW 핵심 파라미터 이해

m (그래프 연결도)

  • 값이 커질수록 그래프가 촘촘해져 리콜이 좋아지는 경향
  • 대신 인덱스 크기와 빌드 시간이 증가하고, 메모리 압박이 커짐
  • 너무 작으면 탐색이 불안정해 tail latency가 튐

실무에서 흔한 시작점은 m = 16 또는 m = 24 입니다. 데이터가 수백만 이상이고 리콜이 중요하면 m = 32 도 고려하지만, 인덱스 크기 증가를 감당할 수 있는지 먼저 봐야 합니다.

ef_construction (인덱스 빌드 품질)

  • 인덱스를 만들 때 후보를 얼마나 넓게 보느냐
  • 높을수록 인덱스 품질이 좋아져 검색 시 더 적은 노력으로도 높은 리콜
  • 대신 인덱스 생성 시간이 급증

일반적으로 ef_constructionm 의 몇 배로 잡습니다. 예를 들어 m = 16 이면 ef_construction = 128 또는 200 정도로 실험합니다.

hnsw.ef_search (검색 시 확장 폭)

  • 런타임에서 조절 가능한 가장 강력한 레버
  • 높을수록 리콜은 올라가고 지연은 증가
  • 낮추면 지연은 줄지만 리콜이 떨어짐

중요한 포인트는, 같은 리콜을 목표로 할 때 좋은 인덱스는 더 낮은 ef_search 로도 품질을 유지한다는 점입니다. 즉, 지연 최적화는 ef_search 만 만지는 게 아니라, 인덱스 품질을 올려 ef_search 를 낮출 수 있게 만드는 조합 게임입니다.

3단계: 튜닝 전략 (인덱스 먼저, 검색은 나중)

권장 순서는 다음입니다.

  1. 목표 리콜을 정한다. 예를 들어 top 20 기준 0.9 이상
  2. mef_construction 을 조합해 인덱스를 만든다
  3. 목표 리콜을 만족하는 최소 ef_search 를 찾는다
  4. 트래픽에 따라 ef_search 를 동적으로 조절한다

실험용 인덱스 생성 패턴

운영 테이블에 바로 인덱스를 갈아끼우기보다는, 별도 인덱스를 만든 뒤 비교합니다.

-- 인덱스 후보 1
CREATE INDEX CONCURRENTLY documents_embedding_hnsw_m16_ec128
  ON documents
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 128);

-- 인덱스 후보 2
CREATE INDEX CONCURRENTLY documents_embedding_hnsw_m24_ec200
  ON documents
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 24, ef_construction = 200);

비교 시에는 동일 쿼리 세트를 돌리고, EXPLAIN (ANALYZE, BUFFERS) 로 실제 실행 시간을 봅니다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;

ef_search 스윕

세션 단위로 hnsw.ef_search 를 바꾸며 성능을 측정합니다.

SET LOCAL hnsw.ef_search = 40;
SELECT id
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;

SET LOCAL hnsw.ef_search = 80;
SELECT id
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;

실제로는 20, 40, 60, 80, 120 같이 구간을 정해 리콜과 지연을 같이 찍고, 목표 리콜을 만족하는 최저값을 선택합니다.

4단계: RAG 워크로드에 맞춘 쿼리 형태 최적화

HNSW 인덱스가 있어도 쿼리 형태가 나쁘면 느려집니다.

top K 이후 필터링은 위험하다

아래처럼 먼저 top K 를 뽑고 나중에 조건을 거는 패턴은, 원하는 결과가 필터로 많이 제거될 경우 K를 크게 잡아야 해서 지연이 늘어납니다.

-- 좋지 않은 예: 먼저 top K, 그 다음 필터
WITH topk AS (
  SELECT id, category_id
  FROM documents
  ORDER BY embedding <=> $1
  LIMIT 200
)
SELECT d.id
FROM topk t
JOIN documents d ON d.id = t.id
WHERE d.category_id = $2
LIMIT 10;

가능하면 필터 선택도가 높은 조건은 벡터 검색과 결합되도록 스키마를 설계합니다. 예를 들어 테넌트 분리, 카테고리별 파티셔닝, 또는 테넌트별 테이블을 두는 방식이 있습니다.

하이브리드 검색이라면 후보 수를 분리한다

BM25 같은 키워드 검색과 벡터 검색을 합치는 경우, 둘 다에서 큰 후보를 뽑아 합치면 느립니다. 실무에서는 보통

  • 키워드 top N
  • 벡터 top M
  • 합쳐서 재정렬

처럼 나누고, N과 M을 작게 시작해 점진적으로 올립니다.

5단계: 운영에서 체감 50퍼센트 줄이는 포인트

HNSW 튜닝으로 평균 지연이 줄어도, 사용자는 p95와 p99를 체감합니다. 아래 항목이 tail latency를 크게 좌우합니다.

1) 캐시 미스와 인덱스 크기

m 을 과도하게 키우면 인덱스가 커져 페이지 캐시에 덜 올라가고, 디스크 접근이 늘어 p99가 튈 수 있습니다. 목표 리콜을 만족하는 선에서 m 을 최소화하고, 대신 ef_constructionef_search 로 보정하는 편이 안정적일 때가 많습니다.

2) 동시성에서의 CPU 경합

ef_search 를 높이면 CPU를 더 씁니다. 동시 요청이 많으면 CPU가 포화되어 오히려 전체 지연이 늘 수 있습니다. 이때는

  • ef_search 를 낮추고
  • rerank 모델이나 LLM 컨텍스트 길이 최적화로 품질을 보완

하는 접근이 효과적입니다.

3) VACUUM과 bloat

테이블과 인덱스가 부풀면 캐시 효율이 떨어지고 I O가 늘어납니다. 벡터 테이블은 업데이트가 잦지 않아도, 배치 적재와 삭제가 반복되면 bloat가 생깁니다. 이 부분은 PostgreSQL VACUUM 안 먹을 때 - bloat·autovacuum 튜닝 글의 점검 체크리스트가 그대로 도움이 됩니다.

4) 애플리케이션 레벨 워터폴 제거

DB 검색이 50ms 줄어도, 서버에서 검색 호출이 연쇄적으로 이어지면 체감은 안 좋아집니다. RAG는 보통

  • 검색
  • 문서 로딩
  • rerank
  • LLM 호출

이 직렬로 이어지기 쉬워서, 캐시와 prefetch로 워터폴을 끊는 게 중요합니다. Next.js 기반이라면 앞서 언급한 RSC 최적화 글을 같이 보면 좋습니다.

6단계: 실전 튜닝 레시피 (재현 가능한 형태)

아래는 흔한 규모에서 시작하기 좋은 레시피입니다.

추천 시작값

  • m = 16
  • ef_construction = 128 또는 200
  • hnsw.ef_search = 40 부터 시작해 목표 리콜까지 올림
  • LIMIT 은 10에서 시작, rerank가 있으면 20 정도로 확장

목표 리콜을 만족하면 ef_search 를 낮추는 방향

  1. 인덱스 품질이 낮아 리콜이 안 나오면 ef_search 를 올리기 전에 ef_construction 을 올린 인덱스를 새로 만들고 비교
  2. 그래도 리콜이 부족하면 m 을 16에서 24로 올려 비교
  3. 목표 리콜을 만족하면 ef_search 를 가능한 낮춰 p95를 줄임

이 과정이 잘 되면, 같은 리콜에서 ef_search 를 절반 수준으로 낮추는 경우가 많고, 동시성 환경에서는 이것이 그대로 지연 50퍼센트 개선으로 이어집니다.

예시: Node.js에서 세션 단위로 ef_search 제어

트래픽이나 플랜에 따라 ef_search 를 다르게 주고 싶을 때가 많습니다. 예를 들어 무료 플랜은 빠르게, 유료 플랜은 리콜을 높게 같은 정책입니다.

import { Pool } from "pg";

const pool = new Pool({ connectionString: process.env.DATABASE_URL });

export async function searchDocs(queryEmbedding: number[], efSearch: number) {
  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    await client.query("SET LOCAL hnsw.ef_search = $1", [efSearch]);

    const sql = `
      SELECT id, content
      FROM documents
      ORDER BY embedding <=> $1::vector
      LIMIT 10
    `;

    const res = await client.query(sql, [queryEmbedding]);
    await client.query("COMMIT");
    return res.rows;
  } catch (e) {
    await client.query("ROLLBACK");
    throw e;
  } finally {
    client.release();
  }
}

포인트는 SET LOCAL 로 트랜잭션 범위에만 적용해, 커넥션 풀에서 다른 요청에 설정이 새는 문제를 막는 것입니다.

장애 예방 체크리스트

  • 인덱스 생성은 CREATE INDEX CONCURRENTLY 로 진행했는가
  • 인덱스 교체 시 구 인덱스 삭제 타이밍을 잡았는가
  • 리콜 측정 샘플이 실제 쿼리 분포를 대표하는가
  • p95와 p99를 분리해서 보고 있는가
  • bloat와 autovacuum 상태를 점검했는가
  • 애플리케이션 워터폴과 캐시를 같이 최적화했는가

마무리

pgvector HNSW 튜닝의 핵심은 mef_construction 으로 좋은 인덱스 품질을 확보하고, 그 다음 hnsw.ef_search목표 리콜을 만족하는 최소값으로 낮춰 p95와 p99 지연을 줄이는 것입니다. 이 접근을 따르면 단순 평균이 아니라, 사용자 체감 지연을 기준으로도 50퍼센트 수준의 개선을 충분히 노릴 수 있습니다.

다음 단계로는 하이브리드 검색, rerank 모델 도입, 그리고 서버 렌더링 워터폴 제거까지 함께 묶어 end to end 지연을 줄이는 것을 권장합니다.