- Published on
pgvector HNSW로 RAG 검색 지연 50% 줄이기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
RAG(Retrieval-Augmented Generation)에서 체감 품질을 좌우하는 건 대개 LLM 추론 시간이 아니라 검색 단계의 p95 지연입니다. 특히 문서가 수십만~수백만 청크로 커지면, 벡터 검색이 매 요청마다 병목이 되기 쉽습니다.
PostgreSQL을 이미 운영 중이라면 pgvector는 “새 벡터 DB를 도입하지 않고도” RAG를 구현할 수 있는 강력한 선택지입니다. 다만 기본 설정이나 단순한 인덱스 선택만으로는 성능이 기대에 못 미치는 경우가 많습니다.
이 글에서는 pgvector의 HNSW 인덱스를 활용해 RAG 검색 지연을 실무에서 약 50% 수준으로 줄이는 접근을 정리합니다. 단순히 “HNSW가 빠르다”가 아니라, 어떤 조건에서 빨라지고 무엇을 튜닝해야 하는지, 그리고 운영에서 어떤 함정이 있는지까지 다룹니다.
왜 HNSW가 RAG에 잘 맞나
RAG의 검색 쿼리는 보통 다음 특성을 가집니다.
top-k가 작다: 보통k=5~20- 필터가 있다: 테넌트, 문서 타입, 권한, 날짜, 언어 등
- 높은 QPS가 나온다: 사용자 대화형 서비스는 동시성이 높음
- p95/p99가 중요하다: 평균이 아니라 꼬리 지연이 UX를 망침
pgvector는 크게 두 계열의 ANN(Approximate Nearest Neighbor) 인덱스를 제공합니다.
- IVF(IVFFlat): 학습(
lists) 기반, 빌드/튜닝이 비교적 단순 - HNSW: 그래프 기반, 낮은 지연에 강하고 재현율-지연 트레이드오프가 직관적
HNSW는 특히 top-k가 작고 “빨리 몇 개만” 가져오면 되는 RAG에 잘 맞습니다. IVF는 적절히 튜닝하면 좋은데, 필터와 결합되거나 분포가 바뀌면 성능이 흔들릴 수 있습니다. HNSW는 보통 p95 지연을 안정적으로 낮추는 쪽에서 강점을 보입니다.
전제: pgvector 설치 및 컬럼 설계
먼저 pgvector 확장을 활성화하고, 임베딩 컬럼을 준비합니다.
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()
);
-- 필터가 자주 걸리면 일반 B-Tree 인덱스도 같이
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);
임베딩 차원(1536)은 예시입니다. 사용하는 모델에 맞추세요.
HNSW 인덱스 생성: 핵심은 m과 ef_construction
HNSW 인덱스는 다음과 같이 생성합니다.
-- 코사인 유사도 기반 예시
CREATE INDEX rag_chunks_embedding_hnsw
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 128);
여기서 중요한 파라미터는 두 가지입니다.
m: 그래프에서 노드가 유지하는 최대 이웃 수(대략적인 연결도)- 보통
8~32범위에서 시작 - 높일수록 재현율이 좋아지지만, 메모리/빌드 시간/쓰기 비용 증가
- 보통
ef_construction: 인덱스 빌드 시 탐색 폭- 높일수록 재현율이 좋아지지만, 빌드 시간이 증가
실무에서의 출발점으로는 다음을 권합니다.
- 데이터가
100k~1M청크:m=16,ef_construction=128 - 더 높은 재현율이 필요:
m=24~32,ef_construction=200~400
주의할 점은, HNSW는 쓰기 비용이 IVF보다 커질 수 있습니다. RAG가 “대량 실시간 업데이트” 워크로드라면, 배치 적재 전략(예: 야간 적재, 큐 기반 upsert)을 같이 설계해야 합니다.
쿼리 패턴: 거리 연산자와 top-k
pgvector에서 코사인 거리로 top-k를 구하는 전형적인 쿼리는 다음과 같습니다.
SELECT id, doc_id, chunk_id, content,
1 - (embedding <=> $1) AS cosine_similarity
FROM rag_chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT 10;
<=>는 코사인 거리 연산(연산자 클래스에 따라 의미가 정해짐)ORDER BY embedding <=> $1 LIMIT k형태가 인덱스를 제대로 태우는 핵심입니다.
여기서 필터(tenant_id)가 붙는 것이 일반적입니다. 이때 성능이 흔들리면 “HNSW가 느린가?”가 아니라, 필터 선택도와 플래너 판단이 문제일 가능성이 큽니다.
지연 50% 줄이기: 실전 튜닝 5단계
아래 단계는 “HNSW를 만들었다”에서 끝내지 않고, p95 지연을 실제로 낮추는 데 초점을 둡니다.
1) EXPLAIN (ANALYZE, BUFFERS)로 인덱스 사용 확인
먼저 인덱스를 타는지부터 확인합니다.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM rag_chunks
WHERE tenant_id = 't1'
ORDER BY embedding <=> '[0.01, 0.02, ...]'::vector
LIMIT 10;
결과에서 Index Scan using ... hnsw 혹은 유사한 형태로 인덱스 경로가 잡히는지 봅니다. 만약 Seq Scan이 보이면 다음을 의심하세요.
- 통계가 낡음:
ANALYZE rag_chunks; - 필터가 너무 넓어 플래너가 다른 경로를 선택
- 쿼리가 연산자/캐스팅 때문에 인덱스를 못 탐
DB에서 지연이 튀는 문제는 종종 락/데드락과 함께 나타나기도 합니다. 운영 중 deadlock detected가 보인다면 검색 성능 문제와 별개로 트랜잭션 설계를 점검해야 합니다. 필요하면 PostgreSQL deadlock detected 진단·해결 9단계도 같이 참고하세요.
2) 런타임 탐색 폭 hnsw.ef_search 튜닝
HNSW의 검색 품질-지연 트레이드오프는 런타임 파라미터인 ef_search로 조절합니다.
-- 세션 단위로 설정 가능
SET hnsw.ef_search = 40;
SELECT id, content
FROM rag_chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT 10;
가이드라인:
ef_search를 낮추면 더 빠르지만 재현율이 떨어짐ef_search를 높이면 재현율이 좋아지지만 느려짐
실무에서는 보통 다음 방식이 효과적입니다.
- 기본
ef_search를 낮게(예:20~60) 잡아 p95를 낮춘다 - “정확도가 중요한 요청”(예: 결제/CS/법무 문서)은 요청 단위로
ef_search를 높인다
애플리케이션에서 트랜잭션 시작 후 SET LOCAL hnsw.ef_search = ...; 같은 형태로 요청별 설정을 주면, 전역 설정을 망치지 않고도 유연하게 대응할 수 있습니다.
3) 필터가 강하면 파티셔닝 또는 테넌트 분리 고려
RAG는 멀티테넌트가 흔합니다. tenant_id로 강하게 필터링한다면, 단일 테이블에 모든 테넌트를 넣고 하나의 HNSW를 공유하는 구조는 비효율적일 수 있습니다.
대안:
- 테넌트별 파티션 테이블 + 파티션별 HNSW
- 테넌트 규모가 크면 아예 DB/스키마 분리
예시(범위/리스트 파티셔닝 개념만 간단히):
-- 예: 테넌트별 리스트 파티션(테넌트 수가 제한적일 때)
CREATE TABLE rag_chunks (
id bigserial,
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()
) PARTITION BY LIST (tenant_id);
CREATE TABLE rag_chunks_t1 PARTITION OF rag_chunks FOR VALUES IN ('t1');
CREATE TABLE rag_chunks_t2 PARTITION OF rag_chunks FOR VALUES IN ('t2');
CREATE INDEX rag_chunks_t1_embedding_hnsw
ON rag_chunks_t1 USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 128);
이렇게 하면 검색 시 탐색 그래프 자체가 작아져 p95가 내려가는 경우가 많습니다.
4) “후보 확장 + 재정렬” 2단계 검색으로 정확도 유지
ef_search를 낮춰 지연을 줄이면 재현율이 떨어질 수 있습니다. 이때 흔히 쓰는 패턴이 2단계 검색입니다.
- HNSW로 넓게
top-k'후보를 빠르게 뽑기(예: 50개) - 후보에 대해 정확한 스코어링/필터/재정렬을 수행해 최종
top-k(예: 10개) 반환
SQL 예시:
WITH candidates AS (
SELECT id, content, embedding
FROM rag_chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT 50
)
SELECT id, content,
1 - (embedding <=> $1) AS cosine_similarity
FROM candidates
ORDER BY embedding <=> $1
LIMIT 10;
후보를 50으로 잡는다고 해서 항상 느려지는 것은 아닙니다. HNSW 탐색은 LIMIT에 영향을 받지만, 실제 병목은 탐색 폭(ef_search)과 메모리 접근 패턴인 경우가 많습니다. 여러 조합을 벤치마크해서 “재현율을 유지하면서도 p95를 낮추는” 지점을 찾는 게 핵심입니다.
5) 커넥션/동시성 병목을 같이 제거
HNSW로 검색 자체가 빨라지면, 다음 병목이 드러납니다.
- DB 커넥션 풀 고갈
- 애플리케이션 스레드/가상 스레드 설정 문제
- 트랜잭션 범위가 불필요하게 큼
특히 Spring Boot 3에서 가상 스레드를 켠 뒤 DB 커넥션이 먼저 바닥나면서 p95가 급등하는 케이스가 흔합니다. 검색 최적화와 별개로 동시성 설계를 점검해야 합니다. 관련해서는 Spring Boot 3 가상스레드 적용 후 DB 커넥션 고갈을 함께 읽어보면 도움이 됩니다.
벤치마크 방법: “50% 개선”을 숫자로 증명하기
개선 효과를 과장하지 않으려면, 최소한 아래를 고정한 상태에서 비교해야 합니다.
- 동일한 데이터 스냅샷(청크 수, 차원, 분포)
- 동일한 쿼리 셋(실제 트래픽에서 샘플링)
- 동일한
k, 동일한 필터 조건 - 동일한 워밍업 조건(캐시가 차이를 만들 수 있음)
간단한 측정 루틴 예시(애플리케이션에서):
import time
import statistics
def measure(fn, n=200):
lat = []
for _ in range(n):
t0 = time.perf_counter()
fn()
lat.append((time.perf_counter() - t0) * 1000)
lat.sort()
return {
"p50": lat[int(n*0.50)],
"p95": lat[int(n*0.95)],
"p99": lat[int(n*0.99)],
"avg": statistics.mean(lat),
}
그리고 DB에서는 최소한 다음을 확인합니다.
pg_stat_statements로 상위 쿼리의 평균/표준편차/호출 수EXPLAIN (ANALYZE, BUFFERS)로 IO/버퍼 히트
실무에서 “지연 50% 감소”는 보통 다음 조합에서 나옵니다.
- IVF에서 HNSW로 전환하며 p95가 크게 감소
ef_search를 적절히 낮추고(예: 80에서 40으로), 2단계 재정렬로 품질 보전- 테넌트 파티셔닝으로 그래프 크기를 줄여 캐시 효율 개선
운영 체크리스트: 장애 없이 굴리기
인덱스 빌드/리빌드 전략
HNSW 인덱스는 빌드 비용이 큽니다. 운영에서는 다음을 고려하세요.
CREATE INDEX CONCURRENTLY사용(가능한 경우)- 대량 적재 후 한 번에 인덱스 생성
- VACUUM/ANALYZE 주기화로 통계 유지
CREATE INDEX CONCURRENTLY rag_chunks_embedding_hnsw
ON rag_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 128);
ANALYZE rag_chunks;
락/트랜잭션 관리
검색은 읽기이지만, 업서트/삭제가 섞이면 락 경합이 늘 수 있습니다. 특히 문서 재색인 파이프라인이 한 트랜잭션에 너무 많은 작업을 묶으면 문제를 키웁니다.
- 청크 삭제/삽입을 작은 배치로 쪼개기
- 가능한
READ COMMITTED유지 - 데드락/락 대기 모니터링
문제가 보이면 앞서 언급한 PostgreSQL deadlock detected 진단·해결 9단계 방식으로 “재현-원인-해결” 루프를 빠르게 돌리는 게 좋습니다.
RAG 애플리케이션 레벨 캐시
HNSW로도 충분히 빨라졌다면, 다음 단계는 “DB를 덜 때리는 것”입니다.
- 동일 질문/유사 질문 캐시
- 대화 메모리/컨텍스트 관리 최적화
LangChain을 쓰면서 대화 메모리가 Redis에 계속 쌓여 비용과 지연이 늘어나는 케이스도 많습니다. 이런 경우 검색 최적화와 함께 TTL 전략이 필요합니다. LangChain 대화기억 폭증? Redis TTL로 해결를 참고해 캐시/메모리 정책도 같이 정리하세요.
자주 하는 실수 6가지
ORDER BY없이 거리 필터만 걸고 “가까운 것”을 기대함- 임베딩 타입 캐스팅이 꼬여 인덱스를 못 탐(파라미터 타입을 명확히
vector로) ef_search를 무작정 높여 p95를 악화시킴- 필터 선택도가 낮은데 테이블을 단일 HNSW로 유지해 탐색 비용이 커짐
- 대량 업서트를 실시간으로 때려 넣어 쓰기 경합/오토베큠 압박을 만듦
- 성능 측정을 평균만 보고 “개선”이라 판단함(p95/p99를 봐야 함)
마무리: HNSW는 “설정값”이 아니라 “운영 전략”이다
pgvector HNSW는 RAG의 검색 지연을 줄이는 데 매우 강력하지만, 효과는 다음 3가지를 함께 맞췄을 때 크게 납니다.
- 올바른 인덱스/쿼리 형태(
ORDER BY distance LIMIT k) ef_search,m,ef_construction의 목적 지향 튜닝- 멀티테넌트/필터/동시성까지 포함한 운영 설계
이 조합이 갖춰지면 “검색 단계 p95가 절반 수준으로 감소”하는 건 충분히 현실적인 목표입니다. 다음 단계로는 실제 트래픽 샘플을 기반으로 ef_search와 2단계 재정렬의 최적점을 찾고, 테넌트 분리나 파티셔닝으로 그래프 크기를 줄이는 실험을 권합니다.