SSD Nodes Learn 🎉 VPS dès $5.50/mois
Guides Matt ConnorPar Matt Connor

Installer un pipeline RAG sur votre propre VPS

Apprenez à découper, vectoriser et rechercher vos documents sur un seul VPS avec pgvector, un index HNSW, Ollama et le SQL qui vérifie la récupération.

À quoi ressemble un pipeline RAG auto-hébergé

Un pipeline RAG (retrieval augmented generation) comporte cinq étapes : découper les documents en chunks, calculer les embeddings des chunks, stocker les vecteurs, récupérer les chunks les plus proches pour une question, puis envoyer ces chunks à un modèle de langage qui rédige la réponse. Sur un VPS que vous louez déjà, les quatre premières étapes s’exécutent sur le serveur. PostgreSQL avec l’extension pgvector stocke les vecteurs, et un petit modèle d’embeddings servi par Ollama transforme le texte en vecteurs. Seule la dernière étape doit sortir du serveur.

C’est le principe de ce guide. Le découpage en chunks utilise simplement le CPU. Le calcul des embeddings repose sur un modèle de 137 millions de paramètres qui tient dans quelques centaines de mégaoctets de RAM. Le stockage utilise une table PostgreSQL dont vous pouvez calculer la taille avant d’insérer une seule ligne. Pour un corpus de plusieurs centaines de milliers de chunks, tout cela fonctionne sur un VPS standard. La génération est différente, car elle a un coût à chaque question, pour toujours.

Quelles parties d’un pipeline RAG coûtent réellement de l’argent

Le tutoriel RAG de bout en bout de DigitalOcean utilise une base de données vectorielle managée et un modèle d’embeddings hébergé. Sa section consacrée aux coûts reste qualitative : mettre en cache les requêtes répétées, limiter le nombre de chunks récupérés et effectuer un reranking avant la génération. Ces conseils sont corrects. Elle ne mentionne toutefois pas l’option qui modifie les calculs : exécuter le modèle d’embeddings sur le serveur que vous payez déjà.

Comptez les tokens plutôt que les dollars, car le nombre de tokens ne devient pas obsolète quand la grille tarifaire change. Prenez un corpus de 100,000 chunks de 400 tokens chacun, 10,000 questions posées sur ce corpus, 8 chunks envoyés au modèle pour chaque réponse, un bloc de question et d’instructions de 100 tokens, et des réponses de 400 tokens.

ChartToken load for a 100,000 chunk corpus and 10,000 questions
The data behind this chart
[
  {
    "label": "Embed the corpus (once)",
    "tokens_millions": 40,
    "tokens_per_question": "4,000"
  },
  {
    "label": "Embed each question",
    "tokens_millions": 0.2,
    "tokens_per_question": "20"
  },
  {
    "label": "Generation input",
    "tokens_millions": 33,
    "tokens_per_question": "3,300"
  },
  {
    "label": "Generation output",
    "tokens_millions": 4,
    "tokens_per_question": "400"
  }
]

L’embedding de l’ensemble du corpus représente 40 millions de tokens et n’a lieu qu’une fois. Réparti sur ces 10,000 questions, cela représente 4,000 tokens par question. Si vous posez 100,000 questions, ce nombre tombe à 400. La génération, elle, ne diminue jamais. Elle coûte 3,300 tokens en entrée et 400 tokens en sortie pour chaque question à laquelle vous répondrez.

Le coût suit donc l’étape qui se répète. Prenez en charge l’embedding, car vous ne le payez qu’une fois et le VPS fonctionne déjà. Achetez la génération, car c’est là qu’un meilleur modèle justifie réellement son coût. Le cache est important pour la même raison : un cache hit ignore la seule étape dont le coût ne s’amortit jamais. La différence entre un KV cache et un prompt cache détermine quelle moitié de ce traitement vous pouvez réutiliser. Un prompt RAG contient un bloc d’instructions stable suivi d’un bloc de chunks variable : c’est la structure qui en bénéficie le plus.

Découpage en segments : pourquoi une taille fixe avec chevauchement est le bon choix par défaut

