Published on

pgvector HNSW 튜닝으로 RAG 응답 2배 빠르게

Authors

RAG에서 체감 성능을 좌우하는 구간은 대개 retrieval입니다. LLM 생성 시간이 아무리 빨라도, 벡터 검색이 느리면 전체 응답이 늘어지고 타임아웃·재시도·비용 증가로 이어집니다. 특히 PostgreSQL + pgvector 조합은 운영 친화적이지만, 기본 설정 그대로 HNSW를 쓰면 데이터 크기·필터 조건·동시성에 따라 p95가 쉽게 튀는 편입니다.

이 글은 pgvector의 HNSW를 RAG 쿼리 패턴에 맞게 튜닝해, 같은 하드웨어에서 응답을 2배 가까이 단축하는 데 초점을 둡니다. 핵심은 다음 3가지입니다.

  • 인덱스 빌드 파라미터 m, ef_construction을 데이터 특성에 맞게 잡기
  • 런타임 파라미터 hnsw.ef_search정확도-지연시간 곡선에서 최적점 찾기
  • 필터링, 쿼리 형태, 통계/메모리 설정까지 함께 정리하기

튜닝은 Docker 빌드 캐시 튜닝처럼 “한 방 옵션”이 아니라, 병목을 계측하고 워크로드에 맞춰 조정해야 효과가 큽니다. 비슷한 접근이 궁금하다면 Docker 빌드 느릴 때 - BuildKit 캐시 10배 튜닝도 참고할 만합니다.

전제: RAG에서 HNSW가 빨라지는 조건

HNSW는 근사 최근접 탐색(ANN)이라, **정확도(recall)**를 조금 양보하고 지연시간을 크게 줄일 수 있습니다. 다만 다음 조건에서 효과가 크게 나옵니다.

  • 데이터가 수십만~수천만 벡터로 커서, 정확 탐색(브루트포스)이 비현실적일 때
  • top_k가 작고(예: 5~50), 질의가 많고, p95/p99가 중요한 서비스일 때
  • 필터가 “너무 강하지” 않아 탐색 공간이 지나치게 줄어들지 않을 때

반대로, 필터가 매우 강해 후보가 수백 개 수준으로 떨어진다면 HNSW의 장점이 줄고, 다른 전략(파티셔닝, 필터 선적용, 하이브리드 검색)이 더 낫기도 합니다.

스키마와 인덱스: RAG에 흔한 테이블 설계

아래는 문서 청크 기반 RAG에서 흔한 형태입니다.

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

RAG 쿼리는 보통 “테넌트별” 또는 “컬렉션별” 필터가 붙습니다. 이때 벡터 인덱스만 믿고 필터를 방치하면, 플래너가 비효율적인 경로를 타거나 후보 재검증 비용이 커질 수 있습니다.

HNSW 인덱스 생성: mef_construction

pgvector HNSW 인덱스는 대략 다음처럼 만듭니다.

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

여기서 중요한 파라미터는 2개입니다.

m: 그래프 연결도(메모리·속도·정확도에 영향)

  • m이 클수록 그래프가 촘촘해져 recall이 좋아지고, 탐색이 안정적이지만
  • 인덱스 크기와 빌드 시간이 늘고, 쓰기/업데이트 비용도 증가합니다.

실무에서 자주 쓰는 시작점은 다음과 같습니다.

  • 범용 시작점: m = 16
  • 더 높은 recall이 필요하거나 데이터가 복잡: m = 24 또는 m = 32
  • 메모리/디스크가 빡빡하고 latency가 최우선: m = 8도 가능하지만 recall 하락을 감수

RAG는 보통 top_k가 작고, 이후 리랭킹이나 LLM이 최종 선택을 하므로 retrieval recall이 100%일 필요는 없지만 너무 낮으면 답변 품질이 바로 무너집니다. 그래서 m = 16에서 시작해 ef_search로 미세조정하는 방식이 안전합니다.

ef_construction: 인덱스 빌드 품질(빌드 시간 vs 검색 품질)

  • 값이 클수록 인덱스가 더 “좋게” 만들어져 검색 recall이 좋아지는 경향
  • 대신 인덱스 생성 시간이 증가

권장 시작점:

  • 일반: ef_construction = 128
  • 오프라인 배치로 인덱스를 만들고 품질을 더 끌어올리고 싶다면: 256 또는 512

팁: 대규모 테이블에서 인덱스를 새로 만드는 경우, 운영 중 락/시간 문제가 생길 수 있으니 CONCURRENTLY를 고려하세요.

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

