Published on

pgvector RAG 인덱스 튜닝 - IVFFlat·HNSW 성능

Authors

서버 사이드 RAG에서 pgvector를 쓰면 장점은 명확합니다. 트랜잭션, 권한, 백업, 조인까지 Postgres 생태계 안에서 해결되니까요. 하지만 운영 단계로 가면 질문이 바뀝니다.

  • 검색 지연시간이 튄다
  • 재현율이 기대보다 낮다
  • 인덱스가 커지고 VACUUM/리인덱스 비용이 부담된다

이 글은 pgvector의 대표적인 ANN 인덱스인 IVFFlatHNSW를 RAG 관점에서 비교하고, 실무에서 “어떤 파라미터를 어디부터 만져야 하는지”를 SQL 중심으로 정리합니다.

전제: RAG의 벡터 검색이 느려지는 지점

RAG의 온라인 경로는 보통 다음 순서입니다.

  1. 쿼리 임베딩 생성
  2. 벡터 근접 검색(Top K)
  3. 후보 문서 필터링(테넌트, 권한, 상태, 시간)
  4. 재랭킹(옵션)
  5. LLM 컨텍스트 구성

이 중 Postgres는 2~3을 담당합니다. 문제는 벡터 검색이 “정렬 동반 Top K”라서, 인덱스가 없거나 설정이 안 맞으면 전체 스캔 + 거리 계산으로 바로 폭발합니다.

또한 RAG는 보통 다음 특성이 있습니다.

  • top_k가 작다(예: 5~50)
  • 필터가 있다(테넌트, 컬렉션, 문서 타입)
  • 업데이트/추가가 지속된다(새 문서 ingestion)

이 특성 때문에 IVFFlatHNSW의 장단점이 더 극명하게 드러납니다.

기본 스키마와 쿼리 패턴

아래는 흔한 RAG 스키마 예시입니다.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE rag_chunks (
  id bigserial PRIMARY KEY,
  tenant_id bigint NOT NULL,
  collection_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_collection_idx
  ON rag_chunks (tenant_id, collection_id);

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

-- $1: tenant_id, $2: collection_id, $3: query_embedding, $4: top_k
SELECT id, doc_id, chunk_index, content
FROM rag_chunks
WHERE tenant_id = $1
  AND collection_id = $2
ORDER BY embedding <=> $3
LIMIT $4;

여기서 핵심은 WHERE 필터와 ORDER BY embedding <=> ...가 같이 있다는 점입니다. 인덱스 선택이 잘못되면 “벡터 인덱스는 타고 싶지만 필터를 못 걸어서 비효율” 혹은 “필터는 잘 타지만 벡터 정렬 때문에 느림”이 됩니다.

IVFFlat vs HNSW: 선택 기준 한 장 요약

둘 다 근사 최근접(ANN) 인덱스지만 성격이 다릅니다.

IVFFlat이 유리한 경우

  • 데이터가 매우 크고(수백만~수천만) 메모리 압박이 크다
  • 인덱스 빌드/리빌드 주기를 운영으로 감당할 수 있다
  • 튜닝 포인트를 단순화하고 싶다(lists, probes)

HNSW가 유리한 경우

  • 낮은 지연시간이 최우선(특히 p95/p99)
  • 재현율을 높게 유지하고 싶다
  • 온라인 삽입이 잦고, 리빌드 비용을 줄이고 싶다

다만 “무조건 HNSW가 더 좋다”는 결론은 위험합니다. HNSW는 메모리/디스크 오버헤드가 커질 수 있고, 파라미터를 과하게 올리면 쓰기 성능과 인덱스 크기가 급격히 증가합니다.

IVFFlat 튜닝: listsprobes가 전부다

IVFFlat은 개념적으로 k-means로 공간을 lists개의 버킷(클러스터)으로 나누고, 검색 시 일부 버킷만 탐색합니다.

  • lists: 인덱스 빌드 시 클러스터 개수(정적)
  • probes: 검색 시 탐색할 클러스터 개수(동적)

IVFFlat 인덱스 생성

코사인 거리(내적 기반) 예시:

CREATE INDEX rag_chunks_embedding_ivfflat
ON rag_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 2000);

ANALYZE rag_chunks;

lists는 데이터 규모에 따라 잡습니다. 실무에서 많이 쓰는 출발점은 아래 중 하나입니다.

  • lists = sqrt(N) 근처
  • 또는 lists = N / 1000 근처(너무 작으면 품질 저하)

여기서 N은 “실제로 함께 검색되는 풀의 크기”가 중요합니다. 테넌트/컬렉션 필터가 강하면 전체 테이블이 아니라 “컬렉션당 평균 청크 수”를 기준으로 보는 게 더 현실적입니다.

probes 설정(세션/쿼리 단위)

probes는 검색 품질과 속도의 트레이드오프를 즉시 바꿉니다.

SET ivfflat.probes = 10;

