Published on

pgvector RAG 인덱스 튜닝 - IVFFlat·HNSW 실전

Authors

서버사이드 RAG에서 검색 성능은 곧 사용자 체감 품질입니다. 같은 임베딩 모델을 써도 pgvector 인덱스 설정에 따라 top-k 결과가 달라지고, p95 지연이 2배 이상 차이 나며, DB CPU가 급격히 튈 수 있습니다. 이 글은 PostgreSQL + pgvector 환경에서 IVFFlatHNSW를 RAG 워크로드에 맞게 튜닝하는 실전 관점(정확도·지연·비용)을 다룹니다.

아래 내용은 다음을 목표로 합니다.

  • 질의당 지연을 안정화(p95/p99)하면서 recall을 유지
  • 데이터가 계속 추가되는 환경에서 인덱스 품질 저하를 최소화
  • 운영 중 장애 포인트(락, vacuum, autovacuum, 통계)를 피함

운영 측면에서 락과 대기열이 튀면 검색이 아니라 DB가 병목이 됩니다. 락 진단은 이 글과 함께 보면 좋습니다: PostgreSQL 락 대기 폭증? deadlock 진단·해결

RAG에서 pgvector 검색이 느려지는 대표 원인

RAG 검색은 대개 다음 패턴으로 반복됩니다.

  • WHERE tenant_id = ... 같은 필터 + ORDER BY embedding <-> query_embedding LIMIT k
  • 짧은 쿼리(수십 ms 목표)지만 QPS가 누적되어 DB가 쉽게 포화
  • 문서가 계속 추가되고, 최신 문서가 더 중요(시간 가중)한 경우도 많음

느려지는 원인은 크게 5가지입니다.

  1. 인덱스 타입 선택 미스: IVFFlat은 빌드/리빌드 특성이 있고, HNSW는 메모리와 쓰기 비용이 큼
  2. 파라미터 기본값 방치: lists, probes, m, ef_search를 워크로드에 맞게 조정하지 않음
  3. 필터와 벡터 검색 결합 방식 문제: 필터 선택도가 낮으면 후보군이 커져서 인덱스 효율이 급감
  4. 통계/플래너 문제: ANALYZE 부족으로 잘못된 플랜 선택
  5. I/O 및 메모리 설정 미스: shared_buffers, work_mem, maintenance_work_mem와 OS 캐시, 디스크 성능

IVFFlat vs HNSW: RAG 관점에서 선택 기준

IVFFlat 특징

  • 장점: 인덱스 크기가 비교적 예측 가능, 검색이 빠르고 단순, 파라미터가 직관적
  • 단점: 빌드 시점의 데이터 분포에 의존하고, 데이터가 많이 변하면 recall이 흔들릴 수 있음
  • 핵심 튜닝: lists(클러스터 수), ivfflat.probes(탐색할 리스트 수)

IVFFlat은 “대량 데이터에서 빠른 근사 검색”에 적합하지만, 삽입이 계속되는 RAG에서는 인덱스 품질 관리(리인덱스 전략)가 중요합니다.

HNSW 특징

  • 장점: 보통 IVFFlat보다 높은 recall을 더 낮은 지연에서 달성하기 쉬움, 점진적 삽입에 강함
  • 단점: 인덱스 빌드/삽입 비용과 메모리 사용량이 큼, 파라미터가 여러 개라 잘못 잡으면 비용 폭증
  • 핵심 튜닝: m, ef_construction, hnsw.ef_search

운영적으로는 HNSW가 “RAG에 더 자연스러운 선택”인 경우가 많지만, 쓰기량이 많고 인프라가 빡빡하면 IVFFlat이 더 안정적일 수 있습니다.

스키마와 쿼리 기본형(필터 포함)

먼저 RAG에서 흔한 테이블 예시입니다.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE rag_chunks (
  id bigserial PRIMARY KEY,
  tenant_id bigint NOT NULL,
  doc_id bigint NOT NULL,
  chunk_index 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_created_idx
  ON rag_chunks (tenant_id, created_at DESC);

검색 쿼리는 대개 이렇게 갑니다.

SELECT id, doc_id, chunk_index, content
FROM rag_chunks
WHERE tenant_id = $1
ORDER BY embedding <-> $2
LIMIT 10;

여기서 중요한 포인트는 두 가지입니다.

  • tenant_id 필터가 강하면(테넌트별 데이터가 충분히 분리되면) 벡터 인덱스 효율이 좋아짐
  • 필터 선택도가 낮으면(거의 전 테이블을 훑으면) probesef_search를 올려도 비용만 커질 수 있음

IVFFlat 튜닝 실전

1) 인덱스 생성과 lists 설정

