Aller au contenu

Accueil / IA et LLM en local

RAG local avec LM Studio et n8n: embeddings, découpage et pièges de qualité sur une base de documents techniques

Construire un RAG 100% local avec LM Studio, n8n et Qdrant sur les articles du blog: découpage, dimension des embeddings, citations et questions pièges.

Rémi Vernier
27 octobre 2026 · 13 min de lecture

L’objectif de cet article n’est pas de refaire un énième tutoriel « RAG en 10 minutes ». Brancher un modèle sur une base vectorielle, n8n le fait en quelques clics. Obtenir des réponses justes sur des documents techniques est une autre affaire. Le sens d’un paragraphe dépend souvent du bloc de commande juste au dessus, et la question de l’utilisateur contient un nom d’attribut ou un code d’erreur exact.

Nous allons construire un RAG entièrement local, sans aucun appel à une API externe, sur un corpus que je connais bien: les articles de ce blog. Sur cette maquette, le modèle de langage est rarement en cause. La plupart des mauvaises réponses viennent du découpage des documents, et de ce que l’on envoie ou non au modèle.

Présentation de la maquette

Comme pour les articles précédents sur l’IA locale, la maquette est la suivante:

  • Machine: Mac mini M4, 32 Go de mémoire unifiée
  • LM Studio, serveur local activé sur le port par défaut 1234
  • Modèle d’embeddings: Qwen3-Embedding-0.6B
  • Modèle de génération: un modèle instruct de taille moyenne chargé dans LM Studio (le choix précis importe peu pour la démonstration)
  • n8n auto-hébergé dans Docker
  • Qdrant dans Docker, collection nommée blog
  • Corpus: les articles de remivernier.com récupérés via l’API REST WordPress

Les noms des nœuds n8n cités dans la suite sont ceux de la documentation officielle à l’heure où j’écris ces lignes (octobre 2026). n8n renomme régulièrement ses nœuds IA, vérifiez donc dans votre version.

Architecture

Il y a deux workflows distincts. Le premier sert à l’ingestion: il récupère les articles, les découpe, calcule les embeddings et les stocke. Le second sert à la requête: il reçoit une question, cherche les passages proches et demande au modèle de répondre en citant ses sources.

Ingestion API REST WordPress /wp-json/wp/v2/posts n8n: Markdown, découpage par section LM Studio /v1/embeddings Qdrant collection blog (1024) Requête Question (Chat Trigger) Qdrant « Get Many » top k + score Code: seuil, contexte numéroté LLM LM Studio réponse + [n] recherche vectorielle Hors base (score trop faible): réponse directe sans appel au LLM
Les deux workflows n8n de la maquette

Côté LM Studio, tout passe par l’API compatible OpenAI. La documentation liste les endpoints /v1/models, /v1/chat/completions, /v1/embeddings et /v1/completions. Côté n8n, nous utiliserons donc les nœuds OpenAI en changeant simplement l’URL de base:

  • « Embeddings OpenAI » pour les vecteurs. Le nœud expose une option « Base URL » prévue pour les services auto-hébergés (doc n8n).
  • « OpenAI Chat Model » pour la génération, avec un credential OpenAI dont l’URL de base pointe vers LM Studio.

n8n tourne dans Docker. localhost désigne donc le conteneur et non le Mac. L’URL à renseigner est du type http://host.docker.internal:1234/v1, avec le /v1 mais sans le chemin complet de l’endpoint: n8n ajoute lui même /embeddings ou /chat/completions. C’est l’erreur la plus fréquente, et elle est bien décrite dans ce retour d’expérience sur le forum n8n. La clé API peut être une valeur quelconque, LM Studio ne la vérifie pas par défaut.

Embeddings: quel modèle, quelle dimension?

Le corpus est en français, truffé de termes anglais et de noms d’attributs. J’écarte donc les modèles entraînés principalement sur l’anglais. Deux candidats courants dans LM Studio:

Modèle Dimension Contexte max Particularité
nomic-embed-text-v1.5 768 (réductible jusqu’à 64) 8192 tokens préfixes search_document: / search_query: obligatoires
Qwen3-Embedding-0.6B 1024 (réductible de 32 à 1024) 32K tokens multilingue (100+ langues), instruction côté requête

Les valeurs viennent des fiches nomic-embed-text-v1.5 et Qwen3-Embedding-0.6B-GGUF sur Hugging Face. Je retiens Qwen3-Embedding-0.6B pour le multilinguisme. Le point qui piège tout le monde avec nomic, ce sont les préfixes de tâche. Le nœud « Embeddings OpenAI » ne les ajoute pas, et sans eux la qualité de la recherche se dégrade sans aucun message d’erreur.

Ne vous fiez jamais à une dimension lue sur une fiche: mesurez la sur ce que renvoie réellement le serveur. Commencez par récupérer l’identifiant exact du modèle tel que LM Studio l’expose:

shell
curl -s http://localhost:1234/v1/models | jq -r '.data[].id'

curl -s http://localhost:1234/v1/embeddings \
  -H "Content-Type: application/json" \
  -d '{"model":"text-embedding-qwen3-embedding-0.6b","input":"Petit rappel sur Kerberos"}' \
  | jq '.data[0].embedding | length'

L’identifiant ci dessus correspond au nommage habituel de LM Studio (préfixe text-embedding-), mais reprenez celui que renvoie /v1/models chez vous. Le second appel doit renvoyer 1024. C’est ce chiffre qui sert à créer la collection Qdrant. Je préfère la créer explicitement plutôt que de laisser n8n le faire à la première insertion:

shell
docker run -d --name qdrant -p 6333:6333 -v qdrant_data:/qdrant/storage qdrant/qdrant

curl -X PUT http://localhost:6333/collections/blog \
  -H "Content-Type: application/json" \
  -d '{"vectors":{"size":1024,"distance":"Cosine"}}'

Si vous changez un jour de modèle d’embeddings, il faut recréer la collection et tout réindexer. Même à dimension identique, les vecteurs de deux modèles différents ne sont pas comparables entre eux.

Ingestion des articles du blog

Le workflow d’ingestion est volontairement linéaire:

  1. « HTTP Request » vers https://remivernier.com/wp-json/wp/v2/posts?per_page=100&_fields=id,link,title,content,modified. Au delà de 100 articles, il faut paginer avec le paramètre page (WordPress renvoie le nombre total de pages dans l’en-tête X-WP-TotalPages).
  2. « Markdown » en mode HTML vers Markdown, sur content.rendered. Les titres <h2> deviennent des lignes ## et les blocs <pre> deviennent des blocs de code délimités. Le découpage s’appuiera sur ces marqueurs.
  3. « Code » pour un premier découpage par section (voir plus bas).
  4. « Qdrant Vector Store » en mode « Insert Documents », avec en sous-nœuds « Embeddings OpenAI » et « Default Data Loader ».

Découpage: le vrai sujet

Par défaut, le « Default Data Loader » propose un mode « Simple » qui, d’après la documentation, utilise le Recursive Character Text Splitter avec des chunks de 1000 caractères et un recouvrement de 200. Ce découpeur essaie de garder ensemble les paragraphes, puis les phrases, puis les mots (doc). Sur de la prose, le résultat est correct. Sur un article technique, voici ce que l’on obtient:

  • -> une commande netsh trace start se retrouve dans un chunk et son explication dans le suivant
  • -> un tableau de valeurs d’attributs est coupé au milieu, sans sa ligne d’en-tête
  • -> un chunk de 1000 caractères pris au milieu d’une section ne dit plus de quel article ni de quel protocole il parle

Le troisième cas est le plus difficile à repérer. Un paragraphe qui commence par « Dans ce cas, le serveur renvoie l’erreur… » est quasiment inexploitable une fois isolé. Il ressemble sémantiquement à des dizaines d’autres.

