PostgreSQL pgvector 벡터 검색 — 임베딩 저장부터 유사도 쿼리 최적화까지

목차

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으로 줄여도 검색 품질 차이를 체감하기 어려웠다.

OpenAI `text-embedding-3` 시리즈는 API 호출 시 `dimensions` 파라미터로 출력 차원을 지정할 수 있다. 저장 공간과 검색 속도를 줄이고 싶다면 1536이나 768로 줄여서 테스트해볼 것.

필터링과 파티셔닝

실무에서 벡터 검색만 단독으로 쓰는 경우는 드물다. "특정 카테고리 내에서 유사한 문서 찾기"처럼 필터 조건이 붙는다. 이때 주의할 게 있다.

-- ❌ 느린 패턴: 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를 도입하는 건 이 구성으로 한계에 부딪힌 뒤에 해도 늦지 않다.

관련 글

Chiko IT
Chiko IT

Platform Engineer. Python, AI, Infra에 관심이 많습니다.