Partager via


Comment optimiser les performances lors de l’utilisation de pgvector sur Azure Database pour PostgreSQL - Serveur flexible

S’APPLIQUE À : Azure Database pour PostgreSQL : serveur flexible

L’extension pgvector ajoute une recherche de similarité vectorielle open source au serveur flexible Azure Database pour PostgreSQL.

Cet article explore les limitations et les compromis de pgvector et montre comment utiliser les paramètres de partitionnement, d’indexation et de recherche pour améliorer les performances.

Pour plus d’informations sur l’extension elle-même, consultez les principes de base de pgvector. Vous pouvez également vous référer au fichier README officiel du projet.

Performances

Vous devez toujours commencer par examiner le plan de requête. Si votre requête se termine assez rapidement, exécutez EXPLAIN (ANALYZE,VERBOSE, BUFFERS).

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

Pour les requêtes qui prennent trop de temps à s’exécuter, envisagez de supprimer le mot clé ANALYZE. Le résultat contient moins de détails, mais est fourni instantanément.

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

Les sites tiers, comme explain.depesz.com peuvent être utiles pour comprendre les plans de requête. Voici quelques questions auxquelles vous devez essayer de répondre :

Si vos vecteurs sont normalisés à la longueur 1, comme les incorporations OpenAI. Vous devez envisager d’utiliser le produit interne (<#>) pour de meilleures performances.

Exécution parallèle

Dans la sortie de votre plan d’explication, recherchez Workers Planned et Workers Launched (ce dernier uniquement lorsque le mot clé ANALYZE a été utilisé). Le paramètre PostgreSQL max_parallel_workers_per_gather définit combien de rôles de travail en arrière-plan la base de données peut lancer pour chaque nœud de plan Gather et Gather Merge. L’augmentation de cette valeur peut accélérer vos requêtes de recherche exactes sans l’obligation de créer d’index. Notez toutefois que la base de données peut ne pas décider d’exécuter le plan en parallèle, même lorsque cette valeur est élevée.

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)

Indexation

Sans index présents, l’extension effectue une recherche exacte, qui fournit un rappel parfait au détriment des performances.

Pour effectuer une recherche approximative du voisin le plus proche, vous pouvez créer des index sur vos données, ce qui échange le rappel pour les performances d’exécution.

Lorsque cela est possible, chargez toujours vos données avant de les indexer. Il est à la fois plus rapide de créer l’index de cette façon et la disposition qui en résulte est plus optimale.

Il existe deux types d’indexes pris en charge :

L’index IVFFlat a des temps de génération plus rapides et utilise moins de mémoire que HNSW, mais a des performances de requête inférieures (en termes de compromis entre rappel et vitesse).

Limites

  • Pour indexer une colonne, des dimensions doivent être définies. La tentative d’indexer une colonne définie comme col vector entraîne l’erreur : ERROR: column does not have dimensions.
  • Vous pouvez uniquement indexer une colonne qui a jusqu’à 2 000 dimensions. La tentative d’indexer une colonne avec plus de dimensions entraîne l’erreur : ERROR: column cannot have more than 2000 dimensions for INDEX_TYPE indexINDEX_TYPE est ivfflat ou hnsw.

Bien que vous puissiez stocker des vecteurs avec plus de 2 000 dimensions, vous ne pouvez pas les indexer. Vous pouvez utiliser la réduction de dimensionnalité pour respecter les limites. Vous pouvez également utiliser le partitionnement et/ou le partitionnement de base de données avec Azure Cosmos DB for PostgreSQL pour obtenir des performances acceptables sans indexation.

Fichier inversé avec compression plate (IVVFlat)

ivfflat est un index pour la recherche approximative du plus proche voisin (ANN). Cette méthode utilise un index de fichier inversé pour partitionner le jeu de données en plusieurs listes. Le paramètre de sondes d’intégrité contrôle le nombre de listes recherchées, ce qui peut améliorer la précision des résultats de recherche au détriment d’une vitesse de recherche plus lente.

Si le paramètre de sondes d’intégrité est défini sur le nombre de listes dans l’index, toutes les listes font l’objet d’une recherche et celle-ci devient la recherche voisine la plus proche exacte. Dans ce cas, le planificateur n’utilise pas l’index, car la recherche dans toutes les listes équivaut à effectuer une recherche en force brute sur l’ensemble du jeu de données.

La méthode d’indexation partitionne le jeu de données en plusieurs listes à l’aide de l’algorithme de clustering k-moyennes. Chaque liste contient des vecteurs qui sont les plus proches d’un centre de cluster particulier. Lors d’une recherche, le vecteur de requête est comparé aux centres de cluster pour déterminer les listes les plus susceptibles de contenir les plus proches voisins. Si le paramètre de sondes d’intégrité a la valeur 1, seule la liste correspondant au centre de cluster le plus proche est recherchée.

Options d'index

La sélection de la valeur correcte pour le nombre de sondes d’intégrité à effectuer et la taille des listes peut influencer les performances de recherche. Les bons points de départ sont les suivants :

  1. Utilisez lists égal à rows / 1000 pour les tables contenant jusqu’à 1 million de lignes et sqrt(rows) pour les jeux de données plus volumineux.
  2. Pour probes commencez par lists / 10 pour les tables contenant jusqu’à 1 million de lignes et sqrt(lists) pour les jeux de données plus volumineux.

La quantité de lists est définie lors de la création de l’index avec l’option lists :

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

Les sondes peuvent être définies pour l’ensemble de la connexion ou par transaction (à l’aide de SET LOCAL dans un bloc de transaction) :

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

Progression de l’indexation

