Condividi tramite


Come ottimizzare le prestazioni quando si usa pgvector in Database di Azure per PostgreSQL - Server flessibile

SI APPLICA A: Database di Azure per PostgreSQL - Server flessibile

L'estensione pgvector aggiunge una ricerca di somiglianza del vettore open source a Database di Azure per PostgreSQL server flessibile.

Questo articolo illustra le limitazioni e i compromessi di pgvector e illustra come usare il partizionamento, l'indicizzazione e le impostazioni di ricerca per migliorare le prestazioni.

Per altre informazioni sull'estensione stessa, vedere nozioni di base di pgvector. È anche possibile fare riferimento al file README ufficiale del progetto.

Prestazioni

È consigliabile iniziare sempre analizzando il piano di query. Se la query termina in tempi ragionevolmente rapidi, eseguire EXPLAIN (ANALYZE,VERBOSE, BUFFERS).

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

Per le query che richiedono troppo tempo per l'esecuzione, è consigliabile eliminare la parola chiave ANALYZE. Il risultato contiene un minor numero di dettagli, ma viene fornito immediatamente.

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

I siti di terze parti, ad esempio explain.depesz.com possono essere utili per comprendere i piani di query. Alcune domande a cui è consigliabile provare a rispondere sono:

Se i vettori vengono normalizzati alla lunghezza 1, ad esempio incorporamenti OpenAI. È consigliabile usare il prodotto interno (<#>) per ottenere prestazioni ottimali.

Esecuzione parallela

Nell'output del piano di spiegazione cercare Workers Planned e Workers Launched (quest'ultimo solo quando è stata usata la parola chiave ANALYZE). Il parametro max_parallel_workers_per_gather di PostgreSQL definisce il numero di ruoli di lavoro in background che il database può avviare per ogni nodo del piano Gather e Gather Merge. L'aumento di questo valore potrebbe velocizzare le query di ricerca esatte senza dover creare indici. Si noti, tuttavia, che il database potrebbe non decidere di eseguire il piano in parallelo anche quando questo valore è elevato.

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)

Indicizzazione

Senza indici presenti, l'estensione esegue una ricerca esatta, che fornisce un richiamo perfetto a discapito delle prestazioni.

Per eseguire una ricerca approssimativa del vicino più prossimo, è possibile creare indici sui dati, che sacrificano i richiami a favore delle prestazioni di esecuzione.

Quando possibile, caricare sempre i dati prima di indicizzarli. In questo modo, creare l'indice è più veloce e il layout risultante è più ottimale.

Sono supportati due tipi di indice:

L'indice IVFFlat ha tempi di compilazione più rapidi e usa meno memoria di HNSW, ma ha prestazioni di query inferiori (in termini di rapporto richiamo-velocità).

Limiti

  • Per essere indicizzata, una colonna deve avere dimensioni definite. Il tentativo di indicizzare una colonna definita come col vector genera l'errore: ERROR: column does not have dimensions.
  • È possibile indicizzare solo una colonna con un massimo di 2000 dimensioni. Il tentativo di indicizzare una colonna con più dimensioni genera l'errore: ERROR: column cannot have more than 2000 dimensions for INDEX_TYPE index dove INDEX_TYPE è ivfflat o hnsw.

Sebbene sia possibile archiviare vettori con più di 2000 dimensioni, non è possibile indicizzarli. È possibile usare la riduzione della dimensionalità per rientrare nei limiti. In alternativa, ricorrere al partizionamento e/o al partizionamento orizzontale con Azure Cosmos DB for PostgreSQL per ottenere prestazioni accettabili senza indicizzazione.

Inverted File with Flat Compression (IVVFlat)

ivfflat è un indice per la ricerca approssimativa del vicino più prossimo (ANN). Questo metodo usa un indice di file invertito per partizionare il set di dati in più elenchi. Il parametro probe controlla il numero di elenchi in cui viene eseguita la ricerca, che può migliorare l'accuratezza dei risultati della ricerca a discapito della velocità di ricerca, più lenta.

Se il parametro probe è impostato sul numero di elenchi nell'indice, tutti gli elenchi vengono ricercati e la ricerca diventa una ricerca esatta del vicino più prossimo. In questo caso, lo strumento di pianificazione non usa l'indice perché la ricerca in tutti gli elenchi equivale a eseguire una ricerca forzata sull'intero set di dati.

Il metodo di indicizzazione partiziona il set di dati in più elenchi usando l'algoritmo di clustering k-means. Ogni elenco contiene vettori più vicini a un particolare centro cluster. Durante una ricerca, il vettore di query viene confrontato con i centri del cluster per determinare quali elenchi hanno maggiori probabilità di contenere i vicini più prossimi. Se il parametro probe è impostato su 1, verrà eseguita la ricerca solo dell'elenco corrispondente al centro cluster più vicino.

Opzioni per indici

La selezione del valore corretto per il numero di probe da eseguire e le dimensioni degli elenchi potrebbero influire sulle prestazioni della ricerca. I migliori punti da cui partire sono i seguenti:

  1. Usare lists uguale a rows / 1000 per le tabelle con un massimo di 1 milione di righe e sqrt(rows) per set di dati di dimensioni maggiori.
  2. Per probes partire con lists / 10 per le tabelle con un massimo di 1 milione di righe e sqrt(lists) per i set di dati con dimensioni maggiori.

La quantità di lists viene definita al momento della creazione dell'indice con l'opzione lists:

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

I probe possono essere impostati per l'intera connessione o per transazione (usando SET LOCAL all'interno di un blocco di transazioni):

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

