Aller au contenu principal
GiwiSoft
Pgvector

RAG avec Ollama et PostgreSQL (pgvector)

Giwi 11 min de lecture DevOps, IA

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, un WHERE, un GROUP BY ou 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 WHERE classiques 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 (
id SERIAL PRIMARY KEY,
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
embedding vector(768) -- dimension selon votre modèle
);

Deux remarques importantes ici :

  1. La dimension vector(768) doit correspondre exactement à celle du modèle d’embedding. nomic-embed-text d’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.
  2. Le champ metadata en JSONB : 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 :

  • hnsw indique 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_ops spé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 :

graph LR A[Document] --> B[Chunker] B --> C[Ollama embeddings] C --> D[pgvector] D --> E[Recherche] F[Question] --> G[Ollama embeddings] G --> E E --> H[Contexte + question] H --> I[Ollama LLM] I --> J[Réponse]

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';
import ollama from 'ollama';

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

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) {
const res = await ollama.embeddings({ model: 'nomic-embed-text', prompt: text });
return res.embedding;
}

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) {
const vec = await embed(query);
const result = await pool.query(
`SELECT content, 1 - (embedding <=> $1) AS score
FROM documents
ORDER BY embedding <=> $1
LIMIT $2`,
[JSON.stringify(vec), limit]
);
return result.rows;
}

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) {
const docs = await search(question);
const context = docs.map(d => d.content).join('\n\n');
const res = await ollama.chat({
model: 'llama3',
messages: [
{ role: 'system', content: `Réponds en français en t'appuyant sur ce contexte :\n\n${context}` },
{ role: 'user', content: question },
],
});
return res.message.content;
}

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';
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';

const text = fs.readFileSync('documentation.md', 'utf-8');
const splitter = new RecursiveCharacterTextSplitter({ chunkSize: 500, chunkOverlap: 50 });
const chunks = await splitter.createDocuments([text]);

for (const chunk of chunks) {
const vec = await embed(chunk.pageContent);
await pool.query(
'INSERT INTO documents (content, metadata, embedding) VALUES ($1, $2, $3)',
[chunk.pageContent, JSON.stringify(chunk.metadata), JSON.stringify(vec)]
);
}

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) {
const vec = await embed(query);
return pool.query(
`SELECT content FROM documents
WHERE metadata->>'category' = $1
ORDER BY embedding <=> $2
LIMIT 10`,
[category, JSON.stringify(vec)]
);
}

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 :

VolumeIndexTemps de requête
10K vecteursHNSW~5 ms
100K vecteursHNSW~15 ms
1M vecteursHNSW~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 chunkSize en 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.