Un segment est l’unité que vous récupérez. Sa taille détermine donc tout ce qui suit. Il doit être suffisamment petit pour que son embedding porte sur un seul sujet, car un embedding est un point unique dans l’espace. Un segment qui couvre quatre sujets se retrouve entre eux et reste proche d’aucun. Il doit aussi être suffisamment grand pour répondre seul à une question, car le modèle de langage voit le segment, mais pas le document qui l’entoure.

Commencez par 300 mots avec un chevauchement de 50 mots. L’anglais compte environ 1.3 tokens par mot, donc 300 mots correspondent à environ 400 tokens. Le chevauchement est nécessaire, car une phrase qui tombe sur une limite serait sinon coupée en deux, et aucune des deux parties ne permettrait de répondre à la question.

Découpez d’abord selon la structure lorsque les documents en ont une. Séparez les sections à partir des headings, puis les paragraphes. N’appliquez la règle de taille fixe qu’à l’intérieur d’une section encore trop longue. Un segment qui commence au milieu d’une phrase donne un mauvais résultat dans la réponse finale, car le modèle reprend ce que vous lui avez fourni.

Ne réglez pas le découpage avant de pouvoir le mesurer. Une taille fixe avec chevauchement est déterministe et peu coûteuse à recalculer. Elle constitue donc une baseline que vous pourrez dépasser. Commencez par construire la requête de scoring plus bas, puis ne modifiez qu’un seul paramètre à la fois.

Intégrer sur le même serveur : coût en RAM et en latence

curl -fsSL https://ollama.com/install.sh | sh
ollama pull nomic-embed-text

nomic-embed-text compte 137 million de paramètres et représente un téléchargement de 274 MB en août 2026. Vérifiez ce qu’il renvoie avant de concevoir une table autour de ce résultat.

curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model": "nomic-embed-text", "input": "search_document: hello"}' |
  python3 -c 'import json,sys; print(len(json.load(sys.stdin)["embeddings"][0]))'

Cette commande affiche 768. Le type de votre colonne doit correspondre exactement à ce nombre.

Deux paramètres de ce modèle posent souvent problème.

Le préfixe de tâche est obligatoire. La fiche du modèle Nomic indique que l’entrée « doit inclure un préfixe d’instruction de tâche ». Les documents sont intégrés avec search_document: au début, et les questions avec search_query: . Si vous les omettez, rien n’échoue : vous obtenez bien des vecteurs, mais la qualité de la recherche diminue et aucune ligne de journal ne vous indique pourquoi.

Les entrées longues sont tronquées silencieusement. L’endpoint /api/embed accepte un champ truncate, dont la valeur par défaut est true, et le modèle fourni par Ollama annonce un contexte de 2K. Un chunk plus long est coupé à cette limite puis intégré quand même ; sa fin devient donc impossible à rechercher. Envoyez "truncate": false pendant vos tests afin qu’un chunk trop volumineux échoue au lieu d’être accepté.

Regroupez les requêtes par lots et gardez le modèle en mémoire.

curl -s http://127.0.0.1:11434/api/embed -d '{
  "model": "nomic-embed-text",
  "input": ["search_document: first chunk", "search_document: second chunk"],
  "keep_alive": "30m"
}' > /dev/null

input accepte une liste. Une requête contenant 32 chunks est plus rapide que 32 requêtes distinctes, car l’aller-retour HTTP et la recherche du modèle n’ont lieu qu’une seule fois au lieu de 32. keep_alive contrôle la durée pendant laquelle le modèle reste en mémoire après une requête ; la valeur par défaut est de 5 minutes. À son expiration, la requête suivante doit à nouveau payer le temps de chargement.

Mesurez les deux valeurs importantes sur votre propre serveur. Elles dépendent du nombre de vCPU, donc aucun chiffre publié ne correspondra exactement à votre environnement.

ollama ps
time curl -s http://127.0.0.1:11434/api/embed \
  -d '{"model":"nomic-embed-text","input":"search_document: ... one real chunk ..."}' > /dev/null

