
RAG avec Ollama et PostgreSQL (pgvector)
Dans mon précédent article sur le RAG, j’utilisais ChromaDB comme vector store. C’était un choix simple, mais dès que l’on veut brancher la recherche sémantique sur une base métier existante, la question se pose : faut-il vraiment ajouter une brique de plus ? La réponse courte est non. PostgreSQL avec l’extension pgvector fait le travail aussi bien, sans service supplémentaire, et en réutilisant tout l’écosystème que vous connaissez déjà.
Cet article part du principe que vous avez un minimum de familiarité avec le RAG (Retrieval-Augmented Generation) : on découpe des documents en morceaux, on les transforme en vecteurs, on les stocke, puis à la question on retrouve les morceaux les plus pertinents et on les donne à un LLM comme contexte. Si ce n’est pas le cas, je vous conseille de lire le premier article de la série avant de continuer.
Pourquoi pgvector ?
Avant de plonger dans le code, il vaut la peine de comprendre ce qui rend pgvector intéressant par rapport aux alternatives comme ChromaDB, Pinecone ou Weaviate.
- Pas de service supplémentaire : votre base PostgreSQL suffit. Vous n’avez pas à orchestrer un container de plus, à le surveiller, à le sauvegarder séparément ou à gérer sa version. Une seule base, une seule source de vérité.
- Indexation HNSW : pgvector implémente l’algorithme HNSW (Hierarchical Navigable Small World), le même type d’index approximatif que celui utilisé par les solutions commerciales. Les performances restent comparables à Pinecone ou Weaviate pour la quasi-totalité des usages.
- SQL standard : vos requêtes vectorielles s’intègrent naturellement aux requêtes métier. Vous pouvez combiner une recherche sémantique avec un
JOIN, unWHERE, unGROUP BYou une agrégation sans écrire une ligne de code supplémentaire. - Filtres hybrides : c’est le grand avantage : combinez la recherche vectorielle et les filtres
WHEREclassiques dans une seule requête. Par exemple “ retrouve les passages les plus proches de ma question mais uniquement dans les documents de catégorie ‘devops’ “.
L’idée générale est simple : si vous avez déjà PostgreSQL en production, ajouter un vector store dédié est une dette technique inutile.
Installation
La première étape est d’activer l’extension. Selon votre installation, deux cas se présentent.
Si vous utilisez l’image officielle pgvector/pgvector, l’extension est déjà compilée :
docker run -e POSTGRES_PASSWORD=pass -p 5432:5432 pgvector/pgvector:pg16 |
Puis, dans la base, on active l’extension une seule fois :
CREATE EXTENSION vector; |
Si vous utilisez une image PostgreSQL standard ou une base installée autrement, il faudra installer l’extension par paquet. Sur Debian/Ubuntu :
sudo apt install postgresql-16-pgvector |
Notez que CREATE EXTENSION ne requiert les droits superuser que la première fois. En production, vous pouvez créer l’extension avec un compte privilégié puis restreindre les droits de votre application par la suite. C’est un détail important si vous suivez le principe du moindre privilège.
Pour vérifier que tout est en ordre :
SELECT extversion FROM pg_extension WHERE extname = 'vector'; |
Création de la table
Avec l’extension active, le type vector(n) devient disponible. On crée alors une table pour stocker les documents et leurs plongements (embeddings) :
CREATE TABLE documents ( |
Deux remarques importantes ici :
- La dimension
vector(768)doit correspondre exactement à celle du modèle d’embedding.nomic-embed-textd’Ollama produit des vecteurs à 768 dimensions. Si vous changez de modèle, vous devrez changer cette dimension, ce qui implique une migration. Choisissez donc votre modèle avant de créer la table, pas après. - Le champ
metadataenJSONB: c’est ce qui vous permet de faire de la recherche hybride. Vous pouvez y stocker n’importe quoi : la catégorie, la source, l’auteur, la date, le chemin du fichier… Toutes ces données seront interrogeables par SQL classique.
Un mot sur le choix de la dimension : plus elle est grande, plus une requête est coûteuse (à la fois en espace et en calcul). Si 768 dimensions suffisent pour la plupart des modèles de taille raisonnable, il existe des modèles en 256 ou 384 dimensions, plus légers mais potentiellement un peu moins précis. Pour un blog ou une doc interne, 768 est un très bon compromis.
Index HNSW
Un index rend la recherche bien plus rapide sur de gros volumes. Sans lui, pgvector fait un scan séquentiel, ce qui est inacceptable au-delà de quelques dizaines de milliers de lignes.
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops); |
Trois points à comprendre :
hnswindique le type d’index approximatif. Il sacrifie une infime précision pour un gain de vitesse considérable. Pour du RAG, ce compromis est parfaitement acceptable.vector_cosine_opsspécifie que l’on compare les vecteurs par similarité cosinus. C’est le cas le plus courant, car normaliser les vecteurs et utiliser le produit scalaire revient au même.- L’alternative est
ivfflat, plus ancien et plus simple, mais moins performant à volume élevé. En 2026, HNSW est le choix par défaut, à moins de vraiment limiter les ressources.
Pour signaler une similarité au-delà d’un seuil, on utilise l’opérateur <=> (distance cosinus). La recherche est ordonnée par cette distance, et on peut filtrer avec un WHERE si besoin.
Le pipeline complet
Avant de regarder le code, visualisons le flux de bout en bout, de l’ingestion du document jusqu’à la réponse finale :
Deux chemins se distinguent clairement :
- Le chemin d’indexation (en haut) : le document brut est découpé en morceaux, chaque morceau est transformé en vecteur, puis stocké dans pgvector. Ce chemin s’exécute une fois par document, à l’ingestion.
- Le chemin d’interrogation (en bas) : la question de l’utilisateur est elle aussi transformée en vecteur, on recherche les morceaux les plus proches, on les concatène en un contexte, puis on demande au LLM de répondre sur cette base.
Cette séparation est la clé du RAG : on règle le problème du “ modèle trop ancien “ ou du “ modèle sans accès à vos données “ en lui donnant, à chaque requête, exactement le contexte dont il a besoin.
Le code Node.js
Venons-en au code. On initialise la connexion PostgreSQL et le client Ollama :
import pg from 'pg'; |
La fonction embed transforme un texte en vecteur via le modèle d’embedding d’Ollama. C’est la pierre angulaire : c’est ce vecteur qui permet la comparaison sémantique.
async function embed(text) { |
La recherche elle-même est une requête SQL. On encode la question, on calcule la distance cosinus (<=>) entre le vecteur de la question et chaque vecteur de la table, et on garde les plus proches :
async function search(query, limit = 5) { |
Détail important : pgvector attend les vecteurs au format JSON, d’où le JSON.stringify(vec). Le 1 - distance transforme une distance (plus petit = mieux) en un score de similarité (plus grand = mieux), ce qui est plus intuitif à afficher ou à loguer.
Enfin, la fonction ask assemble le tout : on retrouve les documents pertinents, on les concatène en contexte, et on demande au LLM de répondre :
async function ask(question) { |
Notez le prompt système : on y précise que la réponse doit s’appuyer sur le contexte fourni. C’est ce qui évite que le LLM “ invente “ une réponse hors contexte (le fameux problème de hallucination partiellement résolu par le RAG).
Inserer des documents
Côté ingestion, on découpe le document en morceaux de taille raisonnable avant de les encoder. Un document trop long donnerait un embedding trop “ dilué “ ; des morceaux trop petits perdraient le contexte local. Le choix classique est 500 caractères avec un chevauchement de 50.
import fs from 'fs'; |
Le chevauchement (chunkOverlap) est important : il garantit qu’une idée qui chevauche deux morceaux ne soit pas coupée en deux sans contexte. Par exemple, si une phrase clé se trouve à cheval sur deux morceaux, chaque côté gardera un petit bout de l’autre, ce qui améliore la qualité de la recherche.
Recherche hybride (vecteur + filtre SQL)
C’est là que pgvector brille par rapport à un vector store dédié. On combine la recherche sémantique avec un filtre SQL tout à fait classique, dans la même requête. Ici, on ne cherche que dans une catégorie donnée :
async function searchByCategory(query, category) { |
Le WHERE metadata->>'category' = $1 filtre d’abord sur la colonne JSONB, puis ORDER BY embedding <=> $2 classe par proximité sémantique. On obtient ainsi des résultats à la fois pertinents sémantiquement et restreints au bon périmètre.
Cela ouvre des possibilités intéressantes : restreindre par utilisateur, par projet, par date, par source… Toutes les contraintes que vous connaissez déjà en SQL s’appliquent à la recherche vectorielle. Inutile de réimplémenter un système d’autorisation dans le vector store.
Performances
Pour se faire une idée du passage à l’échelle, voici des ordres de grandeur avec l’index HNSW sur du matériel classique :
| Volume | Index | Temps de requête |
|---|---|---|
| 10K vecteurs | HNSW | ~5 ms |
| 100K vecteurs | HNSW | ~15 ms |
| 1M vecteurs | HNSW | ~50 ms |
Pour un blog technique ou une doc interne, 10–50 ms c’est plus que suffisant. Même à un million de vecteurs, la latence reste invisible pour l’utilisateur, surtout quand on la compare au temps de génération du LLM, qui se compte en secondes.
Si vous atteignez plusieurs millions de vecteurs, vous pouvez encore optimiser : partitionner la table, jouer sur les paramètres HNSW (m, ef_construction dans la définition d’index, ef_search dans la requête), ou passer sur un matériel plus rapide. Pour la vaste majorité des projets, ce n’est pas nécessaire.
Pourquoi j’ai abandonné ChromaDB
Ce choix mérite un mot d’explication, car ChromaDB est une excellente bibliothèque. Mon besoin était de disposer d’une recherche vectorielle intégrée à une base que je possédais déjà.
- pgvector tient dans PostgreSQL, ChromaDB est un service à part
- pgvector permet les backups PostgreSQL standards, ChromaDB a son propre format
- pgvector s’intègre aux ORM (Prisma, TypeORM), pas besoin de deux couches d’accès
Concrètement, abandonner ChromaDB m’a permis de supprimer un container, de centraliser la sauvegarde sur le mécanisme PostgreSQL classique (environnement de sauvegarde, PITR, réplication) et de faire des requêtes croisées entre données vectorielles et métier sans sortir de la base.
Si vous avez déjà PostgreSQL, pgvector est un choix plus simple et plus maintenable qu’un vector store dédié. La simplicité opérationnelle l’emporte largement sur la sophistication d’un outil séparé, tant que vos volumes restent dans des ordres de grandeur raisonnables.
Pour aller plus loin
Quelques pistes si vous voulez approfondir :
- Normalisation des embeddings : pré-normalisez vos vecteurs et utilisez le produit scalaire (
<#>) au lieu du cosinus pour de meilleures performances d’index. - Quantification : pgvector supporte la quantification (par ex.
halfvec) pour réduire l’empreinte mémoire des gros volumes. - Mises à jour incrémentales : ré-encoder seulement les documents modifiés plutôt que tout le corpus.
- Surveillance : loguez les scores pour détecter les recherches qui retournent de mauvais résultats et ajuster
chunkSizeen conséquence.
Le RAG avec ollama + pgvector est un socle solide, local et auto-hébergeable, idéal pour un blog technique, une documentation interne ou un assistant à base de connaissances privées.