Published on

pgvector RAG 검색 느림? HNSW 튜닝 체크리스트

Authors

RAG 파이프라인에서 체감 지연의 대부분은 retrieval 단계에서 터집니다. 특히 Postgres + pgvector 조합은 운영 친화적이지만, HNSW를 “생성만 해두고” 기본값으로 쓰면 데이터가 커질수록 검색이 급격히 느려지거나, 반대로 정확도가 떨어져 rerank 비용이 폭증하는 형태로 나타납니다.

이 글은 pgvector HNSW에서 검색이 느릴 때 바로 확인할 수 있는 튜닝 체크리스트입니다. 단순히 ef_search만 올리는 식의 처방이 아니라, 인덱스 파라미터, 쿼리 작성, 플래너, 메모리/유지보수까지 한 번에 점검하도록 구성했습니다.

관련해서 HNSW 자체 개념과 튜닝 감각을 더 넓게 잡고 싶다면, 벡터 DB 쪽 실전 튜닝 사례도 함께 참고하세요: RAG 성능 2배 - Qdrant HNSW 튜닝 실전


1) 먼저 “정말 벡터 검색이 병목인지” 확인

RAG가 느릴 때는 벡터 검색이 아니라 아래가 원인인 경우도 많습니다.

  • 네트워크 왕복: 앱 pool 대기, 커넥션 부족
  • 필터 조건으로 인덱스 미사용: 결국 테이블 스캔 후 거리 계산
  • TopK는 빨리 나오는데, 이후 JOIN/ORDER BY/LIMIT에서 폭발
  • rerank 모델 호출이 느려서 “검색이 느린 것처럼” 보임

체크: EXPLAIN (ANALYZE, BUFFERS)로 진짜 비용 분해

아래처럼 실행 계획을 확인해 인덱스를 타는지, 버퍼 hit/read가 어떤지, 정렬이 발생하는지를 봅니다.

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content
FROM documents
ORDER BY embedding <-> $1
LIMIT 20;
  • Index Scan using ... 또는 Bitmap Index Scan류가 나오면 일단 방향은 맞습니다.
  • Seq Scan + Sort가 보이면 거의 무조건 쿼리/인덱스/필터 문제입니다.

운영에서 느린 쿼리를 체계적으로 파고드는 방법론은 아래 글의 흐름도 참고할 만합니다: MongoDB 느린 쿼리 - explain으로 원인 찾고 인덱스 튜닝


2) HNSW 인덱스가 “맞는 연산자/거리”로 만들어졌는지

pgvector는 거리/유사도에 따라 연산자가 달라집니다. 인덱스도 그에 맞게 만들어야 플래너가 제대로 사용합니다.

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

체크: 인덱스 생성 시 vector_*_ops가 쿼리와 일치?

-- 코사인 거리 기반이라면
CREATE INDEX CONCURRENTLY documents_embedding_hnsw
ON documents
USING hnsw (embedding vector_cosine_ops);

쿼리는 코사인 거리 연산자인 <=>를 써야 합니다.

SELECT id
FROM documents
ORDER BY embedding <=> $1
LIMIT 20;

연산자와 ops가 불일치하면, 인덱스가 있어도 플래너가 못 쓰거나 성능이 급락합니다.


HNSW는 “검색 속도 vs 정확도”를 트레이드오프로 조절합니다.

  • m: 그래프에서 노드가 유지하는 이웃 수(대략). 클수록 정확도↑, 메모리/빌드시간↑
  • ef_construction: 인덱스 빌드 품질. 클수록 정확도↑, 빌드시간↑
  • ef_search: 검색 시 탐색 폭. 클수록 recall↑, 검색시간↑

3-1) 인덱스 생성 파라미터 튜닝

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

권장 출발점(경험치):

  • 텍스트 임베딩 RAG(수십만~수백만): m=16 또는 m=24, ef_construction=100~400
  • 데이터가 작고 정확도 최우선: ef_construction을 더 올리되, 빌드 시간을 감수

빌드가 너무 오래 걸리면 CONCURRENTLY와 워커/IO 상황을 함께 보세요. 인덱스는 “한 번 잘 만들어두면” 검색 비용을 지속적으로 줄여줍니다.

3-2) 세션/쿼리 단위로 ef_search 조절

ef_search검색 시점에 조절하는 레버입니다.

SET LOCAL hnsw.ef_search = 80;

