Published on

RAG 응답 느림? pgvector IVFFlat 튜닝 6단계

Authors

RAG에서 “응답이 느리다”는 말은 대개 LLM 추론이 아니라 벡터 검색 단계에서 시작됩니다. 특히 Postgres + pgvector 조합은 운영 편의성이 뛰어나지만, 기본 설정 그대로 쓰면 데이터가 커질수록 지연이 급격히 늘어납니다. 이 글은 pgvector의 대표적인 근사 최근접 검색(ANN) 인덱스인 IVFFlat을 기준으로, 측정부터 재현 가능한 튜닝 순서를 6단계로 정리합니다.

아래 단계는 단순히 파라미터를 “올려보는” 접근이 아니라, 지연(latency)·정확도(recall)·비용(CPU/IO) 트레이드오프를 통제하는 방식으로 구성했습니다.


0. 전제: 느린 원인이 정말 벡터 검색인가?

RAG 파이프라인 전체를 쪼개면 보통 다음 4구간입니다.

  1. 임베딩 생성(encode)
  2. 벡터 검색(retrieve)
  3. 재랭킹(optional)
  4. LLM 생성(generate)

체감상 “RAG가 느리다”의 상당수는 2번 retrieve에서 발생합니다. 하지만 애플리케이션 레벨에서 타임아웃/동시성 설정이 원인일 수도 있습니다. 예를 들어 서버리스나 프록시에서 503/504가 섞여 보이면 검색이 아니라 요청 처리 한계일 수 있습니다. 운영 환경에서 HTTP 타임아웃과 동시성도 같이 점검하세요.

이제 “retrieve가 병목”이라는 가정 하에 본격적으로 들어갑니다.


1단계: EXPLAIN (ANALYZE, BUFFERS)로 병목을 숫자로 고정

튜닝의 시작은 감이 아니라 플랜과 버퍼 통계입니다. pgvector 쿼리는 보통 아래 형태입니다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, doc_id, embedding <-> $1 AS distance
FROM chunks
WHERE tenant_id = $2
ORDER BY embedding <-> $1
LIMIT 10;

여기서 확인할 포인트는 다음입니다.

  • 플랜에 Index Scan using ... ivfflat이 뜨는지
  • BUFFERS에서 shared hit/read 비율(디스크 read가 많으면 느림)
  • Sort가 발생하는지(인덱스가 제대로 쓰이면 불필요한 정렬이 줄어듦)
  • 실행 시간의 대부분이 어디에 있는지

만약 Seq Scan이 보이면, 인덱스가 없거나(혹은 사용 불가) 조건 때문에 인덱스를 못 타는 상태입니다. 이 글의 다음 단계들이 바로 그 원인을 제거하는 과정입니다.

팁: 벡터 검색은 워크로드 특성상 “가끔만 느린” 꼬리 지연이 문제를 크게 만듭니다. p50뿐 아니라 p95, p99도 같이 보세요.


2단계: 거리 함수와 연산자 클래스(opclass) 일치시키기

pgvector는 거리 측정 방식에 따라 인덱스 연산자 클래스가 다릅니다. 여기서 불일치가 나면 인덱스를 만들었어도 쿼리가 인덱스를 못 탑니다.

대표적으로 많이 쓰는 조합은 아래 3가지입니다.

  • L2 거리: vector_l2_ops 와 연산자 <->
  • 내적: vector_ip_ops 와 연산자 <#>
  • 코사인 거리: vector_cosine_ops 와 연산자 <=>

예: 코사인 거리 기반으로 검색할 거면 인덱스도 코사인 opclass로 만들어야 합니다.

CREATE INDEX CONCURRENTLY chunks_embedding_ivfflat_cos
ON chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 200);

그리고 쿼리도 코사인 연산자를 써야 합니다.

SELECT id, doc_id, embedding <=> $1 AS distance
FROM chunks
ORDER BY embedding <=> $1
LIMIT 10;

실무에서 흔한 실수는 “모델은 cosine로 학습/평가했는데, DB는 <->로 돌리고 있었다” 같은 케이스입니다. 이 경우 성능과 품질 둘 다 흔들립니다.


3단계: lists 산정하기(인덱스의 코어 파라미터)

IVFFlat은 전체 벡터를 lists개의 버킷으로 나누고, 쿼리 시 일부 버킷만 탐색합니다. lists가 작으면 빠르지만 recall이 떨어지고, 크면 recall이 좋아지지만 인덱스 크기와 빌드 비용이 증가합니다.

