Un RAG maison sur mon homelab : comment j’ai donné une mémoire documentaire à mon assistant IA

Un RAG maison sur mon homelab : comment j’ai donné une mémoire documentaire à mon assistant IA

Comment j’ai construit un pipeline RAG (Retrieval-Augmented Generation) auto-hébergé : indexer mon coffre Obsidian et ma documentation NAS dans Qdrant, les interroger en langage naturel, et obtenir des réponses sourcées — le tout sur mon homelab Proxmox.


Introduction

Mon assistant IA (Mercure) est intelligent, mais il a un problème : il ne connaît pas mes documents. Mon coffre de notes Obsidian, ma documentation technique, mes notices d’appareils, mes archives — tout ça lui est invisible. Il ne peut pas répondre à une question comme « qu’est-ce que j’ai noté sur la configuration de mon NAS ? » ou « quelle est la procédure pour redimensionner un LXC ? ».

La solution : un RAG (Retrieval-Augmented Generation). Le principe est simple : au lieu de demander au modèle de tout savoir de mémoire, on lui fournit les extraits pertinents de mes documents à chaque question, et il synthétise une réponse sourcée à partir de ces extraits.

Cet article décrit comment j’ai construit ce pipeline de A à Z sur mon homelab : l’architecture, les choix techniques, les pièges rencontrés, et le résultat final.


Le principe du RAG

Un RAG fonctionne en deux temps :

  1. Indexation : on découpe mes documents en petits morceaux (chunks), on les transforme en vecteurs numériques (embeddings) qui capturent leur sens, et on les stocke dans une base vectorielle.
  2. Interrogation : quand je pose une question, on la transforme aussi en vecteur, on cherche les chunks les plus proches sémantiquement dans la base, et on les donne au modèle de langage pour qu’il réponde en s’appuyant dessus.

L’avantage : le modèle ne « devine » pas — il répond à partir de mes documents, avec des citations vers les sources. C’est une mémoire documentaire fiable, qui ne dépend pas de ce que le modèle a appris pendant son entraînement.


L’architecture

Le pipeline est construit en briques indépendantes, chacune avec un rôle précis. Le principe directeur : Qdrant est le seul service permanent ; l’extraction, le découpage et l’embedding sont déclenchés à la demande ou en batch planifié, jamais en daemon résident.

Brique Choix retenu Détail
Stockage vectoriel Qdrant v1.17.0 LXC 217 « qdrant » sur Charly (192.168.1.206), ports 6333 HTTP / 6334 gRPC, interne uniquement (pas de reverse proxy public)
Embeddings bge-m3 sur Ollama local 1024 dimensions, distance Cosine. Sur l’Ollama local d’Alpha (192.168.1.201:11434)
LLM de synthèse deepseek-v4-flash via Ollama Cloud Pour rédiger la réponse finale à partir des extraits
Collections obsidian + nas_doc Deux bases : le coffre de notes et la documentation NAS
Extraction LibreOffice + Markitdown + ocrmypdf/Tesseract Pour lire PDF, ODT, DOC, RTF, PPT, scans

Le payload commun

Chaque chunk stocké dans Qdrant porte une structure de métadonnées commune :

text, source_type (obsidian|nas_doc), file_path, file_hash (sha256),
chunk_index, tags, modified_at, indexed_at

Ce payload est crucial : il permet de retrouver la source exacte de chaque extrait, de filtrer par type de document, et de savoir quand un document a été indexé.


Les choix techniques (et pourquoi)

Qdrant pour le stockage vectoriel

J’ai choisi Qdrant comme base vectorielle. C’est un moteur de recherche vectorielle open source, rapide, et qui tourne très bien dans un LXC Proxmox. Il est interne uniquement — pas exposé sur Internet, car il n’a pas besoin de l’être : seul mon assistant y accède depuis le réseau local.

bge-m3 pour les embeddings

Pour transformer le texte en vecteurs, j’utilise bge-m3, un modèle d’embedding multilingue (important pour mes documents en français) qui produit des vecteurs de 1024 dimensions. Il tourne sur Ollama local — pas sur le cloud.

deepseek-v4-flash pour la synthèse

La rédaction de la réponse finale est confiée à deepseek-v4-flash via Ollama Cloud. C’est le même modèle qui fait tourner mon assistant : cohérent, rapide, et il sait citer ses sources.

Le pipeline en scripts autonomes

Tout le pipeline est une chaîne de scripts Python dans /root/workspace/rag-pipeline/, chacun avec un rôle précis et un --self-test autonome :

