Udostępnij za pośrednictwem


Jak zoptymalizować wydajność podczas korzystania z narzędzia pgvector w usłudze Azure Cosmos DB for PostgreSQL

DOTYCZY: Usługa Azure Cosmos DB for PostgreSQL (obsługiwana przez rozszerzenie bazy danych Citus do bazy danych PostgreSQL)

Rozszerzenie pgvector dodaje wyszukiwanie wektorów typu open source do bazy danych PostgreSQL.

W tym artykule opisano ograniczenia i kompromisy pgvector oraz pokazano, jak używać ustawień partycjonowania, indeksowania i wyszukiwania w celu zwiększenia wydajności.

Aby uzyskać więcej informacji na temat samego rozszerzenia, zobacz podstawy programu pgvector. Możesz również odwołać się do oficjalnego pliku README projektu.

Wydajność

Zawsze należy zacząć od zbadania planu zapytania. Jeśli zapytanie kończy się dość szybko, uruchom polecenie EXPLAIN (ANALYZE,VERBOSE, BUFFERS).

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

W przypadku zapytań, które trwa zbyt długo, rozważ usunięcie słowa kluczowego ANALYZE . Wynik zawiera mniej szczegółów, ale jest dostarczany natychmiast.

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

Witryny innych firm, takie jak explain.depesz.com , mogą być pomocne w zrozumieniu planów zapytań. Oto kilka pytań, na które należy odpowiedzieć:

Jeśli wektory są znormalizowane do długości 1, na przykład osadzanie openAI. Należy rozważyć użycie produktu wewnętrznego () w<#> celu uzyskania najlepszej wydajności.

Równoległego

W danych wyjściowych planu wyjaśnień poszukaj Workers Planned i Workers Launched (ostatnio tylko wtedy, gdy ANALYZE użyto słowa kluczowego). Parametr max_parallel_workers_per_gather PostgreSQL określa, ile procesów roboczych w tle może uruchamiać baza danych dla każdego Gather węzła i Gather Merge planu. Zwiększenie tej wartości może przyspieszyć dokładne zapytania wyszukiwania bez konieczności tworzenia indeksów. Należy jednak pamiętać, że baza danych może nie zdecydować się na równoległe uruchomienie planu nawet wtedy, gdy ta wartość jest wysoka.

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)

Indeksowanie

Bez obecnych indeksów rozszerzenie wykonuje dokładne wyszukiwanie, co zapewnia doskonałą kompletność kosztem wydajności.

Aby przeprowadzić przybliżone wyszukiwanie najbliższych sąsiadów, można utworzyć indeksy danych, które wymieniają wyniki wykonywania.

Jeśli to możliwe, zawsze załaduj dane przed ich indeksowaniem. Zarówno szybsze tworzenie indeksu w ten sposób, jak i wynikowy układ jest bardziej optymalny.

Istnieją dwa obsługiwane typy indeksów:

Indeks IVFFlat ma szybsze czasy kompilacji i używa mniejszej ilości pamięci niż HNSW, ale ma niższą wydajność zapytań (pod względem szybkiego wycofywania).

Limity

  • Aby indeksować kolumnę, musi mieć zdefiniowane wymiary. Próba indeksowania kolumny zdefiniowanej jako col vector powoduje błąd: ERROR: column does not have dimensions.
  • Można indeksować tylko kolumnę, która ma maksymalnie 2000 wymiarów. Próba indeksowania kolumny z większą większa większa liczba wymiarów powoduje błąd: ERROR: column cannot have more than 2000 dimensions for INDEX_TYPE index gdzie INDEX_TYPE jest ivfflat albo hnsw.

Chociaż można przechowywać wektory z ponad 2000 wymiarami, nie można ich indeksować. Można użyć redukcji wymiarowości, aby zmieścić się w granicach. Alternatywnie polegaj na partycjonowaniu i/lub fragmentowaniu za pomocą usługi Azure Cosmos DB for PostgreSQL, aby osiągnąć akceptowalną wydajność bez indeksowania.

Odwrócony plik z kompresją płaską (IVVFlat)

