Optimiser la disposition des données

Effectué

Les décisions de modélisation des données affectent considérablement les performances de recherche vectorielle. La façon dont vous structurez des tables, choisissez des types de données pour les métadonnées et créez des index de prise en charge détermine si les requêtes s’exécutent efficacement à mesure que votre jeu de données augmente.

Note

Les exemples de code de cette unité illustrent des modèles de conception de schéma pour les données vectorielles avec des métadonnées. Adaptez ces modèles à vos besoins spécifiques en matière de modèle de données et de requête.

Considérations relatives au stockage vectoriel

Les colonnes vectorielles consomment des ressources de stockage et de traitement substantielles. Comprendre les caractéristiques de stockage vous aide à prendre des décisions éclairées sur la conception du schéma.

Chaque dimension vectorielle ajoute 4 octets de stockage (pour float à précision unique) ainsi qu’une surcharge fixe. La relation entre les dimensions et le stockage est linéaire :

Taille Octets par vecteur 1 million de vecteurs
384 ~1,5 Ko ~1,5 Go
768 ~3 Ko ~3 Go
1536 ~6 Ko ~6 Go
3 072 ~12 Ko ~12 Go

Pour un catalogue de produits avec deux millions d’éléments utilisant des incorporations de 1536 dimensions, la colonne vectorielle seule nécessite environ 12 Go de stockage. L'ajout d'index HNSW augmente cela d'environ 50%.

De nombreux modèles incorporés offrent plusieurs options de dimension. Les dimensions inférieures réduisent les coûts de stockage et de calcul tout en conservant une qualité raisonnable pour de nombreux cas d’usage. La spécification de dimensions dans la définition de colonne fournit une validation. Les tentatives d'insertion de vecteurs avec des dimensions différentes échouent avec une erreur, ce qui empêche les bogues subtils liés à l'incompatibilité des modèles d'intégration. Définissez votre table avec une contrainte de dimension explicite à l’aide embedding vector(768) de la définition de colonne.

Certaines applications ont besoin de vecteurs provenant de différents modèles. Par exemple, vous pouvez stocker des incorporations de titres de produit, des incorporations d’images et des incorporations de comportement utilisateur séparément. Chaque colonne vectorielle a besoin de son propre index, car vous ne pouvez pas créer un seul index qui couvre plusieurs colonnes vectorielles.

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    title_embedding vector(768),      -- Text embedding model
    image_embedding vector(512),       -- Image embedding model
    category_id INTEGER,
    price NUMERIC(10,2)
);

-- Create separate indexes for each embedding type
CREATE INDEX ON products USING hnsw (title_embedding vector_cosine_ops);
CREATE INDEX ON products USING hnsw (image_embedding vector_cosine_ops);

Types de données de métadonnées : colonnes structurées et JSONB

Les recommandations de produit utilisent rarement la similarité vectorielle seule. Les requêtes filtrent généralement par catégorie, plage de prix, disponibilité ou autres attributs avant ou parallèlement à la recherche vectorielle. La façon dont vous stockez ces métadonnées affecte les performances des requêtes.

Les colonnes structurées utilisent les types de données natifs de PostgreSQL (INTEGER, TIMESTAMP, NUMERIC, TEXT) avec un schéma explicite. Ces colonnes offrent des avantages en matière de performances des requêtes, car les types natifs permettent des index B-tree efficaces pour les requêtes d’égalité et de plage, l’efficacité du stockage grâce à des formats de stockage optimisés, la sécurité des types via la validation au moment de l’insertion et l’optimisation des requêtes par le biais de statistiques de planificateur précises. Utilisez des colonnes structurées lorsque les attributs sont connus au moment du design, vous filtrez ou triez fréquemment par attributs spécifiques, ou les performances des requêtes sont essentielles.

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    embedding vector(1536),
    category_id INTEGER NOT NULL,
    price NUMERIC(10,2) NOT NULL,
    in_stock BOOLEAN DEFAULT true,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    brand TEXT,
    rating NUMERIC(2,1)
);

JSONB stocke des données semi-structurées en tant que JSON binaire, offrant une flexibilité pour les attributs dynamiques. JSONB offre une flexibilité de schéma (différents produits peuvent avoir différents attributs), une évolution facile (ajouter de nouveaux attributs sans migrations de schémas) et des structures imbriquées (stocker des données hiérarchiques complexes). Toutefois, JSONB a une surcharge de requête (l'extraction de valeurs nécessite une analyse), des limitations des index (les index GIN fonctionnent pour les requêtes de contenance, mais pas pour les requêtes d'intervalle) et une incertitude du planificateur (les statistiques sont moins précises pour le contenu JSONB).