Ma démarche est donc la suivante: découper d’abord selon la structure du document (les sections ## ), et préfixer chaque morceau avec le titre de l’article et celui de la section. Le découpeur récursif ne sert ensuite plus que de filet de sécurité pour les sections trop longues. Voici le nœud « Code » (mode « Run Once for All Items »):

code
const out = [];
for (const item of $input.all()) {
  const md = item.json.markdown;          // clé de sortie choisie dans le nœud « Markdown »
  const titre = item.json.title.rendered;
  const url = item.json.link;
  const sections = md.split(/\n(?=## )/);
  sections.forEach((s, i) => {
    const m = s.match(/^## (.+)/);
    const section = m ? m[1].trim() : 'Introduction';
    out.push({
      json: {
        texte: `Article: ${titre}\nSection: ${section}\n\n${s.trim()}`,
        titre, url, section,
        ordre: i,
        modifie: item.json.modified
      }
    });
  });
}
return out;

Dans le « Default Data Loader »:

  • « Type of Data »: JSON
  • « Mode »: « Load Specific Data », avec l’expression {{ $json.texte }}
  • « Text Splitting »: « Custom », avec un « Recursive Character Text Splitter » (chunk de 2000 caractères, recouvrement de 200 par exemple)
  • Dans les options, ajoutez les métadonnées titre, url, section et modifie

Il reste un défaut: quand une section dépasse la taille de chunk, seul le premier sous-morceau garde l’en-tête « Article / Section » dans son texte. Les suivants l’ont uniquement en métadonnées, et les métadonnées ne participent pas à l’embedding. Pour un blog comme celui-ci, les sections sont assez courtes pour que ce soit rare. Sur une documentation fournisseur de 200 pages, il faudrait réinjecter l’en-tête dans chaque sous-morceau, toujours dans un nœud « Code ».

Une fois l’ingestion lancée, vérifiez ce qui a réellement été stocké, vecteur compris:

shell
curl -s -X POST http://localhost:6333/collections/blog/points/scroll \
  -H "Content-Type: application/json" \
  -d '{"limit":1,"with_payload":true,"with_vector":true}' | jq '.result.points[0] | {payload, dim: (.vector | length), debut: .vector[0:5]}'

Ce contrôle répond à un problème déjà rencontré. Une issue GitHub décrit un cas où le nœud « Embeddings OpenAI » renvoyait des vecteurs remplis de zéros avec l’API de LM Studio, alors que le même appel en curl fonctionnait. Je n’ai pas vérifié dans quelles versions le problème se pose. Un vecteur nul ne provoque aucune erreur à l’insertion: la recherche renvoie simplement n’importe quoi.

Dernier piège côté ingestion: relancer le workflow ajoute les mêmes points une seconde fois. Résultat, les mêmes chunks remontent en double dans le top k et prennent la place d’autres passages. Pour une maquette, je supprime et recrée la collection avant chaque ingestion complète. En production, il faudrait supprimer les points par url avant de réinsérer un article modifié.

Requête et prompt avec citations

n8n propose des chaînes toutes faites (« Question and Answer Chain » avec un « Vector Store Retriever », ou le Vector Store branché comme outil d’un « AI Agent »). Je ne les utilise pas ici, pour une raison précise: je veux voir le score de chaque passage et décider moi même de ce qui part au modèle. Le workflow de requête est donc le suivant:

  1. « Chat Trigger »
  2. « Qdrant Vector Store » en mode « Get Many », limite à 6, avec en sous-nœud le même « Embeddings OpenAI » que pour l’ingestion
  3. « Code »: filtrage par score et construction d’un contexte numéroté
  4. « Basic LLM Chain » avec « OpenAI Chat Model » pointant vers LM Studio
  5. « Code »: ajout de la liste des sources à partir des métadonnées

Qwen3-Embedding est un modèle « instruction aware »: la fiche du modèle recommande de préfixer la requête (mais pas les documents) par une instruction de la forme Instruct: …\nQuery:…. Dans le champ « Prompt » du nœud Qdrant:

code
Instruct: Given a technical question, retrieve passages from IT security articles that answer it
Query:{{ $json.chatInput }}

Avec nomic-embed-text, ce serait search_query: {{ $json.chatInput }}, et il aurait fallu ajouter search_document: devant chaque texte à l’ingestion.

Le nœud « Code » qui construit le contexte doit lire la sortie de « Get Many ». Regardez la structure exacte dans l’onglet de sortie du nœud: chez moi chaque item contient un objet document (avec pageContent et metadata) et un score, mais le code ci dessous reste tolérant au cas où:

code
const SEUIL = 0.45;   // à calibrer sur vos propres questions, voir plus bas
const items = $input.all().map(i => ({
  texte: i.json.document?.pageContent ?? i.json.pageContent,
  meta: i.json.document?.metadata ?? i.json.metadata ?? {},
  score: i.json.score
}));
const retenus = items.filter(x => x.score >= SEUIL);
const question = $('When chat message received').first().json.chatInput;

if (retenus.length === 0) {
  return [{ json: { horsBase: true, question } }];
}
const contexte = retenus.map((x, n) =>
  `[${n + 1}] ${x.meta.titre} / ${x.meta.section}\n${x.texte}`
).join('\n\n---\n\n');

return [{ json: {
  horsBase: false, question, contexte,
  sources: retenus.map((x, n) => ({ n: n + 1, titre: x.meta.titre, section: x.meta.section, url: x.meta.url, score: x.score }))
}}];

Le nom du nœud déclencheur dans $('…') doit correspondre exactement au vôtre. Un nœud « If » sur horsBase renvoie directement une réponse fixe sans appeler le modèle. C’est plus fiable que de compter sur le LLM pour avouer qu’il ne sait pas.

Le prompt système de la « Basic LLM Chain »:

code
Tu réponds à des questions techniques uniquement à partir des extraits fournis.
Chaque extrait est numéroté [1], [2], etc.

Règles:
- Chaque affirmation doit être suivie du numéro de l'extrait qui la justifie, par exemple [2].
- Si les extraits ne contiennent pas la réponse, réponds exactement:
  "Je ne trouve pas cette information dans les articles indexés."
- Ne complète pas avec tes connaissances générales.
- Ne rédige aucune URL.
- Recopie les commandes, noms d'attributs et valeurs exactement comme dans les extraits.

Extraits:
{{ $json.contexte }}

Question: {{ $json.question }}

La consigne « Ne rédige aucune URL » est volontaire. Un modèle local de taille moyenne invente très facilement un slug plausible mais faux. Les liens sont ajoutés après coup par le dernier nœud « Code », à partir des métadonnées et des seuls numéros effectivement cités dans la réponse:

code
const rep = $json.text;
const src = $('Contexte').first().json.sources;
const cites = [...new Set([...rep.matchAll(/\[(\d+)\]/g)].map(m => Number(m[1])))];
const inconnus = cites.filter(n => !src.find(s => s.n === n));
const liste = src.filter(s => cites.includes(s.n))
  .map(s => `[${s.n}] ${s.titre} / ${s.section} - ${s.url}`).join('\n');
return [{ json: { reponse: rep + (liste ? `\n\nSources:\n${liste}` : ''), citationsInvalides: inconnus } }];

-> si citationsInvalides n’est pas vide, le modèle a cité un extrait qui n’existe pas. C’est un bon indicateur d’hallucination, qu’il est utile de journaliser.

Questions pièges

Pour évaluer un RAG, il faut chercher les questions sur lesquelles il échoue. Je conseille de constituer une petite liste fixe, une vingtaine de questions avec la réponse attendue et l’article qui la contient, et de la rejouer à chaque changement de découpage, de modèle ou de seuil. Les catégories ci dessous sont celles qui révèlent le plus de défauts.

Documents mal découpés

Exemple de question: « Quelle commande permet de lancer une capture réseau native sous Windows sans installer de driver? ». La réponse se trouve dans un article dont la commande et ses paramètres tiennent dans un bloc de code, et l’explication dans le paragraphe d’après. Avec le découpage « Simple » à 1000 caractères, il est fréquent que le passage remonté contienne le texte mais pas la commande, ou l’inverse. Le modèle donne alors une réponse incomplète, ou complète de mémoire, ce que le prompt lui interdit pourtant.

Pour diagnostiquer, désactivez temporairement le nœud LLM et regardez seulement les 6 passages renvoyés par Qdrant avec leur score. Si la bonne réponse n’y figure pas, inutile de toucher au prompt: le problème est en amont.

Même famille de problème: les questions qui contiennent un identifiant exact (« MaxTokenSize? », un nom d’attribut comme msDS-SupportedEncryptionTypes, un code d’erreur). Les embeddings denses capturent le sens général mais peuvent faire remonter un passage « sur Kerberos » qui ne contient pas le terme exact. Si ce type de question est fréquent chez vous, une recherche hybride (vecteurs + recherche lexicale) ou un reranker apporte souvent plus qu’un changement de modèle d’embeddings. Je ne l’ai pas mis en place sur cette maquette.

Questions hors base

Exemple: « Comment configurer le SSO entre Okta et SAP? ». La base contient des articles SAP et des articles SAML, donc Qdrant renverra toujours 6 passages: une recherche vectorielle trouve toujours un plus proche voisin. Sans seuil, le modèle reçoit des extraits sur SAML et sur SAP, et la tentation de combiner les deux en une réponse plausible est forte.

Le seuil de 0.45 dans le code ci dessus n’est pas une valeur universelle. Elle dépend du modèle d’embeddings, de la langue et de la longueur des chunks. Pour la calibrer, relevez le score du premier résultat sur vos questions « dans la base » et sur une dizaine de questions volontairement hors sujet, puis placez le seuil entre les deux distributions. Si elles se chevauchent beaucoup, le seuil seul ne suffira pas, et la consigne de refus du prompt prend le relais. Le seuil écarte les cas nets, le prompt traite une partie de ceux qui passent.

Contexte trop long

Le réflexe naturel quand une réponse est incomplète est d’augmenter le top k. Sur une maquette locale, il faut faire le calcul. Avec 6 chunks de 2000 caractères, on envoie déjà 12 000 caractères de contexte. En ordre de grandeur, un token représente environ 4 caractères en anglais, et plutôt moins en français ou sur du code. On approche donc les 4000 tokens avant même le prompt système et la réponse.

Or LM Studio charge un modèle avec la longueur de contexte que vous lui donnez, pas forcément le maximum supporté par le modèle. Vérifiez la valeur dans les paramètres de chargement, ou chargez le modèle explicitement:

code
lms ls
lms load <identifiant-du-modele> --context-length 16384

Si le prompt dépasse la fenêtre, vous aurez selon les réglages une erreur ou une troncature. La troncature est la plus gênante: aucun message ne signale qu’une partie du contexte a disparu. Augmenter la fenêtre a aussi un coût: le KV cache occupe de la mémoire unifiée en plus des poids du modèle, et le temps de traitement du prompt augmente avec sa longueur. Mesurez sur votre machine en comparant les statistiques affichées par LM Studio pour un même prompt à 4 et à 12 chunks.

Faire tenir tout le contexte dans la fenêtre ne suffit pas non plus. Les modèles exploitent moins bien les informations placées au milieu d’un long contexte (le phénomène est connu sous le nom de « lost in the middle »). Sur cette maquette, je préfère des chunks bien construits, un top k modeste et un seuil, plutôt que de tout envoyer en espérant que le modèle fasse le tri.

Avant d’ouvrir ce type de workflow au delà de votre poste, un dernier point de sécurité. Le « Chat Trigger » de n8n peut être rendu public, et LM Studio peut écouter sur le réseau local avec l’option « Serve on local network ». Une base vectorielle construite à partir de documents internes reste une copie de ces documents. Elle doit être protégée avec les mêmes droits que la source, et un RAG qui répond à tout le monde à partir de documents à accès restreint est une fuite de données, même s’il ne cite que des extraits.

Rémi Vernier

Rémi Vernier

Responsable des opérations cybersécurité dans un grand groupe international. Identité, Active Directory, Entra ID, cloud hybride, et désormais SAP, IA et modèles en local.

Tous les articles → LinkedIn →

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *