왜 PostgreSQL에 벡터를 저장할까
RAG(검색 증강 생성)는 질문과 의미가 가까운 문서 조각을 찾아 LLM에 함께 넘기는 방식입니다. 이때 "의미가 가깝다"를 계산하려고 문서 조각을 숫자 벡터(임베딩)로 바꿔 저장합니다.
pgvector는 PostgreSQL에서 벡터를 저장하고 유사도로 검색하게 해 주는 오픈소스 확장입니다(2026년 10월 1일 0.8.7 출시). 별도 벡터 데이터베이스 대신 PostgreSQL에 두면 문서의 권한·테넌트·수정 시각 같은 기존 데이터와 같은 트랜잭션, 같은 조인으로 다룰 수 있다는 장점이 있습니다. 그만큼 테이블 설계가 검색 품질과 성능을 좌우합니다.
1. 문서와 청크를 나눈 구조
임베딩은 보통 문서 전체가 아니라 일정 길이로 자른 청크(chunk) 단위로 만듭니다. 문서와 청크를 1:N으로 나눕니다.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
title TEXT NOT NULL,
source_url TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_chunks (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents (id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL,
chunk_index INT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536) NOT NULL,
model TEXT NOT NULL,
embedded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (document_id, chunk_index)
);
설계 포인트입니다.
vector(1536)의 차원은 임베딩 모델의 출력 차원과 같아야 합니다. 다른 차원의 벡터는 같은 컬럼에 넣을 수 없습니다model컬럼: 서로 다른 모델이 만든 벡터는 비교할 수 없습니다. 모델을 바꿀 때 어떤 행을 다시 만들어야 하는지 이 컬럼으로 찾습니다tenant_id를 청크에도 둡니다: 검색할 때 거의 항상 테넌트로 거르므로, 조인 없이 바로 필터링하고 아래의 부분 인덱스·파티셔닝에도 쓸 수 있습니다ON DELETE CASCADE: 문서를 지우면 그 청크와 벡터도 함께 지워져, 삭제한 문서가 검색에 남지 않습니다embedded_at과 문서의updated_at을 비교하면 원문이 바뀐 뒤 다시 임베딩하지 않은 청크를 찾을 수 있습니다
2. 벡터 타입과 차원 제한
pgvector는 네 가지 타입을 제공하며, 저장할 수 있는 차원과 인덱스를 만들 수 있는 차원이 다릅니다. vector와 halfvec는 16,000차원까지 저장할 수 있지만 인덱스 한도는 그보다 낮습니다.
| 타입 | 인덱스를 만들 수 있는 한도 |
|---|---|
vector | 2,000차원 |
halfvec (반정밀도) | 4,000차원 |
bit (이진) | 64,000차원 |
sparsevec (희소) | 0이 아닌 원소 1,000개 (저장은 16,000개까지) |
임베딩이 2,000차원을 넘으면 vector 컬럼에 그대로 인덱스를 만들 수 없습니다. 이때는 반정밀도로 바꾼 표현식 인덱스를 씁니다. 쿼리도 인덱스와 같은 식으로 정렬해야 인덱스를 탑니다.
-- 3072차원 임베딩을 반정밀도로 인덱싱
CREATE INDEX ON document_chunks_large
USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);
SELECT id, content
FROM document_chunks_large
ORDER BY embedding::halfvec(3072) <=> $1::halfvec(3072)
LIMIT 5;
3. 거리 연산자와 HNSW 인덱스
pgvector의 거리 연산자는 <->(L2), <#>(내적의 음수), <=>(코사인 거리), <+>(L1), 그리고 이진 벡터용 <~>(해밍), <%>(자카드)입니다. 임베딩 모델이 권장하는 거리를 고르고, 인덱스의 연산자 클래스(opclass)와 쿼리의 연산자를 맞춰야 합니다. 인덱스는 ORDER BY가 거리 연산자의 오름차순일 때 쓰입니다.
인덱스 종류는 두 가지입니다.
- HNSW: 다층 그래프. 검색 성능이 좋지만 빌드가 느리고 메모리를 더 씁니다. 학습 단계가 필요 없습니다
- IVFFlat: 벡터를 목록으로 나눕니다. 빌드가 빠르고 메모리를 덜 쓰지만 검색 성능은 HNSW보다 낮습니다
-- 코사인 거리 HNSW 인덱스 (기본값: m = 16, ef_construction = 64)
CREATE INDEX ON document_chunks USING hnsw (embedding vector_cosine_ops);
-- 검색 시 후보 수 (기본 40). 올리면 재현율↑ 속도↓
SET hnsw.ef_search = 100;
SELECT id, document_id, content
FROM document_chunks
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 5;
HNSW 인덱스는 그래프가 maintenance_work_mem에 들어갈 때 훨씬 빨리 만들어집니다. 큰 테이블에 인덱스를 만들 때는 이 값을 함께 검토하세요.
4. 필터링: 결과가 모자라는 문제와 해결책
근사 인덱스에서는 인덱스를 먼저 탐색한 뒤에 필터가 적용됩니다. pgvector 문서의 예시로, 조건에 맞는 행이 전체의 10%이고 hnsw.ef_search가 기본값 40이면 평균 4개 행만 남습니다. LIMIT 5를 줘도 5개가 안 나올 수 있다는 뜻입니다.
해결책은 상황에 따라 고릅니다.
반복 인덱스 스캔(0.8.0부터). 결과가 충분히 모일 때까지 인덱스를 더 탐색합니다.
SET hnsw.iterative_scan = strict_order; -- 거리 순서를 정확히 유지
-- 또는
SET hnsw.iterative_scan = relaxed_order; -- 순서가 조금 어긋날 수 있지만 재현율이 더 좋음
필터 컬럼에 일반 인덱스. 조건이 전체 행의 작은 비율만 고른다면 B-tree 인덱스로 먼저 거르는 편이 낫습니다.
CREATE INDEX ON document_chunks (tenant_id);
자주 쓰는 값이 몇 개뿐이면 부분 인덱스, 값이 많으면 파티셔닝.
-- 특정 테넌트 전용 벡터 인덱스
CREATE INDEX ON document_chunks USING hnsw (embedding vector_cosine_ops)
WHERE (tenant_id = 42);
값의 종류가 많다면 PARTITION BY LIST (tenant_id)처럼 필터 컬럼으로 파티션을 나누는 방법을 pgvector 문서가 권합니다.
5. 운영 체크리스트
- 임베딩 모델을 바꿀 때:
model컬럼으로 대상을 고르고, 새 벡터를 다른 컬럼이나 테이블에 만든 뒤 전환하면 검색이 끊기지 않습니다 - 원문이 바뀔 때: 해당 문서의 청크를 지우고 다시 만들거나,
embedded_at < updated_at인 청크를 주기적으로 찾아 갱신합니다 - 차원 제한 확인: 인덱스가 필요한 컬럼은 타입별 인덱스 차원 한도 안에 있어야 합니다
- 검색 품질 측정:
ef_search·반복 스캔 설정은 정확도와 속도를 맞바꿉니다. 실제 질의로 재현율을 재 보고 정하세요
Erdmates로 설계할 때
RAG 스키마의 핵심은 문서–청크의 1:N 관계, 삭제가 연쇄되는 외래 키, 그리고 필터에 쓰일 테넌트 키입니다. Erdmates에서 이 관계를 ERD로 먼저 그려 두면, 벡터 컬럼을 어느 테이블에 두고 어떤 컬럼으로 거를지 팀이 같은 그림을 보며 정할 수 있습니다.
마무리
pgvector 설계에서 성능과 품질을 가르는 것은 결국 평범한 데이터베이스 설계입니다. 문서와 청크를 나누고, 모델과 갱신 시각을 기록하고, 차원 한도에 맞는 타입을 고르고, 필터 조건에 맞는 인덱스 전략(반복 스캔·부분 인덱스·파티셔닝)을 세우는 것. 이 네 가지를 갖추면 벡터 검색도 기존 데이터와 같은 수준으로 관리할 수 있습니다.