Teilen über


Optimieren der Leistung bei Verwendung von „pgvector“ in Azure Cosmos DB for PostgreSQL

GILT FÜR: Azure Cosmos DB for PostgreSQL (unterstützt von der Citus-Datenbankerweiterung auf PostgreSQL)

Die Erweiterung pgvector fügt PostgreSQL eine Open-Source-Vektorähnlichkeitssuche hinzu.

In diesem Artikel werden die Einschränkungen und Kompromisse von pgvector untersucht, und es wird gezeigt, wie Sie mithilfe von Partitionierungs-, Indizierungs- und Sucheinstellungen die Leistung verbessern können.

Weitere Informationen zur Erweiterung selbst finden Sie unter Grundlagen von pgvector. Möglicherweise möchten Sie auch auf die offizielle Infodatei des Projekts verweisen.

Leistung

Sie sollten immer mit der Untersuchung des Abfrageplans beginnen. Wenn Ihre Abfrage relativ schnell beendet wird, führen Sie EXPLAIN (ANALYZE,VERBOSE, BUFFERS) aus.

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

Bei Abfragen, deren Ausführung zu lange dauert, sollten Sie das Schlüsselwort ANALYZE löschen. Das Ergebnis enthält weniger Details, wird aber sofort bereitgestellt.

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

Websites von Drittanbietern, wie explain.depesz.com, können beim Verständnis von Abfrageplänen hilfreich sein. Einige Fragen, die Sie beantworten sollten, lauten:

Wenn Ihre Vektoren auf die Länge 1 normalisiert sind, z. B. OpenAI-Einbettungen. Sie sollten die Verwendung des inneren Produkts (<#>) erwägen, um die optimale Leistung zu erzielen.

Parallele Ausführung

Suchen Sie in der Ausgabe Ihres Erklärungsplans nach Workers Planned und Workers Launched (Letzteres nur, wenn das Schlüsselwort ANALYZE verwendet wurde). Der PostgreSQL-Parameter max_parallel_workers_per_gather definiert, wie viele Hintergrundworker die Datenbank für jeden Gather- und Gather Merge-Planknoten starten dürfen. Eine Erhöhung dieses Werts kann Ihre exakten Suchabfragen beschleunigen, ohne dass Sie Indizes erstellen müssen. Beachten Sie jedoch, dass die Datenbank sich möglicherweise gegen eine parallele Ausführung des Plans entscheidet, selbst wenn dieser Wert hoch ist.

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)

Indizierung

Ohne vorhandene Indizes führt die Erweiterung eine exakte Suche durch, die einen perfekten Abruf auf Kosten der Leistung ermöglicht.

Zur Durchführung einer Suche nach dem ungefähr nächsten Nachbarn können Sie Indizes für Ihre Daten erstellen, die Trades für die Ausführungsleistung abrufen.

Laden Sie ihre Daten nach Möglichkeit immer, bevor Sie sie indizieren. Es ist schneller, den Index auf diese Weise zu erstellen, und das resultierende Layout ist optimaler.

Es werden zwei Indextypen unterstützt:

Der IVFFlat-Index kann schneller erstellt werden und benötigt weniger Speicherplatz als HNSW, hat aber eine geringere Abfrageleistung (im Sinne eines Kompromisses zwischen Geschwindigkeit und Abruf).

Grenzwerte

  • Zum Indizieren einer Spalte müssen dafür Dimensionen definiert worden sein. Der Versuch, eine als col vector definierte Spalte zu indizieren, führt zu dem Fehler: ERROR: column does not have dimensions.
  • Sie können nur eine Spalte mit bis zu 2.000 Dimensionen indizieren. Der Versuch, eine Spalte mit mehr Dimensionen zu indizieren, führt zu dem Fehler ERROR: column cannot have more than 2000 dimensions for INDEX_TYPE index, wobei INDEX_TYPE entweder ivfflat oder hnsw ist.

Vektoren mit mehr als 2.000 Dimensionen können Sie zwar speichern, aber nicht indizieren. Sie können die Dimensionalitätsreduzierung verwenden, damit sie innerhalb der Grenzwerte passen. Alternativ können Sie mit Azure Cosmos DB for PostgreSQL partitionieren und/oder horizontal partitionieren, um eine akzeptable Leistung ohne Indizierung zu erzielen.

Inverted File with Flat Compression (IVVFlat)

