- Published on
RAG, 벡터DB 없이? pgvector+HNSW 튜닝 실전
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서빙 RAG를 만들 때 “벡터DB를 꼭 붙여야 하나?”라는 질문은 꽤 현실적입니다. 팀에 이미 Postgres가 있고, 운영·백업·권한·관측 체계가 갖춰져 있다면 pgvector만으로도 충분히 강력한 검색 레이어를 만들 수 있습니다. 특히 HNSW 인덱스가 들어오면서 “빠른 근사 최근접(ANN)”이 가능해져, 작은/중간 규모 RAG는 별도 벡터DB 없이도 프로덕션에 올릴 수 있는 선택지가 됐습니다.
이 글은 pgvector의 HNSW를 “그냥 만든다” 수준이 아니라, 검색 품질(리콜)과 지연시간(P95/P99)을 균형 있게 맞추는 튜닝 관점에서 정리합니다.
언제 pgvector로 충분한가
다음 조건에 해당하면 pgvector 단독 구성이 특히 효율적입니다.
- 이미 Postgres를 핵심 데이터 저장소로 사용 중이고, RAG 인덱스도 같은 트랜잭션/권한 모델 안에 두고 싶다
- 문서 수가 수십만~수백만 단위까지는 “단일 클러스터”로도 운영 가능하다(하드웨어와 쿼리 패턴에 따라 다름)
- 메타데이터 필터(테넌트, 문서 타입, 권한, 기간 등)를 SQL로 강하게 걸고 싶다
- 운영 복잡도를 최소화하고 싶다(추가 컴포넌트, 네트워크 홉, 별도 백업/복구)
반대로 아래 상황이면 전용 벡터DB나 분산형 검색(혹은 하이브리드)을 고려하세요.
- 샤딩/멀티노드 분산이 필수인 규모
- 초고QPS에서 밀리초 단위 SLA가 강하게 요구됨
- sparse+dense 하이브리드(예: BM25+벡터)와 고급 랭킹 파이프라인이 핵심 경쟁력
핵심 개념: pgvector + HNSW에서 튜닝 포인트
HNSW는 “인덱스 구축 비용과 메모리 사용량”을 지불하고 “쿼리 시 빠른 근사 탐색”을 얻는 구조입니다. 튜닝의 대부분은 아래 3개 축으로 정리됩니다.
- 임베딩 품질과 전처리(청킹, 중복, 노이즈)
- 인덱스 파라미터(
m,ef_construction)와 쿼리 파라미터(ef_search) - SQL 필터링과 플래너(인덱스를 타게 만들기)
스키마 설계: RAG에 맞는 테이블 구조
권장 패턴은 “원문/청크 테이블 분리” + “청크에 벡터와 검색용 메타데이터를 집중”입니다.
-- 확장 설치
CREATE EXTENSION IF NOT EXISTS vector;
-- 원문 문서
CREATE TABLE documents (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
source text NOT NULL,
title text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 청크(검색 단위)
CREATE TABLE doc_chunks (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
document_id bigint NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
chunk_index int NOT NULL,
content text NOT NULL,
-- 예: text-embedding-3-small은 1536 차원
embedding vector(1536) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 필터에 자주 쓰는 컬럼은 일반 인덱스도 준비
CREATE INDEX doc_chunks_tenant_id_idx ON doc_chunks(tenant_id);
CREATE INDEX doc_chunks_document_id_idx ON doc_chunks(document_id);
거리 함수 선택: cosine vs l2 vs inner product
- cosine 유사도: 텍스트 임베딩에서 일반적으로 무난
- l2 거리: 모델/정규화 방식에 따라 성능이 안정적일 수 있음
- inner product: 모델이 IP에 맞게 학습된 경우 유리
pgvector에서는 연산자 형태로 사용합니다. 예를 들어 cosine 거리(정확히는 cosine distance)를 쓰면 embedding < => query_embedding 형태를 사용합니다. MDX 빌드 에러를 피하기 위해 아래는 인라인 코드로 표기합니다.
- cosine distance 연산자:
embedding <=> query_embedding - l2 distance 연산자:
embedding <-> query_embedding - inner product(negative) 연산자:
embedding <#> query_embedding
HNSW 인덱스 만들기: 기본부터 안전하게
HNSW는 USING hnsw로 생성합니다. cosine 기준 예시는 다음과 같습니다.
-- cosine distance용 HNSW
CREATE INDEX doc_chunks_embedding_hnsw_idx
ON doc_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 128);
파라미터 의미와 실전 가이드
m- 그래프에서 각 노드가 유지하는 이웃 수(대략적인 연결도)
- 올리면 리콜이 좋아지는 경향, 대신 인덱스 크기와 빌드 비용 증가
- 텍스트 RAG에서는
m=16또는m=24부터 시작하는 경우가 많음
ef_construction- 인덱스 구축 시 탐색 폭
- 올리면 인덱스 품질(리콜)이 좋아질 수 있으나, 빌드 시간이 증가
- 실무에서는
128또는256이 흔한 출발점
ef_search(쿼리 시)- 탐색 폭. 올리면 리콜 상승, 지연시간 증가
- “리콜이 부족하다”의 대부분은
ef_search가 너무 낮아서 생깁니다
정리하면:
- 리콜 우선:
m과ef_construction을 올리고, 서빙에서는ef_search를 SLA 내에서 최대한 올림 - 지연시간 우선:
ef_search를 낮추되, 품질이 무너지면m을 올려 인덱스 자체 품질을 보강
쿼리 작성: 필터 + TopK + 거리 정렬
가장 흔한 패턴은 테넌트 필터 후 TopK를 가져오는 형태입니다.
-- query_embedding은 애플리케이션에서 바인딩
SELECT
id,
document_id,
chunk_index,
content,
(embedding <=> $1) AS distance
FROM doc_chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT 10;
중요한 포인트: 필터가 인덱스를 망칠 수 있다
- 필터 조건이 너무 복잡하거나, 통계가 부정확하면 플래너가 HNSW 인덱스를 안 타는 경우가 있습니다.
- 테넌트별 데이터가 크고 편차가 크면, 테넌트 조건이 인덱스 사용에 영향을 줍니다.
대응 방법:
ANALYZE를 자주 돌려 통계를 최신으로 유지- 필터 컬럼에 btree 인덱스 준비
- 테넌트별 파티셔닝(필요 시)
ANALYZE doc_chunks;
ef_search 튜닝: 리콜과 지연시간의 다이얼
pgvector는 세션 단위로 HNSW 탐색 폭을 조절할 수 있습니다(버전에 따라 GUC 이름이 다를 수 있으니, 설치된 pgvector 문서를 확인하세요). 개념적으로는 다음처럼 “서빙 요청마다” 조절합니다.
-- 예시: 세션에서 ef_search를 올려 리콜을 확보
SET LOCAL hnsw.ef_search = 80;
SELECT id, content
FROM doc_chunks
WHERE tenant_id = $2
ORDER BY embedding <=> $1
LIMIT 10;
실전 팁:
- P95가 여유롭다면
ef_search를 먼저 올려 리콜을 확보하세요. 인덱스 재빌드 없이 즉시 효과를 봅니다. ef_search를 올려도 리콜이 안 나오면 그때m,ef_construction을 재검토합니다.
인덱스 재빌드 전략: 무중단에 가깝게 가려면
HNSW 파라미터(m, ef_construction)를 바꾸려면 보통 인덱스를 새로 만들어야 합니다. 서비스 영향 최소화를 위해 CONCURRENTLY를 사용합니다.
CREATE INDEX CONCURRENTLY doc_chunks_embedding_hnsw_idx_v2
ON doc_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 24, ef_construction = 256);
-- 검증 후 기존 인덱스 제거
DROP INDEX CONCURRENTLY doc_chunks_embedding_hnsw_idx;
-- 이름을 맞추고 싶으면 재명명
ALTER INDEX doc_chunks_embedding_hnsw_idx_v2
RENAME TO doc_chunks_embedding_hnsw_idx;
주의:
CONCURRENTLY는 시간이 오래 걸릴 수 있고, 트랜잭션 제약이 있습니다.- 인덱스 구축 중 I/O가 증가하므로 운영 시간대 선택이 중요합니다.
청킹이 리콜을 좌우한다: 인덱스보다 먼저 볼 것
pgvector 튜닝에서 흔히 하는 실수는 “인덱스 파라미터만 만지다가” 끝나는 것입니다. 실제로는 청킹이 더 큰 영향을 주는 경우가 많습니다.
권장 체크리스트:
- 청크 길이(토큰 기준) 200~400부터 시작, 도메인에 맞게 조정
- 오버랩 10~20% 정도로 문맥 단절을 완화
- 표/코드/로그는 별도 규칙으로 분리(그대로 넣으면 노이즈가 커짐)
- 중복 청크 제거(동일 문장 반복, 템플릿 푸터 등)
청킹이 나쁘면 ef_search를 아무리 올려도 “찾아야 할 벡터” 자체가 애매해져 리콜이 오르지 않습니다.
메타데이터 필터링을 강하게 쓰는 패턴
RAG에서 실무적으로 중요한 건 “권한/테넌트/기간” 필터입니다. pgvector의 장점은 이걸 SQL로 강하게 걸 수 있다는 점입니다.
예: 최근 90일 문서만 검색
SELECT id, document_id, content
FROM doc_chunks
WHERE tenant_id = $2
AND created_at >= now() - interval '90 days'
ORDER BY embedding <=> $1
LIMIT 10;
단, 기간 필터로 대상이 급격히 줄어들면 HNSW의 이점이 줄 수 있습니다. 이때는 “필터 후 ANN”이 유리한지, “ANN 후 후처리”가 유리한지(권한 모델상 허용된다면) 실험이 필요합니다.
성능 측정: EXPLAIN ANALYZE로 인덱스 사용 확인
반드시 쿼리가 HNSW 인덱스를 타는지 확인하세요.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content
FROM doc_chunks
WHERE tenant_id = 't1'
ORDER BY embedding <=> $1
LIMIT 10;
여기서 보고 싶은 것:
- 계획에
Index Scan혹은 HNSW 관련 경로가 나타나는지 Buffers에서 읽기 패턴이 과도하지 않은지- 실제 실행 시간이 기대치에 맞는지
인덱스를 안 탄다면 통계(ANALYZE), 필터 선택도, 데이터 분포, 파티셔닝을 의심하세요.
운영 이슈: 커넥션/풀/트랜잭션과의 궁합
RAG는 “짧은 쿼리 다발”이 되기 쉬워 커넥션 풀이 흔들리면 지연시간이 튑니다. 특히 SET LOCAL로 ef_search를 조정한다면 트랜잭션 스코프가 명확해야 합니다.
- 요청당 트랜잭션을 짧게
- 커넥션 풀의 대기열이 쌓이지 않게 최대 커넥션과 워커를 조정
- 누수나 장기 점유가 있으면 먼저 잡기
Spring 계열이라면 커넥션 누수 경고를 추적하는 방법을 함께 참고해두면 운영 안정성이 올라갑니다.
RAG 품질 튜닝: 검색만으로 끝내지 말 것
ANN 검색은 “후보 생성”이고, 최종 답변 품질은 재랭킹과 프롬프트, 컨텍스트 구성에서 갈립니다.
실전 권장 파이프라인:
- pgvector TopK 20~50개 후보 검색
- 간단한 규칙 기반 필터(중복 제거, 길이 제한)
- (가능하면) cross-encoder 또는 LLM 기반 rerank로 TopK 5~10개로 압축
- 컨텍스트 윈도우에 맞춰 정렬/요약
에이전트를 쓰는 경우, 검색 실패가 무한 루프나 토큰 폭탄으로 이어지기도 합니다. RAG 검색 품질 튜닝과 함께 “에이전트 안전장치”도 같이 챙기는 게 좋습니다.
또한 벡터 저장이 커지면 “메모리/스토리지 증가”가 운영 비용이 됩니다. TTL, 압축, 오래된 인덱스 정리 전략도 같이 보세요.
실전 튜닝 레시피: 단계별로 이렇게 접근
1) 기준선을 만든다
- 쿼리 50~200개 정도의 평가셋을 만든다(실사용 질문 샘플)
- 정답 문서(또는 정답 청크)를 라벨링한다
- 지표를 정한다: Recall@K, MRR, P95 latency
2) 청킹을 먼저 고친다
- 청크 길이/오버랩 조정
- 템플릿/푸터 제거
- 문서 구조 기반 분리(제목, 섹션, 표)
3) HNSW 인덱스 파라미터는 보수적으로 시작
- 시작점 예시
m=16,ef_construction=128- 서빙
ef_search=40부터 시작
4) 리콜이 부족하면 ef_search부터 올린다
ef_search=80,120처럼 단계적으로 올리고 P95를 확인- P95가 SLA를 넘으면 rerank에서 후보 수를 줄이거나, 하드웨어/캐시/풀 튜닝을 병행
5) 그래도 부족하면 인덱스를 재빌드한다
m=24또는32ef_construction=256CONCURRENTLY로 새 인덱스 생성 후 스위칭
자주 겪는 문제와 처방
인덱스를 만들었는데도 느리다
- 원인: 인덱스를 안 탐, 또는
ef_search가 과도하게 큼, 또는 디스크 I/O 병목 - 처방:
EXPLAIN (ANALYZE, BUFFERS)로 확인ANALYZE수행- 핫 데이터 캐시(메모리) 확보, 스토리지 성능 점검
리콜이 낮다
- 원인: 청킹이 나쁨, 임베딩 모델이 도메인과 불일치,
ef_search가 낮음 - 처방:
- 청킹/정제 먼저
ef_search상향- 필요 시 재랭킹 도입
테넌트/권한 필터가 복잡해서 결과가 이상하다
- 원인: 필터 조건 때문에 후보가 너무 줄거나, 플래너가 비효율 경로 선택
- 처방:
- 필터 조건 단순화 또는 전처리(권한 테이블을 미리 머지한 materialized view 등)
- 파티셔닝 고려
결론: “벡터DB 없이”가 아니라 “Postgres로도 된다”
pgvector+HNSW는 단순한 대체재가 아니라, SQL 필터링과 운영 일원화를 무기로 삼을 수 있는 현실적인 RAG 스택입니다. 핵심은 인덱스 파라미터 자체보다도,
- 청킹/정제 품질
ef_search를 통한 리콜-지연시간 트레이드오프 관리- 플래너가 인덱스를 타도록 만드는 스키마/통계/필터 설계
이 3가지를 “측정 기반”으로 반복 튜닝하는 것입니다. 별도 벡터DB 도입 전에, 이미 가진 Postgres로 어디까지 갈 수 있는지 먼저 끝까지 밀어붙여 보세요.