Como otimizar o desempenho ao usar pgvector
no Banco de Dados do Azure para PostgreSQL – Servidor Flexível
APLICA-SE A: Banco de Dados do Azure para PostgreSQL - Servidor Flexível
A extensão pgvector
adiciona uma pesquisa de similaridade de vetor de código aberto ao servidor flexível do Banco de Dados do Azure para PostgreSQL.
Este artigo explora as limitações e compensações de pgvector
e mostra como usar configurações de particionamento, indexação e pesquisa para melhorar o desempenho.
Para obter mais informações sobre a extensão, confiranoções básicas de pgvector
. Talvez você também queira consultar o LEIAME oficial do projeto.
Desempenho
Você sempre deve começar investigando o plano de consulta. Se a consulta for encerrada de forma razoavelmente rápida, execute EXPLAIN (ANALYZE,VERBOSE, BUFFERS)
.
EXPLAIN (ANALYZE, VERBOSE, BUFFERS) SELECT * FROM t_test ORDER BY embedding <-> '[1,2,3]' LIMIT 5;
Para consultas que levam muito tempo para serem executadas, considere remover a palavra-chave ANALYZE
. O resultado contém menos detalhes, mas é fornecido instantaneamente.
EXPLAIN (VERBOSE, BUFFERS) SELECT * FROM t_test ORDER BY embedding <-> '[1,2,3]' LIMIT 5;
Sites de terceiros, como explain.depesz.com podem ser úteis para entender os planos de consulta. Algumas perguntas que você deve tentar responder são:
- A consulta foi paralelizada?
- Um índice foi usado?
- Usei a mesma condição na cláusula WHERE que em uma definição de índice parcial?
- Se eu usar particionamento, as partições não necessárias foram removidas?
Se os vetores forem normalizados para o tamanho 1, como inserções do OpenAI. Você deve considerar o uso do produto interno (<#>
) para obter o melhor desempenho.
Execução paralela
Na saída do plano de explicação, procure Workers Planned
e Workers Launched
(o último somente quando a palavra-chave ANALYZE
foi usada). O parâmetro max_parallel_workers_per_gather
do PostgreSQL define quantos trabalhos em segundo plano o banco de dados pode iniciar para cada nó de plano Gather
e Gather Merge
. O aumento nesse valor pode acelerar suas consultas de pesquisa exatas sem precisar criar índices. No entanto, observe que o banco de dados pode decidir não executar o plano em paralelo, mesmo quando esse valor for alto.
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)
Indexação
Sem índices presentes, a extensão executa uma pesquisa exata, que fornece um recall perfeito, mas reduz o desempenho.
Para executar uma pesquisa aproximada do vizinho mais próximo, você pode criar índices em seus dados, que trocam o recall pelo desempenho de execução.
Quando possível, sempre carregue seus dados antes de indexá-los. É mais rápido criar o índice dessa maneira e o layout resultante é melhor.
Há dois tipos de índice com suporte:
O índice IVFFlat
leva menos tempo para ser criado e usa menos memória do que o HNSW
, mas tem um desempenho de consulta inferior (em termos de compensação de recuperação de velocidade).
Limites
- Para indexar uma coluna, ela precisa ter dimensões definidas. Tentar indexar uma coluna definida como
col vector
resulta no erro:ERROR: column does not have dimensions
. - Você só pode indexar uma coluna que tenha até 2.000 dimensões. Tentar indexar uma coluna com mais dimensões resulta no erro:
ERROR: column cannot have more than 2000 dimensions for INDEX_TYPE index
ondeINDEX_TYPE
éivfflat
ouhnsw
.
Embora você possa armazenar vetores com mais de 2.000 dimensões, não é possível indexá-los. Você pode usar a redução de dimensionalidade para se ajustar aos limites. Como alternativa, se baseie no particionamento e/ou fragmentação com o Azure Cosmos DB for PostgreSQL para obter um desempenho aceitável sem indexação.
IVFFlat (Arquivo invertido com compactação plana)
ivfflat
é um índice para pesquisa aproximada de ANN (vizinho mais próximo). Esse método usa um índice de arquivo invertido para particionar o conjunto de dados em várias listas. O parâmetro de investigação controla quantas listas são pesquisadas, o que pode melhorar a precisão dos resultados da pesquisa, reduzindo a velocidade de pesquisa.
Se o parâmetro de investigação for definido como o número de listas no índice, todas as listas serão pesquisadas e a pesquisa se tornará uma pesquisa exata do vizinho mais próximo. Nesse caso, o planejador não está usando o índice porque a pesquisa de todas as listas é equivalente a executar uma pesquisa de força bruta em todo o conjunto de dados.
O método de indexação particiona o conjunto de dados em várias listas usando o algoritmo de clustering K-means. Cada lista contém vetores mais próximos de um centro de cluster específico. Durante uma pesquisa, o vetor de consulta é comparado aos centros de cluster para determinar quais listas têm maior probabilidade de conter os vizinhos mais próximos. Se o parâmetro de investigação for definido como 1, somente a lista correspondente ao centro de cluster mais próximo será pesquisada.
Opções de índice
A seleção do valor correto para o número de investigações a serem executadas e os tamanhos das listas podem afetar o desempenho da pesquisa. Algumas opções para começar são:
- Use
lists
igual arows / 1000
para tabelas com até 1 milhão de linhas esqrt(rows)
para conjuntos de dados maiores. - Para
probes
, comece comlists / 10
para tabelas de até 1 milhão de linhas esqrt(lists)
para conjuntos de dados maiores.
A quantidade de lists
é definida após a criação do índice com a opção lists
:
CREATE INDEX t_test_embedding_cosine_idx ON t_test USING ivfflat (embedding vector_cosine_ops) WITH (lists = 5000);
As investigações podem ser definidas para toda a conexão ou por transação (usando SET LOCAL
dentro de um bloco de transação):
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
Progresso da indexação
Com o PostgreSQL 12 ou posterior, você pode usar pg_stat_progress_create_index
para verificar o progresso da indexação.
SELECT phase, round(100.0 * tuples_done / nullif(tuples_total, 0), 1) AS "%" FROM pg_stat_progress_create_index;
Fases para construir os índices IVFFlat são:
initializing
performing k-means
assigning tuples
loading tuples
Observação
Percentual de progresso (%
) somente é preenchido na fase loading tuples
.
HNSW (Hierarchical Navigable Small Worlds)
O hnsw
é um índice para pesquisa de vizinho mais próximo aproximado (ANN) usando o algoritmo HNSW. Ele funciona criando um grafo em torno de pontos de entrada selecionados aleatoriamente, encontrando seus vizinhos mais próximos. O gráfico é então estendido com múltiplas camadas, cada camada inferior contendo mais pontos. Esse gráfico multicamadas, quando pesquisado, começa no topo, estreitando-se até atingir a camada mais baixa que contém os vizinhos mais próximos da consulta.
Construir esse índice leva mais tempo e memória do que o índice IVFFlat, porém tem uma melhor compensação entre velocidade e recuperação. Além disso, não há etapa de treinamento como no IVFFlat, portanto o índice pode ser criado em uma tabela vazia.
Opções de índice
Ao criar um índice, você pode ajustar dois parâmetros:
m
- número máximo de conexões por camada (o padrão é 16)ef_construction
- tamanho da lista dinâmica de candidatos usada para a construção do grafo (o padrão é 64)
CREATE INDEX t_test_hnsw_l2_idx ON t_test USING hnsw (embedding vector_l2_ops) WITH (m = 16, ef_construction = 64);
Durante as consultas, você pode especificar a lista dinâmica de candidatos pra pesquisa (o padrão é 40).
A lista dinâmica de candidatos pode ser definida para toda a conexão ou por transação (usando SET LOCAL
dentro de um bloco de transação):
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
Progresso da indexação
Com o PostgreSQL 12 ou posterior, você pode usar pg_stat_progress_create_index
para verificar o progresso da indexação.
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%" FROM pg_stat_progress_create_index;
Fases para criar os índices HNSW são:
initializing
loading tuples
Seleção da função de acesso ao índice
O tipo vector
permite que você execute três tipos de pesquisas nos vetores armazenados. Você precisa selecionar a função de acesso correta para o índice para que o banco de dados considere seu índice ao executar suas consultas. Os exemplo usam os tipos de índice ivfflat
, mas o mesmo pode ser feito para índices hnsw
. A opção lists
aplica-se somente a índices ivfflat
.
Distância do cosseno
Para pesquisa de similaridade de cosseno, use o método de acesso vector_cosine_ops
.
CREATE INDEX t_test_embedding_cosine_idx ON t_test USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
Para usar o índice acima, a consulta precisa executar uma pesquisa de similaridade de cosseno, que é feita com o operador <=>
.
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)
Distância L2
Para distância L2 (também conhecida como distância euclidiana), use o método de acesso vector_l2_ops
.
CREATE INDEX t_test_embedding_l2_idx ON t_test USING ivfflat (embedding vector_l2_ops) WITH (lists = 100);
Para usar o índice acima, a consulta precisa executar uma pesquisa de distância L2, que é feita com o operador <->
.
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)
Produto interno
Para obter similaridade interna do produto, use o método de acesso vector_ip_ops
.
CREATE INDEX t_test_embedding_ip_idx ON t_test USING ivfflat (embedding vector_ip_ops) WITH (lists = 100);
Para usar o índice acima, a consulta precisa executar uma pesquisa de similaridade de do produto interno, que é feita com o operador <#>
.
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)
Índices parciais
Em alguns cenários, é benéfico ter um índice que abrange apenas um conjunto parcial dos dados. Podemos, por exemplo, criar um índice apenas para nossos usuários Premium:
CREATE INDEX t_premium ON t_test USING ivfflat (vec vector_ip_ops) WITH (lists = 100) WHERE tier = 'premium';
Podemos ver que a camada Premium agora usa o índice:
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)
Enquanto os usuários da camada gratuita não têm o benefício:
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)
Ter apenas um subconjunto de dados indexado significa que o índice ocupa menos espaço no disco e pode ser pesquisado mais rapidamente.
O PostgreSQL pode não reconhecer que o índice é seguro para uso se o formulário usado na cláusula WHERE
da definição de índice parcial não corresponder ao usado em suas consultas.
Em nosso conjunto de dados de exemplo, temos apenas os valores exatos 'free'
, 'test'
e 'premium'
como os valores distintos da coluna de camada. Mesmo com uma consulta usando tier LIKE 'premium'
, o PostgreSQL não está usando o índice.
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)
Particionamento
Uma maneira de melhorar o desempenho é dividir o conjunto de dados em várias partições. Podemos imaginar um sistema onde é natural se referir a dados apenas do ano atual ou talvez dos últimos dois anos. Nesse sistema, você pode particionar seus dados por um intervalo de datas e, em seguida, aproveitar melhor o desempenho quando o sistema puder ler apenas as partições relevantes conforme definido pelo ano consultado.
Vamos definir uma tabela particionada:
CREATE TABLE t_test_partitioned(vec vector(3), vec_date date default now()) partition by range (vec_date);
Podemos criar partições manualmente para cada ano ou usar a função de utilitário Citus (disponível no 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
);
Verifique as partições criadas:
\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')
Para criar manualmente uma partição:
CREATE TABLE t_test_partitioned_p2019 PARTITION OF t_test_partitioned FOR VALUES FROM ('2019-01-01') TO ('2020-01-01');
Em seguida, verifique se as consultas realmente filtram um subconjunto de partições disponíveis. Por exemplo, na consulta abaixo, filtramos duas partições:
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
Você pode indexar uma tabela particionada.
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)
Próximas etapas
Parabéns, você acabou de aprender as compensações, limitações e práticas recomendadas para obter o melhor desempenho com pgvector
.