Dans PostgreSQL 12 et ses versions ultérieures, vous pouvez utiliser pg_stat_progress_create_index pour vérifier la progression de l’indexation.

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

Les phases de création de l’index IVFFlat sont les suivantes :

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

Remarque

Le pourcentage de progression (%) est rempli uniquement pendant la phase loading tuples.

Hierarchical Navigable Small Worlds (HNSW)

hnsw est un index de recherche approximative du plus proche voisin (ANN, approximate nearest neighbor) à l’aide de l’algorithme HNSW. Il fonctionne en créant un graphique autour de points d’entrée sélectionnés de manière aléatoire en trouvant leurs plus proches voisins. Le graphique est ensuite étendu avec plusieurs couches, chaque couche inférieure contenant plus de points. Une recherche sur ce graphique multicouche commence par le haut et descend jusqu’à ce qu’elle atteigne la couche la plus basse qui contient les plus proches voisins de la requête.

La génération de cet index prend plus de temps et de mémoire qu’IVFFlat, mais elle propose un meilleur compromis entre rappel et vitesse. En outre, il n’existe aucune étape de formation comme dans IVFFlat. L’index peut donc être créé sur une table vide.

Options d'index

Lors de la création de l’index, vous pouvez régler deux paramètres :

  1. m : nombre maximal de connexions par couche (par défaut, 16)
  2. ef_construction : taille de la liste de candidats dynamiques utilisée pour la construction du graphique (par défaut, 64)
CREATE INDEX t_test_hnsw_l2_idx ON t_test USING hnsw (embedding vector_l2_ops) WITH (m = 16, ef_construction = 64);

Pendant les requêtes, vous pouvez spécifier la liste de candidats dynamiques pour la recherche (par défaut, 40).

La liste de candidats dynamiques pour la recherche peut être définie pour l’ensemble de la connexion ou par transaction (à l’aide de SET LOCAL dans un bloc de transactions) :

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

Progression de l’indexation

Dans PostgreSQL 12 et ses versions ultérieures, vous pouvez utiliser pg_stat_progress_create_index pour vérifier la progression de l’indexation.

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

Les phases de génération des indexes HNSW sont les suivantes :

  1. initializing
  2. loading tuples

Sélection de la fonction d’accès à l’index

Le type vector vous permet d’effectuer trois types de recherches sur les vecteurs stockés. Vous devez sélectionner la fonction d’accès appropriée pour votre index afin que la base de données considère votre index lors de l’exécution de vos requêtes. Les exemples utilisent les types d’indexes ivfflat, mais la même chose peut être effectuée pour les indexes hnsw. L’option lists s’applique uniquement aux indexes ivfflat.

Distance cosinus

Pour la recherche de similarité cosinus, utilisez la méthode d’accès vector_cosine_ops.

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

Pour utiliser l’index ci-dessus, la requête doit effectuer une recherche de similarité cosinus, qui est effectuée avec l’opérateur <=>.

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)

Distance L2

Pour la distance L2 (également appelée distance euclidienne), utilisez la méthode d’accès vector_l2_ops.

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

Pour utiliser l’index ci-dessus, la requête doit effectuer une recherche de distance L2, qui est effectuée avec l’opérateur <->.

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)

Produit intérieur

Pour la similarité interne du produit, utilisez la méthode d’accès vector_ip_ops.

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

Pour utiliser l’index ci-dessus, la requête doit effectuer une recherche de similarité interne du produit, qui est effectuée avec l’opérateur <#>.

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)

Index partiels

Dans certains scénarios, il est avantageux d’avoir un index qui ne couvre qu’un ensemble partiel des données. Par exemple, nous pouvons générer un index uniquement pour nos utilisateurs premium :

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

Nous pouvons maintenant voir que le niveau Premium utilise désormais l’index :

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)

Bien que les utilisateurs du niveau gratuit ne bénéficient pas de l’avantage :

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)

Le fait de n’avoir qu’un sous-ensemble de données indexées signifie que l’index prend moins d’espace sur le disque et qu’il est plus rapide à rechercher.

PostgreSQL peut ne pas reconnaître que l’index est sûr d’utilisation si le formulaire utilisé dans la clause WHERE de la définition d’index partiel ne correspond pas à celui utilisé dans vos requêtes. Dans notre exemple de jeu de données, nous avons uniquement les valeurs exactes 'free', 'test' et 'premium' comme valeurs distinctes de la colonne de niveau. Même avec une requête utilisant tier LIKE 'premium', PostgreSQL n’utilise pas l’index.

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)

Partitionnement

Une façon d’améliorer les performances consiste à fractionner le jeu de données sur plusieurs partitions. Nous pouvons imaginer un système lorsqu’il est naturel de faire référence à des données de l’année en cours ou peut-être des deux dernières années. Dans un tel système, vous pouvez partitionner vos données selon une plage de dates, puis tirer parti de performances améliorées lorsque le système est en mesure de lire uniquement les partitions pertinentes telles que définies par l’année interrogée.

Définissons une table partitionnée :

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

Nous pouvons créer manuellement des partitions pour chaque année ou utiliser la fonction utilitaire Citus (disponible sur Cosmos DB pour 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
    );

Vérifiez les partitions créées :

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

Pour créer manuellement une partition :

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

Vérifiez ensuite que vos requêtes filtrent effectivement jusqu’à un sous-ensemble de partitions disponibles. Par exemple, dans la requête ci-dessous, nous avons filtré jusqu’à deux partitions :

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

Vous pouvez indexer une table partitionnée.

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)

Étapes suivantes

Félicitations, vous venez d’apprendre les compromis, les limitations et les meilleures pratiques pour obtenir les meilleures performances avec pgvector.