Published on

pgvector RAG 인덱스가 느릴 때 IVFFlat·HNSW 튜닝

Authors

RAG 파이프라인에서 체감 성능을 가장 크게 좌우하는 구간은 대개 “벡터 Top-K 검색”입니다. pgvector를 PostgreSQL에 붙여서 빠르게 시작할 수 있지만, 데이터가 수십만~수백만 chunk로 늘어나면 인덱스가 있어도 느려지거나, 반대로 인덱스를 만들고 나서 쓰기 성능이 무너지는 문제가 자주 발생합니다.

이 글은 pgvector에서 검색이 느릴 때 가장 많이 쓰는 두 인덱스인 IVFFlatHNSW를 어떻게 고르고, 어떤 파라미터를 어떤 순서로 튜닝해야 하는지 “RAG 운영 관점”에서 정리합니다. 또한 인덱스 자체가 아니라 쿼리/통계/진공 문제로 느려지는 케이스도 함께 다룹니다.

관련해서 운영 중 PostgreSQL이 비대해져서(=bloat) 디스크와 캐시 효율이 급락하는 문제는 아래 글도 같이 보면 좋습니다.

느린 원인부터 분해: “인덱스가 느린가, 쿼리가 인덱스를 안 타는가”

pgvector 성능 이슈는 크게 4가지로 나뉩니다.

  1. 아예 인덱스를 안 탐: 연산자/거리 함수가 인덱스 지원 방식과 안 맞거나, ORDER BY 형태가 달라서 플래너가 포기함
  2. 인덱스는 타는데 튜닝이 안 됨: IVFFlatlists가 부적절하거나, HNSWm, ef_construction, ef_search가 비효율적
  3. 데이터/운영 문제: autovacuum 지연, bloat, 통계 부정확, 과도한 업데이트로 인덱스 품질 저하
  4. RAG 쿼리 패턴 문제: WHERE tenant_id = ... 같은 필터를 같이 걸었는데 인덱스 구조가 그 패턴과 안 맞음(특히 멀티테넌트)

가장 먼저 확인할 것은 “정말 벡터 인덱스를 타는지”입니다.

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

여기서 핵심은 다음입니다.

  • 실행 계획에 Index Scan 또는 Bitmap Index Scan이 보이는지
  • ORDER BY embedding <-> $1 형태가 맞는지
  • BUFFERS에서 shared hit/read가 과도하게 발생하는지(캐시 미스)

만약 Seq Scan이 뜬다면, 우선 인덱스 튜닝이 아니라 “인덱스가 타도록 쿼리를 정렬”해야 합니다.

IVFFlat vs HNSW: 선택 기준을 먼저 고정

IVFFlat이 맞는 경우

  • 데이터가 정적(write 적음)이고, 주기적으로 배치로 인덱스 재구성이 가능
  • 메모리 여유가 크지 않고, 디스크 기반으로도 어느 정도 버틸 수 있어야 함
  • “정확도/지연시간”을 probes로 비교적 단순하게 조절하고 싶음

HNSW가 맞는 경우

  • 온라인 삽입/업데이트가 많고, 인덱스 재빌드 비용을 피하고 싶음
  • 낮은 지연시간이 매우 중요하고, 메모리를 더 써도 됨
  • 튜닝 파라미터가 많지만, 한번 잡아두면 운영이 편해지는 편

실무적으로는 “RAG chunk가 계속 추가되는가”가 가장 큰 분기점입니다. 문서가 자주 들어오는 서비스라면 HNSW가 운영 난이도를 낮춰주는 경우가 많습니다.

공통 전제: 거리 함수와 연산자부터 정합성 맞추기

pgvector에서 자주 쓰는 거리는 크게 3가지입니다.

  • L2 거리: embedding <-> query
  • 내적: embedding <#> query
  • 코사인 거리: embedding <=> query

임베딩 모델이 보통 코사인 유사도를 권장하는 경우가 많지만, 실제로는 “정규화 여부”에 따라 내적/코사인이 사실상 동일해지기도 합니다. 중요한 점은 인덱스를 만든 연산자 클래스와 쿼리 연산자가 일치해야 한다는 것입니다.

예를 들어 코사인 기반으로 검색할 거면 인덱스도 코사인용으로 맞춥니다.

-- 예시: 코사인 거리 기반
CREATE INDEX CONCURRENTLY rag_chunks_embedding_hnsw
ON rag_chunks
USING hnsw (embedding vector_cosine_ops);

그리고 쿼리도 ORDER BY embedding <=> $1 형태로 맞춥니다.