SELECT id, content
FROM rag_chunks
WHERE tenant_id = 1 AND collection_id = 10
ORDER BY embedding <=> $1
LIMIT 20;
  • probes를 올리면 재현율 상승, 지연시간 증가
  • 너무 낮으면 “엉뚱한 문서”가 섞여 RAG 품질이 흔들림

권장 흐름은 다음입니다.

  1. probes = 1, 5, 10, 20, 50으로 A/B 측정
  2. 오프라인에서 재현율을 측정(뒤에서 설명)
  3. p95 지연시간이 목표를 넘지 않는 선에서 최대 재현율 지점을 선택

IVFFlat의 실무 함정: 필터가 강하면 오히려 손해

WHERE tenant_id = ... AND collection_id = ...로 후보가 작아지면, IVFFlat의 클러스터링이 “전체 데이터 기준”으로 되어 있어 필터 후 공간 분포가 깨질 수 있습니다.

이때는 다음 중 하나를 고려합니다.

  • 컬렉션별 파티셔닝 후 파티션마다 인덱스(운영 난이도 상승)
  • 컬렉션 단위로 테이블 분리(멀티 테넌트라면 더 복잡)
  • HNSW로 전환(필터 후에도 상대적으로 안정적인 경우가 많음)

HNSW는 그래프 기반 인덱스입니다. 검색 시 그래프를 따라가며 후보를 확장합니다.

  • m: 노드당 연결 수(대략 인덱스 크기와 품질에 영향)
  • ef_construction: 빌드/삽입 시 탐색 폭(품질 vs 빌드/쓰기 비용)
  • ef_search: 검색 시 탐색 폭(품질 vs 지연시간)

HNSW 인덱스 생성

CREATE INDEX rag_chunks_embedding_hnsw
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);

ANALYZE rag_chunks;

출발점으로는 m = 16이 무난하고, 품질이 부족하면 m = 24 또는 32로 올립니다. 다만 m을 올리면 인덱스 크기와 빌드 비용이 빠르게 증가합니다.

ef_search 설정

SET hnsw.ef_search = 80;

SELECT id, content
FROM rag_chunks
WHERE tenant_id = 1 AND collection_id = 10
ORDER BY embedding <=> $1
LIMIT 20;
  • ef_search가 사실상 “품질 다이얼”입니다.
  • top_k가 20이면, ef_search는 보통 40~200 범위에서 최적점이 나옵니다.

권장 튜닝 순서:

  1. m은 고정(16)하고 ef_search를 올리며 재현율/지연시간 곡선 확인
  2. 재현율 상한이 낮다면 m을 단계적으로 증가
  3. 삽입이 많고 품질이 들쭉날쭉하면 ef_construction을 올려 안정화

성능 측정: “지연시간”만 보면 튜닝이 실패한다

RAG는 결국 “정답 문서가 Top K에 들어왔는가”가 중요합니다. 따라서 오프라인 평가로 재현율@K(Recall@K)를 같이 봐야 합니다.

1) 기준선(정확 검색) 만들기

정확 검색은 인덱스를 쓰지 않고 전체 거리 계산을 하는 방식입니다. 데이터가 크면 샘플링하거나, 컬렉션 단위로만 수행합니다.

-- 정확 검색(느림): 작은 컬렉션에서만 수행 권장
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM rag_chunks
WHERE tenant_id = 1 AND collection_id = 10
ORDER BY embedding <=> $1
LIMIT 20;

이 결과를 “정답 Top K”로 저장해두고 ANN 결과와 비교합니다.

2) ANN 결과 측정

SET hnsw.ef_search = 80;

EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM rag_chunks
WHERE tenant_id = 1 AND collection_id = 10
ORDER BY embedding <=> $1
LIMIT 20;

3) Recall@K 계산 아이디어

운영 환경에서는 애플리케이션 레벨에서 다음처럼 계산하는 게 현실적입니다.

  • 동일한 쿼리 셋에 대해
  • 정확 검색 Top K(또는 더 큰 K)를 “정답”으로 저장
  • ANN Top K와 교집합 비율을 계산

SQL만으로도 교집합은 가능합니다(테스트 테이블이 있다고 가정).

-- ground_truth(query_id, chunk_id)
-- ann_result(query_id, chunk_id)

SELECT
  g.query_id,
  COUNT(*)::float / NULLIF((SELECT COUNT(*) FROM ground_truth gg WHERE gg.query_id = g.query_id), 0) AS recall
FROM ground_truth g
JOIN ann_result a
  ON a.query_id = g.query_id
 AND a.chunk_id = g.chunk_id
GROUP BY g.query_id;

필터 최적화: 벡터 인덱스만으로는 부족하다

실무 RAG는 필터가 거의 항상 있습니다. 여기서 흔한 병목은 “필터가 먼저 적용되면서 벡터 인덱스 효율이 떨어지는” 상황입니다.

패턴 1) 컬렉션이 매우 크면 파티셔닝 고려

컬렉션 단위로 접근이 고정이라면 range/list 파티셔닝이 효과적일 수 있습니다.

-- 예시(간단화): collection_id로 파티셔닝
CREATE TABLE rag_chunks_p (
  LIKE rag_chunks INCLUDING ALL
) PARTITION BY LIST (collection_id);

