~/ / Articles sur l'IA / LLM locaux sur un VPS / Stocker et rechercher des embeddings Ollama dans PostgreSQL avec pgvector
Stocker et rechercher des embeddings Ollama dans PostgreSQL avec pgvector
Les embeddings de /api/embed d'Ollama ne servent à la recherche qu'une fois stockés quelque part. pgvector est une extension open source qui ajoute à PostgreSQL la recherche par similarité de vecteurs, de sorte que les vecteurs peuvent se trouver dans la même base de données que les textes et le reste de vos données, sur le même serveur. Cet article explique comment l'installer, créer la table, choisir la distance et l'index, et les réglages qui modifient les résultats, d'après le README de pgvector pour la version 0.8.7. Il transmet ensuite les résultats de la recherche à /api/chat d'Ollama pour que la réponse nomme ses sources, et vérifie ces citations. Nous avons exécuté le SQL sur pgvector 0.8.7 avec PostgreSQL 16, à partir de vrais vecteurs d'Ollama, et les requêtes de chat sur Ollama 0.40.11 avec qwen2.5:7b. Quatre outils de cette page écrivent le SQL et la requête et vérifient la réponse dans votre navigateur.
Publié: · 11 min de lecture · Faits vérifiés dans le README et le code source de pgvector pour la version 0.8.7 et dans la documentation et le code source d'Ollama (0.40.11) le 2026-11-05. Consultez la documentation actuelle avant de vous y fier.
Ce que pgvector ajoute à PostgreSQL
Le README de pgvector le décrit comme une recherche par similarité de vecteurs open source pour Postgres. Il prend en charge la recherche exacte et approximative des plus proches voisins ; les vecteurs en simple précision, en demi-précision, binaires et creux ; ainsi que la distance L2, le produit scalaire, la distance cosinus, la distance L1, la distance de Hamming et la distance de Jaccard. Comme les vecteurs sont de simples colonnes PostgreSQL, ils bénéficient aussi de la conformité ACID, de la restauration à un instant donné et des JOIN.
Pour une recherche dans vos documents, vous stockez une ligne par fragment avec son texte et son vecteur, vous calculez l'embedding de la question avec le même modèle, puis vous triez les lignes selon leur distance au vecteur de la question.
L'installer
pgvector prend en charge PostgreSQL 13 et les versions suivantes. Le README énumère plusieurs façons de l'installer, dont celles-ci :
| Méthode | Ce que dit le README |
|---|---|
| Compiler depuis les sources | git clone --branch v0.8.7, puis make et make install (sudo peut être nécessaire) |
| Docker | L'image pgvector/pgvector ajoute pgvector à l'image Postgres. Des étiquettes comme pg18-trixie ou 0.8.7-pg16 choisissent la version de Postgres. |
| APT (Debian, Ubuntu) | sudo apt install postgresql-18-pgvector depuis le dépôt APT de PostgreSQL, en remplaçant 18 par votre version de Postgres |
| Homebrew | brew install pgvector, qui ne l'ajoute qu'aux formules postgresql@18 et postgresql@17 |
Activez-le ensuite une fois dans chaque base de données où vous voulez l'utiliser, avec CREATE EXTENSION vector;. Pour cet article, nous avons utilisé l'image Docker pgvector/pgvector:0.8.7-pg16.
Créer la table
Une colonne de type vector(n) contient des vecteurs d'exactement n dimensions, et n doit être la longueur des vecteurs de votre modèle d'embeddings. Un vecteur d'une autre longueur est rejeté : quand nous avons inséré un vecteur de 2 dimensions dans une colonne vector(3), PostgreSQL a renvoyé "expected 3 dimensions, not 2". all-minilm renvoie 384 dimensions, donc sa colonne est vector(384).
Le README indique que chaque vecteur occupe 4 × dimensions + 8 octets, chaque élément étant un nombre à virgule flottante en simple précision, et autorise jusqu'à 16,000 dimensions dans une colonne. Un index prend en charge moins de dimensions, comme indiqué plus bas. Le type halfvec stocke des valeurs en demi-précision sur 2 × dimensions + 8 octets.
| Dimensions | vector | halfvec |
|---|---|---|
| 384 | 1,544 octets | 776 octets |
| 768 | 3,080 octets | 1,544 octets |
| 1024 | 4,104 octets | 2,056 octets |
Sur notre table de test, pg_column_size a donné 1,544 octets pour vector(384) et 776 octets pour halfvec(384), conformément à la formule. Il ne s'agit que du vecteur ; la colonne de texte et les index occupent de l'espace en plus.
Charger les vecteurs d'Ollama
pgvector lit un vecteur sous forme de texte entre crochets, comme '[1,2,3]', et le README précise que tous les éléments doivent être finis (ni NaN, ni Infinity, ni -Infinity). Chaque tableau du champ embeddings d'une réponse de /api/embed a déjà cette forme, et la réponse conserve l'ordre de l'entrée, donc le premier texte correspond au premier vecteur.
Le README présente INSERT pour quelques lignes, INSERT … ON CONFLICT pour les upserts et COPY pour le chargement en masse. Le convertisseur ci-dessous associe vos textes à la réponse et écrit des instructions INSERT avec les guillemets échappés. Nous avons exécuté sa sortie avec des textes contenant des guillemets, des barres obliques inverses, des sauts de ligne et des emoji, et ils sont ressortis inchangés.
Essayer: Embeddings to pgvector INSERT
L'exemple utilise des vecteurs inventés, de longueur unitaire et à 4 dimensions pour qu'ils restent lisibles. Les vrais embeddings ont des centaines ou des milliers de dimensions.
3 lignes de 4 dimensions. Textes lus comme le corps de la requête /api/embed, vecteurs comme une réponse d'Ollama /api/embed.
Instructions INSERT
Exécutez-les dans psql ou un autre client. Les lignes sont insérées dans une seule transaction.
BEGIN;
INSERT INTO documents (content, embedding) VALUES
('Embeddings turn text into numeric vectors.', '[0.620186,0.410123,0.550165,0.380114]'),
('pgvector stores vectors in PostgreSQL, next to the rest of your data.', '[0.578815,0.469039,0.518937,0.419142]'),
('Ollama''s /api/embed returns one vector per input, in the same order.', '[0.119982,0.809879,-0.329951,0.46993]');
COMMIT;Format des vecteurs d'après le README de pgvector, vérifié avec pgvector 0.8.7 le 2026-11-04. README de pgvector
Tout se passe dans votre navigateur. L'outil écrit du SQL et ne se connecte à rien.
Choisir la distance
La documentation d'Ollama recommande la similarité cosinus pour la plupart des cas de recherche sémantique et indique que /api/embed renvoie des vecteurs de longueur 1. Pour de tels vecteurs, la distance cosinus, la distance L2 et le produit scalaire classent les lignes dans le même ordre, car le carré de la distance L2 vaut 2 moins 2 fois la similarité cosinus. Dans notre test avec all-minilm, les trois premières lignes étaient les mêmes avec les trois, et la distance L2 de 0.931 correspondait à la similarité cosinus de 0.566.
Chaque distance a son propre opérateur. Une recherche trie selon l'opérateur et prend les premières lignes avec LIMIT, et un index est créé pour une classe d'opérateurs.
| Opérateur | Renvoie | Classe d'opérateurs de l'index |
|---|---|---|
| <=> | Distance cosinus. La similarité cosinus vaut 1 - (a <=> b). | vector_cosine_ops |
| <-> | Distance L2 (euclidienne) | vector_l2_ops |
| <#> | Le produit scalaire négatif. Multipliez par -1 pour obtenir le produit scalaire. | vector_ip_ops |
| <+> | Distance L1 | vector_l1_ops |
Le README explique que <#> renvoie le produit scalaire négatif parce que Postgres ne prend en charge que les parcours d'index en ordre ASC sur les opérateurs. Pour halfvec, les classes d'opérateurs sont halfvec_cosine_ops, halfvec_l2_ops, etc.
Recherche exacte ou index
Sans index, pgvector effectue une recherche exacte des plus proches voisins, qui offre selon le README un rappel (recall) parfait. Un index approximatif sacrifie un peu de rappel pour gagner en vitesse, et le README prévient que les requêtes peuvent renvoyer des résultats différents après l'ajout d'un index.
Un index n'est utilisé que si la requête contient ORDER BY et LIMIT et si l'ORDER BY est l'opérateur de distance lui-même, en ordre croissant. ORDER BY embedding <=> $1 LIMIT 5 peut utiliser l'index ; ORDER BY 1 - (embedding <=> $1) DESC ne le peut pas. Le README note aussi qu'un parcours de la table peut être plus rapide quand la table est petite.
| Point | HNSW | IVFFlat |
|---|---|---|
| Fonctionnement | Un graphe à plusieurs couches | Répartit les vecteurs en listes et parcourt les listes les plus proches de la requête |
| Par rapport à l'autre | Meilleur compromis entre vitesse et rappel, mais plus lent à construire et plus gourmand en mémoire | Plus rapide à construire et moins gourmand en mémoire, mais avec un moins bon compromis entre vitesse et rappel |
| Quand le créer | Peut être créé sur une table vide (pas d'étape d'entraînement) | Une fois que la table contient des données |
| Options de construction | m (16 par défaut) et ef_construction (64 par défaut) | lists : commencez avec rows / 1000 jusqu'à 1M de lignes, et avec sqrt(rows) au-delà |
| Option de recherche | hnsw.ef_search, 40 par défaut | ivfflat.probes, 1 par défaut ; commencez avec sqrt(lists) |
| Dimensions | vector jusqu'à 2,000, halfvec jusqu'à 4,000 | vector jusqu'à 2,000, halfvec jusqu'à 4,000 |
Sur pgvector 0.8.7, nous avons aussi confirmé qu'une colonne vector(2001) ne peut pas avoir d'index HNSW, qu'IVFFlat n'a pas de classe d'opérateurs pour la distance L1 et que ef_construction doit être au moins égal à 2 × m. Pour davantage de dimensions, la FAQ du README mentionne halfvec, la quantification binaire et d'autres options.
Essayer: pgvector SQL Builder
Chaque vecteur occupe 1,544 octets, donc 10,000 lignes occupent environ 15.4 MB pour les seuls vecteurs (index et autres colonnes non inclus).
1. Extension et table
CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE documents ( id bigserial PRIMARY KEY, content text NOT NULL, embedding vector(384) NOT NULL );
2. Index
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
3. Recherche
PREPARE reçoit comme $1 le vecteur de la question obtenu avec /api/embed. Passez-le sous forme de texte, par exemple '[0.01,-0.02,…]'.
PREPARE search_documents(vector(384)) AS
SELECT id, content, 1 - (embedding <=> $1) AS cosine_similarity
FROM documents
ORDER BY embedding <=> $1
LIMIT 5;
-- EXECUTE search_documents('[0.0123, -0.0456, ...]');Limites et valeurs par défaut issues du README et du code source de pgvector 0.8.7, vérifiées le 2026-11-04. README de pgvector
Tout se passe dans votre navigateur. L'outil écrit du SQL et ne se connecte à rien.
Pourquoi une recherche peut renvoyer moins de lignes que LIMIT
Avec HNSW, les résultats sont limités par hnsw.ef_search, qui vaut 40 par défaut. Sur une table de test de 200 lignes avec un index HNSW, LIMIT 100 a renvoyé 40 lignes ; après SET hnsw.ef_search = 100, il en a renvoyé 100. Réglez hnsw.ef_search à au moins le nombre de lignes dont vous avez besoin. SET LOCAL dans une transaction ne le modifie que pour une seule requête.
Le filtrage réduit aussi les résultats. Avec les index approximatifs, le README indique que la condition WHERE est appliquée après le parcours de l'index : si une condition correspond à 10% des lignes, HNSW avec la valeur par défaut de hnsw.ef_search, 40, ne renvoie en moyenne que 4 lignes. Depuis la version 0.8.0, les parcours itératifs de l'index (SET hnsw.iterative_scan = strict_order, ou relaxed_order) parcourent une plus grande partie de l'index jusqu'à trouver assez de lignes, dans la limite de hnsw.max_scan_tuples (20,000 par défaut).
Le README ajoute que les vecteurs NULL ne sont pas indexés, ni les vecteurs nuls pour la distance cosinus.
Transmettre les résultats à /api/chat avec des sources numérotées
Quand la recherche renvoie les lignes les plus proches, le modèle en a besoin dans son prompt. /api/chat d'Ollama prend une liste de messages, chacun avec un rôle (system, user, assistant ou tool) et son content. Une organisation simple consiste en un message system contenant les règles et un message user qui énumère les lignes, chacune avec un numéro, comme [1] et [2], suivies de la question. Avec des numéros, la réponse peut indiquer quelles lignes elle a utilisées, et vous pouvez associer chaque numéro à sa ligne.
Pour obtenir les citations sous une forme fixe, donnez au champ format un JSON Schema avec la réponse et une liste de citations, chacune avec le numéro de la source et un court extrait cité. La page sur les sorties structurées d'Ollama recommande d'écrire aussi le schéma dans le prompt, et de baisser la température (temperature), par exemple à 0, pour une sortie plus déterministe. Dans psql, la fonction json_agg rassemble les lignes d'une requête dans un tableau JSON, que l'outil ci-dessous lit directement.
Essayer: RAG Prompt Builder
Les sources de l'exemple sont écrites à la main sous la forme que renvoie json_agg.
3 sources, lues comme un tableau JSON d'objets.
Commande curl
Envoie la requête à Ollama sur cette machine (localhost:11434).
curl http://localhost:11434/api/chat -d '{
"model": "qwen2.5:7b",
"messages": [
{
"role": "system",
"content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
},
{
"role": "user",
"content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
}
],
"stream": false,
"format": {
"type": "object",
"properties": {
"answer": {
"type": "string"
},
"citations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {
"type": "integer"
},
"quote": {
"type": "string"
}
},
"required": [
"source",
"quote"
]
}
}
},
"required": [
"answer",
"citations"
]
},
"options": {
"temperature": 0
},
"truncate": false
}'Corps de la requête (JSON)
{
"model": "qwen2.5:7b",
"messages": [
{
"role": "system",
"content": "Answer the question using only the numbered sources in the user message. Cite the number of each source you use. If the sources do not contain the answer, say that you do not know and cite nothing. Answer in the same language as the question. For each citation, give the source number and copy a short quote from that source exactly as it is written. Return JSON that matches this schema: {\"type\":\"object\",\"properties\":{\"answer\":{\"type\":\"string\"},\"citations\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"source\":{\"type\":\"integer\"},\"quote\":{\"type\":\"string\"}},\"required\":[\"source\",\"quote\"]}}},\"required\":[\"answer\",\"citations\"]}"
},
{
"role": "user",
"content": "Sources:\n\n[1] (id: 12)\nWith HNSW, hnsw.ef_search is 40 by default, so a query returns at most 40 rows unless you raise it.\n\n[2] (id: 7)\nSET LOCAL inside a transaction changes hnsw.ef_search for a single query.\n\n[3] (id: 3)\npgvector supports PostgreSQL 13 and later.\n\nQuestion: Why does my HNSW search return only 40 rows with LIMIT 100?"
}
],
"stream": false,
"format": {
"type": "object",
"properties": {
"answer": {
"type": "string"
},
"citations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source": {
"type": "integer"
},
"quote": {
"type": "string"
}
},
"required": [
"source",
"quote"
]
}
}
},
"required": [
"answer",
"citations"
]
},
"options": {
"temperature": 0
},
"truncate": false
}Dans la réponse, le texte se trouve dans message.content. Si prompt_eval_count est bien inférieur à ce que vous attendez, le prompt a peut-être été coupé.
Champs de la requête d'après la référence de l'API et le code source d'Ollama, vérifiés sur Ollama 0.40.11 le 2026-11-05. Ollama API: /api/chat
Tout s'exécute dans votre navigateur. L'outil écrit la requête et ne se connecte à rien.
Quand les sources ne tiennent pas, Ollama coupe le début du prompt
Les règles, les lignes et la question doivent tenir dans la longueur de contexte. La page sur la longueur de contexte d'Ollama donne une valeur par défaut de 4k tokens avec moins de 24 GiB de VRAM, et sa FAQ indique 4096 tokens ; ollama ps sur notre machine de test affichait 4096. num_ctx dans options ou la variable d'environnement OLLAMA_CONTEXT_LENGTH la modifie.
Quand le prompt est plus long, /api/chat ne renvoie pas d'erreur par défaut. Dans le code source d'Ollama (0.40.11), le serveur commence par supprimer des messages antérieurs entiers, en conservant les messages system et le dernier message. Si le prompt restant est encore trop long, il conserve les premiers tokens (num_keep, 4 par défaut) et la fin du prompt, et supprime la partie intermédiaire. Dans notre test avec qwen2.5:7b et num_ctx 1024, un prompt de 2,068 tokens a été ramené à 514. Le message system et les premières sources ont été perdus : avec la réponse dans la source [1], le modèle a répondu par un mot de code qui ne figurait dans aucune source, et avec la réponse dans la dernière source, il a répondu correctement mais sans le numéro de source que demandait le message system. La réponse ne contenait aucune erreur ; le journal du serveur contenait l'avertissement "truncating input prompt".
Le code source d'Ollama comporte aussi un champ truncate pour les requêtes de chat, qui ne figure pas sur la page de /api/chat. Avec "truncate": false, la même requête a renvoyé HTTP 400 avec l'erreur "request (2068 tokens) exceeds the available context size (1024 tokens), try increasing it". Le champ prompt_eval_count d'une réponse indique le nombre de tokens du prompt ; dans le test avec coupure, il valait 514, et non 2,068.
Comme la fin du prompt est conservée, placez la question après les sources. Pour savoir quand les sources ne tiennent pas, envoyez "truncate": false, puis augmentez num_ctx ou envoyez moins de lignes. Un num_ctx plus grand demande plus de mémoire.
Vérifier les citations
Une citation peut être vérifiée mécaniquement : le numéro doit renvoyer à une source qui a été envoyée, et l'extrait cité devrait apparaître dans cette source. Dans notre test avec quatre sources et qwen2.5:7b, les extraits cités des réponses en JSON correspondaient mot pour mot aux sources. Cela ne prouve pas que la réponse est correcte : interrogé sur la taille d'un vecteur de 384 dimensions, le modèle a cité la bonne règle (4 fois les dimensions plus 8 octets) et a pourtant répondu 1552 octets au lieu de 1,544.
Examinez aussi les réponses sans citations. Quand les sources ne contenaient pas la réponse, le modèle a une fois répondu à partir de ses propres connaissances sans citations, et une fois dit qu'il ne savait pas tout en citant quand même une source sans rapport. Si done_reason dans la réponse vaut "length", la réponse a été coupée par num_predict et il peut manquer ses citations.
Le vérificateur ci-dessous compare uniquement du texte. Savoir si une source appuie réellement une phrase doit encore être jugé par une personne.
Essayer: RAG Citation Checker
La réponse de l'exemple est écrite à la main pour montrer un extrait cité qui ne figure pas dans sa source.
4 sources (un tableau JSON de chaînes). Réponse lue comme une réponse de /api/chat d'Ollama, sous la forme de JSON avec citations.
Tokens du prompt (prompt_eval_count) : 306
- L'extrait cité de la citation #2 ne figure pas dans la source [4].
Citations
| Source | Extrait cité | Résultat |
|---|---|---|
| [2] | hnsw.ef_search is 40 by default | Trouvé tel quel |
| [4] | 4 bytes per dimension plus 8 | Absent de cette source |
Cela vérifie seulement que les numéros et les extraits correspondent aux sources. Cela ne dit pas si la réponse est correcte ni si une source appuie réellement une phrase.
Format de la réponse d'après la référence de l'API et le code source d'Ollama, vérifié sur Ollama 0.40.11 le 2026-11-05. Ollama API: /api/chat
Tout s'exécute dans votre navigateur. L'outil ne se connecte à rien.
La mémoire sur un petit serveur
Le README indique qu'un index HNSW se construit nettement plus vite quand le graphe tient dans maintenance_work_mem, affiche un avis quand il n'y tient plus, et prévient de ne pas fixer maintenance_work_mem si haut qu'il épuise la mémoire du serveur. Comme pour les autres index, il est plus rapide de créer l'index après le chargement des données initiales. Les index n'ont pas besoin de tenir en mémoire, mais vous obtiendrez probablement de meilleures performances s'ils y tiennent ; SELECT pg_size_pretty(pg_relation_size('index_name')) affiche la taille d'un index.
Pour un ensemble de travail et des index plus petits, le README suggère halfvec, l'indexation en demi-précision ou la quantification binaire. Si Ollama tourne sur le même serveur, PostgreSQL et les modèles se partagent la même mémoire ; notre article sur la surveillance de la mémoire d'Ollama explique ce qui détermine la quantité utilisée par les modèles.
Sources
- pgvector README (v0.8.7)
- pgvector source: HNSW limits (src/hnsw.h)
- pgvector source: IVFFlat limits (src/ivfflat.h)
- Ollama: Embeddings
- Ollama API: Generate embeddings (/api/embed)
- PostgreSQL documentation: Lexical Structure (string constants)
- Ollama API: Generate a chat message (/api/chat)
- Ollama: Structured Outputs
- Ollama: Context length
- Ollama FAQ (context window size)
- Ollama source v0.40.11: server/prompt.go (dropping earlier messages)
- Ollama source v0.40.11: llm/llama_server.go (truncating the prompt)
- Ollama source v0.40.11: api/types.go (truncate, num_keep)
- PostgreSQL documentation: Aggregate Functions (json_agg)
À lire ensuite
- Embeddings avec Ollama : les bases pour la recherche et le RAGCréez des embeddings avec le /api/embed d'Ollama : les modèles et leurs tailles, truncate et dimensions, vecteurs de longueur unitaire et similarité cosinus, d'après la documentation officielle.
- Surveiller la mémoire d'Ollama sur un petit VPSVoir ce qu'Ollama garde en mémoire, combien de temps et quels réglages changent cela : ollama ps, keep_alive et les limites, d'après la FAQ officielle.
- Connecter Dify à Ollama sur votre propre VPSFaites tourner Dify sur votre propre VPS avec Ollama comme modèle : installation, champs du plugin, problème d'adresse dans Docker et embeddings, d'après la documentation officielle.
- Lancer un LLM local sans PC coûteux : guide du débutant pour utiliser un VPSPas de PC puissant ? Louez un VPS et faites tourner un petit LLM local depuis votre navigateur : six étapes, de l'estimation de la mémoire à un écran de discussion protégé, avec les limites indiquées honnêtement.