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 :
- 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.
- 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.py → chunk.py → index.py → search.py → synthesize.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 :
- txt/md → lecture directe (utf-8, repli latin-1)
- odt/doc/rtf/ppt → conversion via LibreOffice (
soffice --headless) en PDF, puis extraction - PDF texte-natif → détection de la couche texte (pymupdf, seuil ~200 caractères) → extraction via Markitdown
- 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: utiliseheadingsi le document a au moins 2 titres H2/H3, sinonsliding.
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
/rechercheavancé : 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.