데이터 레이아웃 최적화

Completed

데이터 모델링 결정은 벡터 검색 성능에 크게 영향을 줍니다. 테이블을 구성하고, 메타데이터에 대한 데이터 형식을 선택하고, 지원 인덱스를 만드는 방법은 데이터 세트가 증가함에 따라 쿼리가 효율적으로 실행되는지 여부를 결정합니다.

비고

이 단원의 코드 예제에서는 메타데이터가 있는 벡터 데이터에 대한 스키마 디자인 패턴을 보여 줍니다. 이러한 패턴을 특정 데이터 모델 및 쿼리 요구 사항에 맞게 조정합니다.

벡터 스토리지 고려 사항

벡터 열은 많은 스토리지 및 처리 리소스를 소모합니다. 스토리지 특성을 이해하면 스키마 디자인에 대한 정보에 입각한 결정을 내릴 수 있습니다.

각 벡터 차원은 4바이트의 저장 공간(단정밀도 부동소수점의 경우)과 고정 오버헤드를 추가합니다. 차원과 스토리지 간의 관계는 선형입니다.

치수 벡터당 바이트 수 1백만 개의 벡터
384 ~1.5KB ~1.5GB
768 ~3KB ~3GB
1536 ~6KB ~6GB
3072 ~12KB ~12GB

1536차원 임베딩을 사용하는 2백만 개의 항목이 있는 제품 카탈로그의 경우 벡터 열에만 약 12GB의 스토리지가 필요합니다. HNSW 인덱스를 추가할 경우 약 50% 증가시킵니다.

많은 임베딩 모델은 여러 차원 옵션을 제공합니다. 낮은 차원은 스토리지 및 계산 비용을 줄이면서 많은 사용 사례에 적합한 품질을 유지합니다. 열 정의에서 차원을 지정하면 유효성 검사가 제공됩니다. 서로 다른 차원의 벡터를 삽입하려고 하면 오류가 발생하여, 임베딩 모델 불일치로 인해 발생할 수 있는 미묘한 버그를 방지합니다. 열 정의에서 사용하는 embedding vector(768) 명시적 차원 제약 조건으로 테이블을 정의합니다.

일부 애플리케이션에는 다른 모델의 벡터가 필요합니다. 예를 들어 제품 타이틀 포함, 이미지 포함 및 사용자 동작 포함을 별도로 저장할 수 있습니다. 여러 벡터 열을 포함하는 단일 인덱스를 만들 수 없으므로 각 벡터 열에는 자체 인덱스가 필요합니다.

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    title_embedding vector(768),      -- Text embedding model
    image_embedding vector(512),       -- Image embedding model
    category_id INTEGER,
    price NUMERIC(10,2)
);

-- Create separate indexes for each embedding type
CREATE INDEX ON products USING hnsw (title_embedding vector_cosine_ops);
CREATE INDEX ON products USING hnsw (image_embedding vector_cosine_ops);

메타데이터 데이터 형식: 구조적 열과 JSONB 비교

제품 권장 사항은 거의 벡터 유사성만을 사용하지 않습니다. 쿼리는 일반적으로 벡터 검색 전이나 함께 범주, 가격 범위, 가용성 또는 기타 특성을 필터링합니다. 이 메타데이터를 저장하는 방법은 쿼리 성능에 영향을 줍니다.

구조적 열은 명시적 스키마와 함께 PostgreSQL의 네이티브 데이터 형식(INTEGER, TIMESTAMP, NUMERIC, TEXT)을 사용합니다. 네이티브 형식은 같음 및 범위 쿼리를 위한 효율적인 B-트리 인덱스, 최적화된 스토리지 형식을 통한 스토리지 효율성, 삽입 시간 유효성 검사를 통한 형식 안전성 및 정확한 Planner 통계를 통한 쿼리 최적화를 가능하게 하므로 이러한 열은 쿼리 성능 이점을 제공합니다. 디자인 타임에 특성이 알려지거나, 특정 특성을 기준으로 필터링하거나 정렬하는 경우가 많거나, 쿼리 성능이 중요한 경우 구조화된 열을 사용합니다.

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    embedding vector(1536),
    category_id INTEGER NOT NULL,
    price NUMERIC(10,2) NOT NULL,
    in_stock BOOLEAN DEFAULT true,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    brand TEXT,
    rating NUMERIC(2,1)
);

JSONB는 반구조화된 데이터를 이진 JSON으로 저장하여 동적 특성에 대한 유연성을 제공합니다. JSONB는 스키마 유연성(다양한 제품에 다른 특성이 있을 수 있음), 쉽게 진화(스키마 마이그레이션 없이 새 특성 추가) 및 중첩된 구조(복잡한 계층적 데이터 저장)를 제공합니다. 그러나 JSONB에는 쿼리 오버헤드(값을 추출하려면 구문 분석 필요), 인덱스 제한 사항(GIN 인덱스는 포함 쿼리에는 작동하지만 범위 쿼리는 작동하지 않음) 및 Planner 불확실성(통계는 JSONB 내용에 대해 덜 정확함)이 있습니다.

