Published on

Postgres pgvector RAG 인덱스 튜닝 - IVFFlat·HNSW

Authors

RAG(Retrieval-Augmented Generation)에서 검색 품질은 곧 답변 품질로 이어집니다. pgvector는 Postgres 안에서 벡터 유사도 검색을 제공해 운영 단순성을 얻을 수 있지만, 기본 설정만으로는 지연시간(latency)과 재현율(recall) 사이의 균형이 쉽게 깨집니다. 특히 데이터가 수십만~수천만으로 커지면 ORDER BY embedding <-> query LIMIT k 형태의 정확 검색(exact search)은 비용이 급격히 증가합니다.

이 글에서는 pgvector의 대표적인 근사 최근접 탐색(ANN) 인덱스인 IVFFlat과 HNSW를 RAG 관점에서 비교하고, 실제로 성능을 끌어올리는 튜닝 포인트(파라미터, 쿼리 패턴, 운영 체크리스트)를 정리합니다. 운영 중 장애/성능 이슈를 다루는 관점은 락 분석 글인 MySQL 8.0 MDL 잠금 대기·교착 진단과 해결과도 결이 비슷하니, “원인-측정-개선” 흐름으로 읽으면 도움이 됩니다.

RAG에서 pgvector 인덱스가 중요한 이유

RAG 검색 단계는 대개 다음 요구를 동시에 만족해야 합니다.

  • 낮은 p95/p99 지연시간: LLM 호출 전에 검색이 병목이 되면 전체 응답시간이 늘어납니다.
  • 충분한 recall: 상위 k 문서가 잘못 뽑히면 생성 품질이 급락합니다.
  • 필터링과 결합: 테넌트, 문서 타입, 최신성, 권한 등 조건이 함께 들어갑니다.
  • 빈번한 업데이트: 문서가 계속 추가되고 임베딩이 갱신됩니다.

pgvector는 Postgres의 트랜잭션/백업/권한/SQL 생태계를 그대로 활용할 수 있어 매력적이지만, ANN 인덱스는 “대충 빨라지는 마법”이 아니라 워크로드에 맞춘 파라미터 설계가 핵심입니다.

기본 스키마와 거리 함수 선택

pgvector는 보통 코사인/내적/유클리드 거리 중 하나를 씁니다. 임베딩 모델이 정규화(normalize)된 벡터를 출력한다면 코사인과 내적이 사실상 비슷한 순위를 만들 수 있습니다.

아래는 RAG용 최소 스키마 예시입니다.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE rag_chunks (
  id           bigserial PRIMARY KEY,
  tenant_id    text NOT NULL,
  doc_id       text NOT NULL,
  chunk_id     int  NOT NULL,
  content      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);
CREATE INDEX rag_chunks_created_at_idx ON rag_chunks (created_at);

검색 쿼리는 대개 다음 형태입니다.

-- 코사인 거리 예시(모델/설정에 맞게 연산자 선택)
SELECT id, doc_id, chunk_id, content
FROM rag_chunks
WHERE tenant_id = $1
ORDER BY embedding <-> $2
LIMIT 10;

중요한 점은, ANN 인덱스를 쓰려면 연산자 클래스(opclass)인덱스 타입을 올바르게 선택해야 한다는 것입니다. (pgvector 버전에 따라 지원이 조금씩 다를 수 있으니, 운영 버전의 문서를 함께 확인하세요.)

Exact vs ANN: 언제 무엇을 쓰나

  • Exact(인덱스 없이 정렬): 데이터가 작거나, 필터가 매우 강해서 후보가 극히 적을 때 유리합니다.
  • IVFFlat: 대규모 데이터에서 빠른 검색을 제공하지만, 빌드 시점의 클러스터링 품질과 probes 튜닝에 성능이 좌우됩니다.
  • HNSW: 높은 recall과 낮은 지연시간을 달성하기 쉬운 편이며, 파라미터(m, ef_construction, ef_search)로 품질/비용을 조절합니다.

실전에서는 “기본은 HNSW, 특정 조건에서는 IVFFlat”처럼 단일 정답이 있는 게 아니라, 데이터 크기, 업데이트 패턴, 메모리 예산으로 선택이 갈립니다.

IVFFlat 튜닝 포인트

IVFFlat은 벡터를 여러 리스트(list)로 나누고, 검색 시 일부 리스트만 탐색하는 방식입니다.

1) lists: 클러스터 개수

  • lists가 너무 작으면 리스트 하나가 커져서 탐색 비용이 증가합니다.
  • lists가 너무 크면 각 리스트가 너무 작아져서 분할 오버헤드가 늘고, recall이 떨어질 수도 있습니다.