런타임 핵심: hnsw.ef_search로 p95를 깎는다

HNSW 튜닝에서 “2배 빨라졌다”가 가장 자주 나오는 지점은 ef_search입니다.

  • hnsw.ef_search는 탐색 시 후보를 얼마나 넓게 볼지 결정
  • 값이 작을수록 빠르지만 recall이 떨어질 수 있음
  • 값이 클수록 recall이 좋아지지만 느려짐

세션 단위로 바꿀 수 있어 A/B 테스트에 좋습니다.

-- 세션에서만 적용
SET hnsw.ef_search = 40;

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

일반적인 튜닝 절차는 이렇습니다.

  1. 기준값(예: ef_search = 100)에서 품질/지연시간 측정
  2. 80 -> 60 -> 40 -> 30 -> 20처럼 낮추며 p50/p95와 품질을 함께 관찰
  3. 품질이 유의미하게 떨어지기 직전 지점을 선택

실제로는 ef_search를 100에서 40~60으로 내렸을 때 p95가 크게 줄어 “2배” 체감이 나오는 경우가 많습니다. 다만 데이터 분포와 필터 조건에 따라 최적점은 달라집니다.

품질(Recall) 측정 방법: 간단한 오프라인 평가

서비스에서 “빠르기만” 하면 안 되고, 답변 품질이 유지돼야 합니다. 최소한 아래 정도는 자동화하는 게 좋습니다.

  • 샘플 쿼리 N개를 뽑고
  • ef_search 후보군별로 top_k 결과를 비교
  • 기준(큰 ef_search 또는 브루트포스) 대비 겹치는 비율을 recall 근사치로 사용

브루트포스 기준을 만들고 싶다면(샘플만), 임시로 전체 스캔을 강제해 비교할 수 있습니다.

-- 매우 느릴 수 있으니 샘플에서만!
SET enable_indexscan = off;
SET enable_bitmapscan = off;
SET enable_seqscan = on;

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

필터링이 있는 RAG 쿼리: 플래너가 삐끗하는 지점

RAG에서는 다음과 같은 쿼리가 흔합니다.

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

여기서 주의할 점은 “벡터 인덱스로 먼저 찾고 필터를 나중에 적용”하면 불필요한 후보를 많이 읽을 수 있다는 것입니다. 해결책은 상황별로 다릅니다.

1) 테넌트/컬렉션이 크면: 파티셔닝 고려

테넌트별 데이터가 크고 쿼리가 항상 tenant_id를 포함한다면, 테이블 파티셔닝으로 탐색 공간 자체를 줄이는 게 강력합니다.

CREATE TABLE rag_chunks (
  id bigserial,
  tenant_id bigint NOT NULL,
  doc_id bigint NOT NULL,
  content text NOT NULL,
  embedding vector(1536) NOT NULL,
  created_at timestamptz NOT NULL DEFAULT now()
) PARTITION BY LIST (tenant_id);

각 파티션에 HNSW를 따로 만들면, 인덱스가 작아지고 캐시 적중이 좋아져 p95가 안정되는 경우가 많습니다.

2) 필터가 약하면: ef_search를 낮추는 쪽이 유리

필터가 약해 후보가 많다면, 결국 HNSW 탐색 비용이 지배적입니다. 이때는 ef_search 최적화가 가장 효율적입니다.

3) 필터가 강하면: 후보를 넓게 보고 재검증 비용을 줄이기

필터가 강한데도 벡터 인덱스가 먼저 적용되면 “필터에 걸려서 탈락”하는 후보가 많아져 오히려 느려질 수 있습니다. 이 경우는 다음을 검토하세요.

  • 파티셔닝
  • 필터 컬럼 통계 개선(ANALYZE)
  • tenant_id별 별도 컬렉션 테이블로 물리 분리

운영 튜닝: ANALYZE, VACUUM, 메모리

HNSW 자체 튜닝만큼이나, 운영에서 p95를 흔드는 건 “통계/캐시/블로트”입니다.

ANALYZE로 플래너 안정화

데이터를 대량 적재한 뒤에는 통계가 낡아 잘못된 플랜이 나올 수 있습니다.

ANALYZE rag_chunks;

특히 테넌트 편차가 큰 테이블은 통계 타겟을 올려 플래너 판단을 돕는 것도 방법입니다.

ALTER TABLE rag_chunks ALTER COLUMN tenant_id SET STATISTICS 1000;
ANALYZE rag_chunks;

VACUUM/Autovacuum 튜닝