필터링된 벡터 검색의 경우 메타데이터 필터 성능은 총 쿼리 시간에 직접 영향을 줍니다. B-트리 인덱스가 있는 구조화된 열을 사용하면 PostgreSQL이 벡터 거리 계산 전에 후보 범위를 빠르게 좁힐 수 있지만 JSONB에는 다양한 쿼리 패턴과 인덱스 형식이 필요합니다. 구조화된 열 필터인 WHERE category_id = 5 AND price BETWEEN 100 AND 500는 (category_id, price)의 B-트리 인덱스를 사용하여 후보 행을 신속하게 식별할 수 있습니다. 같은 WHERE attributes @> '{"category": "electronics"}' AND (attributes->>'price')::numeric BETWEEN 100 AND 500 JSONB 필터에는 GIN 인덱스(가격에 대한 범위 쿼리에 도움이 되지 않음) 또는 JSONB 열의 순차적 검사가 필요합니다.

많은 애플리케이션은 구조화된 열과 JSONB를 결합하여 이점을 얻을 수 있습니다. 쿼리 성능이 중요한 자주 필터링되는 특성에는 구조화된 열을 사용하고, 스키마 유연성이 더 중요한 동적 또는 드물게 필터링된 특성에는 JSONB를 사용합니다. 이 패턴을 사용하면 에지 사례에 대한 유연성을 희생하지 않고도 일반적인 사례를 최적화할 수 있습니다.

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    embedding vector(1536),
    -- Structured columns for common filters
    category_id INTEGER NOT NULL,
    price NUMERIC(10,2) NOT NULL,
    in_stock BOOLEAN DEFAULT true,
    -- JSONB for dynamic attributes
    attributes JSONB DEFAULT '{}'
);

필터링된 검색에 대한 메타데이터 인덱스

메타데이터 인덱스는 쿼리의 필터링 단계를 가속화하여 벡터 인덱스를 보완합니다. 적절한 메타데이터 인덱스가 없으면 PostgreSQL은 모든 행을 검색하여 벡터 검색 전에 필터를 적용해야 할 수 있습니다.

WHERE 절에 사용되는 열에 B-트리 인덱스를 만듭니다. 단일 열 인덱스는 정확한 일치 항목을 처리하고 복합 인덱스는 필터 조합을 처리합니다. 복합 인덱스는 쿼리가 맨 왼쪽 열을 필터링할 때 가장 효과적입니다. 인덱스 (category_id, price)는 WHERE category_id = 5와 WHERE category_id = 5 AND price < 100를 효율적으로 처리하지만, 가격이 가장 왼쪽 열이 아니기 때문에 단독으로는 도움이 되지 않습니다.

-- Single-column index for exact matches
CREATE INDEX idx_products_category ON products (category_id);

-- Composite index for common filter combinations
CREATE INDEX idx_products_category_price ON products (category_id, price);

대부분의 쿼리가 동일한 조건(예: 재고 내 제품)을 필터링하는 경우 부분 인덱스는 인덱스 크기를 줄이고 성능을 향상시킵니다. 이 인덱스는 전체 인덱스보다 작으며 포함하는 WHERE in_stock = true쿼리에만 사용됩니다. 거의 모든 쿼리가 사용 가능한 제품을 대상으로 하는 전자 상거래 권장 사항 엔진의 경우 인덱스 유지 관리 오버헤드를 크게 줄일 수 있습니다. 를 사용하여 부분 인덱스 CREATE INDEX idx_products_instock_category ON products (category_id) WHERE in_stock = true;만들기

특성에 JSONB를 사용하는 경우, GIN 인덱스는 @> (포함), <@ (내포), ? (키 존재), 그리고 ?|/?& (하나/모든 키가 존재) 연산자를 사용하여 쿼리를 지원합니다. 범위 쿼리 또는 임의의 JSON 경로 식을 가속화하지 않습니다. 를 사용하여 GIN 인덱스 CREATE INDEX idx_products_attributes ON products USING gin (attributes);만들기 범위 쿼리가 필요한 자주 쿼리되는 JSONB 필드의 경우 식 인덱스를 고려합니다. 해당 필드에 범위 쿼리를 사용하도록 설정하기 위해 숫자 CREATE INDEX idx_products_json_price ON products (((attributes->>'price')::numeric)); 로 추출된 JSONB 필드에 식 인덱스를 만듭니다.

메타데이터 필터와 벡터 검색 결합

PostgreSQL은 인덱스 검색과 필터링을 결합하여 쿼리를 실행합니다. 실행 패턴을 이해하면 효율적인 쿼리를 작성하는 데 도움이 됩니다.

가장 효율적인 패턴은 메타데이터 필터를 먼저 적용하여 유사성 계산이 필요한 벡터 집합을 줄입니다. PostgreSQL은 메타데이터 인덱스를 사용하여 필터와 일치하는 제품을 식별한 다음 해당 후보에게만 벡터 유사성을 적용합니다. 5개% 제품이 필터와 일치하는 경우 200만 개 대신 100,000개의 벡터를 검색합니다.