ollama ps affiche la taille résidente du modèle chargé, c’est-à-dire la RAM réservée tant que keep_alive le conserve en mémoire. La sortie de time divisée par la taille du lot donne le nombre de secondes par chunk. Multipliez ce résultat par le nombre de chunks pour obtenir le coût total ponctuel de l’indexation. Avec un traitement uniquement sur CPU, prévoyez plusieurs heures plutôt que quelques minutes pour un corpus de 100,000 chunks. Ce n’est pas un problème, car l’opération n’a lieu qu’une fois et peut s’exécuter pendant la nuit avec nice -n 19. Si plusieurs heures sont inacceptables, la vraie question est de savoir si la location d’un GPU est rentable. Il s’agit alors d’un calcul de seuil de rentabilité par rapport au coût des tokens d’API, et non d’une préférence.

Si le serveur héberge déjà un modèle de chat, le modèle d’embedding est un second modèle résident et la consommation de RAM s’additionne. Exécuter Ollama sur un VPS explique comment dimensionner la partie génération, et ce qui se passe avec un modèle auto-hébergé lorsque plusieurs utilisateurs l’interrogent en même temps traite le cas des requêtes concurrentes. Le modèle d’embedding est suffisamment petit pour cohabiter avec l’un ou l’autre.

Le script d’indexation, du début à la fin

Sur Ubuntu 24.04, un pip install exécuté en dehors d’un environnement virtuel s’arrête avec error: externally-managed-environment, car le Python système est géré par apt.

python3 -m venv ~/rag
~/rag/bin/pip install "psycopg[binary]" pgvector
import json, urllib.request
import psycopg
from pgvector.psycopg import register_vector
from pgvector import Vector

OLLAMA = "http://127.0.0.1:11434/api/embed"
MODEL = "nomic-embed-text"

def embed(texts, prefix="search_document: "):
    payload = {"model": MODEL,
               "input": [prefix + t for t in texts],
               "truncate": False,
               "keep_alive": "30m"}
    req = urllib.request.Request(OLLAMA, data=json.dumps(payload).encode(),
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as resp:
        return json.load(resp)["embeddings"]

def split(text, size=300, overlap=50):
    words = text.split()
    step = size - overlap
    return [" ".join(words[i:i + size]) for i in range(0, len(words), step)]

with psycopg.connect("dbname=rag user=rag") as conn:
    register_vector(conn)
    for doc_id, text in documents():          # your loader
        pieces = split(text)
        for start in range(0, len(pieces), 32):
            batch = pieces[start:start + 32]
            vectors = embed(batch)
            with conn.cursor() as cur:
                cur.executemany(
                    "INSERT INTO chunks (doc_id, seq, body, embedding)"
                    " VALUES (%s, %s, %s, %s)",
                    [(doc_id, start + i, body, Vector(vec))
                     for i, (body, vec) in enumerate(zip(batch, vectors))])
        conn.commit()

documents() vous appartient : il s’agit de tout code qui parcourt vos fichiers ou vos lignes et produit un identifiant de document avec son texte. Tout le reste constitue le pipeline.

Stockage : le schéma pgvector et sa taille

Ubuntu 24.04 fournit postgresql-16-pgvector en version 0.6.0, qui est antérieure au type halfvec. Utilisez le dépôt du projet PostgreSQL pour installer une version récente.

sudo apt update && sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17 postgresql-17-pgvector

Le nombre dans le nom du paquet doit correspondre à la version majeure de votre serveur. Créez ensuite le rôle, la base de données et l’extension.

sudo -u postgres createuser --pwprompt rag
sudo -u postgres createdb --owner rag rag
sudo -u postgres psql -d rag -c 'CREATE EXTENSION vector;'
CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    text NOT NULL,
  seq       int  NOT NULL,
  body      text NOT NULL,
  embedding vector(768) NOT NULL,
  fts       tsvector GENERATED ALWAYS AS (to_tsvector('english', body)) STORED
);

CREATE INDEX chunks_fts ON chunks USING gin (fts);

vector(768) doit correspondre à la sortie du modèle. Si vous insérez dans cette colonne un vecteur de dimension 1024, Postgres le refuse avec expected 768 dimensions, not 1024. C’est le message d’erreur le plus explicite de tout ce pipeline. La colonne fts générée ne coûte rien à maintenir et permet d’utiliser ultérieurement une recherche par mots-clés.