업데이트/삭제가 많으면 테이블과 인덱스 블로트로 캐시 효율이 떨어져 지연시간이 늘어납니다. RAG가 append-only에 가깝다면 영향이 덜하지만, 재임베딩·정정 작업이 잦다면 신경 써야 합니다.

  • autovacuum이 밀리면 p95가 서서히 악화
  • 대량 삭제 후에는 VACUUM (ANALYZE) 또는 상황에 따라 VACUUM FULL 검토

메모리와 동시성: 캐시 미스가 p95를 만든다

HNSW는 인덱스가 메모리에 잘 올라가면 매우 빠릅니다. 반대로 캐시 미스가 나면 랜덤 I/O로 p95가 튑니다.

  • 인덱스 크기를 추정하고, 메모리(특히 shared_buffers + OS page cache) 여유를 확인
  • 동시성에서 p95가 치솟으면 커넥션 풀과 워커 수를 조절

RAG 파이프라인 전체로 보면, DB뿐 아니라 애플리케이션 레이어의 타임아웃/재시도 정책도 중요합니다. gRPC 기반이라면 타임아웃 전파가 성능 안정성에 큰 영향을 주니 Go gRPC DEADLINE_EXCEEDED 원인별 해결 7가지도 함께 보면 좋습니다.

실전 레시피: “2배 빠르게”에 가장 근접한 조합

워크로드가 “읽기 위주 + top_k 10 + 테넌트 필터”라고 가정하면, 다음 순서로 시도하는 것이 효율적입니다.

1) 인덱스 빌드 품질을 올리되 과하지 않게

CREATE INDEX CONCURRENTLY rag_chunks_embedding_hnsw
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 256);
  • m = 16은 대체로 안전한 출발점
  • ef_construction = 256은 빌드 시간은 늘지만 검색 안정성이 좋아지는 편

2) ef_search를 단계적으로 낮춰 최적점 찾기

-- 예: 서비스 기본값을 60으로 시작
ALTER DATABASE your_db SET hnsw.ef_search = 60;

그리고 품질이 충분하면 40까지 내리는 식으로 접근합니다. “2배” 체감은 보통 여기서 나옵니다.

3) 필터가 강하거나 테넌트가 크면 파티셔닝/물리 분리

  • 테넌트별 데이터가 크고 항상 필터가 붙는다
  • 특정 테넌트에서만 p95가 튄다

이런 패턴이면 파티셔닝으로 인덱스 크기를 줄이는 것이 가장 확실합니다.

벤치마크 예시: EXPLAIN (ANALYZE, BUFFERS)로 확인

튜닝 전후를 비교할 때는 애플리케이션 로그만 보지 말고 DB에서 직접 확인해야 합니다.

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

체크 포인트:

  • 실행 시간의 p95가 튀는 날, shared hit 대비 read가 늘었는지
  • 불필요한 정렬/재검증이 과도한지
  • 필터로 인해 후보가 많이 버려지는지(결과는 10개인데 내부 작업이 과도)

흔한 실수 6가지

  1. ef_search를 높게 고정하고 “HNSW가 느리다”고 결론 내림
  2. 필터 컬럼 인덱스/통계 없이 벡터 인덱스만 믿음
  3. 테넌트 편차가 큰데 단일 테이블로만 운영해 p95가 불안정
  4. 대량 적재 후 ANALYZE를 안 해서 플랜이 흔들림
  5. 인덱스가 너무 커서 메모리에 못 올라가는데도 하드웨어 탓만 함
  6. 정확도 평가 없이 지연시간만 보고 파라미터를 과하게 낮춤

마무리: HNSW 튜닝은 “정확도-지연시간” 곡선 찾기

pgvector HNSW로 RAG를 2배 빠르게 만드는 가장 현실적인 방법은 다음 한 줄로 요약됩니다.

  • 인덱스는 m = 16, ef_construction = 128~256으로 안정적으로 만들고
  • 런타임에서 hnsw.ef_search를 낮추며 품질이 무너지기 직전의 최적점을 찾는다

여기에 테넌트 필터링 구조(파티셔닝/물리 분리)와 운영 요소(ANALYZE, autovacuum, 캐시)를 같이 잡으면, 단순 평균이 아니라 p95까지 의미 있게 내려가 “응답이 빨라졌다”는 체감이 나옵니다.

RAG 저장소를 더 가볍게 만드는 접근도 병행하면 비용과 성능이 함께 좋아질 수 있습니다. 예를 들어 압축/요약 전략은 AutoGPT 메모리 폭주 막기 - SQLite RAG 압축과도 연결되는 주제입니다.