파티션별로 벡터 인덱스를 만들면 “검색 풀 크기”가 줄고 ANN 품질이 안정화되는 경우가 많습니다. 대신 파티션 관리 비용이 생깁니다.

패턴 2) 후보군을 먼저 줄이는 2단계 검색

필터가 복잡하거나 권한 테이블과 조인이 필요하면, 먼저 후보 doc을 좁히고 그 안에서 벡터 검색을 하는 전략이 있습니다.

WITH allowed_docs AS (
  SELECT doc_id
  FROM doc_acl
  WHERE tenant_id = $1 AND user_id = $2
), candidates AS (
  SELECT c.*
  FROM rag_chunks c
  JOIN allowed_docs d ON d.doc_id = c.doc_id
  WHERE c.collection_id = $3
)
SELECT id, content
FROM candidates
ORDER BY embedding <=> $4
LIMIT 20;

단, CTE가 항상 최적화되는 것은 아니니 EXPLAIN (ANALYZE, BUFFERS)로 실제 플랜을 확인해야 합니다.

운영 팁: 인덱스/메모리/장애 포인트

1) 인덱스 빌드와 리빌드 전략

  • IVFFlat은 데이터 분포가 바뀌면 품질이 흔들릴 수 있어 주기적 리빌드가 필요해질 수 있습니다.
  • HNSW는 삽입이 잦아도 상대적으로 유지가 편하지만, 파라미터를 과하게 잡으면 인덱스가 커지고 캐시 미스가 늘어 p99가 악화될 수 있습니다.

빌드 시에는 다음을 점검합니다.

-- 인덱스 크기 확인
SELECT
  indexrelid::regclass AS index_name,
  pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_index
WHERE indrelid = 'rag_chunks'::regclass;

2) 워크로드가 커지면 “메모리 부족”이 먼저 온다

벡터 검색은 랜덤 I/O와 캐시 의존도가 큽니다. 인덱스가 커지면 페이지 캐시 적중률이 떨어지고 지연시간이 튑니다. 이 문제는 애플리케이션 레벨에서 보면 “간헐적인 타임아웃”으로 보입니다.

K8s 환경이라면 OOM/GC/limit 이슈가 동반되기도 합니다. 이런 종류의 장애 패턴은 인덱스 튜닝과 별개로 인프라 튜닝이 필요할 수 있습니다. 관련해서는 EKS Pod OOMKilled 반복 원인과 메모리·GC·Limit 튜닝도 함께 참고하면 좋습니다.

3) 쿼리 플랜 고정관념을 버리고 항상 확인

튜닝은 “설정값”보다 “실제 플랜”이 답입니다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM rag_chunks
WHERE tenant_id = 1 AND collection_id = 10
ORDER BY embedding <=> $1
LIMIT 20;

여기서 확인할 것:

  • Index Scan using ... ivfflat/hnsw가 뜨는가
  • Rows Removed by Filter가 과도하게 큰가
  • shared hit 대비 read가 과도하게 큰가(캐시 미스)

추천 튜닝 레시피(실무용)

레시피 A: 데이터가 크고 비용을 예측 가능하게

  • IVFFlat 선택
  • lists를 컬렉션 평균 크기 기준으로 산정
  • probes를 낮게 시작해 점진적으로 올리며 Recall@K를 맞추기

예:

CREATE INDEX rag_chunks_embedding_ivfflat
ON rag_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 4000);

SET ivfflat.probes = 10;

레시피 B: 온라인 RAG에서 p95를 최우선

  • HNSW 선택
  • m = 16에서 시작
  • ef_search로 품질을 맞추고, 부족하면 m을 올리기

예:

CREATE INDEX rag_chunks_embedding_hnsw
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);

SET hnsw.ef_search = 120;

자주 하는 실수 체크리스트

  • 임베딩 차원(vector(1536) 등)과 실제 임베딩이 불일치
  • 거리 연산자(<=>, <#>, <->)를 혼용하면서 ops 클래스를 잘못 선택
  • 필터 컬럼 인덱스 없이 벡터 인덱스만 믿고 쿼리
  • probes 또는 ef_search를 “높을수록 좋다”로 고정해 지연시간 폭발
  • 재현율을 측정하지 않고 지연시간만 보고 튜닝 완료 선언

마무리

pgvector에서 RAG 성능을 안정적으로 끌어올리려면, 인덱스 선택(IVFFlat vs HNSW) 자체보다도 다음 3가지를 같이 맞춰야 합니다.

  1. 필터를 포함한 실제 검색 풀 크기 기준으로 파라미터를 잡기
  2. EXPLAIN (ANALYZE, BUFFERS)로 플랜과 I/O를 확인하며 튜닝하기
  3. 지연시간과 함께 Recall@K를 반드시 측정하기

이 과정을 한 번 체계화해두면, 데이터가 늘어나거나 컬렉션 구조가 바뀌어도 “감”이 아니라 “측정”으로 튜닝을 반복할 수 있습니다.