Jest ivfflat to indeks dla przybliżonego wyszukiwania najbliższego sąsiada (ANN). Ta metoda używa odwróconego indeksu plików do partycjonowania zestawu danych na wiele list. Parametr sondy określa liczbę wyszukiwanych list, co może poprawić dokładność wyników wyszukiwania kosztem wolniejszej szybkości wyszukiwania.

Jeśli parametr sondy jest ustawiony na liczbę list w indeksie, wszystkie listy są przeszukiwane, a wyszukiwanie stanie się dokładnym wyszukiwaniem najbliższych sąsiadów. W takim przypadku planista nie używa indeksu, ponieważ wyszukiwanie wszystkich list jest równoważne wykonywaniu wyszukiwania siłowego w całym zestawie danych.

Metoda indeksowania dzieli zestaw danych na wiele list przy użyciu algorytmu klastrowania metodą k-średnich. Każda lista zawiera wektory znajdujące się najbliżej określonego centrum klastra. Podczas wyszukiwania wektor zapytań jest porównywany z centrami klastrów, aby określić, które listy najprawdopodobniej będą zawierać najbliższych sąsiadów. Jeśli parametr sondy jest ustawiony na 1, przeszukana zostanie tylko lista odpowiadająca najbliższemu centrum klastra.

Opcje indeksu

Wybranie poprawnej wartości liczby sond do wykonania i rozmiary list mogą mieć wpływ na wydajność wyszukiwania. Dobre miejsca do rozpoczęcia to:

  1. Użyj lists wartości równej rows / 1000 dla tabel z maksymalnie 1 milionami wierszy i sqrt(rows) większych zestawów danych.
  2. Na probes początek lists / 10 dla tabel do 1 milionów wierszy i sqrt(lists) dla większych zestawów danych.

Ilość jest definiowana lists podczas tworzenia indeksu z opcją lists :

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

Sondy można ustawić dla całego połączenia lub na transakcję (przy użyciu SET LOCAL bloku transakcji):

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

Postęp indeksowania

Korzystając z programu PostgreSQL 12 i nowszych, możesz użyć pg_stat_progress_create_index polecenia , aby sprawdzić postęp indeksowania.

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

Fazy tworzenia indeksów IVFFlat są następujące:

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

Uwaga

Procent postępu (%) jest wypełniany tylko w loading tuples fazie.

Hierarchiczne nawigowalne małe światy (HNSW)

Jest hnsw to indeks dla przybliżonego wyszukiwania najbliższego sąsiada (ANN) przy użyciu algorytmu Hierarchiczny nawigowalny small worlds. Działa to przez utworzenie grafu wokół losowo wybranych punktów wejścia, które znajdują najbliższych sąsiadów, wykres jest następnie rozszerzony o wiele warstw, z których każda niższa warstwa zawiera więcej punktów. Ten wielowarstwowy graf podczas wyszukiwania rozpoczyna się u góry, zawężając do momentu trafienia do najniższej warstwy zawierającej najbliższych sąsiadów zapytania.

Tworzenie tego indeksu zajmuje więcej czasu i pamięci niż IVFFlat, jednak ma lepszą szybkość wycofywania. Ponadto nie ma takiego kroku trenowania, jak w przypadku narzędzia IVFFlat, więc indeks można utworzyć w pustej tabeli.

Opcje indeksu

Podczas tworzenia indeksu można dostroić dwa parametry:

  1. m — maksymalna liczba połączeń na warstwę (wartość domyślna to 16)
  2. ef_construction - rozmiar listy kandydatów dynamicznych używanych do budowy grafu (domyślnie 64)
CREATE INDEX t_test_hnsw_l2_idx ON t_test USING hnsw (embedding vector_l2_ops) WITH (m = 16, ef_construction = 64);

Podczas zapytań można określić dynamiczną listę kandydatów do wyszukiwania (wartość domyślna to 40).

Dynamiczna lista kandydatów do wyszukiwania może być ustawiona dla całego połączenia lub na transakcję (przy użyciu SET LOCAL bloku transakcji):

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

