LLM WikiAccess-protected knowledge portal
← 스터디 홈
135편 · 약 15분

pgvector 0.8.x: 반복 인덱스 스캔·sparsevec·필터 검색으로 PostgreSQL 벡터 DB를 운영하는 법

요약

pgvector 0.8 시리즈(2024-10-30 첫 릴리스 → 2026-07-29 0.8.6 최신)는 PostgreSQL 안에서 프로덕션급 벡터 검색을 가능하게 한 두 가지 핵심 기능을 가져왔다. 첫째는 반복 인덱스 스캔(iterative index scan) — WHERE 절 필터가 많은 후보를 걸러내더라도 HNSW/IVFFlat 인덱스가 스스로 더 깊이 탐색해 요청 개수를 채운다. 둘째는 sparsevec 타입 — BM25·SPLADE 같은 희소(sparse) 임베딩을 0이 아닌 값만 저장해 메모리와 디스크를 절약한다. 두 기능을 조합하면 밀집·희소 벡터를 하나의 테이블에 두고, 필터 조건이 복잡한 상황에서도 정확한 k-NN 결과를 돌려줄 수 있다.


배경: 왜 0.7 → 0.8이 중요한가

0.6 이전 pgvector는 단순했다. vector 타입, L2/코사인/내적 세 가지 연산자, HNSW·IVFFlat 두 인덱스. 그것만으로도 소규모 사용에는 충분했지만, 실무에서는 두 가지 문제가 자주 터졌다.

문제 1 — 필터 후 후보 고갈. WHERE user_id = 42 ORDER BY embedding <=> query LIMIT 10처럼 필터를 붙이면, HNSW가 처음 꺼내는 ef_search개 후보 중 필터를 통과하는 게 10개 미만일 수 있다. 결과가 적게 나오거나 아예 0개가 된다.

문제 2 — 희소 임베딩 저장 낭비. BM25나 SPLADE의 출력은 수만 차원에 걸쳐 99% 이상이 0.0이다. 기존 vector 타입은 모든 0까지 float4로 저장하므로 100,000차원 벡터는 ~400KB를 차지한다.

0.7.0은 sparsevec·halfvec·bit 타입으로 두 번째 문제를 해결했다. 0.8.0은 반복 스캔으로 첫 번째 문제를 해결했다. 0.8.x 패치 시리즈는 플래너 비용 추정·성능·안정성을 꾸준히 개선했고, 2026-07-29 출시된 0.8.6이 현재 최신 버전이다.


네 가지 벡터 타입

pgvector 0.8.x가 제공하는 타입은 총 네 가지다.

타입저장 단위최대 차원대표 용도
vectorfloat4 (4B)16,000OpenAI, Cohere 밀집 임베딩
halfvecfloat2 (2B)16,000메모리 절약, 정밀도 소폭 감소
bit1bit64,000이진 해싱, Hamming 거리
sparsevec(index, float4) pairs구성 가능BM25, SPLADE, 범주형 원-핫

sparsevec 실전

-- 100,000차원 sparsevec 컬럼
ALTER TABLE documents ADD COLUMN sparse_emb sparsevec(100000);

-- 파이썬에서 삽입 (scipy sparse → pgvector 표현)
from pgvector.psycopg import SparseVector
row.sparse_emb = SparseVector(100000, {42: 0.9, 1337: 0.3, 99999: 0.1})

sparsevec는 {index:value, ...}/dimensions 리터럴로 직렬화된다. 내부 표현은 비-0 인덱스 배열과 값 배열의 쌍이므로, 100,000차원 중 300개만 비-0이라면 저장 크기는 ~2.4KB로 줄어든다.