Script Rôle
extract.py Extraction des documents → markdown
chunk.py Découpe du markdown → chunks JSONL
index.py Embedding bge-m3 + insertion dans Qdrant
search.py Recherche sémantique dans Qdrant
synthesize.py Synthèse LLM avec citations
batch_index.py Orchestrateur d’indexation du corpus NAS

La chaîne complète : extract.pychunk.pyindex.pysearch.pysynthesize.py.


L’extraction des documents (la partie ingrate)

La première difficulté : lire des documents hétérogènes. Mon corpus contient des PDF, des ODT, des DOC, des RTF, des PPT, des fichiers texte. Et certains PDF sont des scans (pas de texte natif).

Le script extract.py gère tout ça :

  1. txt/md → lecture directe (utf-8, repli latin-1)
  2. odt/doc/rtf/ppt → conversion via LibreOffice (soffice --headless) en PDF, puis extraction
  3. PDF texte-natif → détection de la couche texte (pymupdf, seuil ~200 caractères) → extraction via Markitdown
  4. PDF scanné → OCR via ocrmypdf + Tesseract (langue française), puis extraction

Le script est robuste : en cas de document corrompu, il log l’erreur et passe au suivant — il ne plante jamais sur un fichier cassé.

Le piège des PDF scannés

Beaucoup de mes documents sont des scans. Sans OCR, ils sont illisibles pour le pipeline. La détection est automatique : si la couche texte est vide (moins de ~200 caractères), on bascule sur l’OCR. C’est plus lent, mais indispensable pour les notices et archives scannées.


Le découpage en chunks (chunking)

Une fois le document en markdown, il faut le découper en morceaux de taille raisonnable pour l’embedding. Trois stratégies :

  • heading : découpe par titres H2/H3, avec un chemin hiérarchique (heading_path) qui préserve la structure du document.
  • sliding : fenêtre glissante avec chevauchement, pour les documents sans structure claire.
  • auto : utilise heading si le document a au moins 2 titres H2/H3, sinon sliding.

Le heading_path est reconstruit via une pile de titres (on retire les niveaux supérieurs ou égaux au titre courant, on pousse le nouveau) — pas par une recherche naïve d’ancêtres, qui inverserait l’ordre et inclurait des non-parents.


L’indexation dans Qdrant

L’embedding et l’insertion dans Qdrant se font par batch (défaut 64) avec ?wait=true pour garantir la persistance.

Le piège des IDs

Qdrant rejette les IDs string (abc:0) avec une erreur HTTP 400. La solution : générer un UUID v5 déterministe à partir de (file_hash, chunk_index). C’est idempotent : relancer l’indexation ne crée pas de doublon, car le même chunk produit toujours le même UUID. Vérifié : 48 points restent 48 après relance.

La lenteur de l’embedding sur CPU

bge-m3 sur CPU prend ~4,4 secondes par chunk. Pour un corpus réel (des centaines de fichiers), l’indexation initiale est longue. Leçon : toujours lancer l’indexation en arrière-plan, jamais en foreground avec un timeout. Et ne pas paniquer si le log reste vide pendant l’embedding — vérifier ps aux (CPU > 0) et curl /api/ps sur Ollama.


La recherche et la synthèse

La recherche sémantique

Pour interroger, on embed la requête, puis on cherche les chunks les plus proches dans Qdrant (POST /collections/{c}/points/search avec with_payload: true). Les scores bge-m3 sont cohérents : ~0.55 pour des correspondances pertinentes.

La synthèse LLM

Le script synthesize.py fait la recherche automatiquement, puis appelle deepseek-v4-flash via Ollama Cloud (endpoint OpenAI-compatible). Le prompt RAG balise les extraits (<extraits>) et demande au modèle de citer les sources [n] et de répondre en français. Le modèle signale honnêtement quand l’information n’est pas dans les extraits — c’est le comportement RAG attendu.

Le piège embedding vs LLM

Il ne faut jamais réutiliser le modèle LLM pour l’embedding de la requête. deepseek n’a pas d’endpoint embeddings (HTTP 404). Il faut un modèle d’embedding séparé (--embed-model bge-m3).


L’indexation du corpus NAS

Le corpus principal est sur mon NAS Synology : /homes/jpr/Documentation, soit 386 fichiers, ~8 Go, dont 349 PDF. Sous-dossiers : magazines et revues (107), notices matériel (81), notices logiciel (50), sciences et techniques (30), électronique (36), docs (16), dys (12)…

Le script batch_index.py orchestre l’indexation : pour chaque sous-dossier cible, il télécharge le dossier en zip via l’API DSM FileStation, décompresse, extrait, découpe et indexe.

Le piège de l’accès NAS

