본문으로 건너뛰기

"index" 태그로 연결된 2개 게시물개의 게시물이 있습니다.

모든 태그 보기

PostgreSQL UUID v7 PK

· 약 5분

primary key를 UUID로 쓰기로 하면 대개 v4를 씁니다. 그런데 그 선택이 index를 얼마나 부풀리는지 숫자로 본 적은 없었어요. pgEdge의 Shaun Thomas(숀 토머스)가 The Time Traveler's Primary Key에서 그 숫자를 냈고, PostgreSQL 18 컨테이너로 직접 재현해 봤습니다.

v4가 index를 흩어 놓는 방식

B-tree는 정렬된 구조입니다. 새 키가 들어오면 그 값이 들어갈 자리를 찾아 해당 leaf page에 씁니다.

시퀀스로 만든 키는 항상 오른쪽 끝으로 갑니다. 마지막 page를 채우고, 차면 새 page를 붙입니다. page 하나가 거의 꽉 찬 상태로 남습니다.

UUID v4는 값이 완전히 무작위입니다. 원문 표현대로 "생성된 각 값이 맨 첫 행보다 앞에 정렬될 확률과 맨 마지막 행보다 뒤에 정렬될 확률이 같습니다". 그래서 삽입이 index 전체에 흩어집니다. 이미 꽉 찬 page 가운데에 값이 들어오면 page split이 일어나고, split된 두 page는 각각 절반씩만 채워집니다. 같은 개수의 키를 담는 데 page가 두 배로 필요해집니다.

UUID v7은 상위 48비트에 밀리초 단위 Unix 타임스탬프를 최상위 비트부터 담습니다. version 정보 뒤에 12비트를 더해 밀리초 이하 정밀도까지 표현합니다. 남는 약 62비트가 무작위입니다. 결과적으로 나중에 만든 값이 앞서 만든 값보다 크게 정렬됩니다. 시퀀스와 같은 삽입 패턴이 됩니다.

직접 재 봤다

PostgreSQL 18에 uuidv7()uuidv4()가 내장되어 있습니다. 기본 설정 컨테이너로 세 가지 테이블에 100만 행씩 넣었습니다.

create table t_bigint (id bigserial primary key, payload text);
create table t_v4 (id uuid primary key, payload text);
create table t_v7 (id uuid primary key, payload text);

insert into t_bigint (payload) select 'x' from generate_series(1,1000000);
insert into t_v4 (id,payload) select uuidv4(),'x' from generate_series(1,1000000);
insert into t_v7 (id,payload) select uuidv7(),'x' from generate_series(1,1000000);

크기를 뽑았습니다.

relname | heap | idx
----------+-------+-------
t_bigint | 42 MB | 21 MB
t_v4 | 50 MB | 37 MB
t_v7 | 50 MB | 30 MB

원문이 낸 수치가 v4 index 38MB, v7 30MB였는데 거의 그대로 나왔습니다. index만 놓고 보면 v7이 v4보다 19% 작습니다.

이유를 직접 확인하려면 pgstattuplepgstatindex()를 봅니다.

select 'v4' as k, avg_leaf_density, leaf_fragmentation from pgstatindex('t_v4_pkey')
union all
select 'v7', avg_leaf_density, leaf_fragmentation from pgstatindex('t_v7_pkey')
union all
select 'bigint', avg_leaf_density, leaf_fragmentation from pgstatindex('t_bigint_pkey');
k | avg_leaf_density | leaf_fragmentation
--------+------------------+--------------------
v4 | 72.61 | 49.86
v7 | 89.98 | 0
bigint | 90.01 | 0

여기가 이 실험에서 가장 선명한 부분입니다. v4의 leaf 밀도가 72.61%이고 단편화가 49.86%입니다. v7은 밀도 89.98%에 단편화 0입니다. bigserial의 90.01%와 사실상 같습니다. v7의 삽입 패턴이 시퀀스와 구분되지 않는다는 뜻입니다.

원문 수치가 71.53%와 49.89%, 89.98%와 0.00%였으니 소수점 자리까지 맞았습니다.

heap은 줄지 않는다

