Come ottimizzare le prestazioni quando si usa pgvector in Azure Cosmos DB per PostgreSQL

SI APPLICA A: Azure Cosmos DB per PostgreSQL (basato sull'estensione del database Citus in PostgreSQL)

L'estensione pgvector aggiunge una ricerca di somiglianza del vettore open source a PostgreSQL.

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 su pgvector. È anche possibile fare riferimento al FILE LEGGIMI ufficiale del progetto.

Prestazioni

È consigliabile iniziare analizzando sempre il piano di query. Se la query termina ragionevolmente rapidamente, 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 ANALYZE parola chiave . 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 che è consigliabile provare a rispondere sono:

Se i vettori vengono normalizzati per la 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 ANALYZE è stata usata la parola chiave). Il max_parallel_workers_per_gather parametro PostgreSQL definisce il numero di ruoli di lavoro in background che il database può avviare per ogni Gather nodo e Gather Merge piano. 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 scapito delle prestazioni.

Per eseguire una ricerca approssimativa vicina più vicina, è possibile creare indici sui dati, che richiamano per le prestazioni di esecuzione.

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

Esistono due tipi di indice supportati:

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

Limiti

  • Per indicizzare una colonna, deve avere dimensioni definite. Tentativo di indicizzare una colonna definita come col vector risultato dell'errore: ERROR: column does not have dimensions.
  • È possibile indicizzare solo una colonna con dimensioni fino a 2000. 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, fare affidamento sul partizionamento orizzontale e/o sul partizionamento orizzontale con Azure Cosmos DB per PostgreSQL per ottenere prestazioni accettabili senza indicizzazione.

File invertito con compressione flat (IVVFlat)

ivfflat è un indice per la ricerca ann (nearest neighbor) approssimativa. 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 al costo della velocità di ricerca più lenta.

Se il parametro probes è impostato sul numero di elenchi nell'indice, tutti gli elenchi vengono cercati e la ricerca diventa una ricerca esatta vicina più vicina. In questo caso, lo strumento di pianificazione non usa l'indice perché la ricerca in tutti gli elenchi equivale a eseguire una ricerca di forza bruta 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 sono più probabili contenere i vicini più vicini. Se il parametro probes è 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 punti di partenza 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 iniziare con lists / 10 per le tabelle fino a 1 milione di righe e sqrt(lists) per set di dati di dimensioni maggiori.

La quantità di viene definita al momento della creazione dell'indice lists 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 loading tuples la fase.

Mondi piccoli navigabili gerarchici (HNSW)

hnsw è un indice per la ricerca ANN (NearEst NearEst Neighbor) approssimativa usando l'algoritmo Gerarchico Navigable Small Worlds. Funziona creando un grafico intorno ai punti di ingresso selezionati casualmente trovando i vicini più vicini, il grafico viene quindi esteso con più livelli, ogni livello inferiore contenente più punti. Questo grafico a più livelli quando viene eseguita la ricerca inizia nella parte superiore, restringendo verso il basso fino a raggiungere il livello più basso che contiene i vicini più vicini della query.

La compilazione di questo indice richiede più tempo e memoria rispetto a IVFFlat, ma ha un migliore compromesso di richiamo della 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 ottimizzare due parametri:

  1. m - Numero massimo di connessioni per livello (il valore predefinito è 16)
  2. ef_construction - dimensioni dell'elenco dei candidati dinamici usati per la costruzione del grafo (per impostazione predefinita è 64)
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).

L'elenco dei candidati dinamici per la ricerca può essere impostato per l'intera connessione o per 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 vector tipo 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. Gli esempi illustrano i ivfflat tipi di indice, ma è possibile eseguire la stessa operazione per hnsw gli indici. L'opzione lists si applica solo agli ivfflat indici.

Distanza coseno

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

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 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), usare il vector_l2_ops metodo di accesso.

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 a 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 vector_ip_ops metodo di accesso.

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 del prodotto interno, 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';

Ora è 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)

Anche se gli utenti del livello gratuito non hanno il 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 cercare.

PostgreSQL potrebbe non riconoscere che l'indice è sicuro da usare se il modulo usato nella WHERE clausola della definizione di indice parziale non corrisponde a quello usato nelle query. Nel set di dati di esempio sono disponibili solo i valori 'free''test' esatti 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 forse negli 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.

Definire 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 filtrati 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)

Conclusione

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