-- Efficient: filter narrows candidates before vector search
SELECT id, name, embedding <=> $1 AS distance
FROM products
WHERE category_id = 5
  AND in_stock = true
  AND price BETWEEN 100 AND 500
ORDER BY embedding <=> $1
LIMIT 10;

쿼리가 예상 인덱스를 사용하는지 확인하고 성능 병목 상태를 식별하는 데 사용합니다 EXPLAIN ANALYZE . 쿼리 계획은 PostgreSQL이 메타데이터 인덱스를 사용하여 벡터 검색 전에 후보를 필터링하는지 또는 모든 행을 검사하는 순차적 검사에 의존하는지 여부를 보여 줍니다. 메타데이터 열에서 인덱스 검색 또는 비트맵 인덱스 검색 (효율적), 벡터 인덱스(효율적인)를 사용한 인덱스 검사 및 큰 테이블에서 Seq Scan (잠재적으로 비효율적일 수 있음)을 찾습니다. 예상치 못한 순차 스캔이 발생한다면 ANALYZE products;를 이용해 적절한 인덱스가 존재하며 통계가 최신 상태인지 확인하십시오.

일부 쿼리는 효율적인 필터링에만 적용되지 않습니다. 필터가 많은 행(예: WHERE price > 0 거의 모든 제품이 일치하는 행)을 제거하지 않는 경우 PostgreSQL은 메타데이터 인덱스를 완전히 건너뛰고 벡터 인덱스만 사용할 수 있습니다. 이는 최적화 프로그램이 비용 기반 결정을 내리기 때문에 예상되는 동작입니다.

경우에 따라 사전에 효율적으로 필터링할 수 없는 제약 조건을 충족하는 벡터 인덱스의 결과가 필요합니다. 사후 필터링 패턴은 필요한 것보다 더 많은 벡터 유사 후보를 가져온 다음 필터를 적용합니다. 예상되는 필터 선택성에 따라 내부 LIMIT를 조정합니다.

-- Get more candidates than needed, then filter
WITH candidates AS (
    SELECT id, name, price, in_stock, embedding <=> $1 AS distance
    FROM products
    ORDER BY embedding <=> $1
    LIMIT 100
)
SELECT id, name, distance
FROM candidates
WHERE in_stock = true AND price BETWEEN 100 AND 500
ORDER BY distance
LIMIT 10;

큰 데이터 세트에 대한 테이블 분할

분할하면 큰 테이블이 더 작고 관리하기 쉬운 조각으로 나눕니다. 벡터 워크로드의 경우 분할은 쿼리 성능을 향상시키고 유지 관리를 간소화할 수 있습니다.

테이블이 수천만 개의 행을 초과하거나, 쿼리가 파티션 키(날짜, 범주, 테넌트)를 기준으로 자연스럽게 필터링되거나, 이전 데이터(파티션 정리)를 효율적으로 삭제하거나, 인덱스 빌드 시간이 전체 테이블에서 금지되는 경우 분할을 고려합니다.

시계열 포함을 처리하는 애플리케이션(예: 사용자 작업 벡터 또는 시간에 따라 게시된 콘텐츠)의 경우 날짜별 범위 분할이 적용됩니다. 날짜별로 필터링하는 쿼리는 관련 파티션만 검색합니다. 각 파티션에는 자체 인덱스가 있으므로 유지 관리를 보다 쉽게 관리할 수 있습니다.

-- Create partitioned table
CREATE TABLE user_interactions (
    id BIGSERIAL,
    user_id BIGINT NOT NULL,
    embedding vector(768),
    created_at TIMESTAMP NOT NULL,
    interaction_type TEXT
) PARTITION BY RANGE (created_at);

-- Create partitions for each month
CREATE TABLE user_interactions_2025_01
    PARTITION OF user_interactions
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

자연 범주가 있는 다중 테넌트 애플리케이션 또는 제품 카탈로그의 경우 목록 또는 해시 분할이 도움이 될 수 있습니다. 범주별로 필터링된 쿼리는 관련 파티션만 검색하여 검색된 데이터와 인덱스 크기를 모두 줄입니다.

부모 테이블에 인덱스를 생성하여, CREATE INDEX ON products USING hnsw (embedding vector_cosine_ops);을 사용해 모든 파티션에서 자동으로 일치하는 인덱스를 생성합니다. 각 파티션에는 독립적으로 빌드하거나 다시 작성할 수 있는 자체 인덱스가 있습니다. 단일 글로벌 인덱스를 다시 빌드하는 데 몇 시간이 걸리는 대규모 데이터 세트에 유용합니다.

분할은 복잡성을 더합니다. 많은 파티션에 걸쳐 있는 쿼리는 단일 테이블보다 느려질 수 있습니다. 파티션 간 고유 제약 조건에는 제약 조건의 파티션 키가 필요합니다. 애플리케이션 논리는 파티션 경계에 대한 인식이 필요할 수 있습니다. 분할을 구현하기 전에 쿼리 패턴이 잠재적인 파티션 키와 일치하는지 여부를 평가합니다.

추가 리소스