목차
- Before: 별도 벡터 DB를 붙이던 시절
- After: pgvector로 통합한 현재
- pgvector 설치와 기본 설정
- 유사도 검색 쿼리
- 인덱스 — IVFFlat vs HNSW
- 성능 튜닝 포인트
- 언제 pgvector를 쓰고, 언제 전용 벡터 DB를 쓸 것인가
Before: 별도 벡터 DB를 붙이던 시절
RAG 파이프라인에 유사도 검색이 필요해서 Pinecone 무료 티어를 쓰고 있었다. 데이터는 PostgreSQL에 있고, 임베딩만 Pinecone에 넣고, 검색 결과의 ID를 다시 PostgreSQL에서 조회하는 구조. 동작은 했지만 문제가 두 가지 있었다.
첫째, 데이터 동기화. PostgreSQL에서 row를 삭제해도 Pinecone에는 벡터가 남아 있다. 동기화 스크립트를 따로 짜야 했고, 이게 은근히 귀찮다. 둘째, 레이턴시. 한 번의 검색에 네트워크 홉이 두 번 — Pinecone 호출 후 PostgreSQL 재조회. 10만 건 이하 규모에서 이 오버헤드가 본 검색 시간보다 클 때가 많았다.
After: pgvector로 통합한 현재
PostgreSQL 하나에 원본 데이터와 임베딩 벡터가 같이 들어간다. JOIN 한 방이면 유사도 검색 결과에 메타데이터가 바로 붙는다. 동기화 문제가 사라졌고, 네트워크 홉도 하나 줄었다. 이 전환 과정에서 새로 알게 된 것들을 정리한다.
pgvector 설치와 기본 설정
확장 설치
Ubuntu 기준으로 PostgreSQL 16에 pgvector를 붙이는 과정이다. 패키지 매니저로 설치하면 빌드 과정 없이 바로 쓸 수 있다.
# PostgreSQL 16 기준 pgvector 설치
sudo apt install postgresql-16-pgvector
# PostgreSQL 접속 후 확장 활성화
psql -U postgres -d mydb -c "CREATE EXTENSION vector;"
CREATE EXTENSION이 실패하면 십중팔구 postgresql-16-pgvector 패키지가 안 깔린 거다. shared_preload_libraries에 뭔가 추가해야 하는 건 아니냐는 질문을 가끔 보는데, pgvector는 preload 없이 동작한다.
테이블 설계
임베딩 차원 수는 사용하는 모델에 따라 달라진다. OpenAI의 text-embedding-3-small은 1536차원, text-embedding-3-large는 3072차원까지 지원한다.
-- 문서 + 임베딩을 한 테이블에 저장
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536), -- 모델 차원에 맞춤
created_at TIMESTAMPTZ DEFAULT NOW()
);
여기서 한 가지. 프론트엔드 하다가 백엔드로 넘어온 입장에서 vector(1536) 같은 커스텀 타입이 PostgreSQL에서 이렇게 깔끔하게 동작한다는 게 처음엔 신기했다. JavaScript 세계에서는 배열에 float 1536개를 넣고 직접 코사인 유사도 함수를 짜야 했을 텐데, SQL 한 줄로 해결된다.
임베딩을 별도 테이블로 분리하는 설계도 있다. 원본 데이터에 대한 일반 쿼리가 빈번하다면 분리가 낫다. 임베딩 컬럼이 있으면 SELECT * 할 때 1536 × 4바이트 = 약 6KB가 row마다 딸려온다.
-- 임베딩 분리 설계
CREATE TABLE doc_embeddings (
doc_id BIGINT REFERENCES documents(id) ON DELETE CASCADE,
embedding vector(1536),
model_version TEXT DEFAULT 'text-embedding-3-small',
PRIMARY KEY (doc_id)
);
ON DELETE CASCADE가 핵심이다. 원본 문서가 삭제되면 임베딩도 같이 사라진다. 이게 별도 벡터 DB를 쓸 때는 직접 구현해야 했던 동기화 로직이다.
유사도 검색 쿼리
거리 연산자 선택
pgvector는 세 가지 거리 연산자를 지원한다.
| 연산자 | 거리 함수 | 용도 | 비고 |
|---|---|---|---|
<-> |
L2 (유클리드) | 일반 유사도 | 값이 작을수록 유사 |
<=> |
코사인 거리 | 텍스트 임베딩 | 1 – cosine similarity |
<#> |
내적 (음수) | 정규화된 벡터 | 값이 작을수록 유사 |
텍스트 임베딩을 다룬다면 <=>(코사인 거리)를 쓰면 된다. OpenAI 임베딩은 이미 정규화되어 있어서 <#>(내적)을 써도 결과가 같다. 체감상 차이를 느끼기 어려웠다.
기본 유사도 검색 쿼리
-- 입력 텍스트의 임베딩으로 상위 5개 유사 문서 검색
SELECT
d.id,
d.title,
1 - (de.embedding <=> $1::vector) AS similarity -- 코사인 유사도로 변환
FROM doc_embeddings de
JOIN documents d ON d.id = de.doc_id
ORDER BY de.embedding <=> $1::vector
LIMIT 5;
$1에는 검색 쿼리 텍스트를 임베딩한 벡터가 들어간다. <=> 연산자가 반환하는 건 "거리"라서 값이 작을수록 유사하다. 유사도 점수로 보여주고 싶으면 1 - distance 하면 된다.
한 가지 주의할 점이 있다. ORDER BY ... LIMIT 패턴에서 인덱스가 없으면 풀 스캔이 돈다. 10만 건 이하에서는 체감이 안 되지만, 100만 건을 넘기면 초 단위로 느려진다. 인덱스가 필요한 시점이다.
인덱스 — IVFFlat vs HNSW
pgvector에서 벡터 인덱스는 두 종류다. 이 선택이 성능을 결정한다.
IVFFlat
IVFFlat은 벡터를 여러 클러스터(리스트)로 나눈 뒤, 검색 시 가장 가까운 클러스터 몇 개만 스캔하는 방식이다. 빌드가 빠르고 메모리를 적게 먹는다.
-- IVFFlat 인덱스 생성
-- lists 값은 sqrt(row 수) 정도가 권장 (10만 건이면 ~316)
CREATE INDEX ON doc_embeddings
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 300);
lists 파라미터가 클러스터 수다. 공식 문서에서는 100만 건 이하일 때 sqrt(rows), 100만 건 이상일 때 sqrt(rows) / 2 ~ sqrt(rows)를 권장한다(출처: pgvector GitHub README). 문제는 데이터가 추가된 후 클러스터 분포가 달라지면 인덱스를 다시 빌드해야 한다는 것이다. REINDEX가 필요한 시점을 판단하기가 어렵다.
검색 시 probes 값으로 몇 개의 클러스터를 탐색할지 조절한다.
-- 검색 정확도 조절 (기본값 1, 높일수록 정확하지만 느림)
SET ivfflat.probes = 10;
HNSW
HNSW(Hierarchical Navigable Small World)는 그래프 기반 인덱스다. 빌드 시간이 IVFFlat보다 오래 걸리고 메모리도 더 먹지만, 검색 품질이 더 좋다. 데이터가 추가되어도 인덱스를 재빌드할 필요가 없다.
-- HNSW 인덱스 생성
CREATE INDEX ON doc_embeddings
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
m은 그래프에서 각 노드의 연결 수, ef_construction은 빌드 시 탐색 범위다. m을 올리면 검색 정확도가 올라가지만 인덱스 크기도 커진다. 검색 시에는 ef_search로 정확도를 조절한다.
SET hnsw.ef_search = 100; -- 기본값 40
어느 쪽을 골라야 하는가
결론부터. 대부분의 경우 HNSW를 쓰면 된다.
| 항목 | IVFFlat | HNSW |
|---|---|---|
| 빌드 속도 | 빠름 | 느림 (2~5배) |
| 검색 정확도(recall) | probes에 의존 | 일반적으로 더 높음 |
| 메모리 사용 | 적음 | 많음 (2~3배) |
| 데이터 추가 시 | 재빌드 권장 | 자동 반영 |
| 적합한 규모 | 100만 건 이상 대규모 | 대부분 |
IVFFlat이 유리한 경우는 메모리가 극도로 제한된 환경이거나, 데이터가 거의 변하지 않는 정적 데이터셋일 때다. 그 외에는 HNSW가 운영 편의성에서 압도적으로 편하다. 인덱스 재빌드를 신경 쓰지 않아도 되니까.
프론트엔드에서 백엔드로 넘어와서 느끼는 건데, 인덱스 선택이라는 게 프론트에서의 렌더링 최적화(useMemo vs React.memo vs 구조 변경)와 비슷한 성격이다. "일단 되게 만들고, 느리면 그때 최적화" 전략이 여기서도 통한다. 처음부터 IVFFlat의 lists 값을 정교하게 튜닝하는 것보다 HNSW를 걸어두고 나중에 ef_search만 조절하는 게 현실적이다.
성능 튜닝 포인트
PostgreSQL 설정
벡터 인덱스 빌드와 검색은 메모리를 많이 쓴다. PostgreSQL 기본값으로는 부족할 수 있다.
-- 인덱스 빌드 시 메모리 (세션 레벨)
SET maintenance_work_mem = '2GB';
-- 병렬 인덱스 빌드 (PostgreSQL 16+)
SET max_parallel_maintenance_workers = 4;
maintenance_work_mem은 인덱스 빌드 속도에 직접 영향을 준다. 100만 건 × 1536차원 기준으로 기본값(64MB)과 2GB 설정의 HNSW 빌드 시간 차이가 체감상 3~4배 났다. 정확한 벤치마크는 환경마다 다르겠지만, 메모리 여유가 있다면 올려두는 게 맞다.
차원 축소
text-embedding-3-large의 3072차원을 그대로 쓰면 인덱스 크기가 1536차원의 2배다. OpenAI의 Matryoshka 임베딩은 차원을 줄여도 성능 저하가 크지 않다는 게 특징인데, 실제로 3072 → 1536으로 줄여도 검색 품질 차이를 체감하기 어려웠다.
필터링과 파티셔닝
실무에서 벡터 검색만 단독으로 쓰는 경우는 드물다. "특정 카테고리 내에서 유사한 문서 찾기"처럼 필터 조건이 붙는다. 이때 주의할 게 있다.
-- ❌ 느린 패턴: WHERE 필터 후 벡터 검색
SELECT * FROM documents
WHERE category = 'tech'
ORDER BY embedding <=> $1::vector
LIMIT 5;
이 쿼리는 벡터 인덱스를 타지 못할 수 있다. PostgreSQL 플래너가 category 필터를 먼저 적용한 뒤 필터된 결과에서 순차 스캔으로 벡터 정렬을 하는 경우가 있다. EXPLAIN ANALYZE로 확인해보면 인덱스 스캔이 아닌 시퀀셜 스캔이 잡히는 걸 볼 수 있다.
해결 방법은 파티셔닝이다.
-- 카테고리별 파티션 테이블
CREATE TABLE doc_embeddings (
doc_id BIGINT,
category TEXT,
embedding vector(1536)
) PARTITION BY LIST (category);
CREATE TABLE doc_embeddings_tech PARTITION OF doc_embeddings
FOR VALUES IN ('tech');
CREATE TABLE doc_embeddings_science PARTITION OF doc_embeddings
FOR VALUES IN ('science');
-- 각 파티션에 HNSW 인덱스
CREATE INDEX ON doc_embeddings_tech
USING hnsw (embedding vector_cosine_ops);
파티션별로 인덱스가 생기니까, WHERE category = 'tech' 조건이 걸리면 해당 파티션의 HNSW 인덱스만 탄다. 카테고리 수가 적고 고정적일 때 이 방식이 잘 동작한다. 카테고리가 수백 개로 늘어나면 파티션 관리가 지옥이 되니까 그때는 다른 접근이 필요하다(이건 아직 안 해봐서 모르겠다).
벡터 정규화
코사인 거리(<=>)를 쓸 때 벡터가 이미 정규화되어 있다면, 내적(<#>)으로 바꾸는 것만으로도 연산이 줄어든다. 내적은 나눗셈이 빠지니까.
-- 저장 시 정규화 (Python 쪽에서 처리)
import numpy as np
def normalize(v):
norm = np.linalg.norm(v)
return v / norm if norm > 0 else v
# 정규화된 벡터를 INSERT
normalized = normalize(embedding)
OpenAI 임베딩은 이미 정규화되어 나오기 때문에 이 과정이 필요 없다. 직접 학습한 모델이나 Sentence Transformers 같은 오픈소스 모델을 쓸 때 체크하면 된다.
언제 pgvector를 쓰고, 언제 전용 벡터 DB를 쓸 것인가
pgvector를 쓰는 게 맞는 상황:
- 벡터 데이터가 500만 건 이하
- 이미 PostgreSQL을 메인 DB로 쓰고 있음
- 벡터 검색 결과에 관계형 데이터(사용자 정보, 메타데이터 등)를 JOIN해야 함
- 인프라를 단순하게 유지하고 싶음
전용 벡터 DB(Pinecone, Weaviate, Qdrant 등)가 맞는 상황:
- 벡터 데이터가 수천만 건 이상
- 벡터 검색이 서비스의 핵심이고, 밀리초 단위 최적화가 필요함
- 멀티 테넌시, 분산 검색 같은 기능이 필요함
500만 건이라는 기준은 절대적인 게 아니다. 하드웨어 스펙, 차원 수, 동시 쿼리 수에 따라 달라진다. 필자 기준으로는 1536차원 × 100만 건 정도에서 HNSW 인덱스 기준 검색 응답이 10~20ms 내로 나왔다. 이 정도면 대부분의 서비스에서 충분하다.
PostgreSQL 16 이상에서 pgvector 0.7+(2024년 기준 최신 안정 버전은 GitHub 릴리즈 페이지에서 확인할 것)을 쓸 때 HNSW 인덱스를 걸고, ef_search를 40~100 사이에서 조절하고, 필터링이 필요하면 파티셔닝을 검토하는 것. 이게 현재 pgvector로 벡터 검색을 운영하는 데 필요한 거의 전부다. 별도 벡터 DB를 도입하는 건 이 구성으로 한계에 부딪힌 뒤에 해도 늦지 않다.
관련 글
- PostgreSQL vs MySQL 선택 기준 — 2026년 신규 프로젝트 실전 비교 – PostgreSQL과 MySQL 중 뭘 골라야 하는지, JSON 쿼리 성능·확장성·AI 인프라 연동까지 직접 써보고 비교한 기록이다. 통념…
- GraphQL N+1 문제 해결 — DataLoader 도입기와 실무 체크리스트 – GraphQL resolver 구조 때문에 주문 20건 조회에 SELECT가 62개 실행되는 상황을 겪었다. DataLoader를 적용해 …
- 벡터 데이터베이스 선택 기준 — Pinecone vs Weaviate vs pgvector 실전 비교 – RAG 파이프라인에 벡터DB를 붙여야 하는데 선택지가 너무 많다. Pinecone, Weaviate, pgvector 세 가지를 실제로 써…