sparsevec가 지원하는 연산자는 현재 내적(<#>)과 코사인(<=>)이다. L2는 미지원이므로 BM25/SPLADE용으로는 코사인이나 내적을 쓴다.


여섯 가지 거리 연산자

pgvector 0.8.x — 여섯 가지 거리 연산자 연산자 거리 종류 지원 타입 주 용도 <-> L2 (유클리드) vector, halfvec 밀집 임베딩 유사도 <=> 코사인 vector, halfvec, sparsevec 정규화된 임베딩, BM25 <#> 내적 (음수) vector, halfvec, sparsevec SPLADE, 단위 벡터 <+> L1 (맨해튼) vector, halfvec 이상치 강인성 <~> 해밍 bit 이진 해시 유사도 <%> 자카드 bit 집합 유사도 ※ <#> 는 내적의 음수를 반환하므로 ORDER BY 시 작은 값이 더 유사함 (MAX-IP = MIN(-IP))
pgvector 0.8.x 거리 연산자 요약

반복 인덱스 스캔: 핵심 원리

문제: 필터 후 후보 고갈

HNSW는 그래프를 탐색하며 가장 가까운 ef_search개 후보를 꺼낸다. 그런데 WHERE tenant_id = 'acme' 같은 필터를 PostgreSQL이 적용하면, ef_search개 중 상당수가 걸러진다. 결과가 10개 미만이 되면 PostgreSQL은 조용히 부족한 개수를 반환한다.

해결: iterative scan

0.8.0부터 hnsw.iterative_scan을 활성화하면 HNSW가 배치 단위로 추가 탐색을 반복한다. 필터를 통과한 후보가 LIMIT를 채울 때까지 계속 그래프를 더 깊이 뒤진다.

-- 세션 레벨
SET hnsw.iterative_scan = strict_order;

-- 또는 IVFFlat
SET ivfflat.iterative_scan = on;

strict_order는 거리 정렬 순서를 보장하고, relaxed_order는 약간의 순서 완화를 허용하는 대신 더 빠르다. 필터 선택도(selectivity)가 매우 높을 때는 relaxed_order가 실용적이다.

최대 탐색 제한

무한 탐색을 막기 위해 두 파라미터가 있다.

-- HNSW: 최대 스캔 후보 수 (기본값 20000)
SET hnsw.max_scan_tuples = 50000;

-- IVFFlat: 최대 프로브 수 (기본값 lists 수)
SET ivfflat.max_probes = 100;

max_scan_tuples에 도달하면 탐색을 멈추고 그때까지 모인 결과를 반환한다. 쿼리가 LIMIT를 채우지 못할 수 있다는 뜻이므로, 애플리케이션에서 반환 개수를 검증해야 한다.

동작 흐름

HNSW Iterative Scan — 동작 흐름 SELECT ... ORDER BY emb <=> $q LIMIT 10 HNSW 1차 배치 스캔 ef_search개 후보 추출 PostgreSQL 필터 적용 WHERE tenant_id = 'acme' 결과 ≥ LIMIT? 10개 이상? YES 결과 반환 거리 순 정렬 후 리턴 NO max_scan_tuples 도달? 한계에 도달하면 부분 결과 반환 추가 배치 스캔
반복 인덱스 스캔 동작 흐름

비용 추정 개선 (0.8.x)

0.8.0 이전에는 플래너가 HNSW 인덱스 비용을 과소 추정해 순차 스캔(seqscan)을 선택하거나, 반대로 B-tree 복합 인덱스가 더 효율적인 상황에서 HNSW를 선택하는 일이 있었다.

0.8.x는 필터 선택도에 따른 비용 추정 로직을 개선했다. 실제 운영에서는 EXPLAIN (ANALYZE, BUFFERS) 로 어느 인덱스가 선택되는지 확인하고, 필요하면 SET enable_seqscan = off 또는 인덱스 힌트로 강제한다.

-- 플래너 결정 확인
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content
FROM documents
WHERE tenant_id = 'acme'
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 10;

출력에서 Index Scan using documents_embedding_idx 가 보여야 한다. Seq Scan이 나오면 테이블 통계(ANALYZE)가 오래됐거나 인덱스 파라미터 조정이 필요하다.


DBA 프로덕션 체크리스트

인덱스 생성

-- HNSW: m(그래프 연결도)과 ef_construction(빌드 품질)
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- halfvec 사용 시 메모리 절반으로 절약
CREATE INDEX ON documents USING hnsw (embedding halfvec_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- sparsevec (내적)
CREATE INDEX ON documents USING hnsw (sparse_emb sparsevec_ip_ops)
  WITH (m = 16, ef_construction = 64);

m을 높이면 정확도↑ 빌드 시간↑ 메모리↑, ef_construction을 높이면 인덱스 품질↑ 빌드 시간↑. 기본값 m=16, ef_construction=64는 좋은 시작점이다.

쿼리 파라미터 튜닝

-- ef_search: 검색 시 후보 수 (기본값 40)
SET hnsw.ef_search = 100;   -- 정확도↑ 속도↓

-- iterative scan 활성화
SET hnsw.iterative_scan = strict_order;

-- 최대 스캔 한계 (필터 선택도에 맞게 조정)
SET hnsw.max_scan_tuples = 40000;

필터 선택도가 1%라면 LIMIT 10을 채우려면 평균 1,000개 후보가 필요하다. max_scan_tuples는 그보다 여유 있게 설정한다.

메모리 설정

-- HNSW 인덱스 빌드 시 shared_buffers를 활용
-- postgresql.conf
maintenance_work_mem = '2GB'    -- 인덱스 빌드 시 사용
work_mem = '256MB'              -- 쿼리 정렬 버퍼

정기 유지관리

-- 벡터 통계 갱신 (기본 ANALYZE는 벡터 컬럼 샘플링 부족)
ALTER TABLE documents ALTER COLUMN embedding SET STATISTICS 1000;
ANALYZE documents;

-- 인덱스 재구축 (INSERT/DELETE 많을 경우)
REINDEX INDEX CONCURRENTLY documents_embedding_idx;

-- bloat 확인
SELECT pg_size_pretty(pg_relation_size('documents_embedding_idx')) AS idx_size;

멀티테넌트 필터 패턴

테넌트마다 벡터 품질이 다른 경우 파티셔닝이 효과적이다.

-- tenant_id 기반 파티션 테이블
CREATE TABLE documents (
  id bigserial,
  tenant_id text NOT NULL,
  embedding vector(1536),
  content text
) PARTITION BY LIST (tenant_id);

-- 테넌트별 파티션 + 개별 HNSW 인덱스
CREATE TABLE documents_acme PARTITION OF documents FOR VALUES IN ('acme');
CREATE INDEX ON documents_acme USING hnsw (embedding vector_cosine_ops);

파티션 프루닝(pruning)이 작동하면 플래너는 acme 테넌트 쿼리에서 해당 파티션 인덱스만 스캔한다. iterative scan과 조합 시 필터 비용이 크게 줄어든다.


하이브리드 검색: 밀집 + 희소 결합

BM25(희소)와 벡터 임베딩(밀집)을 결합하는 패턴은 검색 품질을 높인다. pgvector 0.8.x에서는 단일 테이블로 구현한다.

CREATE TABLE documents (
  id bigserial PRIMARY KEY,
  content text,
  dense_emb  vector(1536),         -- OpenAI text-embedding-3-small
  sparse_emb sparsevec(100000)     -- SPLADE 희소 벡터
);

CREATE INDEX ON documents USING hnsw (dense_emb  vector_cosine_ops);
CREATE INDEX ON documents USING hnsw (sparse_emb sparsevec_ip_ops);
-- 하이브리드 RRF(Reciprocal Rank Fusion) 쿼리
WITH dense AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY dense_emb <=> $1) AS rank
  FROM documents LIMIT 50
),
sparse AS (
  SELECT id, ROW_NUMBER() OVER (ORDER BY sparse_emb <#> $2) AS rank
  FROM documents LIMIT 50
)
SELECT d.id, 1.0/(60+d.rank) + 1.0/(60+s.rank) AS rrf_score
FROM dense d JOIN sparse s USING (id)
ORDER BY rrf_score DESC
LIMIT 10;

RRF 가중치 60은 관행적 기본값이다. 도메인에 따라 조정한다.


버전별 주요 변경 요약

버전날짜주요 변경
0.7.02024-01halfvec, bit, sparsevec 타입 추가
0.8.02024-10-30iterative scan, 비용 추정 개선, HNSW 빌드 성능↑
0.8.12024-11필터 비용 추정 버그 수정
0.8.22025-01플래너 안정성 개선
0.8.3–0.8.52025보안 패치, PG 17 호환성
0.8.62026-07-29현재 최신 — 안정성·성능 패치

제한사항과 주의사항

인덱스 지원 거리 연산자: 모든 타입이 모든 연산자를 지원하지 않는다. sparsevec는 코사인(<=>)과 내적(<#>)만 HNSW 인덱싱이 가능하다. L2 거리는 순차 스캔이다.

정확도 vs 속도 트레이드오프: ef_search가 낮으면 빠르지만 근사 오류가 커진다. 프로덕션 배포 전 recall@10 측정을 권장한다.

iterative scan 보장: max_scan_tuples에 도달하면 LIMIT보다 적은 결과가 올 수 있다. 애플리케이션이 이 경우를 처리해야 한다.

VACUUM 필요: DELETE가 많으면 HNSW 그래프에 dead tuple이 쌓인다. autovacuum 설정을 벡터 테이블에 맞게 조정한다(autovacuum_vacuum_scale_factor = 0.01).

Large vector dimensions: 3072차원(text-embedding-3-large) HNSW 인덱스는 메모리를 많이 쓴다. halfvec로 전환하면 메모리를 절반으로 줄이되 정밀도 손실이 미미하다.


참고 자료