Published on

PostgreSQL pgvector로 RAG 리콜·지연 최적화

Authors

RAG(Retrieval-Augmented Generation)에서 성능 병목은 대개 두 가지로 귀결됩니다. 첫째는 리콜입니다. “정답 근처 문서가 검색 후보에 들어오지 않으면” LLM이 아무리 좋아도 답이 흔들립니다. 둘째는 지연입니다. 검색이 느리면 전체 응답 시간이 늘어나고, 동시 요청이 늘수록 DB가 먼저 무너집니다.

PostgreSQL의 pgvector는 “애플리케이션 스택을 단순하게 유지”하면서도 벡터 검색을 제공하는 강력한 선택지입니다. 다만 기본 설정 그대로 쓰면 리콜과 지연 모두 만족하기 어렵습니다. 이 글은 pgvector 기반 RAG에서 리콜을 올리면서도 p95 지연을 낮추는 실전 튜닝 포인트를 정리합니다.

아래 내용은 pgvector의 ANN 인덱스(HNSW, IVFFlat)와 쿼리 패턴, 하이브리드 검색, 운영 관측을 함께 다룹니다.

RAG에서 리콜과 지연이 트레이드오프가 되는 이유

벡터 검색은 크게 두 방식이 있습니다.

  • 정확 검색(Exact): 모든 후보와 거리를 계산하므로 리콜은 높지만 지연이 커집니다.
  • 근사 최근접(ANN): 일부 후보만 탐색하므로 지연은 낮지만 리콜이 떨어질 수 있습니다.

RAG는 보통 다음 파이프라인을 가집니다.

  1. 쿼리 임베딩 생성
  2. Top k 벡터 검색
  3. (선택) 재랭킹, 필터링
  4. LLM 프롬프트 구성

여기서 2를 “빠르게” 만들기 위해 ANN을 쓰면 리콜이 흔들리고, 리콜을 올리기 위해 탐색을 늘리면 지연이 올라갑니다. 최적화의 핵심은 다음 두 질문으로 정리됩니다.

  • DB가 탐색해야 하는 후보 수를 줄일 수 있는가 (필터, 파티셔닝, 하이브리드)
  • 탐색 자체를 더 똑똑하게 할 수 있는가 (인덱스/파라미터/쿼리 패턴)

스키마 설계: “벡터만” 저장하지 말고 검색 단위를 먼저 정하자

RAG에서 흔한 실수는 “문서 1개를 1 row로 저장”하거나, 반대로 “너무 잘게 쪼개서 row가 폭증”하는 것입니다. pgvector 튜닝은 결국 row 수와 후보군 크기를 다루는 문제라서, chunk 전략이 곧 성능 전략입니다.

권장 방향:

  • chunk 크기: 300~800 토큰 수준에서 시작 (도메인에 따라 조정)
  • chunk 메타데이터: doc_id, tenant_id, language, source, created_at 등 필터에 쓸 컬럼을 명확히
  • 검색 단위: “LLM에 넣을 문맥” 단위로 chunk를 정의

예시 스키마:

create extension if not exists vector;