IVFFlat은 lists가 핵심입니다. 경험적으로는 “데이터 건수 N에 대해 lists를 너무 작게 잡으면 recall이 떨어지고, 너무 크게 잡으면 probes를 올려야 해서 지연이 늘어날 수 있음”이 자주 발생합니다.

-- cosine distance 예시
CREATE INDEX rag_chunks_embedding_ivfflat_idx
ON rag_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 2000);

ANALYZE rag_chunks;

실무 감각으로 시작점을 잡는 방법:

  • N이 수십만 이하: lists 100~1000 범위에서 시작
  • N이 수백만: lists 1000~10000 범위에서 시작

단, 이건 절대 규칙이 아니라 “튜닝 시작점”입니다. 결국 아래의 probes와 함께 맞춰야 합니다.

2) 검색 시 probes 조정

ivfflat.probes는 “얼마나 많은 리스트를 탐색할지”입니다. 올리면 recall이 좋아지지만 비용이 증가합니다.

SET ivfflat.probes = 10;

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

실전 패턴:

  • p95 지연이 여유롭고 recall이 부족하면 probes를 5, 10, 20 식으로 단계적으로 올림
  • CPU가 먼저 포화되면 probes를 올리는 대신 lists를 재조정하거나 HNSW로 전환 검토

중요: probes는 세션/트랜잭션 단위로도 바꿀 수 있어, “온라인 A/B 튜닝”에 유리합니다.

3) IVFFlat 운영 팁: 리인덱스 전략

IVFFlat은 데이터가 계속 추가되면 클러스터링 품질이 흔들릴 수 있습니다. 다음 중 하나를 고려하세요.

  • 주기적 REINDEX INDEX CONCURRENTLY ...로 품질 회복
  • 배치로 대량 적재 후 인덱스 재생성(가능하면)
REINDEX INDEX CONCURRENTLY rag_chunks_embedding_ivfflat_idx;

다만 CONCURRENTLY는 시간이 오래 걸리고 I/O가 늘 수 있으니, 트래픽 저점 시간대에 돌리고 모니터링을 붙이세요.

HNSW 튜닝 실전

1) 인덱스 생성: m, ef_construction

HNSW는 그래프 기반 근사 검색입니다.

  • m: 노드당 연결 수(대략적인 그래프 밀도). 올리면 recall이 좋아지지만 메모리/빌드 비용이 증가
  • ef_construction: 빌드 품질. 올리면 빌드는 느려지지만 검색 recall이 좋아지는 경향
CREATE INDEX rag_chunks_embedding_hnsw_idx
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 128);

ANALYZE rag_chunks;

실전 시작점:

  • m = 16 또는 m = 24
  • ef_construction = 64에서 시작해, recall이 부족하면 128, 256으로 올려보기

쓰기량이 많으면 ef_construction을 과도하게 올리면 삽입 지연이 커질 수 있습니다.

2) 검색 시 ef_search 조정

HNSW 검색 품질/지연의 핵심은 hnsw.ef_search입니다.

SET hnsw.ef_search = 40;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, doc_id
FROM rag_chunks
WHERE tenant_id = 42
ORDER BY embedding <-> $1
LIMIT 10;
  • 낮으면 빠르지만 recall이 떨어질 수 있음
  • 높이면 recall이 좋아지지만 CPU와 지연이 증가

권장 접근:

  • LIMIT 10 기준으로 ef_search 20, 40, 80을 찍어보며 recall과 p95를 같이 측정
  • recall이 충분하면 더 올리지 말기(비용만 증가)

RAG 품질 측정: “정확도”를 어떻게 재는가

인덱스 튜닝은 결국 “recall 대비 지연” 최적화입니다. 그런데 RAG에서 recall은 정답 라벨이 없으면 측정이 어렵습니다. 실전에서는 다음 대안을 많이 씁니다.

  • 오프라인 골든셋: 질의 200~1000개를 만들고, brute-force(정확 검색) 결과의 top-k를 기준으로 근사 검색의 recall@k를 측정
  • 온라인 간접 지표: 답변 채택률, 재질문율, 인용 chunk 다양성, top-1 거리 분포 변화

오프라인에서 brute-force 비교를 하려면 인덱스를 타지 않는 쿼리도 필요합니다.

-- 인덱스 비활성화는 세션 레벨에서 조심해서 사용
SET enable_indexscan = off;
SET enable_bitmapscan = off;

SELECT id
FROM rag_chunks
WHERE tenant_id = 42
ORDER BY embedding <-> $1
LIMIT 10;

주의: 운영 DB에서 위 설정을 그대로 쓰면 위험합니다. 별도 환경에서 측정하거나, 샘플 테이블로 실험하세요.

필터(tenant, time, doc)와 벡터 인덱스의 충돌 해결