Le stockage se calcule simplement. La documentation de pgvector indique qu’un vector occupe 4 * dimensions + 8 octets et qu’un halfvec occupe 2 * dimensions + 8. Les dimensions ci-dessous correspondent à la taille de sortie publiée de chaque modèle.

ChartVector column size per 100,000 chunks, by embedding dimension
The data behind this chart
[
  {
    "label": "384 (all-minilm)",
    "bytes_per_vector": "1,544",
    "vector_mib_per_100k": 147,
    "halfvec_mib_per_100k": 74
  },
  {
    "label": "768 (nomic-embed-text)",
    "bytes_per_vector": "3,080",
    "vector_mib_per_100k": 294,
    "halfvec_mib_per_100k": 147
  },
  {
    "label": "1024 (mxbai-embed-large)",
    "bytes_per_vector": "4,104",
    "vector_mib_per_100k": 391,
    "halfvec_mib_per_100k": 196
  },
  {
    "label": "1536 (hosted API model)",
    "bytes_per_vector": "6,152",
    "vector_mib_per_100k": 587,
    "halfvec_mib_per_100k": 294
  }
]

Avec 768 dimensions, chaque vecteur occupe 3,080 octets. 100,000 chunks occupent donc 294 MiB de données vectorielles. Le même corpus encodé par un modèle hébergé de dimension 1536 nécessite 587 MiB, et l’index associé augmente dans les mêmes proportions. La précision réduite divise ces deux valeurs par deux : halfvec(768) stocke ce corpus dans 147 MiB. La question de savoir si cela réduit le rappel est traitée par la requête de scoring ci-dessous, exécutée en une seule fois.

Ces chiffres concernent uniquement la colonne vectorielle. Le texte, la surcharge des lignes et les index s’y ajoutent. Mesurez donc la taille réelle de la table.

SELECT pg_size_pretty(pg_total_relation_size('chunks')) AS total,
       pg_size_pretty(pg_relation_size('chunks'))       AS heap,
       count(*) AS n_rows
FROM chunks;

Si vous préférez utiliser la même extension avec une API et une gestion des comptes utilisateur, une stack Supabase auto-hébergée fournit PostgreSQL avec pgvector déjà activé. Toutes les requêtes de ce guide y fonctionnent sans modification.

Indexation : les paramètres HNSW importants

En dessous de quelques milliers de lignes, n’utilisez pas d’index. Une recherche exacte lit chaque ligne. Elle reste suffisamment rapide à cette taille et son rappel est parfait. Ajoutez l’index lorsque le scan séquentiel devient trop lent. Gardez à l’esprit le compromis : un index approximatif renvoie des voisins approximativement corrects.

SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 3;
CREATE INDEX chunks_embedding ON chunks
  USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

m = 16 et ef_construction = 64 sont les valeurs par défaut de pgvector. Les augmenter améliore le rappel, mais augmente la durée de construction et la taille de l’index. Utilisez vector_cosine_ops avec l’opérateur <=>, sauf si vous savez que votre modèle produit des vecteurs de longueur unitaire, car la distance cosinus ignore la longueur du vecteur, contrairement au produit scalaire.

Surveillez la construction. Lorsque le graphe dépasse maintenance_work_mem, pgvector le signale :

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.

Ce n’est pas une erreur et la construction se termine quand même, mais elle passe par un chemin beaucoup plus lent. Augmentez maintenance_work_mem dans la session qui construit l’index et laissez la valeur par défaut du serveur inchangée, car ce paramètre s’applique à chaque opération de maintenance. Une valeur globale élevée peut épuiser la mémoire du serveur. Suivez une construction longue depuis une deuxième session.

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

Comparez ensuite l’index terminé avec la mémoire disponible sur le serveur.

SELECT pg_size_pretty(pg_relation_size('chunks_embedding'));
SHOW shared_buffers;

Une recherche HNSW parcourt un graphe. Elle accède donc à des pages réparties dans l’index au lieu de lire une plage contiguë. Un index qui ne tient pas en mémoire transforme chaque requête en série de lectures disque. C’est cette latence en queue de distribution que les utilisateurs remarquent. C’est la règle de dimensionnement principale du serveur : l’index et les lignes que vous servez réellement doivent tenir dans la RAM. free -m et la taille indiquée plus haut sont les deux valeurs à comparer.