경험칙으로는 데이터가 N개일 때 lists를 대략 sqrt(N) 근방에서 시작해 측정으로 조정하는 경우가 많습니다. 예를 들어 N=1,000,000이면 lists를 1000 전후로 시작해볼 수 있습니다.

2) probes: 검색 시 탐색할 리스트 수

probes는 IVFFlat의 핵심 튜닝 노브입니다.

  • probes를 늘리면 recall이 올라가지만 지연시간이 증가합니다.
  • probes를 줄이면 빨라지지만 recall이 떨어집니다.

probes는 세션 단위로도 조정 가능하므로, 온라인 트래픽과 배치 평가에서 다른 값을 쓰는 전략이 가능합니다.

3) 생성 및 사용 예시

(연산자 클래스는 거리 타입에 맞춰 선택해야 하며, 아래는 예시입니다.)

-- IVFFlat 인덱스 생성 예시
CREATE INDEX rag_chunks_embedding_ivfflat_idx
ON rag_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 1000);

ANALYZE rag_chunks;

-- 검색 품질/지연시간에 직접 영향
SET ivfflat.probes = 10;

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

4) IVFFlat 운영 팁

  • 데이터가 계속 늘어나는 경우: 초기 lists가 시간이 지나며 부적절해질 수 있습니다. 주기적으로 재평가하고, 필요하면 인덱스를 재생성하는 계획이 필요합니다.
  • 빌드 비용: 대규모 테이블에서 IVFFlat 인덱스 생성은 시간이 걸릴 수 있으니, CREATE INDEX CONCURRENTLY로 영향도를 낮추는 것도 고려하세요.

HNSW 튜닝 포인트

HNSW는 그래프 기반 ANN으로, 보통 IVFFlat보다 높은 recall을 더 쉽게 얻는 편입니다. 대신 인덱스 크기와 빌드/삽입 비용이 커질 수 있습니다.

1) m: 그래프 연결 수(대략적 복잡도)

  • m이 커질수록 recall이 좋아지는 경향이 있지만, 인덱스 크기와 빌드 비용이 증가합니다.
  • RAG에서 흔한 시작점은 m=16 또는 m=32입니다. (데이터 크기/차원/분포에 따라 달라집니다.)

2) ef_construction: 인덱스 빌드 품질

  • 인덱스 생성(또는 대량 삽입) 시 품질과 시간을 바꿉니다.
  • 크게 잡으면 품질이 좋아질 수 있으나 빌드 시간이 늘어납니다.

3) ef_search: 검색 시 탐색 폭

  • ef_search를 올리면 recall이 올라가고 지연시간이 증가합니다.
  • IVFFlat의 probes와 유사한 역할로, 실시간 트래픽에서 가장 자주 만지는 값입니다.

4) 생성 및 사용 예시

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

ANALYZE rag_chunks;

-- 세션 단위로 recall/latency 트레이드오프 조절
SET hnsw.ef_search = 64;

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

RAG에서 자주 터지는 “필터 + 벡터” 함정

실전 RAG는 거의 항상 tenant_id, doc_type, created_at, 권한 같은 필터가 있습니다. 여기서 흔한 함정은 다음입니다.

1) 필터가 강한데도 ANN을 강제로 쓰는 경우

후보가 수백 건뿐인데도 ANN 인덱스를 타면 오히려 손해일 수 있습니다. 이런 경우는 필터로 먼저 줄인 뒤 exact 정렬이 더 빠를 때가 있습니다.

-- 필터로 후보를 크게 줄일 수 있다면 exact가 유리할 수 있음
WITH candidates AS (
  SELECT id, embedding
  FROM rag_chunks
  WHERE tenant_id = $1
    AND doc_id = $2
)
SELECT id
FROM candidates
ORDER BY embedding <-> $3
LIMIT 10;

2) 필터가 약한데도 복합 인덱스를 기대하는 경우

Postgres는 “벡터 ANN 인덱스 + B-tree 필터”를 한 번에 완벽히 결합해 주지 않습니다. 보통은 플래너가 한쪽을 택하고, 나머지는 필터로 처리합니다. 따라서 테넌트별 파티셔닝, 부분 인덱스, 테넌트별 별도 테이블 같은 물리 설계가 효과적일 수 있습니다.

-- 테넌트가 소수이고 트래픽이 크다면 부분 인덱스도 고려
CREATE INDEX rag_chunks_t1_hnsw_idx
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200)
WHERE tenant_id = 't1';

부분 인덱스는 인덱스 크기를 줄이고 캐시 효율을 높이지만, 테넌트가 많으면 관리 비용이 급증합니다.

3) 최신성 가중치(Recency bias)를 SQL로 억지 구현