경험칙(절대 법칙 아님):

  • 데이터가 N개일 때 listssqrt(N) 근처에서 시작
  • 또는 N / 1000 수준에서 시작(데이터 분포에 따라 조정)

예시:

  • N = 1,000,000 이면 sqrt(N) = 1000 이므로 lists = 1000부터 시도
DROP INDEX CONCURRENTLY IF EXISTS chunks_embedding_ivfflat_cos;

CREATE INDEX CONCURRENTLY chunks_embedding_ivfflat_cos
ON chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 1000);

중요한 포인트:

  • lists는 인덱스 생성 시 결정되며, 바꾸려면 재생성이 필요합니다.
  • lists를 너무 크게 잡으면 각 list가 너무 작아져 오히려 효율이 떨어질 수 있습니다(오버헤드 증가).

운영 팁:

  • 대규모 테이블에서 인덱스 재생성은 I/O를 크게 씁니다. CONCURRENTLY를 사용하고, 유지보수 윈도우를 확보하세요.

4단계: ivfflat.probes로 지연-정확도 트레이드오프 제어

probes는 쿼리 시 몇 개의 list를 탐색할지 결정합니다. probes가 커질수록 recall은 좋아지지만 느려집니다.

세션 단위로 빠르게 실험할 수 있다는 점이 핵심입니다.

SET LOCAL ivfflat.probes = 5;

SELECT id, doc_id
FROM chunks
ORDER BY embedding <=> $1
LIMIT 10;

튜닝 순서 권장:

  1. 먼저 lists를 “합리적으로” 잡는다(3단계)
  2. 그 다음 probes를 올리며 목표 recall을 맞춘다

실무에서 자주 쓰는 접근:

  • 기본값을 낮게(예: 5~10) 두고
  • “정확도가 중요한 요청”(예: 최종 답변 생성)에서만 probes를 높이는 전략

예를 들어, 1차 후보군은 빠르게 뽑고(낮은 probes), 상위 요청에서만 정확도를 끌어올리는 방식입니다.

주의:

  • probeslists와 같게 만들면 사실상 대부분을 훑게 되어 ANN 이점이 줄어듭니다.

5단계: 필터 조건(테넌트/권한/문서 타입)이 느리게 만드는 구조 개선

RAG는 거의 항상 필터가 있습니다.

  • tenant_id
  • document_id in (...)
  • visibility
  • lang

문제는 IVFFlat이 “벡터 기준으로 후보를 찾은 뒤” 필터를 적용하는 형태가 되기 쉬워, 필터 선택도가 높으면(즉, 조건이 빡세면) 후보가 많이 탈락하면서 더 많은 탐색이 필요해집니다.

이때 선택지는 크게 3가지입니다.

5-1. 파티셔닝으로 검색 공간 자체를 줄이기

테넌트 단위가 명확하면 테이블 파티셔닝이 강력합니다.

CREATE TABLE chunks (
  id bigserial PRIMARY KEY,
  tenant_id bigint NOT NULL,
  doc_id bigint NOT NULL,
  embedding vector(1536) NOT NULL,
  content text NOT NULL
) PARTITION BY LIST (tenant_id);

각 파티션에 IVFFlat 인덱스를 만들면, 특정 테넌트 쿼리는 해당 파티션에서만 검색합니다.

5-2. 부분 인덱스(partial index)로 “자주 조회되는 subset” 최적화

예: visibility = 'public'만 자주 검색한다면 부분 인덱스를 고려합니다.

CREATE INDEX CONCURRENTLY chunks_public_embedding_ivfflat
ON chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 500)
WHERE visibility = 'public';

5-3. 메타데이터 인덱스와의 조합을 점검

벡터 검색 전에 필터 후보를 줄이려면 tenant_id, doc_id 같은 컬럼에도 적절한 B-tree 인덱스가 필요합니다.

CREATE INDEX CONCURRENTLY chunks_tenant_doc_idx
ON chunks (tenant_id, doc_id);

다만 Postgres 플래너가 어떤 순서로 실행할지는 실제 통계/카디널리티에 따라 달라지므로, 1단계의 EXPLAIN으로 확인하면서 조정해야 합니다.


6단계: 운영 체크리스트(리빌드, 통계, 메모리, 커넥션)

튜닝이 “한 번 하고 끝”이 아닌 이유는, 데이터가 늘고 분포가 바뀌면 최적점이 이동하기 때문입니다. 아래는 운영에서 자주 놓치는 항목들입니다.