표에서 놓치기 쉬운 칸이 heap입니다. t_bigint가 42MB, UUID 두 개는 50MB로 같습니다. UUID는 128비트, bigint는 64비트니까 행마다 8바이트가 더 붙습니다. v7으로 바꿔도 이 차이는 그대로입니다.

정리하면 v7이 개선하는 것은 index의 공간 효율과 삽입 시 page split이고, 값 자체의 크기는 아닙니다. bigint와 UUID 사이의 선택은 여전히 별개 문제입니다. 원문도 이 부분을 분명히 합니다. 데이터가 단일 노드에 있고, 하나의 primary 인스턴스가 키를 발급하고, 키가 외부로 돌아다니지 않는다면 BIGINT GENERATED ALWAYS AS IDENTITY가 최선이라고 적었습니다. 위 실측에서도 bigint index가 21MB로 v7의 70% 수준입니다.

타임스탬프가 공개된다

v7의 대가는 성능이 아니라 정보입니다. 상위 비트가 생성 시각이라서 값에서 그대로 읽힙니다. PostgreSQL 18은 그 추출 함수를 내장으로 제공합니다.

select id, uuid_extract_timestamp(id) from t_v7 limit 2;
01a03337-728f-7bf2-bdf8-69d062bfa627|2026-08-24 10:01:06.959+00
01a03337-7290-7767-bc60-69fb35dd6c00|2026-08-24 10:01:06.96+00

버전도 확인됩니다.

select uuid_extract_version(id) from t_v7 limit 1; -- 7

이게 뜻하는 바가 셋입니다. 키를 받은 쪽이 그 레코드의 생성 시각을 밀리초 단위로 압니다. 키 두 개를 비교하면 그 사이 생성 간격을 알 수 있어서 생성 속도가 측정됩니다. 그리고 값이 시간순으로 인접하니 이웃 키를 추측해 보는 것이 무작위 값보다 훨씬 그럴듯해집니다.

주문 번호나 사용자 ID를 URL에 노출하는 구조라면 이 세 가지가 실제 문제가 됩니다. 가입 시각, 하루 주문 건수, 경쟁사 성장 속도가 키에서 읽힙니다. 원문도 생성 시각과 삽입 속도가 비공개여야 하는 경우에는 v4가 여전히 적합하다고 적었습니다.

오른쪽 끝이 경합 지점이 된다

또 하나 짚어 둘 만한 대가가 있습니다. v7의 삽입이 index 오른쪽 끝으로 몰린다는 것은 곧 그 page가 모든 writer의 경합 지점이 된다는 뜻입니다.

샤딩된 환경이나 병렬 쓰기가 많은 구성에서는 이게 실제 병목이 될 수 있습니다. v4는 흩어져서 비효율적인 대신, 쓰기를 고르게 분산합니다. 단일 노드에서 순차 삽입이 주된 패턴이라면 v7이 유리하고, writer가 많고 오른쪽 끝 경합이 이미 보이는 환경이라면 계산이 달라집니다.

어떻게 고를까

세 선택지의 성격을 정리하면 이렇습니다.

index 효율분산 생성생성 시각 비공개크기
bigserial / identity가장 좋음 (21MB)어려움유지8바이트
UUID v4나쁨 (37MB, 단편화 50%)지원유지16바이트
UUID v7좋음 (30MB, 단편화 0)지원노출16바이트

이미 UUID v4를 쓰고 있고 분산 생성이 필요해서 그렇게 한 것이라면, v7으로 옮기는 건 대체로 이득입니다. index가 작아지고 page split이 사라지는 대신 잃는 것은 생성 시각의 비공개성뿐입니다. 그 시각이 민감하지 않다면 바꿀 이유가 충분합니다.

반대로 UUID를 쓰는 이유가 "ID를 추측할 수 없게 하려고"였다면 v7은 그 목적과 충돌합니다. v7의 무작위 비트가 62비트라 값 자체를 맞히기는 여전히 어렵지만, 시간순 인접성 때문에 열거 시도의 탐색 공간이 좁아집니다.

새로 만드는 테이블이고 단일 노드라면 bigint가 답입니다. 위 실측에서 index가 v7의 70%, heap이 84%였고 그게 전부 실제 I/O입니다. UUID를 고르는 이유는 성능이 아니라 분산 생성이나 외부 노출이니까요.