create table rag_chunks (
  id bigserial primary key,
  tenant_id bigint not null,
  doc_id bigint not null,
  chunk_index int not null,
  content text not null,
  embedding vector(1536) not null,
  language text,
  source text,
  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_tenant_lang_idx on rag_chunks (tenant_id, language);
create index rag_chunks_created_at_idx on rag_chunks (created_at);

핵심은 “벡터 인덱스 하나로 모든 걸 해결”하려 하지 않고, 벡터 검색 전 후보군을 줄일 수 있는 필터를 설계 단계에서 확보하는 것입니다.

pgvector 인덱스 선택: HNSW vs IVFFlat

pgvector에서 대표적인 ANN 인덱스는 HNSW와 IVFFlat입니다.

  • HNSW: 일반적으로 리콜이 높고 튜닝이 직관적입니다. 다만 인덱스 빌드/메모리 비용이 커질 수 있습니다.
  • IVFFlat: 학습 기반(centroid)으로 동작하며, 설정에 따라 성능이 크게 달라집니다. 대규모 데이터에서 비용 대비 효율이 좋을 수 있습니다.

운영 관점에서 자주 맞닥뜨리는 선택 기준:

  • 데이터가 수십만~수백만 chunk이고, 리콜이 중요하며, 메모리 여유가 있다면 HNSW부터
  • 데이터가 매우 크고, 쓰기/재빌드 비용을 관리해야 하며, 튜닝을 감수할 수 있으면 IVFFlat 고려

Milvus의 인덱스 튜닝 비교가 도움이 된다면 이 글도 같이 참고할 만합니다. 구조는 다르지만 “리콜-지연-메모리”의 감각을 잡는 데 유용합니다.

HNSW 인덱스 생성과 핵심 파라미터

-- 코사인 유사도 기준 예시
create index rag_chunks_embedding_hnsw_idx
on rag_chunks
using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 200);
  • m: 그래프 연결도. 높을수록 리콜이 좋아지기 쉬우나 메모리/빌드 비용 증가
  • ef_construction: 빌드 시 탐색 폭. 높을수록 품질이 좋아지지만 빌드가 느려짐

쿼리 시에는 ef_search가 리콜과 지연을 직접 좌우합니다.

set local hnsw.ef_search = 80;

select id, doc_id, content,
       1 - (embedding <=> $1) as score
from rag_chunks
where tenant_id = $2
order by embedding <=> $1
limit 10;

여기서 중요한 점은 다음입니다.

  • ef_search는 “리콜을 위해 지불하는 비용”입니다. p95 지연 목표를 정해두고 상한을 잡아야 합니다.
  • where tenant_id = $2 같은 필터가 있으면, 데이터 분포에 따라 인덱스 효율이 크게 달라집니다. 필터가 강하면 강할수록 “실질 후보군”이 줄어 리콜과 지연이 함께 개선됩니다.

IVFFlat 인덱스 생성과 핵심 파라미터

create index rag_chunks_embedding_ivf_idx
on rag_chunks
using ivfflat (embedding vector_cosine_ops)
with (lists = 2000);

-- 통계 갱신은 품질에 직접 영향
analyze rag_chunks;

쿼리 시에는 ivfflat.probes가 핵심입니다.

set local ivfflat.probes = 10;

select id, doc_id, content,
       1 - (embedding <=> $1) as score
from rag_chunks
where tenant_id = $2
order by embedding <=> $1
limit 10;
  • lists: 클러스터 수. 데이터 규모가 커질수록 적절히 늘려야 합니다.
  • probes: 탐색할 클러스터 수. 높이면 리콜이 올라가고 지연이 증가합니다.

운영 팁:

  • IVFFlat은 데이터가 계속 추가될 때 “분포 변화”가 생깁니다. 주기적인 reindex 또는 재구성이 필요할 수 있습니다.
  • 데이터 삽입이 많은 워크로드라면 인덱스 빌드/유지 비용까지 포함해 총비용을 비교해야 합니다.

쿼리 패턴 최적화: Top k만으로 끝내지 말고 “후보 확장 후 재랭킹”을 고려

RAG에서 리콜 문제는 종종 “Top k가 너무 작아서”가 아니라 “ANN이 놓친 후보가 있어서” 발생합니다. 이때 흔한 전략이 후보를 넓게 뽑고(예: k=50), 애플리케이션에서 재랭킹해서 최종 k=10을 선택하는 방식입니다.

예시:

-- DB에서 후보 50개를 빠르게 가져오고
set local hnsw.ef_search = 80;

select id, doc_id, content,
       (embedding <=> $1) as distance
from rag_chunks
where tenant_id = $2
order by embedding <=> $1
limit 50;

그 다음 애플리케이션에서:

  • BM25 점수, 최신성 가중치, 문서 타입 가중치
  • 교차 인코더(cross-encoder) 재랭킹

등을 적용해 최종 10개를 고릅니다. 이 방식은 “DB에서 리콜을 무리하게 끌어올리기 위한 파라미터 폭증”을 줄여 p95 지연을 안정화하는 데 도움이 됩니다.