RAG에서 “비슷한데 최신 문서를 더” 같은 요구가 많습니다. 하지만 ORDER BY (distance + alpha * time_decay) 같은 형태는 ANN 인덱스가 활용되기 어렵습니다. 이때는 다음 중 하나로 우회합니다.

  • 1차: 벡터로 top k'(예: 100) 후보를 뽑고
  • 2차: 후보에 대해 재정렬(최신성/권한/비즈니스 점수) 적용
-- 2-stage rerank 패턴
WITH nn AS (
  SELECT id, content, created_at, embedding
  FROM rag_chunks
  WHERE tenant_id = $1
  ORDER BY embedding <-> $2
  LIMIT 100
)
SELECT id, content
FROM nn
ORDER BY (embedding <-> $2) + (EXTRACT(EPOCH FROM (now() - created_at)) / 86400.0) * 0.01
LIMIT 10;

이 방식은 ANN 인덱스를 살리면서도 랭킹 정책을 유연하게 적용할 수 있어, RAG 운영에서 매우 자주 쓰입니다.

측정: recall과 latency를 같이 본다

튜닝은 감이 아니라 측정입니다. 최소한 아래 3가지는 루틴으로 확보하는 것을 권합니다.

  1. EXPLAIN (ANALYZE, BUFFERS)로 플랜과 I/O 확인
  2. p95/p99 지연시간(애플리케이션 레벨)
  3. recall@k (오프라인 평가)

간단한 recall@k 평가 아이디어

  • 샘플 쿼리 Q개를 뽑습니다.
  • exact 검색 결과 top k를 “정답 근사”로 둡니다.
  • ANN 결과 top k와 교집합 비율을 recall로 봅니다.

SQL만으로 완전 자동화하긴 어렵지만, 애플리케이션에서 쿼리 벡터를 주고 결과를 비교하는 방식으로 충분히 실용적인 지표를 만들 수 있습니다.

추천 시작 설정(출발점)

데이터/차원/하드웨어에 따라 달라지지만, 출발점으로는 다음이 무난합니다.

  • HNSW
    • m=16
    • ef_construction=200
    • ef_search=40~100 범위에서 latency/recall 맞추기
  • IVFFlat
    • listssqrt(N) 근방에서 시작
    • probes=5~50 범위에서 latency/recall 맞추기

그 다음 아래 질문으로 분기합니다.

  • 쓰기(삽입/업데이트)가 많다: HNSW의 인덱스 유지 비용이 부담인지 확인
  • 메모리가 빡빡하다: 인덱스 크기와 캐시 적중률을 측정
  • 테넌트/권한 필터가 강하다: 파티셔닝/부분 인덱스/2-stage 전략 검토

운영 체크리스트

  • ANALYZE 주기: 통계가 틀어지면 플랜이 흔들립니다.
  • VACUUM/Autovacuum: 업데이트가 많으면 테이블/인덱스 팽창으로 성능이 떨어질 수 있습니다.
  • 커넥션 풀: SET hnsw.ef_search 같은 세션 파라미터를 쓰면 풀 환경에서 세션 재사용에 주의해야 합니다.
  • 벡터 차원: 차원이 커질수록 비용이 늘어납니다. 모델 변경(예: 1536에서 768로)도 큰 튜닝 레버입니다.

인프라 관점에서 장애/성능 튜닝을 체계화하는 접근은 쿠버네티스 장애 글인 K8s CrashLoopBackOff 원인별 로그·Probe 해결 가이드처럼 “관측-가설-검증” 루프를 만드는 것이 장기적으로 가장 효과적입니다.

마무리: IVFFlat vs HNSW 선택 가이드

  • HNSW를 우선 고려: 낮은 지연시간과 높은 recall을 달성하기 쉬워 RAG 기본값으로 좋습니다. ef_search 하나로 운영 중 품질을 조절하기도 편합니다.
  • IVFFlat이 유리한 경우: 메모리/인덱스 크기 제약이 크거나, 데이터가 매우 크고 특정 패턴에서 튜닝이 잘 맞는 경우에 좋은 선택이 될 수 있습니다. 다만 listsprobes의 조합을 반드시 측정으로 찾아야 합니다.

결국 정답은 “내 데이터와 내 쿼리”가 결정합니다. 먼저 HNSW로 기준선을 만들고, 2-stage rerank와 필터 전략까지 포함해 end-to-end로 측정한 뒤, 필요하면 IVFFlat을 비교 실험으로 올려보는 순서가 가장 안전합니다.

추가로 RAG 파이프라인 전체(서빙, 배포, 롤링 업데이트)까지 고려한다면 GPU 추론 운영 관점의 KServe+Knative로 GPU 모델 무중단 카나리 배포 같은 글과 함께 보면 “검색-생성-서빙”을 하나의 시스템으로 튜닝하는 감을 잡는 데 도움이 됩니다.