Au moment de la requête, hnsw.ef_search contrôle le rappel et vaut 40 par défaut.

BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 8;
COMMIT;

Une valeur plus élevée explore une plus grande partie du graphe, trouve de meilleurs voisins et augmente la latence. Il s’agit d’un paramètre de session. Vous pouvez donc l’augmenter pour une seule requête sans modifier l’index.

Si une requête n’utilise pas du tout l’index, le plan l’indique.

EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM chunks ORDER BY embedding <=> $1 LIMIT 8;

Un scan séquentiel dans ce cas est souvent lié au stockage. Un vecteur de dimension 768 occupe 3,080 octets, soit plus que ce que Postgres stocke en ligne. La valeur est donc déplacée dans la table TOAST, le stockage hors ligne des valeurs trop volumineuses. pgvector précise que le planner ne tient pas compte du stockage hors ligne dans ses estimations de coût. Un scan séquentiel peut donc sembler moins coûteux qu’il ne l’est réellement. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; conserve les vecteurs en ligne. Ce paramètre s’applique aux lignes écrites après la modification. Les lignes existantes nécessitent donc une réécriture de la table.

Récupération : une requête, deux signaux

La recherche vectorielle trouve les textes qui ont le même sens que la question. Elle est moins efficace avec les chaînes exactes : une référence de pièce, un code d’erreur ou un nom de famille. La recherche par mots-clés a le comportement inverse, et Postgres la fournit déjà. Combinez les deux dans une seule requête au lieu d’exécuter un second système.

La fusion des rangs réciproques est le combinateur le plus simple qui fonctionne. Chaque résultat reçoit 1 / (60 + rank) de chaque liste dans laquelle il apparaît, puis les deux scores sont additionnés. Aucune normalisation des scores n’est nécessaire, car cette méthode utilise les positions plutôt que les distances.

WITH semantic AS (
  SELECT id, row_number() OVER (ORDER BY distance) AS rank
  FROM (SELECT id, embedding <=> $1 AS distance
        FROM chunks ORDER BY embedding <=> $1 LIMIT 40) s
),
keyword AS (
  SELECT id, row_number() OVER (ORDER BY score DESC) AS rank
  FROM (SELECT c.id, ts_rank_cd(c.fts, q) AS score
        FROM chunks c, websearch_to_tsquery('english', $2) q
        WHERE c.fts @@ q
        ORDER BY score DESC LIMIT 40) k
)
SELECT c.id, c.body,
       coalesce(1.0 / (60 + s.rank), 0) + coalesce(1.0 / (60 + k.rank), 0) AS rrf
FROM (SELECT id FROM semantic UNION SELECT id FROM keyword) u
JOIN chunks c ON c.id = u.id
LEFT JOIN semantic s ON s.id = u.id
LEFT JOIN keyword  k ON k.id = u.id
ORDER BY rrf DESC
LIMIT 8;

$1 est l’embedding de la question produit avec le même modèle et construit avec le préfixe search_query: . $2 est la question sous forme de texte. Votre application fournit les deux comme paramètres liés. websearch_to_tsquery accepte une véritable question utilisateur sans problème avec la ponctuation, contrairement à to_tsquery. Autre point à connaître : l’ajout d’un filtre WHERE à un scan HNSW peut renvoyer moins de lignes que demandé, car l’index est parcouru en premier et le filtre est appliqué ensuite. SET hnsw.iterative_scan = relaxed_order; demande à pgvector de poursuivre le scan jusqu’à obtenir suffisamment de lignes.

Comment savoir si la retrieval est suffisamment bonne ?

C’est l’étape que presque tous les guides sur le RAG omettent. C’est aussi la seule qui indique si les autres choix ont été utiles. Vous n’avez pas besoin d’un framework d’évaluation. Il vous faut 30 questions et l’id du chunk qui répond à chacune.