RAG는 보통 “벡터 유사도 + 메타데이터 필터”가 필수입니다. 여기서 자주 겪는 문제는 다음입니다.

  • 필터가 강하면 좋은데, 필터가 약하면 후보군이 커져서 벡터 인덱스가 사실상 전역 탐색처럼 됨
  • tenant_id가 수천 개이고 각 테넌트 데이터가 작으면, 전역 인덱스 하나가 비효율적일 수 있음

해결 전략:

  1. 파티셔닝: 테넌트나 기간 기준 파티션으로 물리적으로 분리
  2. 부분 인덱스: 특정 조건(예: 최근 90일)만 벡터 인덱스 적용
  3. 2단계 검색: 1차로 필터를 강하게 걸어 후보 doc을 좁히고, 2차로 벡터 정렬

부분 인덱스 예시:

CREATE INDEX rag_chunks_recent_hnsw_idx
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WHERE created_at >= now() - interval '90 days'
WITH (m = 16, ef_construction = 128);

최근 데이터가 더 중요하고 트래픽도 최근에 몰리면, 이 방식이 비용 대비 효과가 큽니다.

EXPLAIN으로 “진짜로” 인덱스를 타는지 확인

튜닝할 때 가장 흔한 실수는 “인덱스를 만들었는데 플래너가 안 탄다”입니다. 반드시 확인하세요.

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

체크 포인트:

  • 플랜에 Index Scan using ... ivfflat 또는 ... hnsw가 보이는지
  • BUFFERS에서 shared hit vs read 비율(디스크 read가 많으면 캐시/스토리지 병목)
  • 실행 시간이 안정적인지(p95/p99)

쓰기(ingestion)와 인덱스 비용: RAG는 결국 파이프라인 문제

RAG는 보통 다음 파이프라인을 가집니다.

  • 문서 수집 → chunking → embedding 생성 → DB insert

여기서 embedding 생성이 느리면 전체가 막히고, 반대로 DB insert가 느려도 적재 지연이 커집니다. 로컬 모델로 임베딩을 뽑는다면 OOM이나 속도 이슈가 빈번하니 이 글을 함께 참고하면 좋습니다: Transformers 로컬 LLM OOM·속도 최적화 가이드

DB 레벨에서는 다음을 고려하세요.

  • 대량 insert는 가능한 배치로 묶고, 트랜잭션을 너무 잘게 쪼개지 않기
  • maintenance_work_mem을 충분히 주어 인덱스 빌드/유지 비용을 낮추기
  • autovacuum이 밀리면 테이블/인덱스 부풀림으로 검색이 느려짐

추천 튜닝 레시피(출발점)

데이터 분포와 하드웨어에 따라 달라지지만, RAG에서 자주 쓰는 출발점은 아래와 같습니다.

규모: 100만 chunk, 1536-d, 테넌트 필터 있음

  • HNSW 우선 시도
    • m = 16
    • ef_construction = 128
    • hnsw.ef_search = 40에서 시작, recall 부족 시 80

규모: 500만 chunk, 쓰기량 큼, 비용 민감

  • IVFFlat 고려
    • lists = 5000에서 시작
    • ivfflat.probes = 10에서 시작, recall 부족 시 20
    • 주기적 REINDEX ... CONCURRENTLY 계획

장애/운영 체크리스트

  • 인덱스 생성/리인덱스가 트래픽 시간대에 락과 I/O 스파이크를 유발하지 않는가
  • ANALYZE가 충분히 자주 수행되는가(대량 적재 후 필수)
  • p95/p99 지연이 특정 테넌트에서만 튀는가(데이터 편중 의심)
  • 디스크 read가 급증하는가(캐시 미스, 인덱스/테이블 부풀림, 워킹셋 증가)
  • 커넥션 풀에서 대기가 생기는가(검색이 느려지면 2차로 풀 고갈이 옴)

마무리: “파라미터”보다 중요한 건 측정과 분리

IVFFlat과 HNSW는 모두 강력하지만, RAG에서는 단순히 인덱스만 바꿔서는 끝나지 않습니다. 필터 설계(테넌트, 기간), 파티셔닝/부분 인덱스 같은 물리 설계, 그리고 오프라인 골든셋 기반의 recall 측정이 함께 가야 합니다.

정리하면:

  • 쓰기/메모리 여유가 있고 높은 recall이 필요하면 HNSW가 유리한 경우가 많음
  • 비용 민감하고 워크로드가 안정적이면 IVFFlat도 충분히 강력함
  • EXPLAIN (ANALYZE, BUFFERS)로 실제 플랜을 확인하고, probes 또는 ef_search를 단계적으로 조정

원하면 다음 단계로, “테넌트별 파티셔닝 설계 예시”나 “골든셋으로 recall@k 자동 측정 스크립트(psql + Python)”까지 이어서 정리해줄 수 있습니다.