SELECT id, content
FROM rag_chunks
ORDER BY embedding <=> $1
LIMIT 10;

여기서 연산자 불일치가 나면, 인덱스가 있어도 플래너가 못 타거나 성능이 급락합니다.

IVFFlat 튜닝: listsprobes의 균형

IVFFlat은 “클러스터를 lists개로 나누고, 검색 시 probes개 클러스터만 훑는” 구조입니다.

  • lists: 인덱스 생성 시 결정(재생성 필요)
  • probes: 런타임에 조절 가능(세션/트랜잭션 단위)

1) lists 가이드라인

정답은 없지만 운영에서 많이 쓰는 출발점은 아래입니다.

  • 데이터가 N개일 때 lists를 대략 sqrt(N) 또는 그 근처로 시작
  • N = 1,000,000이면 lists1000 전후로 시작해 측정

인덱스 생성 예시는 다음과 같습니다.

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

주의할 점:

  • IVFFlat은 인덱스 생성 시 테이블이 충분히 분석되어야 클러스터가 그럴듯하게 잡힙니다.
  • 인덱스 생성 전후로 ANALYZE를 해주는 것이 안전합니다.
ANALYZE rag_chunks;

2) probes 튜닝: 정확도 vs 지연시간 다이얼

probes는 “얼마나 더 찾아볼 것인가”입니다. 올리면 recall이 좋아지고, 지연시간이 늘어납니다.

-- 세션에서만 적용
SET ivfflat.probes = 10;

SELECT id, content
FROM rag_chunks
ORDER BY embedding <=> $1
LIMIT 10;

튜닝 순서는 보통 이렇게 갑니다.

  1. probes를 1, 5, 10, 20… 식으로 올려가며 latency/recall 측정
  2. 목표 recall이 안 나오면 lists를 재조정(대개 증가) 후 재생성

실무 팁:

  • RAG에서 Top-K가 5~20인 경우가 많기 때문에, probes를 과도하게 올리면 “Top-K는 그대로인데 후보 탐색만 늘어” 손해가 커집니다.
  • 멀티테넌트에서 WHERE tenant_id = ...로 강하게 필터링한다면, 전역 IVFFlat 하나로는 효율이 떨어질 수 있습니다(아래 “필터와 인덱스” 참고).

HNSW는 그래프 기반 근사 최근접 탐색입니다.

  • m: 각 노드가 유지하는 이웃 수(대체로 메모리/인덱스 크기와 연관)
  • ef_construction: 인덱스 구축 품질(클수록 구축 느리지만 품질↑)
  • ef_search: 검색 시 후보 확장 폭(클수록 recall↑, latency↑)

1) 인덱스 생성 파라미터

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

출발점으로 많이 쓰는 조합은 다음입니다.

  • m = 16 또는 m = 32
  • ef_construction = 100~400

데이터가 크고 품질이 중요하면 ef_construction을 올리되, 구축 시간이 길어지는 것을 감안해야 합니다.

2) 런타임: ef_search로 검색 품질 조절

ef_search는 세션 단위로 조절합니다.

SET hnsw.ef_search = 50;

SELECT id, content
FROM rag_chunks
ORDER BY embedding <=> $1
LIMIT 10;

튜닝 방법은 IVFFlat의 probes와 유사합니다.

  1. ef_search를 20, 50, 100…으로 올려가며 latency/recall 측정
  2. 목표 recall이 안 나오면 m 또는 ef_construction을 재조정(재인덱싱 필요)

실무에서 자주 보는 패턴:

  • 지연시간이 불안정하게 튄다: ef_search가 너무 크거나, 캐시 미스가 많거나, 동시성으로 CPU가 튄다
  • recall이 낮다: ef_search만 올려도 해결되는 경우가 많고, 그래도 안 되면 m을 올려야 합니다

RAG에서 자주 터지는 함정: 벡터 검색에 필터를 섞는 방식

대부분의 서비스는 다음 중 하나를 합니다.

  • 멀티테넌트: WHERE tenant_id = ...
  • 권한/스코프: WHERE doc_id IN (...) 또는 WHERE workspace_id = ...
  • 최신 문서 우선: WHERE created_at >= ...

문제는 “벡터 인덱스가 먼저 후보를 뽑고, 그 뒤 필터링”이 되면 후보가 버려져 Top-K 품질이 떨어지거나, 반대로 “필터가 먼저 적용되어야 하는데” 플래너가 비효율적으로 실행할 수 있다는 점입니다.

해결 전략 1) 테넌트별 파티셔닝 + 로컬 인덱스

테넌트가 크고 분리가 명확하면 파티셔닝이 깔끔합니다.