Rédigez-les manuellement. Prenez des questions que les utilisateurs posent réellement sur ce corpus, exécutez-les une par une, lisez les résultats et notez l’id du chunk qui aurait dû arriver en tête. Trente questions ne suffiront pas à départager de faibles écarts. Elles feront ressortir les différences importantes, car celles-ci sont généralement nettes.

CREATE TABLE gold (
  id        bigserial PRIMARY KEY,
  question  text   NOT NULL,
  chunk_id  bigint NOT NULL REFERENCES chunks(id),
  embedding vector(768) NOT NULL
);

Générez l’embedding de chaque question avec le préfixe search_query: , stockez-le, puis évaluez l’ensemble en une seule requête.

WITH hits AS (
  SELECT g.id,
         min(r.rank) FILTER (WHERE r.id = g.chunk_id) AS hit_rank
  FROM gold g
  CROSS JOIN LATERAL (
    SELECT top.id, row_number() OVER (ORDER BY top.distance) AS rank
    FROM (SELECT c.id, c.embedding <=> g.embedding AS distance
          FROM chunks c
          ORDER BY c.embedding <=> g.embedding
          LIMIT 10) top
  ) r
  GROUP BY g.id
)
SELECT count(*)         AS questions,
       count(hit_rank)  AS found_in_top_10,
       round(avg(coalesce(1.0 / hit_rank, 0)), 3) AS mrr
FROM hits;

found_in_top_10 divisé par questions correspond au recall@10 : il indique à quelle fréquence la réponse se trouvait dans la fenêtre envoyée au modèle. Le MRR (mean reciprocal rank) calcule la moyenne de 1 divisé par la position du bon chunk et compte une absence comme zéro. Il favorise donc le classement de la réponse en première position plutôt qu’en huitième. Ces deux valeurs changent lorsque vous modifiez la taille des chunks, remplacez le modèle d’embedding ou ajoutez une recherche par mots-clés. Vous pouvez alors voir dans quel sens elles évoluent.

Donnez la priorité au recall@10, car le générateur ne peut pas utiliser un chunk qu’il n’a jamais reçu. Lorsque le recall@10 atteint 0.9 mais que les réponses restent incorrectes, le problème vient du prompt ou du modèle, pas de la retrieval. Cette séparation vous évite plusieurs jours d’hypothèses inutiles.

Vérifiez l’index séparément. La recherche approximative réduit le recall, et pgvector vous permet de mesurer cette baisse : exécutez la même requête avec une recherche exacte, puis comparez les ids.

BEGIN;
SET LOCAL enable_indexscan = off; -- use exact search
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

Neuf ids sur dix en commun signifie que ef_search convient. Quatre sur dix signifie qu’il faut l’augmenter.

Reranking et génération : là où une API justifie son coût

Un reranker est un autre type de modèle. Il lit la question et un chunk ensemble, puis attribue un score à cette paire. Cette approche est plus efficace que la comparaison de deux embeddings calculés indépendamment. En revanche, elle est beaucoup trop lente pour être appliquée à l’ensemble d’un corpus. C’est précisément pour cela qu’elle intervient à ce stade. Elle analyse les 40 candidats renvoyés par le retrieval, et non les 100,000 chunks de la table. Une API de reranking hébergée ne facture donc que 40 paires courtes par question et élimine les faux positifs les moins pertinents avant leur transmission à l’étape coûteuse.

La génération représente la dépense récurrente, et deux leviers permettent de la réduire. Envoyez moins de chunks : utilisez le recall à 10 pour déterminer combien vous pouvez en envoyer sans perdre de réponses. Conservez le début du prompt strictement identique, octet par octet, afin que le prompt cache du fournisseur puisse être utilisé, puis placez les chunks récupérés après cette partie stable. Mettez également en cache les réponses finales par question : le token généré le moins cher est celui que vous avez généré la semaine dernière.

Dimensionnement du serveur et limites de cette approche