참고

Aurora pgvector 이진 양자화

· 약 5분

벡터 검색에서 제일 자주 부딪히는 벽이 "index가 메모리에 안 들어간다"입니다. AWS가 그 벽을 이진 양자화로 넘은 사례를 냈습니다. 1억 벡터 index가 367GB에서 약 38GB가 됐습니다.

부호만 남긴다

이진 양자화의 방식은 이름 그대로입니다. 각 차원의 float32 값을 0을 기준으로 잘라 1비트로 만듭니다. 양수면 1, 음수면 0입니다.

768차원 벡터라면 원래 768 × 4바이트 = 3,072바이트입니다. 양자화하면 768비트, 즉 96바이트입니다. 32분의 1입니다.

정보를 크게 버리는 방식인데 벡터 검색에서 통하는 이유가 있습니다. 고차원 임베딩에서는 각 차원의 정확한 크기보다 부호 패턴이 방향을 상당히 결정합니다. 768개 차원의 부호가 얼마나 겹치는지만 봐도 두 벡터가 비슷한지 대략 알 수 있습니다. 이 비교가 Hamming 거리입니다.

직접 재 봤다

pgvector 0.8.6이 들어간 PostgreSQL 18 컨테이너로 확인했습니다. 768차원 벡터 3만 개를 넣고 두 종류 index를 만들었습니다.

create table docs (id bigserial primary key, embedding vector(768));
insert into docs (embedding)
select (select array_agg(random()-0.5) from generate_series(1,768))::vector
from generate_series(1,30000);

일반 HNSW와 이진 양자화 HNSW입니다.

create index idx_full on docs
using hnsw (embedding vector_cosine_ops)
with (m=16, ef_construction=64);

create index idx_bq on docs
using hnsw ((binary_quantize(embedding)::bit(768)) bit_hamming_ops)
with (m=16, ef_construction=64);

bit_hamming_ops가 bit 타입에 Hamming 거리를 적용하는 연산자 클래스입니다. 크기를 비교했습니다.

indexrelname | size
--------------+--------
docs_pkey | 672 kB
idx_bq | 10 MB
idx_full | 104 MB

10.4배 차이입니다. 벡터 자체는 32배 줄었는데 index는 10배 남짓입니다. 이 격차가 이 글에서 짚고 싶은 부분입니다.

32배와 10배 사이

차이의 이유는 HNSW index에 든 것이 벡터만이 아니라는 데 있습니다. HNSW는 계층 그래프입니다. 각 노드가 이웃으로 향하는 링크를 들고 있고, 그 개수를 m 파라미터가 정합니다. 위 실험에서는 m=16이었습니다.

링크는 양자화 대상이 아닙니다. 벡터를 96바이트로 줄여도 링크는 그대로입니다. 그래서 압축률이 벡터 크기 비율보다 낮게 나옵니다.

원문 수치로 확인해 봐도 같은 방향입니다. 367GB에서 38GB는 약 9.7배입니다. AWS가 "32배 압축"이라고 표현한 것은 벡터 데이터 자체의 비율이고, index 전체는 10배 안쪽입니다. 용량 계획을 세울 때 32배로 잡으면 어긋납니다.

원문이 준 벡터당 크기도 이 관점에서 읽힙니다. 1536차원에서 벡터당 약 680바이트, 768차원에서 약 400바이트입니다. 768비트는 96바이트니까 나머지 300바이트 정도가 그래프 구조입니다.

재순위화가 없으면 반쪽이다

이진 양자화만으로 검색을 끝내면 정확도가 떨어집니다. 부호만 봤으니 당연합니다. 그래서 두 단계로 나눕니다. 양자화된 index로 후보를 넉넉히 뽑고, 그 후보들만 원본 벡터로 다시 정렬합니다.

원문이 제시한 쿼리 모양입니다.

SELECT * FROM (
SELECT id, embedding <=> '[query_vector]'::vector AS exact_distance
FROM your_table
ORDER BY binary_quantize(embedding)::bit(768) <~>
binary_quantize('[query_vector]'::vector)::bit(768)
LIMIT 200
) candidates
ORDER BY exact_distance
LIMIT 10;