하이브리드 검색: 벡터만으로 리콜을 올리려 하지 말자

실무에서 리콜이 흔들릴 때, 원인이 임베딩 품질이 아니라 “키워드 신호가 강한 질문”인 경우가 많습니다. 예를 들어 오류 코드, 함수명, 제품명, 약어는 벡터만으로 놓치기 쉽습니다.

PostgreSQL에서는 tsvector 기반 전문 검색과 벡터 검색을 결합한 하이브리드가 현실적인 해법입니다.

tsvector 컬럼 추가

alter table rag_chunks add column content_tsv tsvector;

update rag_chunks
set content_tsv = to_tsvector('simple', content);

create index rag_chunks_content_tsv_idx on rag_chunks using gin (content_tsv);

하이브리드 점수 결합 예시

아래는 “키워드 점수 + 벡터 유사도”를 단순 결합하는 예시입니다. 실제로는 정규화가 중요합니다.

with q as (
  select
    $1::vector as query_embedding,
    plainto_tsquery('simple', $2) as query_ts
)
select
  c.id,
  c.doc_id,
  c.content,
  -- ts_rank는 높을수록 좋고, distance는 낮을수록 좋으니 방향을 맞춘다
  (0.6 * ts_rank(c.content_tsv, q.query_ts)
   + 0.4 * (1 - (c.embedding <=> q.query_embedding))) as score
from rag_chunks c
cross join q
where c.tenant_id = $3
  and c.content_tsv @@ q.query_ts
order by score desc
limit 20;

핵심은 다음입니다.

  • 키워드 필터(@@)로 후보군을 줄이면 지연이 줄고 리콜도 오히려 좋아질 수 있습니다.
  • 반대로 키워드가 너무 강하게 필터링되어 리콜이 떨어지면, @@를 “필터”가 아니라 “가중치”로만 활용하는 방식도 고려합니다.

필터링 최적화: 테넌트, 시간, 도메인으로 후보군을 줄여라

RAG는 보통 멀티테넌트, 제품별 지식 베이스, 기간별 문서 같은 “강한 필터 조건”이 존재합니다. 이 필터를 벡터 검색과 함께 쓰는 전략이 매우 중요합니다.

권장 패턴:

  • 반드시 where tenant_id = ... 같은 조건을 포함
  • 최신성 요구가 있으면 created_at 범위를 제한
  • 특정 소스만 대상으로 한다면 source in (...)

예시:

set local hnsw.ef_search = 60;

select id, doc_id, content
from rag_chunks
where tenant_id = $1
  and source = $2
  and created_at >= now() - interval '180 days'
order by embedding <=> $3
limit 10;

이렇게 하면 ANN 파라미터를 과도하게 올리지 않고도 리콜이 유지되는 경우가 많습니다.

운영 관측: EXPLAIN (ANALYZE, BUFFERS)로 “느린 원인”을 분해

지연 최적화는 체감이 아니라 측정으로 합니다. 다음을 습관화하는 것이 좋습니다.

  • EXPLAIN (ANALYZE, BUFFERS)로 실제 실행 계획 확인
  • p50, p95, p99 지연을 분리해서 관리
  • DB CPU 사용률, I/O, 캐시 히트율 확인

예시:

explain (analyze, buffers)
select id, doc_id
from rag_chunks
where tenant_id = 42
order by embedding <=> $1
limit 10;

여기서 확인할 포인트:

  • 인덱스를 제대로 타는지
  • Buffers: shared read가 과도한지(디스크 I/O)
  • 정렬 비용이 커지는지

또한 RAG는 외부 API 호출(OpenAI 등)이 섞이기 때문에 “DB는 빨라졌는데 전체 지연은 그대로”인 상황이 흔합니다. 검색 이후 LLM 호출에서 429 재시도나 백오프가 발생하면 전체 지연이 튈 수 있으니, 애플리케이션 레벨의 재시도 정책도 함께 점검해야 합니다.

실전 튜닝 레시피: 리콜과 지연을 함께 맞추는 절차

아래는 팀에서 합의하기 좋은 “반복 가능한” 절차입니다.