Chaque règle de dimensionnement présentée ici repose sur une mesure, pas sur une estimation.

  • La RAM est la contrainte principale : la taille du modèle résident issue de ollama ps, plus la taille de l’index HNSW, plus shared_buffers, en conservant une marge pour les connexions et le page cache.
  • Le disque doit disposer de deux fois pg_total_relation_size('chunks'), car la reconstruction d’un index conserve simultanément les deux copies.
  • Le CPU détermine la durée du réindexage : le nombre de secondes mesuré par chunk, multiplié par le nombre de chunks.
  • Le réindexage intervient plus souvent que prévu, car changer de modèle d’embedding invalide tous les vecteurs déjà stockés.

Cette architecture atteint une limite que vous pouvez anticiper. Lorsque l’index HNSW ne tient plus dans la RAM que vous pouvez acheter, la latence des requêtes se transforme en accès disque, et aucun réglage ne permet de la rétablir. Lorsqu’une même table sert plusieurs tenants et que chaque requête applique un filtre sur le tenant, le partitionnement de la table devient nécessaire, ce qui demande un travail réel. Lorsque les écritures de l’indexation entrent en concurrence avec les requêtes utilisateur sur le même serveur, déplacez le worker d’embedding vers un second serveur avant de déplacer la base de données. Tant qu’aucun de ces cas ne se présente, Postgres avec pgvector sur le VPS que vous louez déjà constitue une solution de production, et les chiffres ci-dessus indiquent la distance qui vous sépare de la limite.

FAQ

Puis-je exécuter un pipeline RAG sur un seul VPS, ou ai-je besoin d’une base de données vectorielle ?

Un seul VPS suffit pour des corpus de quelques centaines de milliers de chunks. Avec 768 dimensions, 100,000 chunks représentent 294 MiB de données vectorielles, auxquels s’ajoutent le texte et l’index HNSW. Le tout tient dans la RAM d’un plan ordinaire. La limite vient de la mémoire plutôt que du nombre de lignes, car une recherche HNSW parcourt l’index de manière dispersée. La latence se dégrade donc lorsque l’index ne tient plus en RAM. Comparez pg_relation_size sur l’index avec free -m pour savoir où vous vous situez.

Ai-je besoin d’un GPU pour générer les embeddings de mes documents ?

Non, si vous générez les embeddings une seule fois, puis effectuez les requêtes. Un modèle de 137 millions de paramètres comme nomic-embed-text fonctionne sur CPU. Un passage complet sur un gros corpus prend plusieurs heures, que vous pouvez laisser s’exécuter pendant la nuit. Un GPU devient utile lorsque les documents arrivent en continu ou lorsque vous voulez exécuter la génération sur le même serveur. Mesurez le temps d’un batch avec /api/embed sur votre propre serveur, puis multipliez-le par le nombre de chunks. Le nombre de vCPU varie trop d’une offre à l’autre pour qu’une valeur publiée soit utile.

Pourquoi ma requête vectorielle utilise-t-elle un sequential scan au lieu de l’index HNSW ?

Lisez le plan avec EXPLAIN (ANALYZE, BUFFERS). La cause la plus fréquente est le stockage : pgvector indique que le planner ne comptabilise pas le stockage out of line dans ses estimations de coût. Un serial scan semble donc moins coûteux qu’il ne l’est réellement. De plus, un vecteur de 768 dimensions occupe 3,080 octets et se trouve par défaut dans la table TOAST. ALTER TABLE chunks ALTER COLUMN embedding SET STORAGE PLAIN; conserve les nouvelles lignes inline. Les deux autres causes sont un operator qui ne correspond pas à l’index, car un index créé avec vector_cosine_ops n’est utilisé que par <=>, et une requête sans ORDER BY ... LIMIT, car un index approximatif ne sert que les requêtes de recherche des plus proches voisins avec tri.

Comment savoir si ma recherche documentaire est efficace ?

Constituez un gold set de 30 questions, chacune associée à l’identifiant du chunk qui y répond, puis stockez leurs embeddings avec ces données. Mesurez ensuite le recall@10, c’est-à-dire la fréquence à laquelle le bon chunk apparaît dans les 10 premiers résultats, ainsi que le MRR, qui favorise son classement en première position. Ces deux valeurs indiquent si une modification de la taille des chunks, du modèle d’embedding ou de la fusion des rangs a amélioré le résultat. Sans ces mesures, vous modifiez les paramètres et vous fiez à votre impression fondée sur quelques réponses.