-- 예시(개념): tenant_id 기준 파티셔닝 후 각 파티션에 벡터 인덱스

이러면 검색 범위 자체가 줄어 벡터 인덱스 효율이 좋아집니다.

해결 전략 2) 2단계 검색(후보 확장 후 재랭킹)

필터가 강하면 “후보를 넉넉히 뽑고 애플리케이션에서 필터링/재랭킹”이 더 안정적일 때가 있습니다.

  • 1단계: 벡터로 Top-K * alpha 후보 검색
  • 2단계: 필터 적용 + 필요하면 리랭커 적용

리랭커로 환각을 줄이고 검색 품질을 올리는 접근은 아래 글도 참고할 수 있습니다.

운영 체크리스트: 인덱스 튜닝 전에 반드시 볼 것들

1) 통계 갱신과 플래너

대량 적재 후 ANALYZE가 안 되어 있으면 플래너가 잘못된 비용 추정으로 엉뚱한 계획을 선택할 수 있습니다.

VACUUM (ANALYZE) rag_chunks;

2) bloat와 autovacuum

업데이트/삭제가 많은 chunk 테이블은 bloat가 빠르게 쌓여 캐시 효율이 무너지고, 결과적으로 벡터 인덱스도 느려집니다. autovacuum이 밀리면 특히 심각합니다.

  • 테이블/인덱스 크기 증가 추이
  • dead tuple 증가
  • autovacuum 실행 간격

이 주제는 아래 글이 실전 대응에 도움이 됩니다.

3) 동시성과 커넥션 풀

벡터 검색은 CPU를 많이 씁니다. 동시 요청이 늘면 “각 쿼리는 빠른데 p95/p99가 느려지는” 현상이 흔합니다.

  • 커넥션 풀에서 동시 쿼리 수 제한
  • 애플리케이션 레벨에서 검색 요청 rate limit 또는 큐잉
  • 필요한 경우 read replica로 검색 분리

실전 튜닝 플로우(권장 순서)

  1. 쿼리 형태 고정: ORDER BY embedding 연산자 $1 LIMIT K 형태로, 인덱스 연산자 클래스와 일치
  2. EXPLAIN으로 인덱스 탑승 확인: Seq Scan이면 인덱스/연산자/통계부터
  3. 필터 패턴 정리: 멀티테넌트/권한 필터가 강하면 파티셔닝 또는 2단계 검색 고려
  4. 인덱스 선택
    • 업데이트가 많으면 HNSW 우선 검토
    • 배치 위주면 IVFFlat도 충분
  5. 런타임 파라미터부터 튜닝
    • IVFFlat: ivfflat.probes
    • HNSW: hnsw.ef_search
  6. 인덱스 생성 파라미터 재조정(재인덱싱)
    • IVFFlat: lists
    • HNSW: m, ef_construction
  7. 운영 안정화: autovacuum/통계/캐시/동시성 점검

예시: RAG 테이블 스키마와 인덱스 세트

아래는 흔한 chunk 테이블 예시입니다.

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 CONCURRENTLY rag_chunks_tenant_doc
ON rag_chunks (tenant_id, doc_id);

-- 벡터 인덱스: HNSW 예시
CREATE INDEX CONCURRENTLY rag_chunks_embedding_hnsw
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);

검색 시에는 세션에서 ef_search를 조절해 품질/지연시간을 맞춥니다.

BEGIN;
SET LOCAL hnsw.ef_search = 50;

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

COMMIT;

SET LOCAL을 쓰면 트랜잭션 범위로만 적용되어, 특정 API 엔드포인트에서만 공격적으로 튜닝하기 쉽습니다.

마무리: “튜닝”은 숫자 맞추기가 아니라 목표를 정하는 일

pgvector에서 인덱스가 느릴 때는 무작정 listsef_search를 올리기보다, 먼저 목표를 수치로 고정하는 것이 중요합니다.

  • 목표 p95 지연시간(예: 80ms)
  • 목표 recall(예: 오프라인 평가셋 기준 0.95)
  • 허용 가능한 인덱스 크기/메모리
  • 쓰기 빈도(온라인 삽입 vs 배치)

그 다음에 IVFFlat이면 probes부터, HNSW면 ef_search부터 조절하고, 마지막에 재인덱싱이 필요한 파라미터로 넘어가면 시행착오가 크게 줄어듭니다. RAG 품질 자체를 더 끌어올려야 한다면 “검색 후보 확장 + 리랭킹”까지 포함해 전체 파이프라인을 보는 것이 가장 효과적입니다.