- Published on
pgvector HNSW 튜닝으로 RAG 응답 2배 빠르게
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
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 인덱스 생성: m과 ef_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;
일반적인 튜닝 절차는 이렇습니다.
- 기준값(예:
ef_search = 100)에서 품질/지연시간 측정 80 -> 60 -> 40 -> 30 -> 20처럼 낮추며 p50/p95와 품질을 함께 관찰- 품질이 유의미하게 떨어지기 직전 지점을 선택
실제로는 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가지
ef_search를 높게 고정하고 “HNSW가 느리다”고 결론 내림- 필터 컬럼 인덱스/통계 없이 벡터 인덱스만 믿음
- 테넌트 편차가 큰데 단일 테이블로만 운영해 p95가 불안정
- 대량 적재 후
ANALYZE를 안 해서 플랜이 흔들림 - 인덱스가 너무 커서 메모리에 못 올라가는데도 하드웨어 탓만 함
- 정확도 평가 없이 지연시간만 보고 파라미터를 과하게 낮춤
마무리: HNSW 튜닝은 “정확도-지연시간” 곡선 찾기
pgvector HNSW로 RAG를 2배 빠르게 만드는 가장 현실적인 방법은 다음 한 줄로 요약됩니다.
- 인덱스는
m = 16,ef_construction = 128~256으로 안정적으로 만들고 - 런타임에서
hnsw.ef_search를 낮추며 품질이 무너지기 직전의 최적점을 찾는다
여기에 테넌트 필터링 구조(파티셔닝/물리 분리)와 운영 요소(ANALYZE, autovacuum, 캐시)를 같이 잡으면, 단순 평균이 아니라 p95까지 의미 있게 내려가 “응답이 빨라졌다”는 체감이 나옵니다.
RAG 저장소를 더 가볍게 만드는 접근도 병행하면 비용과 성능이 함께 좋아질 수 있습니다. 예를 들어 압축/요약 전략은 AutoGPT 메모리 폭주 막기 - SQLite RAG 압축과도 연결되는 주제입니다.