연산자 두 개가 각각 다른 일을 합니다. <~>가 bit 타입의 Hamming 거리로 후보 200개를 고릅니다. <=>가 원본 vector의 코사인 거리로 그 200개를 다시 정렬합니다. 최종 10개는 원본 정밀도로 판단된 결과입니다.

이 구조에서 후보 개수가 정확도와 지연의 조절 손잡이입니다. 200개를 뽑으면 100개보다 정확하고 느립니다.

수치는 어디까지 좋아졌나

원문이 낸 측정치 중 눈에 띄는 것들입니다.

OpenAI 임베딩 500만 개(1536차원)를 r8g.large에서 돌린 결과입니다. 이진 양자화 HNSW가 recall 0.951에 138 QPS, p99 지연 18.9ms였습니다. 원본 정밀도 HNSW는 78 QPS에 p99 1,356ms였습니다. 지연 차이가 70배 넘게 벌어집니다.

LAION 1억 개(768차원)를 r8g.4xlarge에서 cold cache로 돌린 쪽이 더 극적입니다. 이진 양자화가 recall 0.931에 13.5 QPS, 원본 정밀도가 3.4 QPS입니다. 원문은 원본 정밀도 index가 메모리에 들어가지 않았다고 적었습니다. 이 한 줄이 사실 이 기법의 존재 이유입니다. 압축 자체가 목적이 아니라, index를 buffer cache 안에 들여놓는 것이 목적입니다.

index 빌드 시간도 1.1시간 대 16.1시간으로 갈렸습니다.

빌드 설정은 이렇게 잡았습니다.

maintenance_work_mem = 96GB
max_parallel_maintenance_workers = 48

쿼리 쪽 설정입니다.

SET hnsw.ef_search = 800;
SET hnsw.iterative_scan = relaxed_order;
SET hnsw.max_scan_tuples = 1400;

hnsw.iterative_scan이 여기서 역할이 있습니다. ef_search의 후보 한계인 1,000개를 넘겨 재순위화하려면 이 설정이 필요합니다.

요구 사항은 Aurora PostgreSQL 16.8 이상 또는 17.x이고 pgvector 0.8.0 이상입니다.

제 실측의 한계

위에서 크기는 재 봤지만 recall은 재지 않았습니다. 이유를 밝혀 두는 게 맞습니다.

제가 넣은 벡터는 random()-0.5로 만든 균등 난수입니다. 실제 임베딩과 분포가 다릅니다. 임베딩은 의미가 비슷한 것끼리 방향이 모이는 구조인데, 균등 난수는 모든 벡터가 서로 비슷하게 멀리 있습니다. 이런 데이터에서 recall을 재면 이진 양자화의 정보 손실이 실제와 다르게 나옵니다.

index 크기는 데이터 분포와 무관한 구조적 값이라 그대로 쓸 수 있고, 정확도는 실제 임베딩으로 재야 의미가 있습니다. 그 부분은 원문 수치를 인용하는 쪽이 정직합니다.

언제 쓸까

기준이 비교적 선명합니다. index가 메모리에 들어가느냐입니다.

들어가는 규모라면 이진 양자화의 이득이 작습니다. 원본 정밀도 HNSW가 이미 충분히 빠르고, 재순위화 단계가 붙지 않아 쿼리도 단순합니다.

들어가지 않는 규모라면 계산이 완전히 달라집니다. index를 디스크에서 읽는 순간 지연이 자리수 단위로 뛰기 때문에, recall을 0.95 근처로 유지하면서 index를 10분의 1로 줄이는 거래가 압도적으로 유리해집니다. 위 LAION 사례에서 QPS가 4배 차이 난 것이 그 지점입니다.

AlloyDB가 AI 에이전트에 문을 연 이야기를 다룰 때도 느낀 건데, 클라우드 3사가 벡터 쪽에서 경쟁하는 방향이 새 엔진이 아니라 PostgreSQL 위에 얹는 최적화로 모이고 있어요. 이번 것도 pgvector의 기존 기능을 조합한 결과입니다. binary_quantizebit_hamming_ops는 pgvector 0.8.0부터 있던 것이고, AWS가 한 일은 그것을 1억 벡터 규모에서 검증하고 설정값을 찾은 것입니다.

참고