SELECT id, content
FROM documents
ORDER BY embedding <=> $1
LIMIT 20;

실전 패턴:

  • 1차 후보군 TopK는 ef_search를 중간값으로: 속도 우선
  • recall이 부족해서 rerank가 헛돈을 쓰면 ef_search를 올려 후보군 품질 개선

중요: ef_search를 무작정 올리면 “검색이 느린” 문제를 악화시킵니다. 정확도 부족으로 rerank 비용이 폭증하는지까지 같이 측정해야 합니다.


4) TopK, 후보군 크기, rerank 비용의 균형

RAG는 보통 2단 구조입니다.

  1. 벡터 검색으로 TopK 후보 추출
  2. rerank(크로스 인코더 등)로 최종 N개 선택

여기서 흔한 함정:

  • 벡터 검색 TopK를 너무 크게 잡아 rerank가 병목이 됨
  • 반대로 TopK가 너무 작아 recall이 떨어지고, 답변 품질이 나빠짐

체크리스트

  • LIMIT 20으로 충분한가, LIMIT 50이 필요한가
  • rerank 입력 토큰이 과도하지 않은가(문서 chunk 크기)
  • ef_search를 올려 TopK 품질을 개선하면 TopK를 줄일 수 있는가

RAG 품질/환각 측면에서 rerank 최적화는 아래 글과도 연결됩니다: RAG 환각 줄이기 - ColBERTv2+Rerank 최적화


5) 필터링이 있는 RAG: “필터 먼저”가 성능을 죽인다

멀티테넌트, 권한, 기간, 카테고리 같은 필터가 붙는 순간 벡터 검색이 느려지기 쉽습니다.

문제 패턴:

  • WHERE tenant_id = ... 같은 조건이 선택도가 낮거나 통계가 부정확
  • 플래너가 HNSW를 포기하고 다른 플랜을 선택
  • 또는 인덱스는 타지만, 필터로 인해 후보가 부족해 탐색이 비효율

대응 전략 A: 필터 컬럼에 인덱스 추가(기본)

CREATE INDEX CONCURRENTLY documents_tenant_id_idx
ON documents (tenant_id);

이 자체로 해결되기도 하지만, 벡터 검색과 결합된 플랜에서는 여전히 애매할 수 있습니다.

대응 전략 B: 파티셔닝(테넌트/시간)으로 검색 공간 축소

  • 테넌트 단위 파티션
  • 시간(월별) 파티션

파티션 프루닝이 잘 되면 “애초에 HNSW가 보는 데이터 수”가 줄어 체감 성능이 크게 개선됩니다.

대응 전략 C: 2단 쿼리로 후보군을 먼저 뽑고 조인

예: 메타데이터 테이블과 조인 때문에 폭발하는 경우, 먼저 벡터 TopK를 뽑고 그 결과에만 조인합니다.

WITH candidates AS (
  SELECT id
  FROM documents
  WHERE tenant_id = $2
  ORDER BY embedding <=> $1
  LIMIT 50
)
SELECT d.id, d.content, m.source
FROM candidates c
JOIN documents d ON d.id = c.id
JOIN doc_meta m ON m.doc_id = d.id;

6) 테이블/인덱스 블로트와 VACUUM: “업데이트 많은 RAG”의 복병

임베딩 테이블은 보통 INSERT 위주라 괜찮지만, 다음이 섞이면 급격히 느려질 수 있습니다.

  • chunk 재생성으로 DELETE/INSERT가 반복
  • 메타데이터 업데이트가 잦음
  • autovacuum이 밀려 dead tuple이 누적

체크

  • pg_stat_user_tables에서 n_dead_tup 증가
  • EXPLAIN (ANALYZE, BUFFERS)에서 disk read 비중 증가

대응

  • autovacuum 파라미터를 테이블 단위로 강화
  • 대량 삭제 후 VACUUM (ANALYZE) 수행
  • 통계가 틀어지면 ANALYZE로 플래너 교정
VACUUM (ANALYZE) documents;

7) 메모리/캐시: HNSW는 “따뜻한 캐시”에서 훨씬 빠르다

HNSW는 인덱스 탐색 과정에서 랜덤 접근이 많아, 캐시 적중률이 성능에 직접적입니다.

체크 포인트

  • shared_buffers가 너무 작아 인덱스 페이지가 자주 밀려나는가
  • 디스크가 느린 네트워크 스토리지인지
  • BUFFERS에서 read가 과도하게 발생하는지