1) 기준 데이터셋과 평가 쿼리를 만든다

  • 실제 사용자 질문에서 200~1000개 샘플
  • 각 질문에 대해 “정답 문서” 또는 “정답 chunk” 라벨링(가능한 범위에서)
  • 지표: Recall@k, MRR, nDCG 등

2) 먼저 필터로 후보군을 줄인다

  • 테넌트/도메인/언어/기간 필터를 기본으로 포함
  • 필터가 없다면 “논리적 파티셔닝”을 다시 설계

3) 인덱스는 HNSW로 시작하고 목표 p95에 맞춰 ef_search를 찾는다

  • ef_search를 20, 40, 80, 120… 식으로 올리며 Recall@k와 p95를 같이 기록
  • p95 예산을 넘는 순간부터는 “후보 확장 + 재랭킹”으로 방향 전환

4) 하이브리드 검색을 도입해 리콜을 안정화한다

  • 오류 코드, 제품명, 고유명사 비중이 높다면 특히 효과적
  • 벡터 파라미터를 무리하게 올리는 대신 키워드 신호로 보정

5) 동시성에서의 지연을 확인한다

단일 쿼리 최적화만으로는 부족합니다. 동시 요청이 늘면 다음 문제가 생깁니다.

  • 커넥션 풀 고갈
  • CPU 경합
  • I/O 급증

부하 테스트에서 병목이 애플리케이션 배포/오토스케일링과 엮여 보인다면, 컨테이너 환경에서 장애 진단 루틴도 함께 갖춰두는 게 좋습니다.

자주 겪는 함정 6가지

1) k만 키우면 리콜이 오른다고 믿는다

ANN에서 k는 “출력 개수”일 뿐, 탐색 품질은 ef_search 또는 probes가 좌우합니다. k=100인데 탐색이 부실하면 100개 모두 엉뚱할 수 있습니다.

2) 필터 컬럼 인덱스를 안 만든다

벡터 인덱스만 있으면 될 것 같지만, 실무에서는 tenant_id 같은 필터가 항상 붙습니다. B-tree 인덱스가 없으면 플래너가 비효율적인 계획을 선택할 수 있습니다.

3) 임베딩 차원과 거리 함수 선택을 대충 한다

  • 코사인 유사도는 텍스트 임베딩에서 흔한 기본값
  • L2를 쓰면 정규화 여부에 따라 품질이 흔들릴 수 있음

4) ANALYZE를 안 한다

통계가 오래되면 플래너가 잘못된 비용 추정으로 느린 계획을 고를 수 있습니다. 특히 IVFFlat은 통계와 운영 루틴이 중요합니다.

5) “검색 품질 문제”를 DB 파라미터로만 해결하려 한다

하이브리드, 재랭킹, chunk 전략, 메타데이터 필터가 더 큰 레버리지인 경우가 많습니다.

6) 캐시 전략이 없다

RAG는 동일/유사 질문이 반복됩니다. 쿼리 임베딩 결과와 검색 결과를 짧게라도 캐시하면 DB 부하와 지연이 크게 줄어듭니다.

마무리: pgvector 최적화의 핵심은 “후보군 설계”다

pgvector 튜닝은 단순히 인덱스 파라미터를 만지는 작업이 아닙니다. RAG에서 리콜과 지연을 동시에 잡으려면 다음 순서가 가장 효율적입니다.

  • chunk와 메타데이터로 검색 단위를 올바르게 정의
  • 강한 필터로 후보군을 줄여 ANN의 부담을 낮춤
  • HNSW 또는 IVFFlat의 탐색 파라미터를 p95 예산 내에서 조정
  • 후보 확장 후 재랭킹, 그리고 키워드 결합으로 리콜을 안정화
  • EXPLAIN (ANALYZE, BUFFERS)와 부하 테스트로 병목을 지속 관측

이 흐름으로 접근하면 “리콜을 올리기 위해 지연을 희생”하거나 “지연을 줄이려다 답변 품질이 무너지는” 상황을 훨씬 덜 겪게 됩니다.