Der ivfflat ist ein Index für die Suche nach dem ungefähr nächsten Nachbarn (Approximate Nearest Neighbor, ANN). Diese Methode verwendet einen invertierten Dateiindex zum Partitionieren des Datasets in mehrere Listen. Der Testparameter steuert, wie viele Listen durchsucht werden. Dadurch kann die Genauigkeit der Suchergebnisse auf Kosten einer langsameren Suchgeschwindigkeit verbessert werden.

Wenn der Testparameter auf die Anzahl der Listen im Index festgelegt wurde, werden alle Listen durchsucht, und die Suche wird zu einer exakten Suche nach dem nächsten Nachbar. In diesem Fall verwendet der Planer den Index nicht, weil das Durchsuchen aller Listen einer Brute-Force-Suche für das gesamte Dataset entspricht.

Die Indizierungsmethode partitioniert das Dataset mithilfe des Clustering-Algorithmus „k-means“ in mehrere Listen. Jede Liste enthält Vektoren, die einem bestimmten Clusterzentrum am nächsten sind. Während einer Suche wird der Abfragevektor mit den Clusterzentren verglichen, um zu ermitteln, welche Listen höchstwahrscheinlich die nächsten Nachbarn enthalten. Wenn der Testparameter auf „1“ festgelegt wurde, wird nur die Liste gesucht, die dem nächstgelegenen Clusterzentrum entspricht.

Indexoptionen

Die Auswahl des richtigen Werts für die Anzahl der durchzuführenden Tests und die Größe der Listen können die Suchleistung beeinträchtigen. Gute Ausgangspunkte sind die folgenden:

  1. Verwenden Sie lists gleich rows / 1000 bei Tabellen mit bis zu 1 Million Zeilen und sqrt(rows) bei größeren Datasets.
  2. Beginnen Sie für probes mit lists / 10 bei Tabellen mit bis zu 1 Million Zeilen und mit sqrt(lists) bei größeren Datasets.

Die Menge von lists wird bei der Indexerstellung mit der Option lists definiert:

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

Die Tests können für die gesamte Verbindung oder pro Transaktion (mithilfe von SET LOCAL in einem Transaktionsblock) festgelegt werden:

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

Indizierungsfortschritt

Mit PostgreSQL 12 und höher können Sie pg_stat_progress_create_index verwenden, um den Indizierungsfortschritt zu überprüfen.

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

Phasen für die Erstellung von IVFFlat-Indizes sind:

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

Hinweis

Der Statusprozentsatz (%) wird nur während der loading tuples-Phase aufgefüllt.

Hierarchical Navigable Small Worlds (HNSW)

hnsw ist ein Index für die Suche nach dem Approximate Nearest Neighbour (ANN) mit dem Hierarchical Navigable Small Worlds-Algorithmus. Er erstellt einen Graph um zufällig ausgewählte Einstiegspunkte, die ihre nächsten Nachbarn finden. Der Graph wird dann um mehrere Ebenen erweitert, wobei jede niedrigere Ebene mehr Punkte hat. Dieser mehrschichtige Graph beginnt bei der Suche ganz oben und verengt sich nach unten, bis er auf die unterste Schicht trifft, die die nächsten Nachbarn der Suchanfrage enthält.

Das Erstellen dieses Index benötigt mehr Zeit und Speicherplatz als IVFFlat, bietet jedoch ein besseres Verhältnis zwischen Geschwindigkeit und Abruf. Darüber hinaus gibt es keinen Trainingsschritt wie bei IVFFlat, sodass der Index auf einer leeren Tabelle erstellt werden kann.

Indexoptionen

Beim Erstellen des Indexes können Sie zwei Parameter optimieren:

  1. m: Maximale Anzahl von Verbindungen pro Ebene (Standard ist 16)
  2. ef_construction: Größe der dynamischen Kandidatenliste, die für die Grapherstellung verwendet wird (Standardwert ist 64)
CREATE INDEX t_test_hnsw_l2_idx ON t_test USING hnsw (embedding vector_l2_ops) WITH (m = 16, ef_construction = 64);

Während Abfragen können Sie die dynamische Kandidatenliste für die Suche angeben (Standardwert ist 40).

Die dynamische Kandidatenliste für die Suche kann für die gesamte Verbindung oder pro Transaktion (mithilfe von SET LOCAL in einem Transaktionsblock) festgelegt werden:

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