대응 방향

  • 메모리 증설이 가장 단순하고 효과적
  • 인덱스/테이블이 RAM에 어느 정도 상주하도록 워킹셋 관리
  • RAG 트래픽 패턴이 특정 테넌트/카테고리에 몰리면 파티셔닝이 다시 유효

8) 동시성: 커넥션 풀과 work_mem이 검색을 망친다

벡터 검색 자체는 빠른데, 동시 요청이 늘면 갑자기 느려지는 경우:

  • 앱 커넥션 풀이 작아 대기열이 생김
  • 반대로 풀이 너무 커서 DB가 컨텍스트 스위칭/IO로 무너짐
  • work_mem 부족으로 정렬/해시가 디스크로 스필

체크

  • DB에서 active connection 수, 대기 이벤트
  • 쿼리 플랜에 Sort Method: external merge 같은 문구

대응

  • 풀 크기를 DB 코어/IO에 맞게 제한
  • 필요한 쿼리에만 SET LOCAL work_mem = ... 적용
BEGIN;
SET LOCAL work_mem = '128MB';
SET LOCAL hnsw.ef_search = 60;

SELECT id
FROM documents
ORDER BY embedding <=> $1
LIMIT 20;
COMMIT;

9) 운영 체크리스트: 빠르게 진단하는 순서

아래 순서대로 보면 “삽질”을 많이 줄일 수 있습니다.

  1. EXPLAIN (ANALYZE, BUFFERS)Seq Scan 여부 확인
  2. 쿼리 연산자(<->, <=>, <#>)와 인덱스 ops 일치 확인
  3. 필터가 있으면 선택도/인덱스/파티셔닝/2단 쿼리 중 무엇이 필요한지 결정
  4. ef_search를 올리기 전에 TopK와 rerank 비용을 같이 측정
  5. m, ef_construction이 데이터 규모 대비 너무 보수적인지 점검(필요시 재빌드)
  6. VACUUM/ANALYZE로 통계/블로트 정리
  7. 캐시 적중률과 스토리지 성능 확인
  8. 커넥션 풀/동시성/work_mem로 인한 스필 여부 확인

10) 실전 예시: “느린 검색”을 재현하고 개선하기

10-1) 기본 테이블과 인덱스

CREATE TABLE documents (
  id BIGSERIAL PRIMARY KEY,
  tenant_id BIGINT NOT NULL,
  content TEXT NOT NULL,
  embedding vector(1536) NOT NULL
);

CREATE INDEX CONCURRENTLY documents_tenant_id_idx
ON documents (tenant_id);

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

10-2) 검색 쿼리(필터 포함) + 세션 튜닝

BEGIN;
SET LOCAL hnsw.ef_search = 60;

WITH candidates AS (
  SELECT id
  FROM documents
  WHERE tenant_id = $2
  ORDER BY embedding <=> $1
  LIMIT 50
)
SELECT d.id, d.content
FROM candidates c
JOIN documents d ON d.id = c.id
LIMIT 20;

COMMIT;
  • 후보군 50은 rerank 유무에 따라 조정합니다.
  • rerank가 있다면 50로 뽑고, 최종 답변에는 5~10만 사용 같은 방식이 흔합니다.

마무리: ef_search만 만지지 말고 “플랜+필터+유지보수”를 같이 보자

pgvector HNSW 성능 이슈는 대부분 한 가지 원인으로 고정되지 않습니다. 인덱스는 맞게 만들었는데 필터 때문에 플래너가 포기한다거나, 검색은 빠른데 rerank가 느려서 전체가 느려 보인다거나, autovacuum이 밀려서 갑자기 IO가 튄다거나 하는 식입니다.

그래서 가장 효율적인 접근은 다음입니다.

  • 실행 계획으로 인덱스 사용 여부를 먼저 확정
  • 연산자/ops 불일치 같은 “구조적 오류”를 제거
  • 필터/조인 패턴을 2단 쿼리나 파티셔닝으로 정리
  • 그 다음에야 m, ef_construction, ef_search를 조절

원하시면 사용 중인

  • Postgres 버전과 pgvector 버전
  • 테이블 스키마(컬럼, 인덱스)
  • 대표 쿼리와 EXPLAIN (ANALYZE, BUFFERS) 결과

를 기준으로, 어떤 체크 항목부터 손대는 게 가장 효과적인지 “우선순위” 형태로 같이 정리해드릴게요.