Stato di indicizzazione

Con PostgreSQL 12 e versioni successive, è possibile usare pg_stat_progress_create_index per controllare lo stato di avanzamento dell'indicizzazione.

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

Le fasi per la compilazione di indici IVFFlat sono:

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

Nota

La percentuale di avanzamento (%) viene popolata solo durante la fase di loading tuples.

Hierarchical Navigable Small Worlds (HNSW)

hnsw è un indice per una ricerca del vicino più prossimo approssimativo (AAN) usando l'algoritmo Hierarchical Navigable Small Worlds. Funziona creando un grafico intorno ai punti di ingresso selezionati casualmente trovando i vicini più prossimi; il grafico viene quindi esteso con più livelli, in cui ogni livello inferiore contiene più punti. Questo grafico a più livelli, quando viene eseguita la ricerca inizia nella parte superiore, si restringe verso il basso fino a raggiungere il livello più basso che contiene i vicini più prossimi della query.

La compilazione di questo indice richiede più tempo e memoria rispetto a IVFFlat, ma ha un migliore rapporto richiamo-velocità. Inoltre, non esiste alcun passaggio di training come con IVFFlat, in modo che l'indice possa essere creato in una tabella vuota.

Opzioni per indici

Quando si crea l'indice, è possibile regolare due parametri:

  1. m: numero massimo di connessioni per ogni livello (16 per impostazione predefinita)
  2. ef_construction: dimensioni dell'elenco di candidati dinamici usati per la costruzione del grafico (64 per impostazione predefinita)
CREATE INDEX t_test_hnsw_l2_idx ON t_test USING hnsw (embedding vector_l2_ops) WITH (m = 16, ef_construction = 64);

Durante le query, è possibile specificare l'elenco dei candidati dinamici per la ricerca (il valore predefinito è 40).

I probe possono essere impostati per l'intera connessione o per singola transazione (usando SET LOCAL all'interno di un blocco di transazioni):

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

Stato di indicizzazione

Con PostgreSQL 12 e versioni successive, è possibile usare pg_stat_progress_create_index per controllare lo stato di avanzamento dell'indicizzazione.

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

Le fasi per la compilazione di indici HNSW sono:

  1. initializing
  2. loading tuples

Selezione della funzione di accesso all'indice

Il tipo vector consente di eseguire tre tipi di ricerche sui vettori archiviati. È necessario selezionare la funzione di accesso corretta per l'indice per fare in modo che il database consideri l'indice durante l'esecuzione delle query. Negli esempi vengono illustrati i tipi di indice ivfflat, ma è possibile eseguire lo stesso metodo per gli indici hnsw. L'opzione lists si applica solo agli indici ivfflat.

Distanza del coseno

Per la ricerca di somiglianza del coseno, usare il metodo di accesso vector_cosine_ops.

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

Per usare l'indice precedente, la query deve eseguire una ricerca di somiglianza del coseno, eseguita con l'operatore <=>.

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)

Distanza L2

Per la distanza L2 (nota anche come distanza euclidea), utilizzare il metodo di accesso vector_l2_ops.

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

Per usare l'indice precedente, la query deve eseguire una ricerca della distanza L2, eseguita con l'operatore <->.

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)

Prodotto interno

Per la somiglianza interna del prodotto, usare il metodo di accesso vector_ip_ops.

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

Per usare l'indice precedente, la query deve eseguire una ricerca di somiglianza interna del prodotto, eseguita con l'operatore <#>.

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)

Indici parziali

In alcuni scenari, è utile avere un indice che copre solo un set parziale di dati. Ad esempio, è possibile compilare un indice solo per gli utenti premium:

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

A questo punto, è possibile vedere che il livello premium usa l'indice:

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)

Mentre gli utenti del livello gratuito non godono del vantaggio:

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)

La presenza di un solo subset di dati indicizzati significa che l'indice occupa meno spazio su disco ed è più veloce da ricercare.

PostgreSQL potrebbe non riconoscere che l'indice è sicuro da usare se il modulo usato nella clausola WHERE della definizione di indice parziale non corrisponde a quello usato nelle query. Nel set di dati di esempio, sono disponibili solo i valori esatti 'free', 'test' e 'premium' come valori distinti della colonna del livello. Anche con una query che usa tier LIKE 'premium' PostgreSQL non usa l'indice.

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)

Partizionamento

Un modo per migliorare le prestazioni consiste nel suddividere il set di dati su più partizioni. Possiamo immaginare un sistema quando è naturale fare riferimento ai dati solo dall'anno corrente o magari degli ultimi due anni. In un sistema di questo tipo, è possibile partizionare i dati in base a un intervallo di date e quindi sfruttare al meglio le prestazioni quando il sistema è in grado di leggere solo le partizioni pertinenti, come definito dall'anno sottoposto a query.

Creiamo una tabella partizionata:

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

È possibile creare manualmente partizioni per ogni anno o usare la funzione di utilità Citus (disponibile in Cosmos DB per 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
    );

Controllare le partizioni create:

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

Per creare manualmente una partizione:

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

Assicurarsi quindi che le query vengano effettivamente filtrate in base a un subset di partizioni disponibili. Ad esempio, nella query seguente sono stati applicati filtri in base a due partizioni:

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

È possibile indicizzare una tabella partizionata.

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)

Passaggi successivi

Congratulazioni, sono stati appresi i compromessi, le limitazioni e le procedure consigliate per ottenere prestazioni ottimali con pgvector.