Indizierungsfortschritt

Mit PostgreSQL 12 und höher können Sie pg_stat_progress_create_index verwenden, um den Indizierungsfortschritt zu überprüfen.

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

Phasen für die Erstellung von HNSW-Indizes sind:

  1. initializing
  2. loading tuples

Auswählen der Indexzugriffsfunktion

Mit dem Typ vector können Sie drei Arten von Suchen für die gespeicherten Vektoren durchführen. Sie müssen die richtige Zugriffsfunktion für Ihren Index auswählen, damit die Datenbank Ihren Index bei der Ausführung Ihrer Abfragen berücksichtigt. Die Beispiele verwenden ivfflat-Indextypen, können jedoch für hnsw-Indizes identisch sein. Diese Option lists gilt nur für ivfflat-Indizes.

Kosinusabstand

Verwenden Sie für die Suche nach der Kosinusähnlichkeit die Zugriffsmethode vector_cosine_ops.

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

Zur Verwendung des vorstehenden Index muss die Abfrage eine Suche nach der Kosinusähnlichkeit mithilfe des Operators <=> durchführen.

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)

L2-Abstand

Verwenden Sie für den L2-Abstand (auch als „euklidischer Abstand“ bezeichnet) die Zugriffsmethode vector_l2_ops.

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

Zur Verwendung des vorstehenden Index muss die Abfrage eine Suche nach dem L2-Abstand mithilfe des Operators <-> durchführen.

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)

Inneres Produkt

Verwenden Sie für die innere Produktähnlichkeit die Zugriffsmethode vector_ip_ops.

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

Zur Verwendung des vorstehenden Index muss die Abfrage eine Suche nach der inneren Produktähnlichkeit mithilfe des Operators <#> durchführen.

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)

Partielle Indizes

In einigen Szenarien ist es vorteilhaft, einen Index zu haben, der nur eine Teilmenge der Daten abdeckt. Sie können beispielsweise einen Index nur für Ihre Premium-Benutzer erstellen:

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

Nun können Sie sehen, dass der Premium-Tarif jetzt den Index verwendet:

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)

Während den Benutzern des kostenlosen Tarifs der Vorteil fehlt:

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)

Wenn nur eine Teilmenge der Daten indiziert ist, bedeutet dies, dass der Index weniger Speicherplatz auf dem Datenträger benötigt und schneller durchsucht werden kann.

PostgreSQL erkennt möglicherweise nicht, dass der Index sicher verwendet werden kann, wenn das in der Klausel WHERE der partiellen Indexdefinition verwendete Formular nicht mit dem in Ihren Abfragen verwendeten Formular übereinstimmt. Unser Beispieldataset enthält nur die genauen Werte 'free', 'test' und 'premium' als unterschiedliche Werte der Tarifspalte. Sogar bei einer Abfrage mithilfe von tier LIKE 'premium' verwendet PostgreSQL nicht den 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)

Partitionierung

Eine Möglichkeit zur Leistungsverbesserung besteht darin, das Dataset auf mehrere Partitionen aufzuteilen. Wir können uns ein System vorstellen, bei dem es natürlich ist, sich nur auf Daten aus dem aktuellen Jahr oder vielleicht aus den letzten beiden Jahren zu beziehen. In einem solchen System können Sie Ihre Daten nach einem Datumsbereich partitionieren und dann von der verbesserten Leistung profitieren, wenn das System nur die relevanten Partitionen lesen kann, die durch das abgefragte Jahr definiert wurden.

Definieren Sie jetzt eine partitionierte Tabelle:

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

Sie können Partitionen für jedes Jahr manuell oder mit der Citus-Hilfsprogrammfunktion (verfügbar in Cosmos DB for PostgreSQL) erstellen.

    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
    );

Überprüfen Sie die erstellten Partitionen:

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

So erstellen Sie eine Partition manuell:

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

Stellen Sie dann sicher, dass Ihre Abfragen tatsächlich in eine Teilmenge der verfügbaren Partitionen gefiltert werden. In der folgenden Abfrage haben wir beispielsweise nach zwei Partitionen gefiltert:

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

Sie können eine partitionierte Tabelle indizieren.

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)

Schlussbemerkung

Herzlichen Glückwunsch! Sie haben gerade die Kompromisse, Einschränkungen und bewährten Methoden kennengelernt, um mit pgvector die optimale Leistung zu erzielen.