Pour les recherches vectorielles filtrées, les performances du filtre de métadonnées affectent directement le temps total de requête. Les colonnes structurées avec des index B-tree permettent à PostgreSQL de réduire rapidement les candidats avant les calculs de distance vectorielle, tandis que JSONB nécessite différents modèles de requête et types d’index. Un filtre de colonne structuré tel que WHERE category_id = 5 AND price BETWEEN 100 AND 500 peut utiliser un index B-tree sur (category_id, price) pour identifier rapidement les lignes candidates. Un filtre JSONB tel que WHERE attributes @> '{"category": "electronics"}' AND (attributes->>'price')::numeric BETWEEN 100 AND 500 nécessite un index GIN (qui n'aide pas pour les requêtes de plage sur le prix) ou une analyse séquentielle de la colonne JSONB.

De nombreuses applications tirent parti de la combinaison de colonnes structurées et JSONB : utilisez des colonnes structurées pour les attributs fréquemment filtrés où les performances des requêtes sont importantes et JSONB pour les attributs dynamiques ou rarement filtrés où la flexibilité du schéma est plus précieuse. Ce modèle vous permet d’optimiser le cas courant sans sacrifier la flexibilité pour les cas de périphérie.

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    embedding vector(1536),
    -- Structured columns for common filters
    category_id INTEGER NOT NULL,
    price NUMERIC(10,2) NOT NULL,
    in_stock BOOLEAN DEFAULT true,
    -- JSONB for dynamic attributes
    attributes JSONB DEFAULT '{}'
);

Index de métadonnées pour les recherches filtrées

Les index de métadonnées complètent les index vectoriels en accélérant la phase de filtrage des requêtes. Sans index de métadonnées appropriés, PostgreSQL peut avoir besoin d’analyser toutes les lignes pour appliquer des filtres avant la recherche vectorielle.

Créez des index B-tree sur des colonnes utilisées dans les clauses WHERE. Les index à colonne unique gèrent les correspondances exactes, tandis que les index composites gèrent les combinaisons de filtres. Les index composites sont les plus efficaces lorsque les requêtes filtrent sur les colonnes les plus à gauche. Un index sur le (category_id, price) gère efficacement WHERE category_id = 5 et WHERE category_id = 5 AND price < 100, mais il n’aide pas avec WHERE price < 100 à lui seul, car le prix n’est pas la colonne la plus à gauche.

-- Single-column index for exact matches
CREATE INDEX idx_products_category ON products (category_id);

-- Composite index for common filter combinations
CREATE INDEX idx_products_category_price ON products (category_id, price);

Si la plupart des requêtes filtrent sur la même condition (par exemple, les produits en stock), un index partiel réduit la taille de l’index et améliore les performances. Cet index est inférieur à un index complet et est utilisé uniquement pour les requêtes qui incluent WHERE in_stock = true. Pour un moteur de recommandation de commerce électronique où presque toutes les requêtes ciblent des produits disponibles, cela peut réduire considérablement la surcharge de maintenance des index. Créez un index partiel avec CREATE INDEX idx_products_instock_category ON products (category_id) WHERE in_stock = true;.

