Comment implémenter la recherche vectorielle dans Postgres
Implémentez la recherche vectorielle dans Postgres avec pgvector : ajoutez des embeddings, interrogez par distance cosinus, indexez en HNSW ou IVFFlat, puis combinez avec la recherche plein texte.
pgvector ajoute un type de colonne vector à Postgres, et une requête de similarité se résume à un ORDER BY sur un opérateur de distance suivi d’un LIMIT.
Le sujet surgit généralement après qu’une recherche dans le centre d’aide n’a rien renvoyé pour « annuler mon abonnement » parce que l’article s’intitule « Mettre fin à votre forfait », et que quelqu’un propose de déployer une base de données vectorielle managée à côté du Postgres que vous exploitez déjà. Ce second service est rarement nécessaire. La suite de cet article présente l’intégralité du parcours en SQL : activation de l’extension, dimensionnement de la colonne selon votre modèle d’embedding, stockage des vecteurs, interrogation par distance cosinus, indexation avec HNSW ou IVFFlat, jointure des résultats vectoriels avec des tables ordinaires, et enfin les requêtes pour lesquelles la recherche vectorielle est le mauvais outil et où la recherche plein texte de Postgres doit prendre le relais.
Points clés à retenir
- Le nombre indiqué dans
vector(n)doit correspondre exactement à la longueur des vecteurs produits par votre modèle d’embedding, et les vecteurs issus de modèles différents ne peuvent pas être comparés de manière pertinente. - L’opérateur
<=>renvoie la distance cosinus : un tri ascendant place donc la correspondance la plus proche en premier ; soustrayez le résultat de 1 lorsque vous avez besoin de la similarité cosinus. - HNSW et IVFFlat sont tous deux des index approximatifs ; la recherche exacte est ce que vous obtenez en l’absence d’index vectoriel sur la colonne.
- Le planificateur ne considère un index vectoriel que si la requête comporte un
ORDER BYportant directement sur un opérateur de distance, en ordre ascendant, avec unLIMIT. - La recherche vectorielle trouve les lignes qui signifient la même chose ; la recherche plein texte trouve les lignes qui contiennent les mêmes mots, et une recherche en production exécute généralement les deux avant de fusionner les listes classées.
À quoi sert la recherche vectorielle ?
La recherche vectorielle s’appuie sur le sens : une requête pour « annuler mon abonnement » peut donc renvoyer un document intitulé « Mettre fin à votre forfait » alors même que les deux ne partagent aucun mot. Chaque fragment de texte est converti par un modèle d’embedding en une liste de nombres de longueur fixe, et les textes de sens voisin se retrouvent proches les uns des autres dans cet espace. Rechercher revient à trouver les vecteurs stockés les plus proches du vecteur de la requête. Pour comprendre le fonctionnement des embeddings, consultez Vector Databases Explained.
Le procédé s’avère payant dans trois cas : la recherche sur site qui tolère la paraphrase, la mise en correspondance des tickets de support (retrouver les tickets antérieurs similaires à celui-ci) et la récupération pour le RAG, où les documents les plus proches sont fournis comme contexte à un modèle de langage, comme l’explique l’introduction au RAG pour les applications web.
Activer pgvector et ajouter une colonne vectorielle
pgvector s’active une fois par base de données avec CREATE EXTENSION vector, et le type de colonne est vector(n), où n désigne le nombre de dimensions. La version 0.8.6 prend en charge Postgres 13 et les versions ultérieures, mais Postgres 13 n’est plus supporté par la communauté : la version 14 ou plus récente constitue donc le plancher réaliste.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
embedding vector(1536) -- replace 1536 with your embedding model's output size
);
-- or, on a table you already have:
ALTER TABLE documents ADD COLUMN embedding vector(1536);
Le nombre indiqué dans vector(n) doit correspondre exactement à la longueur des vecteurs produits par votre modèle d’embedding. Ainsi, text-embedding-3-small d’OpenAI renvoie par défaut des vecteurs à 1536 dimensions. Les vecteurs issus de modèles différents ne peuvent pas être comparés de manière pertinente : changer de modèle implique donc de recalculer les embeddings de toute la colonne.
Stocker les embeddings de n’importe quel modèle
Les embeddings s’écrivent comme n’importe quelle autre valeur de colonne : générez le vecteur dans le code applicatif, puis transmettez-le comme paramètre lié converti en vector. pgvector n’appelle aucun modèle à votre place, et tout langage disposant d’un pilote Postgres fait l’affaire.
INSERT INTO documents (title, body, embedding)
VALUES ($1, $2, $3::vector);
-- re-embed an existing row without creating a duplicate
INSERT INTO documents (id, title, body, embedding)
VALUES ($1, $2, $3, $4::vector)
ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding;
Pour un remplissage initial, pgvector recommande de charger les données en masse avec COPY ... FROM STDIN WITH (FORMAT BINARY) et de construire les index une fois les données en place plutôt qu’avant.
Quel opérateur de distance pgvector choisir ?
Utilisez <=> pour les embeddings textuels, sauf indication contraire dans la documentation de votre modèle. Il renvoie la distance cosinus : un tri ascendant place donc la correspondance la plus proche en premier ; soustrayez le résultat de 1 lorsque vous avez besoin de la similarité cosinus pour l’affichage.
-- five closest documents; $1 is the query embedding
SELECT id, title, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 5;
pgvector prend en charge six opérateurs de distance au total, et chacun requiert une classe d’opérateurs d’index correspondante :
| Opérateur | Mesure | À utiliser quand | Classe d’opérateurs |
|---|---|---|---|
<=> | distance cosinus | embeddings textuels ; la valeur par défaut habituelle | vector_cosine_ops |
<-> | distance L2 (euclidienne) | la magnitude porte du sens | vector_l2_ops |
<#> | produit scalaire négatif | vecteurs déjà normalisés à une longueur de 1 | vector_ip_ops |
<+> | distance L1 (de Manhattan) | rarement pour le texte ; HNSW uniquement | vector_l1_ops |
<#> renvoie le produit scalaire avec le signe inversé. Postgres ne parcourt les index qu’en ordre ascendant : la forme négative garantit donc que le plus petit nombre corresponde à la correspondance la plus proche ; multipliez par -1 pour retrouver le produit scalaire brut. Si vos vecteurs sont déjà normalisés à une longueur de 1, le produit scalaire est l’option la plus rapide pour une recherche exacte. <~> (Hamming) et <%> (Jaccard) existent pour les vecteurs binaires et sortent du cadre de cet article.
Faut-il indexer avec HNSW ou IVFFlat ?
Sans index, pgvector compare le vecteur de la requête à chaque ligne et renvoie les plus proches voisins exacts. L’ajout d’un index HNSW ou IVFFlat bascule vers une recherche approximative, bien plus rapide, qui trouve la majorité des voisins réels et peut renvoyer des lignes différentes de celles de la requête exacte. Les deux types d’index sont approximatifs. La recherche exacte est simplement ce qui se produit lorsque la colonne ne porte aucun index vectoriel.
Optez pour HNSW par défaut, sauf si le temps de construction ou la mémoire vous imposent IVFFlat. La classe d’opérateurs doit correspondre à l’opérateur utilisé dans vos requêtes.
-- production: avoid blocking writes
CREATE INDEX CONCURRENTLY documents_embedding_hnsw
ON documents USING hnsw (embedding vector_cosine_ops);
-- IVFFlat alternative; create only after the table has data
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
| HNSW | IVFFlat | |
|---|---|---|
| Structure | graphe à plusieurs couches | vecteurs répartis dans des listes |
| Compromis vitesse/rappel | meilleur | moins bon |
| Temps de construction et mémoire | plus lent, plus gourmand | plus rapide, plus léger |
| Construction sur table vide | oui, aucune étape d’entraînement | non ; les centroïdes proviennent des données présentes au moment de la construction |
| Paramètre de requête | hnsw.ef_search (40 par défaut) | ivfflat.probes (1 par défaut ; commencez à sqrt(lists)) |
HNSW ne comporte aucune étape d’entraînement : l’index peut donc être construit avant qu’une seule ligne n’atterrisse dans la table. IVFFlat en comporte une : ses listes découlent des données présentes au moment de la construction de l’index, il faut donc lui fournir au préalable un échantillon représentatif de lignes. Augmenter ef_search ou probes avec SET LOCAL à l’intérieur d’une transaction améliore le rappel pour une requête donnée, au détriment de la vitesse.
Le planificateur ne considérera un index vectoriel que si la requête comporte un ORDER BY portant directement sur un opérateur de distance, trié en ordre ascendant, accompagné d’un LIMIT ; ORDER BY 1 - (embedding <=> $1) DESC ne l’utilisera pas. Sur une petite table, le planificateur peut malgré tout préférer un parcours séquentiel : vérifiez avec EXPLAIN. Pour mesurer ce que l’index vous a coûté en rappel, forcez une recherche exacte et comparez les deux jeux de résultats :
BEGIN;
SET LOCAL enable_indexscan = off; -- exact search for comparison
SELECT id FROM documents ORDER BY embedding <=> $1::vector LIMIT 5;
COMMIT;
Avez-vous besoin d’une base de données vectorielle distincte ?
Si vous exploitez déjà Postgres, vous n’avez probablement pas besoin d’une base de données vectorielle séparée : pgvector vous offre la recherche vectorielle avec les mêmes sauvegardes, les mêmes transactions, et la possibilité de joindre les résultats vectoriels à des tables classiques dans une seule requête. Un document et son embedding s’écrivent dans un unique INSERT : il n’y a donc ni pipeline de synchronisation entre deux systèmes de stockage, ni scénario d’échec impliquant des vecteurs orphelins. Les vecteurs transitent par le journal des écritures anticipées (WAL) comme n’importe quelle autre colonne : les réplicas et les restaurations à un instant donné (PITR) les prennent en compte sans travail supplémentaire.
C’est la jointure qui rend cet avantage concret. Trouver les tickets de support les plus proches, en se limitant aux clients entreprise, tient en une seule instruction :
SELECT t.id, t.subject, c.plan, t.embedding <=> $1::vector AS distance
FROM tickets t
JOIN customers c ON c.id = t.customer_id
WHERE c.plan = 'enterprise'
ORDER BY t.embedding <=> $1::vector
LIMIT 10;
Les index approximatifs comportent une subtilité : l’index est parcouru en premier, puis la clause WHERE est appliquée aux lignes ainsi renvoyées ; un filtre sélectif peut donc vous laisser avec moins de lignes que ce que demandait le LIMIT. Un index B-tree sur la colonne de filtrage donne souvent des résultats exacts rapidement ; les parcours itératifs et les index partiels couvrent le reste.
Les bases de données vectorielles dédiées gardent leur raison d’être à l’échelle du milliard de vecteurs ou face à des débits d’écriture extrêmes. En deçà, le service supplémentaire représente un coût opérationnel sans bénéfice visible pour l’utilisateur.
Les limites de la recherche vectorielle : la recherche hybride avec le plein texte
La recherche vectorielle trouve les lignes qui signifient la même chose ; la recherche plein texte trouve les lignes qui contiennent les mêmes mots, et une recherche en production exécute généralement les deux avant de fusionner les deux listes classées. Les identifiants exacts, les codes produits, les chaînes d’erreur et les noms de personnes n’ont aucun « sens » exploitable pour un modèle d’embedding, et une requête sur SKU-4471 doit renvoyer la ligne contenant ce jeton, et non des lignes portant sur des produits similaires. Ces requêtes appellent la recherche plein texte de Postgres, que la documentation de pgvector associe à la recherche vectorielle pour les requêtes hybrides.
Commencez par une colonne tsvector générée et stockée accompagnée d’un index GIN :
ALTER TABLE documents
ADD COLUMN body_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX ON documents USING gin (body_tsv);
Combinez ensuite les deux classements avec la fusion par rang réciproque (Reciprocal Rank Fusion), en suivant la structure de l’exemple RRF fourni par pgvector :
-- $1 = query embedding, $2 = raw query text
WITH semantic AS (
SELECT id, RANK() OVER (ORDER BY embedding <=> $1::vector) AS rnk
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 20
),
lexical AS (
SELECT id, RANK() OVER (ORDER BY ts_rank_cd(body_tsv, q) DESC) AS rnk
FROM documents, plainto_tsquery('english', $2) AS q
WHERE body_tsv @@ q
ORDER BY ts_rank_cd(body_tsv, q) DESC
LIMIT 20
)
SELECT COALESCE(s.id, l.id) AS id,
COALESCE(1.0 / (60 + s.rnk), 0.0) + COALESCE(1.0 / (60 + l.rnk), 0.0) AS score
FROM semantic s
FULL OUTER JOIN lexical l ON l.id = s.id
ORDER BY score DESC
LIMIT 10;
Chaque liste contribue à hauteur de 1 / (k + rang) : un document en tête de l’une ou l’autre liste obtient donc un bon score, et celui présent dans les deux obtient le meilleur. La constante k = 60 provient des travaux de Cormack, Clarke et Büttcher, qui l’ont fixée lors d’une étude pilote en indiquant que sa valeur exacte n’est pas déterminante. Considérez cette requête comme une illustration et vérifiez avec EXPLAIN (ANALYZE, BUFFERS) que les index HNSW et GIN sont bien utilisés sur vos données.
Par où commencer
L’implémentation complète tient en une colonne, un opérateur et un index, le tout à l’intérieur de la base de données que vous sauvegardez et répliquez déjà. Ajoutez la colonne vector(n) dimensionnée selon votre modèle, écrivez d’abord la requête ORDER BY ... <=> ... LIMIT sans index, puis ajoutez HNSW et mesurez l’écart de rappel avec enable_indexscan = off. Une fois les résultats sémantiques satisfaisants, branchez le volet plein texte, car le premier utilisateur à coller un numéro de commande mettra sinon la lacune en évidence.
FAQ
pgvector peut-il indexer des embeddings de plus de 2 000 dimensions ?
Pas avec le type vector standard seul. HNSW et IVFFlat indexent les colonnes vectorielles jusqu'à 2 000 dimensions, alors que la colonne elle-même en stocke jusqu'à 16 000. Pour des embeddings plus volumineux, convertissez en halfvec dans un index d'expression (indexable jusqu'à 4 000 dimensions), utilisez la quantification binaire avec réordonnancement (jusqu'à 64 000), indexez un sous-vecteur, ou demandez moins de dimensions en sortie au modèle, ce que les modèles text-embedding-3 d'OpenAI permettent via un paramètre dimensions.
Dois-je reconstruire un index pgvector après l'insertion de nouvelles lignes ?
Pas pour HNSW : les nouvelles lignes sont ajoutées au graphe au fil des insertions, ce qui explique que l'index puisse être construit sur une table vide. IVFFlat se comporte différemment. Les centroïdes de ses listes sont calculés une seule fois par k-means au moment de la construction et ne bougent jamais : le rappel peut donc se dégrader à mesure que la table grossit ou que sa distribution évolue. Reconstruisez les index IVFFlat après des chargements massifs ou des changements de distribution avec REINDEX INDEX CONCURRENTLY.
Pourquoi une requête renvoie-t-elle moins de lignes que le LIMIT après l'ajout d'un index HNSW ?
Parce qu'un parcours HNSW renvoie au plus hnsw.ef_search candidats, soit 40 par défaut, et que tout filtre WHERE est appliqué ensuite à ces candidats. Un LIMIT supérieur à 40, un filtre sélectif ou des tuples morts peuvent tous aboutir à un résultat incomplet. Augmentez hnsw.ef_search avec SET LOCAL, ou, à partir de pgvector 0.8.0, activez les parcours d'index itératifs en définissant hnsw.iterative_scan à strict_order afin que le parcours se poursuive jusqu'à ce qu'un nombre suffisant de lignes corresponde.
Puis-je stocker des embeddings provenant de deux modèles différents dans la même table ?
Oui, mais pas dans une colonne indexée commune. Utilisez une colonne vector(n) distincte par modèle, chacune dimensionnée selon la sortie de ce modèle et dotée de son propre index. pgvector autorise également une colonne vector non typée contenant des dimensions mixtes, mais un index ne peut couvrir que des lignes d'une seule dimension, construit sous forme d'index d'expression avec une conversion assortie d'une clause WHERE partielle sur l'identifiant du modèle. Les distances entre modèles n'ont aucun sens.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k