6-1. 통계 갱신: ANALYZE 없으면 플랜이 틀어진다

대량 적재 후에는 통계를 갱신하세요.

VACUUM (ANALYZE) chunks;

6-2. 인덱스 재생성 타이밍

IVFFlat은 데이터가 크게 변하면 품질/성능이 흔들릴 수 있습니다. 다음 상황이면 재생성을 고려합니다.

  • 데이터가 초기 대비 2배 이상 증가
  • 테넌트/도메인 분포가 크게 변함
  • recall을 맞추려 probes를 계속 올리고 있음(= 인덱스 구조가 현재 분포에 비효율)

6-3. 메모리/캐시 관점: 디스크 read가 많으면 체감이 급락

BUFFERS에서 read가 많으면, 단순히 lists/probes 문제가 아니라 캐시 미스가 큽니다.

  • shared_buffers가 너무 작지 않은지
  • 워킹셋이 메모리에 올라갈 수 있는지
  • 스토리지가 느리지 않은지

특히 RAG는 피크 트래픽에서 같은 테넌트/같은 문서군이 반복 조회되는 경우가 많아 캐시 효율이 중요합니다.

6-4. 커넥션/풀링과 꼬리 지연

DB 커넥션 풀이 고갈되면 검색 쿼리 자체가 느린 게 아니라 “대기”가 느려집니다. 애플리케이션 APM에서 DB 대기 시간을 분리해 보세요.

6-5. 벤치마크는 “정확도”까지 같이 본다

속도만 보고 probes를 낮추면 답변 품질이 무너질 수 있습니다. 최소한 다음을 같이 측정하세요.

  • latency: p50/p95/p99
  • recall@k 또는 hit rate(정답 문서가 top-k에 들어오는 비율)
  • 비용: CPU 사용률, I/O read, 인덱스 크기

추가로, 벡터 DB/인덱스는 시간이 지나며 임베딩 분포가 변하는 “드리프트”가 생길 수 있습니다. 품질이 서서히 떨어진다면 드리프트 관점 점검도 병행하세요.


실전 예시: 튜닝 실험을 재현 가능하게 만드는 SQL 스니펫

아래는 같은 쿼리를 probes만 바꿔가며 측정할 때 유용한 패턴입니다.

-- 1) 코사인 기반 IVFFlat 인덱스가 이미 있다고 가정
-- 2) probes를 바꿔가며 플랜/버퍼/시간을 비교

DO $$
DECLARE
  p int;
BEGIN
  FOREACH p IN ARRAY ARRAY[1, 5, 10, 20, 50] LOOP
    RAISE NOTICE 'probes=%', p;
    EXECUTE format('SET LOCAL ivfflat.probes = %s', p);

    -- 실제로는 애플리케이션에서 바인딩 파라미터를 사용하세요.
    -- 여기서는 예시를 위해 $1, $2 형태로 표기합니다.
    EXECUTE $q$
      EXPLAIN (ANALYZE, BUFFERS)
      SELECT id, doc_id
      FROM chunks
      WHERE tenant_id = 42
      ORDER BY embedding <=> $1
      LIMIT 10
    $q$;
  END LOOP;
END $$;

이렇게 하면 “어느 probes에서 p95가 목표치에 들어오는지”를 팀 내에서 동일한 방식으로 합의할 수 있습니다.


마무리: 6단계 요약

IVFFlat 튜닝은 결국 다음 2가지를 맞추는 작업입니다.

  • 인덱스 구조(lists)를 데이터 규모/분포에 맞게 잡고
  • 탐색량(probes)을 SLO와 품질 목표에 맞게 조절

정리하면:

  1. EXPLAIN (ANALYZE, BUFFERS)로 병목을 수치화
  2. 거리 함수와 opclass를 일치(L2/IP/Cosine)
  3. lists를 데이터 규모에 맞게 산정하고 재생성
  4. ivfflat.probes로 지연-정확도 트레이드오프 제어
  5. 필터 구조(파티셔닝/부분 인덱스/메타 인덱스)로 검색 공간 축소
  6. 운영 체크(ANALYZE, 캐시, 리빌드 타이밍, 커넥션)로 꼬리 지연 방지

이 6단계를 순서대로 적용하면 “RAG가 느리다”를 막연한 감상이 아니라, 재현 가능한 튜닝 과제로 바꿀 수 있습니다.