Si vous utilisez JSONB pour les attributs, les index GIN prennent en charge les requêtes de confinement à l’aide des opérateurs @> (contient), <@ (contenus par), ? (clé existe) et ?|/?& (n'importe quelle/toutes les clés existent). Ils n’accélèrent pas les requêtes de plage ou les expressions de chemin JSON arbitraires. Créez un index GIN avec CREATE INDEX idx_products_attributes ON products USING gin (attributes);. Pour les champs JSONB fréquemment interrogés qui ont besoin de requêtes de plage, tenez compte des index d’expression. Créez un index d’expression sur un champ JSONB extrait sous forme numérique avec CREATE INDEX idx_products_json_price ON products (((attributes->>'price')::numeric)); pour activer les requêtes de plage sur ce champ.

Combiner la recherche vectorielle avec des filtres de métadonnées

PostgreSQL exécute des requêtes en combinant les analyses d’index avec le filtrage. Comprendre les modèles d’exécution vous aide à écrire des requêtes efficaces.

Le modèle le plus efficace applique d’abord les filtres de métadonnées, ce qui réduit l’ensemble de vecteurs nécessitant des calculs de similarité. PostgreSQL utilise des index de métadonnées pour identifier les produits correspondant aux filtres, puis applique la similarité vectorielle uniquement à ces candidats. Si 5% de produits correspondent aux filtres, vous recherchez 100 000 vecteurs au lieu de 2 millions.

-- Efficient: filter narrows candidates before vector search
SELECT id, name, embedding <=> $1 AS distance
FROM products
WHERE category_id = 5
  AND in_stock = true
  AND price BETWEEN 100 AND 500
ORDER BY embedding <=> $1
LIMIT 10;

Permet EXPLAIN ANALYZE de vérifier que les requêtes utilisent des index attendus et identifient les goulots d’étranglement des performances. Le plan de requête indique si PostgreSQL utilise vos index de métadonnées pour filtrer les candidats avant la recherche vectorielle ou s’il utilise des analyses séquentielles qui examinent chaque ligne. Recherchez Index Scan ou Bitmap Index Scan sur les colonnes de métadonnées (efficace), Index Scan à l’aide de l’index vectoriel (efficace) et Seq Scan sur de grandes tables (potentiellement inefficace). Si vous voyez des analyses séquentielles inattendues, vérifiez que les index appropriés existent et que les statistiques sont actuelles à l’aide ANALYZE products;.

Certaines requêtes ne se prêtent pas à un filtrage efficace. Lorsque les filtres n’éliminent pas beaucoup de lignes (comme celles WHERE price > 0 qui correspondent à presque tous les produits), PostgreSQL peut ignorer entièrement les index de métadonnées et s’appuyer uniquement sur l’index vectoriel. Ce comportement est attendu, car l’optimiseur prend des décisions basées sur les coûts.

Parfois, vous avez besoin de résultats à partir de l’index vectoriel qui répondent également à des contraintes qui ne peuvent pas être filtrées efficacement au préalable. Le modèle de post-filtrage extrait plus de candidats similaires à vecteurs que nécessaire, puis applique des filtres. Ajustez la limite interne en fonction de la sélectivité de filtre attendue.

-- Get more candidates than needed, then filter
WITH candidates AS (
    SELECT id, name, price, in_stock, embedding <=> $1 AS distance
    FROM products
    ORDER BY embedding <=> $1
    LIMIT 100
)
SELECT id, name, distance
FROM candidates
WHERE in_stock = true AND price BETWEEN 100 AND 500
ORDER BY distance
LIMIT 10;

Partitionnement de tables pour les jeux de données volumineux

Le partitionnement divise une grande table en éléments plus petits et plus gérables. Pour les charges de travail vectorielles, le partitionnement peut améliorer les performances des requêtes et simplifier la maintenance.

Envisagez de partitionner lorsque les tables dépassent des dizaines de millions de lignes, que les requêtes filtrent naturellement par clé de partition (date, catégorie, locataire), que vous devez supprimer efficacement les anciennes données (élagage des partitions) ou que les temps de génération d'index deviennent prohibitifs sur la table complète.

Pour les applications qui traitent les incorporations de séries chronologiques (telles que les vecteurs d’activité utilisateur ou le contenu publié au fil du temps), le partitionnement de plage par date est effectif. Requêtes qui filtrent par date analysent uniquement les partitions pertinentes. Chaque partition a ses propres index, ce qui rend la maintenance plus gérable.

-- Create partitioned table
CREATE TABLE user_interactions (
    id BIGSERIAL,
    user_id BIGINT NOT NULL,
    embedding vector(768),
    created_at TIMESTAMP NOT NULL,
    interaction_type TEXT
) PARTITION BY RANGE (created_at);

-- Create partitions for each month
CREATE TABLE user_interactions_2025_01
    PARTITION OF user_interactions
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

Pour les applications multilocataires ou les catalogues de produits avec des catégories naturelles, une liste ou le partitionnement par hachage peut vous aider. Les requêtes filtrées par catégorie analysent uniquement la partition appropriée, ce qui réduit la taille des données analysées et de l’index.

Créez des index sur la table parente pour créer automatiquement des index correspondants à l'aide de CREATE INDEX ON products USING hnsw (embedding vector_cosine_ops); sur toutes les partitions. Chaque partition a son propre index, qui peut être généré ou reconstruit indépendamment. Cela est utile pour les jeux de données volumineux où la reconstruction d’un seul index global prend des heures.

Le partitionnement ajoute de la complexité. Les requêtes qui s’étendent sur de nombreuses partitions peuvent être plus lentes que sur une seule table. Les contraintes uniques entre partitions nécessitent la clé de partition dans la contrainte. La logique d’application peut avoir besoin de connaître les limites de partition. Déterminez si vos modèles de requête s’alignent sur les clés de partition potentielles avant d’implémenter le partitionnement.

Ressources supplémentaires