Postęp indeksowania

Korzystając z programu PostgreSQL 12 i nowszych, możesz użyć pg_stat_progress_create_index polecenia , aby sprawdzić postęp indeksowania.

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

Fazy tworzenia indeksów HNSW to:

  1. initializing
  2. loading tuples

Wybieranie funkcji dostępu do indeksu

Typ vector umożliwia wykonywanie trzech typów wyszukiwań w przechowywanych wektorach. Musisz wybrać poprawną funkcję dostępu dla indeksu, aby baza danych uwzględniała indeks podczas wykonywania zapytań. Przykłady pokazują typy ivfflat indeksów, jednak można to zrobić dla hnsw indeksów. Opcja lists dotyczy ivfflat tylko indeksów.

Odległość cosinusu

W przypadku wyszukiwania podobieństwa cosinus użyj vector_cosine_ops metody dostępu.

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

Aby użyć powyższego indeksu, zapytanie musi wykonać wyszukiwanie podobieństwa cosinus, które odbywa się za <=> pomocą operatora .

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)

Odległość L2

W przypadku odległości L2 (znanej również jako odległość euklidesowa vector_l2_ops ) użyj metody dostępu.

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

Aby użyć powyższego indeksu, zapytanie musi wykonać wyszukiwanie odległości L2, które odbywa się za <-> pomocą operatora .

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)

Produkt wewnętrzny

W przypadku podobieństwa produktu wewnętrznego vector_ip_ops użyj metody dostępu.

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

Aby użyć powyższego indeksu, zapytanie musi wykonać wewnętrzne wyszukiwanie podobieństwa produktu, które odbywa się za <#> pomocą operatora .

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)

Indeksy częściowe

W niektórych scenariuszach korzystne jest posiadanie indeksu, który obejmuje tylko częściowy zestaw danych. Możemy na przykład utworzyć indeks tylko dla naszych użytkowników w warstwie Premium:

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

Teraz możemy zobaczyć warstwę Premium używającą indeksu:

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)

Chociaż użytkownicy warstwy Bezpłatna nie mają korzyści:

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)

Posiadanie tylko podzbioru indeksowanych danych oznacza, że indeks zajmuje mniej miejsca na dysku i jest szybszy do przeszukiwania.

Usługa PostgreSQL może nie rozpoznać, że indeks jest bezpieczny do użycia, jeśli formularz używany w WHERE klauzuli definicji indeksu częściowego nie jest zgodny z używanym w zapytaniach. W naszym przykładowym zestawie danych mamy tylko dokładne wartości 'free'i 'test' 'premium' jako odrębne wartości kolumny warstwy. Nawet w przypadku zapytania korzystającego z bazy tier LIKE 'premium' danych PostgreSQL indeks nie jest używany.

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)

Partycjonowanie

Jednym ze sposobów poprawy wydajności jest podzielenie zestawu danych na wiele partycji. Możemy sobie wyobrazić system, gdy naturalne jest odwoływanie się do danych tylko z bieżącego roku, a może z ostatnich dwóch lat. W takim systemie można podzielić dane na partycje według zakresu dat, a następnie wykorzystać lepszą wydajność, gdy system będzie mógł odczytywać tylko odpowiednie partycje zdefiniowane przez badany rok.

Zdefiniujmy tabelę partycjonowaną:

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

Możemy ręcznie utworzyć partycje dla każdego roku lub użyć funkcji narzędzia Citus (dostępnej w usłudze 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
    );

Sprawdź utworzone partycje:

\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')

Aby ręcznie utworzyć partycję:

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

Następnie upewnij się, że zapytania rzeczywiście filtruj w dół do podzestawu dostępnych partycji. Na przykład w poniższym zapytaniu przefiltrowaliśmy do dwóch partycji:

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

Tabelę partycjonowaną można indeksować.

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)

Podsumowanie

Gratulacje, właśnie znasz kompromisy, ograniczenia i najlepsze rozwiązania, aby osiągnąć najlepszą wydajność za pomocą polecenia pgvector.