Le SID seul échoue en SMB (NT_STATUS_LOGON_FAILURE). Le pattern fiable est l’API DSM FileStation (port 5001) : login auth.cgi → SID → entry.cgi?api=SYNO.FileStation.List/Download. Et le chemin API est le chemin virtuel : /volume1/homes/jpr/Documentation/homes/jpr/Documentation en API.

Le piège du batch

chunk.py n’accepte qu’un seul chemin (nargs="?"). Passer une liste de fichiers → exit 2. Il faut passer le dossier markdown entier (traité récursivement) à la place.


Les pièges techniques (récapitulatif)

Voici les pièges les plus marquants rencontrés, qui valent leur pesant d’or :

Piège Solution
Qdrant v1.19+ exige GLIBC 2.38 (Debian 13) Utiliser v1.17.0, conforme au critère « ≥1.17 » et compatible Debian 12 (GLIBC 2.36)
bge-m3 n’est PAS sur Ollama Cloud Ollama Cloud ne liste que des LLM, pas d’embeddings. Utiliser l’Ollama local (/api/pull puis /api/embed)
Docling trop lourd pour un LXC 4 cœurs Docling télécharge des modèles ML et compile des kernels torch → plusieurs minutes/doc. Markitdown extrait un PDF texte-natif en ~6s, léger. Docling reste en option
Qdrant rejette les IDs string Générer un UUID v5 déterministe par (file_hash, chunk_index) → idempotent
Markitdown produit du texte brut Pas de titres H2/H3 → le chunking auto retombe sur sliding. Comportement attendu, pas un bug
bge-m3 sur CPU est lent (~4,4 s/chunk) Toujours lancer l’indexation en arrière-plan
Regex markdown : flag MULTILINE obligatoire Sans re.MULTILINE, aucun titre n’est détecté
Accès NAS : API DSM, pas SMB Le SID seul échoue en SMB. Utiliser l’API DSM FileStation (port 5001)
Ne pas réutiliser le LLM pour l’embedding deepseek n’a pas d’endpoint embeddings (404). Modèle d’embedding séparé (bge-m3)

Le résultat

Le pipeline est opérationnel. Deux collections sont indexées dans Qdrant :

Collection Points Dimensions Distance
obsidian (coffre de notes) 9 637 1024 Cosine
nas_doc (documentation NAS) 17 961 1024 Cosine

Mon assistant expose une commande /recherche : je pose une question en langage naturel, et il renvoie une réponse synthétique sourcée avec des citations [n, section x.y] et un bloc Chunks utilisés listant les fichiers source. Le temps de réponse est de ~15-60 secondes (embedding sur CPU + appel LLM cloud).

Le pipeline est honnête sur ses limites : si les extraits ne couvrent pas la question, il le dit au lieu d’inventer.


Les leçons

1. Des briques indépendantes, un seul service permanent

Qdrant est le seul daemon résident. Tout le reste (extraction, chunking, embedding) est déclenché à la demande. Ça simplifie l’exploitation et ça réduit la surface de maintenance.

2. L’idempotence est un super-pouvoir

Grâce aux UUID v5 déterministes, relancer l’indexation ne crée jamais de doublon. On peut réessayer sans crainte — essentiel quand l’embedding est lent.

3. Le plus léger d’abord

Markitdown plutôt que Docling, l’API DSM plutôt que SMB, l’Ollama local plutôt que le cloud pour les embeddings. À chaque fois, le choix le plus simple et le plus léger a gagné.

4. Les pièges se découvrent en dur

GLIBC, IDs Qdrant, embeddings absents du cloud, regex MULTILINE… autant de pièges qu’aucune doc ne prévoyait. La robustesse vient de l’expérience.

5. Un RAG honnête vaut mieux qu’un RAG inventif

Le modèle doit dire « je ne trouve pas cette information dans vos documents » plutôt que d’inventer. C’est la différence entre un outil fiable et un hallucinateur.


Et la suite ?

Le pipeline est en place, mais il reste des améliorations :

  • Watcher automatique : surveiller le coffre Obsidian (inotify) et le NAS (polling incrémental par comparaison de hash) pour indexer automatiquement les nouveaux documents.
  • Mode /recherche avancé : décomposer une question complexe en plusieurs recherches, dédupliquer, et synthétiser avec citations.
  • Élargir le corpus : indexer d’autres sources (emails, veille, archives).

Cet article a été rédigé avec l’aide de mon assistant Mercure, qui utilise ce pipeline RAG au quotidien pour répondre à mes questions sur mes propres documents.

† 1 revision

Laisser un commentaire

To respond on your own website, enter the URL of your response which should contain a link to this post's permalink URL. Your response will then appear (possibly after moderation) on this page. Want to update or remove your response? Update or delete your post and re-enter your post's URL again. (Find out more about Webmentions.)