다음을 통해 공유


Azure Database for PostgreSQL - 유연한 서버에서 pgvector를 사용할 때 성능을 최적화하는 방법

적용 대상: Azure Database for PostgreSQL - 유연한 서버

pgvector 확장은 Azure Database for PostgreSQL 유연한 서버에 오픈 소스 벡터 유사성 검색을 추가합니다.

이 문서에서는 분할, 인덱싱 및 pgvector 검색 설정을 사용하여 성능을 향상시키는 방법을 보여 줍니다.

확장 자체에 대한 자세한 내용은 pgvector 기본 사항을 참조하세요. 프로젝트의 공식 추가 정보도 참조할 수 있습니다.

성능

항상 쿼리 계획을 조사하여 시작해야 합니다. 쿼리가 상당히 빨리 종료되면 EXPLAIN (ANALYZE,VERBOSE, BUFFERS)을 실행합니다.

EXPLAIN (ANALYZE, VERBOSE, BUFFERS) SELECT * FROM t_test ORDER BY embedding <-> '[1,2,3]' LIMIT 5;

실행하는 데 너무 오래 걸리는 쿼리의 경우 ANALYZE 키워드를 삭제하는 것이 좋습니다. 결과에는 더 적은 세부 정보가 포함되지만 즉시 제공됩니다.

EXPLAIN (VERBOSE, BUFFERS) SELECT * FROM t_test ORDER BY embedding <-> '[1,2,3]' LIMIT 5;

explain.depesz.com과 같은 타사 사이트는 쿼리 계획을 이해하는 데 도움이 될 수 있습니다. 대답해야 할 몇 가지 질문은 다음과 같습니다.

OpenAI 포함과 같이 벡터가 길이 1로 정규화되는 경우가 있습니다. 최상의 성능을 위해 내적(<#>)을 사용하는 것이 좋습니다.

병렬 실행

설명 계획의 출력에서 Workers PlannedWorkers Launched를 찾습니다(후자는 ANALYZE 키워드가 사용된 경우에만 해당). max_parallel_workers_per_gather PostgreSQL 매개 변수는 데이터베이스에서 모든 GatherGather Merge 계획 노드에 대해 시작할 수 있는 백그라운드 작업자 수를 정의합니다. 이 값을 늘리면 인덱스를 만들지 않고도 정확한 검색 쿼리 속도를 높일 수 있습니다. 그러나 이 값이 높더라도 데이터베이스는 계획을 병렬로 실행하도록 결정하지 않을 수도 있습니다.

EXPLAIN SELECT * FROM t_test ORDER BY embedding <-> '[1,2,3]' LIMIT 3;
                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Limit  (cost=4214.82..4215.16 rows=3 width=33)
   ->  Gather Merge  (cost=4214.82..13961.30 rows=84752 width=33)
         Workers Planned: 1
         ->  Sort  (cost=3214.81..3426.69 rows=84752 width=33)
               Sort Key: ((embedding <-> '[1,2,3]'::vector))
               ->  Parallel Seq Scan on t_test  (cost=0.00..2119.40 rows=84752 width=33)
(6 rows)

인덱싱

인덱스가 없으면 확장에서 정확한 검색을 수행하므로 성능이 저하되지만 완벽한 재현을 제공합니다.

근사한 인접 항목을 검색하기 위해 실행 성능에 대한 재현을 교환하는 인덱스를 데이터에 만들 수 있습니다.

가능하면 항상 데이터를 로드한 후에 인덱싱합니다. 인덱스를 이 방식으로 만드는 것이 더 빠르며 결과 레이아웃이 더 적합합니다.

지원되는 인덱스 형식은 두 가지입니다.

IVFFlat 인덱스는 HNSW보다 빌드 시간이 더 빠르고 메모리를 덜 사용하지만 쿼리 성능은 더 낮습니다(속도-리콜 장단점 측면에서).

제한

  • 열을 인덱싱하려면 차원이 정의되어 있어야 합니다. col vector로 정의된 열을 인덱싱하려고 하면 ERROR: column does not have dimensions 오류가 발생합니다.
  • 최대 2,000개의 차원이 있는 열만 인덱싱할 수 있습니다. 더 많은 차원이 포함된 열을 인덱싱하려고 하면 오류가 발생합니다. ERROR: column cannot have more than 2000 dimensions for INDEX_TYPE index 여기서 INDEX_TYPEivfflat 또는 hnsw입니다.

2,000개 차원이 넘는 벡터는 저장할 수 있지만 인덱싱할 수는 없습니다. 차원 감소를 사용하여 한도 내에 맞출 수 있습니다. 또는 Azure Cosmos DB for PostgreSQL을 분할(partitioning) 및/또는 분할(sharding)을 사용하여 인덱싱 없이 허용 가능한 성능을 달성합니다.

IVVFlat(플랫 압축을 사용한 반전 파일)

ivfflat은 ANN(근사한 인접 항목) 검색에 대한 인덱스입니다. 이 메서드 반전된 파일 인덱스를 사용하여 데이터 세트를 여러 목록으로 분할합니다. probes 매개 변수는 검색되는 목록 수를 제어하여 검색 속도가 느려지지만 검색 결과의 정확도를 향상시킬 수 있습니다.

probes 매개 변수가 인덱스의 목록 수로 설정되면 모든 목록이 검색되고 검색은 정확한 근사 인접 항목 검색이 됩니다. 이 경우 모든 목록을 검색하는 것은 전체 데이터 세트에 대해 무차별 검색을 수행하는 것과 동일하므로 계획 도구에서 인덱스를 사용하지 않습니다.

인덱싱 메서드는 k-평균 클러스터링 알고리즘을 사용하여 데이터 세트를 여러 목록으로 분할합니다. 각 목록에는 특정 클러스터 중심에 가장 가까운 벡터가 포함되어 있습니다. 검색 중에 쿼리 벡터는 클러스터 중심과 비교되어 가장 인접한 항목을 포함할 가능성이 가장 높은 목록을 결정합니다. probes 매개 변수가 1로 설정되면 가장 가까운 클러스터 중심에 해당하는 목록만 검색됩니다.

인덱스 옵션

수행할 프로브 수와 목록 크기에 대한 올바른 값을 선택하면 검색 성능에 영향을 줄 수 있습니다. 적절한 시작 위치는 다음과 같습니다.

  1. 최대 100만 개의 행이 있는 테이블의 경우 listsrows / 1000과 동일하게 사용하고, 더 큰 데이터 세트의 경우 sqrt(rows)를 사용합니다.
  2. probes의 경우 최대 100만 개의 행이 있는 테이블의 경우 lists / 10으로 시작하고 더 큰 데이터 세트의 경우 sqrt(lists)로 시작합니다.

lists의 크기는 인덱스를 만들 때 lists 옵션을 사용하여 정의됩니다.

CREATE INDEX t_test_embedding_cosine_idx ON t_test USING ivfflat (embedding vector_cosine_ops) WITH (lists = 5000);

프로브는 전체 연결에 대해 설정하거나 트랜잭션별로 설정할 수 있습니다(트랜잭션 블록 내에서 SET LOCAL 사용).

SET ivfflat.probes = 10;
SELECT * FROM t_test ORDER BY embedding <=> '[1,2,3]' LIMIT 5; -- uses 10 probes
SELECT * FROM t_test ORDER BY embedding <=> '[1,2,3]' LIMIT 5; -- uses 10 probes
BEGIN;

SET LOCAL ivfflat.probes = 10;
SELECT * FROM t_test ORDER BY embedding <=> '[1,2,3]' LIMIT 5; -- uses 10 probes

COMMIT;

SELECT * FROM t_test ORDER BY embedding <=> '[1,2,3]' LIMIT 5; -- uses default, one probe

인덱싱 진행

PostgreSQL 12 이상에서는 pg_stat_progress_create_index를 사용하여 인덱싱 진행률을 확인할 수 있습니다.

SELECT phase, round(100.0 * tuples_done / nullif(tuples_total, 0), 1) AS "%" FROM pg_stat_progress_create_index;

IVFFlat 인덱스 빌드 단계는 다음과 같습니다.

  1. initializing
  2. performing k-means
  3. assigning tuples
  4. loading tuples

참고 항목

진행률(%)은 loading tuples 단계에서만 채워집니다.

HNSW(계층적 탐색 가능한 작은 세계)

hnsw는 계층적 탐색 가능한 작은 세계 알고리즘을 사용하는 ANN(가장 인접한 항목) 검색을 위한 인덱스입니다. 가장 인접한 항목을 찾는 임의로 선택된 진입점 주위에 그래프를 만드는 방식으로 작동합니다. 그런 다음, 그래프는 여러 계층으로 확장되며 각 하위 계층에는 더 많은 포인트가 포함됩니다. 쿼리 시 이 다층 그래프는 상단에서 시작하여 쿼리의 가장 인접한 항목을 포함하는 가장 낮은 계층에 도달할 때까지 범위가 좁아집니다.

이 인덱스를 빌드하는 데는 IVFFlat보다 더 많은 시간과 메모리가 필요하지만 속도-재현율 절충안이 더 좋습니다. 또한 IVFFlat와 같은 학습 단계가 없으므로 빈 테이블에 인덱스를 만들 수 있습니다.

인덱스 옵션

인덱스를 만들 때 다음 두 가지 매개 변수를 조정할 수 있습니다.

  1. m - 계층당 최대 연결 수(기본값: 16)
  2. ef_construction - 그래프 구문에 사용되는 동적 후보 목록의 크기(기본값: 64)
CREATE INDEX t_test_hnsw_l2_idx ON t_test USING hnsw (embedding vector_l2_ops) WITH (m = 16, ef_construction = 64);

쿼리 중에 쿼리할 동적 후보 목록을 지정할 수 있습니다(기본값: 40).

검색을 위한 동적 후보 목록은 전체 연결 또는 트랜잭션별로 설정할 수 있습니다(트랜잭션 블록 내에서 SET LOCAL 사용).

SET hnsw.ef_search = 100;
SELECT * FROM t_test ORDER BY embedding <=> '[1,2,3]' LIMIT 5; -- uses 100 candidates
SELECT * FROM t_test ORDER BY embedding <=> '[1,2,3]' LIMIT 5; -- uses 100 candidates
BEGIN;

SET hnsw.ef_search = 100;
SELECT * FROM t_test ORDER BY embedding <=> '[1,2,3]' LIMIT 5; -- uses 100 candidates

COMMIT;

SELECT * FROM t_test ORDER BY embedding <=> '[1,2,3]' LIMIT 5; -- uses default, 40 candidates

인덱싱 진행

PostgreSQL 12 이상에서는 pg_stat_progress_create_index를 사용하여 인덱싱 진행률을 확인할 수 있습니다.

SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%" FROM pg_stat_progress_create_index;

HNSW 인덱스 빌드 단계는 다음과 같습니다.

  1. initializing
  2. loading tuples

인덱스 액세스 함수 선택

vector 유형을 사용하면 저장된 벡터에 대해 세 가지 유형의 검색을 수행할 수 있습니다. 쿼리를 실행할 때 데이터베이스에서 인덱스를 고려하도록 하려면 인덱스에 대한 올바른 액세스 함수를 선택해야 합니다. 이 예제에서는 ivfflat 인덱스 형식을 설명하지만 hnsw 인덱스에 대해서도 동일한 작업을 수행할 수 있습니다. lists 옵션은 ivfflat 인덱스에만 적용됩니다.

코사인 거리

코사인 유사성 검색의 경우 vector_cosine_ops 액세스 메서드를 사용합니다.

CREATE INDEX t_test_embedding_cosine_idx ON t_test USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

위의 인덱스를 사용하려면 쿼리에서 <=> 연산자를 사용하여 수행되는 코사인 유사성 검색을 수행해야 합니다.

EXPLAIN SELECT * FROM t_test ORDER BY embedding <=> '[1,2,3]' LIMIT 5;
                                              QUERY PLAN
------------------------------------------------------------------------------------------------------
 Limit  (cost=5.02..5.23 rows=5 width=33)
   ->  Index Scan using t_test_embedding_cosine_idx on t_test  (cost=5.02..175.06 rows=4003 width=33)
         Order By: (embedding <=> '[1,2,3]'::vector)
(3 rows)

L2 거리

L2 거리(유클리드 거리라고도 함)의 경우 vector_l2_ops 액세스 메서드를 사용합니다.

CREATE INDEX t_test_embedding_l2_idx ON t_test USING ivfflat (embedding vector_l2_ops) WITH (lists = 100);

위의 인덱스를 사용하려면 쿼리에서 <-> 연산자를 사용하여 수행되는 L2 거리 검색을 수행해야 합니다.

EXPLAIN SELECT * FROM t_test ORDER BY embedding <-> '[1,2,3]' LIMIT 5;
                                            QUERY PLAN
--------------------------------------------------------------------------------------------------
 Limit  (cost=5.02..5.23 rows=5 width=33)
   ->  Index Scan using t_test_embedding_l2_idx on t_test  (cost=5.02..175.06 rows=4003 width=33)
         Order By: (embedding <-> '[1,2,3]'::vector)
(3 rows)

내적

내적 유사성의 경우 vector_ip_ops 액세스 메서드를 사용합니다.

CREATE INDEX t_test_embedding_ip_idx ON t_test USING ivfflat (embedding vector_ip_ops) WITH (lists = 100);

위의 인덱스를 사용하려면 쿼리에서 <#> 연산자를 사용하여 수행되는 내적 유사성 검색을 수행해야 합니다.

EXPLAIN SELECT * FROM t_test ORDER BY embedding <#> '[1,2,3]' LIMIT 5;
                                            QUERY PLAN
--------------------------------------------------------------------------------------------------
 Limit  (cost=5.02..5.23 rows=5 width=33)
   ->  Index Scan using t_test_embedding_ip_idx on t_test  (cost=5.02..175.06 rows=4003 width=33)
         Order By: (embedding <#> '[1,2,3]'::vector)
(3 rows)

부분 인덱스

일부 시나리오에서는 데이터 세트 일부만 포함하는 인덱스를 사용하는 것이 좋습니다. 예를 들어, 프리미엄 사용자만을 위한 인덱스를 빌드할 수 있습니다.

CREATE INDEX t_premium ON t_test USING ivfflat (vec vector_ip_ops) WITH (lists = 100) WHERE tier = 'premium';

이제 프리미엄 계층에서 인덱스를 사용하는 것을 볼 수 있습니다.

explain select * from t_test where tier = 'premium' order by vec <#> '[2,2,2]';
                                     QUERY PLAN
------------------------------------------------------------------------------------
 Index Scan using t_premium on t_test  (cost=65.57..25638.05 rows=245478 width=39)
   Order By: (vec <#> '[2,2,2]'::vector)
(2 rows)

한편 무료 계층 사용자에게는 다음과 같은 이점이 없습니다.

explain select * from t_test where tier = 'free' order by vec <#> '[2,2,2]';
                              QUERY PLAN
-----------------------------------------------------------------------
 Sort  (cost=44019.01..44631.37 rows=244941 width=39)
   Sort Key: ((vec <#> '[2,2,2]'::vector))
   ->  Seq Scan on t_test  (cost=0.00..15395.25 rows=244941 width=39)
         Filter: (tier = 'free'::text)
(4 rows)

데이터의 하위 집합만 인덱싱한다는 것은 인덱스가 디스크에서 더 적은 공간을 차지하고 검색 속도가 더 빠르다는 것을 의미합니다.

부분 인덱스 정의의 WHERE 절에 사용된 형식이 쿼리에 사용된 형식과 일치하지 않는 경우 PostgreSQL에서 인덱스가 사용하기에 안전하다는 것을 인식하지 못할 수 있습니다. 데이터 세트 예에는 계층 열의 고유 값으로 정확한 값 'free', 'test''premium'만 있습니다. tier LIKE 'premium'을 사용하는 쿼리에서도 PostgreSQL은 인덱스를 사용하지 않습니다.

explain select * from t_test where tier like 'premium' order by vec <#> '[2,2,2]';
                              QUERY PLAN
-----------------------------------------------------------------------
 Sort  (cost=44086.30..44700.00 rows=245478 width=39)
   Sort Key: ((vec <#> '[2,2,2]'::vector))
   ->  Seq Scan on t_test  (cost=0.00..15396.59 rows=245478 width=39)
         Filter: (tier ~~ 'premium'::text)
(4 rows)

분할

성능을 향상시키는 한 가지 방법은 데이터 세트를 여러 파티션으로 분할하는 것입니다. 현재 연도 또는 지난 2년 동안의 데이터를 참조하는 것이 자연스러운 시스템을 상상할 수 있습니다. 이러한 시스템에서는 날짜 범위별로 데이터를 분할한 다음, 시스템에서 쿼리된 연도에 정의된 관련 파티션만 읽을 수 있는 경우 향상된 성능을 활용할 수 있습니다.

분할된 테이블을 정의해 보겠습니다.

CREATE TABLE t_test_partitioned(vec vector(3), vec_date date default now()) partition by range (vec_date);

매년 수동으로 파티션을 만들거나 Citus 유틸리티 함수(Cosmos DB for PostgreSQL에서 사용 가능)를 사용할 수 있습니다.

    select create_time_partitions(
      table_name         := 't_test_partitioned',
      partition_interval := '1 year',
      start_from         := '2020-01-01'::timestamptz,
      end_at             := '2024-01-01'::timestamptz
    );

만든 파티션을 확인합니다.

\d+ t_test_partitioned
                                Partitioned table "public.t_test_partitioned"
  Column  |   Type    | Collation | Nullable | Default | Storage  | Compression | Stats target | Description
----------+-----------+-----------+----------+---------+----------+-------------+--------------+-------------
 vec      | vector(3) |           |          |         | extended |             |              |
 vec_date | date      |           |          | now()   | plain    |             |              |
Partition key: RANGE (vec_date)
Partitions: t_test_partitioned_p2020 FOR VALUES FROM ('2020-01-01') TO ('2021-01-01'),
            t_test_partitioned_p2021 FOR VALUES FROM ('2021-01-01') TO ('2022-01-01'),
            t_test_partitioned_p2022 FOR VALUES FROM ('2022-01-01') TO ('2023-01-01'),
            t_test_partitioned_p2023 FOR VALUES FROM ('2023-01-01') TO ('2024-01-01')

파티션을 수동으로 만들려면 다음을 수행합니다.

CREATE TABLE t_test_partitioned_p2019 PARTITION OF t_test_partitioned FOR VALUES FROM ('2019-01-01') TO ('2020-01-01');

그런 다음, 쿼리가 실제로 사용 가능한 파티션의 하위 집합으로 필터링되는지 확인합니다. 예를 들어, 아래 쿼리에서는 두 개의 파티션으로 필터링했습니다.

explain analyze select * from t_test_partitioned where vec_date between '2022-01-01' and '2024-01-01';
                                                                  QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------
 Append  (cost=0.00..58.16 rows=12 width=36) (actual time=0.014..0.018 rows=3 loops=1)
   ->  Seq Scan on t_test_partitioned_p2022 t_test_partitioned_1  (cost=0.00..29.05 rows=6 width=36) (actual time=0.013..0.014 rows=1 loops=1)
         Filter: ((vec_date >= '2022-01-01'::date) AND (vec_date <= '2024-01-01'::date))
   ->  Seq Scan on t_test_partitioned_p2023 t_test_partitioned_2  (cost=0.00..29.05 rows=6 width=36) (actual time=0.002..0.003 rows=2 loops=1)
         Filter: ((vec_date >= '2022-01-01'::date) AND (vec_date <= '2024-01-01'::date))
 Planning Time: 0.125 ms
 Execution Time: 0.036 ms

분할된 테이블을 인덱싱할 수 있습니다.

CREATE INDEX ON t_test_partitioned USING ivfflat (vec vector_cosine_ops) WITH (lists = 100);
explain analyze select * from t_test_partitioned where vec_date between '2022-01-01' and '2024-01-01' order by vec <=> '[1,2,3]' limit 5;
                                                                                         QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=4.13..12.20 rows=2 width=44) (actual time=0.040..0.042 rows=1 loops=1)
   ->  Merge Append  (cost=4.13..12.20 rows=2 width=44) (actual time=0.039..0.040 rows=1 loops=1)
         Sort Key: ((t_test_partitioned.vec <=> '[1,2,3]'::vector))
         ->  Index Scan using t_test_partitioned_p2022_vec_idx on t_test_partitioned_p2022 t_test_partitioned_1  (cost=0.04..4.06 rows=1 width=44) (actual time=0.022..0.023 rows=0 loops=1)
               Order By: (vec <=> '[1,2,3]'::vector)
               Filter: ((vec_date >= '2022-01-01'::date) AND (vec_date <= '2024-01-01'::date))
         ->  Index Scan using t_test_partitioned_p2023_vec_idx on t_test_partitioned_p2023 t_test_partitioned_2  (cost=4.08..8.11 rows=1 width=44) (actual time=0.015..0.016 rows=1 loops=1)
               Order By: (vec <=> '[1,2,3]'::vector)
               Filter: ((vec_date >= '2022-01-01'::date) AND (vec_date <= '2024-01-01'::date))
 Planning Time: 0.167 ms
 Execution Time: 0.139 ms
(11 rows)

다음 단계

축하합니다. 이제 pgvector를 사용하여 최상의 성능을 달성하기 위한 절충, 제한 사항 및 모범 사례를 알아보았습니다.