Temps de lecture : ~12 min
TL;DR
- Le problème : grep et keyword search échouent sur un vault Zettelkasten — les titres sont des claims, pas des mots-clés
- L’architecture réponse : un MCP local en 400 lignes de JavaScript exposant 4 outils à Claude
(
search_vault,get_related,list_recent_activations,query_frontmatter) - Modèle :
paraphrase-multilingual-MiniLM-L12-v2, 384 dims, ~90 MB, local, supporte le français nativement — zéro dépendance réseau - Store : JSON plat — 6 collections, scan linéaire cosinus. Aucune base vectorielle. Sous ~500 notes, le scan linéaire prend < 5ms et la complexité opérationnelle zéro l’emporte
- Lazy reindex sur
mtime: seuls les fichiers modifiés depuis la dernière session sont ré-embeddés — ~400ms pour 8 fichiers vs ~17 secondes pour tout le corpus - Ce qui a échoué : l’API OpenAI (dépendance réseau, non offline), ChromaDB (processus externe, état à gérer), embedder tout le corps sans pondérer le titre
Le problème spécifique du Zettelkasten
Répondre à un appel d’offre dans le conseil, c’est souvent prouver qu’on a déjà résolu le problème. Il faut les bonnes références, les bons projets, les bons résultats, dans le bon secteur. Elles existent. Quelque part dans des notes de mission, des livrables archivés, des comptes-rendus de réunion. Le problème systématique : les retrouver au moment où ça compte.
J’avais le même problème à titre personnel. En 7 ans de missions, j’avais accumulé des analyses, des diagnostics, des patterns d’organisation qui se répètent d’un client à l’autre. Le problème : retrouver la bonne analyse, au bon moment, sur la bonne question. Pendant des années, “retrouver” signifiait “reconstruire”, en espérant que la version reconstituée de mémoire valait la version originale.
Ce n’est pas un problème de mémoire. C’est un problème de récupération.
J’ai construit un vault Obsidian pour structurer cette connaissance. Le nouveau problème : comment la rendre accessible à Claude sans tout charger en contexte ?
Grep fonctionne bien quand les documents sont riches en synonymes et en reformulations. Une note Zettelkasten est l’opposé de ça.
Le titre d’une note Zettelkasten est un claim, une affirmation précise et délibérément non-redondante.
“L’agent sans API est aveugle” n’a aucun mot en commun avec “interopérabilité”, “legacy”, ou “SI de l’entreprise”. Pourtant, une recherche sur ces termes devrait retourner cette note.
Le corpus dans JARVIS illustre le problème :
| Requête | Grep trouve | Sémantique trouve |
|---|---|---|
| ”interopérabilité” | 2 notes (mot exact) | 8 notes (dont “L’agent sans API est aveugle”) |
| “amélioration continue” | 1 note | 6 notes (dont 3 sur le kaizen, 2 sur DORA) |
| “résistance au changement” | 3 notes | 9 notes (dont des notes stoïciennes sur l’obstacle) |
La raison est structurelle. La méthode Zettelkasten encourage la densité sémantique : un concept, une note, une formulation précise. Il n’y a pas de paragraphe d’introduction qui pose les synonymes. Ce qui rend les notes puissantes pour la pensée les rend opaques pour le grep.
Pourquoi un MCP plutôt qu’un pipeline RAG
Un pipeline RAG intercepte la requête de l’utilisateur, cherche des documents, les injecte dans le contexte, puis passe au LLM. Le LLM ne sait pas qu’il y a eu une recherche. Il reçoit un contexte enrichi qu’il n’a pas demandé.
Avec le MCP, c’est le LLM qui décide quand chercher et quoi chercher. Claude appelle
search_vault("agent interopérabilité DSI") quand il juge que c’est pertinent. Il peut appeler
get_related sur une note spécifique pour trouver ses voisins sémantiques. Il peut ne rien
appeler si les fichiers déjà chargés suffisent.
La différence pratique : dans un RAG, le contexte est pollué par des documents potentiellement non pertinents injectés automatiquement. Avec le MCP, Claude contrôle ce qu’il charge et il est meilleur que n’importe quelle heuristique fixe pour décider quand chercher.
C’est exactement le principe de chargement sélectif de l’article 1 appliqué à la couche sémantique : le contexte n’est enrichi que sur demande explicite du modèle.
Architecture : 4 outils, 6 collections
Les 4 outils du MCP
search_vault: recherche sémantique libre en langage naturel (français ou anglais). Retourne
les N documents les plus proches avec leurs scores de similarité cosinus.
server.tool(
'search_vault',
'Recherche sémantique dans le vault JARVIS...',
{
query: z.string(),
collections: z.array(
z.enum(['notes', 'sources', 'published', 'trilogie', 'mocs', 'terrain'])
).optional(),
n: z.number().int().min(1).max(30).default(10),
},
async ({ query, collections, n }) => {
await lazyReindex(store);
const qEmbed = await embed(query);
const results = store.search(qEmbed, { collections, n });
// ...
}
);
Le paramètre collections est essentiel : quand /linkedin-posts cherche des notes, il filtre
sur notes. Quand /mens-libera écrit un article, il peut croiser notes, sources et
published. Le filtre évite le bruit cross-collection.
get_related: voisins sémantiques d’un document donné. Clé pour /emerge (connexions
non évidentes entre collections) et /reweave (liens rétrospectifs). Donner le chemin d’une
note, recevoir ses 10 voisins dans tout le graphe, une note stoïcienne et une note delivery
qui n’ont aucun tag commun peuvent avoir un score cosinus de 0.82.
list_recent_activations: mémoire éditoriale. Lit le champ utilisé_dans du frontmatter
de chaque note, renseigné automatiquement par les skills de production après chaque publication.
En début de session éditoriale : quelles notes ont été activées dans les 30 derniers jours ?
Évite de recycler les mêmes claims d’une semaine sur l’autre.
query_frontmatter: requête structurée sur les métadonnées. Filtre par tags, état
d’activation, date de mise à jour, ou champ arbitraire. Cas d’usage typique : trouver toutes
les notes avec le tag dora jamais activées en production, matière dormante prête à exploiter.
server.tool(
'query_frontmatter',
'...',
{
collection: z.enum([...]).optional(),
tags: z.array(z.string()).optional(),
tags_mode: z.enum(['any', 'all']).default('any'),
activated: z.boolean().optional(),
updated_since: z.string().optional(),
field: z.string().optional(),
value: z.string().optional(),
limit: z.number().int().min(1).max(100).default(20),
},
// ...
);
Ce 4ème outil n’existait pas dans la version initiale, il a été ajouté quand le vault a dépassé 200 notes et que la navigation par tags depuis Obsidian ne suffisait plus pour identifier la matière dormante.
Les 6 collections
// config.js
export const COLLECTIONS = {
notes: { path: join(VAULT_ROOT, 'RESSOURCES/Notes'), chunk: false },
sources: { path: join(VAULT_ROOT, 'WRITING PRODUCT/Mens Libera/Sources'), chunk: false },
published: { path: join(VAULT_ROOT, 'WRITING PRODUCT/Mens Libera'),
filePattern: /^Article.+\.md$/, rootOnly: true, chunk: false },
trilogie: { path: join(VAULT_ROOT, 'WRITING PRODUCT/Livres Blancs'),
rootOnly: true, chunk: true },
mocs: { path: join(VAULT_ROOT, 'RESSOURCES/MOCs'), chunk: false },
terrain: { path: join(VAULT_ROOT, 'WRITING PRODUCT/Notes_de_terrain'),
filePattern: /^Article.+\.md$/, chunk: false },
};
La version initiale avait 4 collections (notes, sources, published, trilogie). Deux ont été
ajoutées depuis : mocs pour indexer les cartes de navigation du graphe (utile pour /reflect),
et terrain pour les articles Notes de terrain publiés, cette série.
Le modèle d’embedding : pourquoi local et pourquoi ce modèle
// embedder.js
export const EMBEDDING_MODEL = 'Xenova/paraphrase-multilingual-MiniLM-L12-v2';
export async function embed(text) {
const model = await loadEmbedder();
const out = await model(text.slice(0, 4000), {
pooling: 'mean',
normalize: true
});
return Array.from(out.data);
}
Pourquoi local et pas l’API OpenAI ? J’ai commencé avec text-embedding-3-small. Le coût
est dérisoire (0,02$ pour 1M de tokens). Mais le problème n’est pas le coût, c’est la dépendance
réseau. Un vault qui ne fonctionne plus en avion ou en zone sans réseau est un vault qu’on
n’utilise plus. Et surtout : chaque session de /reweave peut déclencher 40 à 60 embeddings.
La latence réseau cumulée devient perceptible sur une journée de travail intensive.
Pourquoi paraphrase-multilingual-MiniLM-L12-v2 ? Quatre critères :
- Français natif: entraîné sur 50 langues dont le français. Un vault en français avec des références anglaises mixtes est géré nativement.
- Taille: ~90 MB une fois téléchargé dans le cache HuggingFace
- Vitesse: embedding d’un document en ~50ms sur CPU (M1/M2)
- 384 dimensions: suffisant pour discriminer un corpus de cette taille ; inutile d’aller à 1536 dims (ada-002) pour quelques centaines de documents
La librairie @xenova/transformers fait tourner le modèle en ONNX dans Node.js, sans Python, sans serveur séparé, sans Docker. Le modèle est chargé une fois en mémoire au démarrage du
serveur MCP et réutilisé pour tous les embeddings de la session.
Le store : JSON plat vs base vectorielle
// store.js — la recherche en entier
function cosine(a, b) {
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
const denom = Math.sqrt(na) * Math.sqrt(nb);
return denom === 0 ? 0 : dot / denom;
}
search(queryEmbedding, { collections = null, n = 10 } = {}) {
let docs = this.all();
if (collections?.length) docs = docs.filter(d => collections.includes(d.collection));
return docs
.map(d => ({ ...d, score: cosine(queryEmbedding, d.embedding) }))
.sort((a, b) => b.score - a.score)
.slice(0, n);
}
La question évidente : pourquoi pas ChromaDB, Pinecone, ou Qdrant ?
Le calcul de taille : avec les 6 collections (notes + sources + articles publiés + trilogie chunkée + MOCs + Notes de terrain), le corpus fait quelques centaines de documents. Un scan linéaire avec calcul cosinus sur 400 documents prend < 5ms. Les index approximatifs (HNSW, IVF) ont du sens à partir de quelques dizaines de milliers de vecteurs. En dessous, leur overhead dépasse leur gain.
La dépendance ChromaDB : Docker, un processus serveur séparé, un état à gérer indépendamment du vault. Quand le MCP redémarre (ce qui arrive à chaque nouvelle session Claude Code), ChromaDB doit aussi être démarré. Sur plusieurs machines en rotation, c’est devenu une source d’erreurs silencieuses : MCP actif, ChromaDB inactif, embeddings servis depuis un index périmé. Le JSON plat a résolu le problème en supprimant l’état externe.
Le JSON plat a un seul inconvénient réel : on réécrit tout le fichier à chaque changement. Avec ~3,5 MB et quelques écritures par session, ce n’est pas un problème pratique.
L’indexation : stratégies par collection
Deux stratégies selon la nature des documents :
Stratégie 1 — document unique (notes, sources, articles publiés, MOCs) : chaque fichier produit un vecteur. Le texte embarqué est construit selon la collection :
function buildEmbedText(collectionId, title, fm, body) {
const parts = [title];
if (collectionId === 'notes') {
if (fm.description) parts.push(fm.description);
if (fm.tags?.length) parts.push(fm.tags.join(' '));
parts.push(body.slice(0, 1500));
} else if (collectionId === 'sources') {
if (fm.themes?.length) parts.push(fm.themes.join(' '));
parts.push(body.slice(0, 2000));
} else {
parts.push(body.slice(0, 2000));
}
return parts.join('
');
}
Pour les notes Zettelkasten : titre (le claim) en tête + description + tags + début du corps. Le titre est le signal le plus fort, c’est lui qui représente la thèse de la note.
Stratégie 2 — chunking par section ## (trilogie) : les tomes font 8 000 à 15 000 mots.
Embarder tout ça dans un vecteur unique noie la précision. Solution : chaque section ##
devient un vecteur indépendant.
function splitBySections(body) {
const parts = body.split(/^(?=##\s)/m);
return parts
.map(part => {
const match = part.match(/^##\s+(.+)
([\s\S]*)/);
if (match) return { heading: match[1].trim(), text: match[2].trim() };
return { heading: '', text: part.trim() };
})
.filter(c => c.text.length > 100);
}
Un tome de 12 000 mots avec 15 sections produit 15 vecteurs. Chercher “slopsquatting” retourne la section exacte du tome 3 qui traite ce risque, pas le tome entier.
Le lazy reindex
Recalculer les embeddings de tout le corpus à chaque démarrage prendrait ~17 secondes. La
solution : comparer le mtime de chaque fichier avec celui stocké en index.
export async function lazyReindex(store) {
for (const col of Object.values(COLLECTIONS)) {
const files = await collectFiles(col);
for (const filePath of files) {
const fileStat = await stat(filePath);
const mtime = fileStat.mtimeMs;
const existing = store.get(key);
if (existing && existing.mtime === mtime) continue; // rien à faire
// seulement ici : lire et recalculer
const embedding = await embed(embedText);
store.upsert(key, { ..., embedding, mtime });
}
// Purger les entrées orphelines (fichiers déplacés ou supprimés)
for (const key of store.keys()) {
if (!allCurrentKeys.has(key)) store.delete(key);
}
}
}
En pratique, une session modifie 3 à 8 notes. Le reindex suivant prend ~400ms pour ces fichiers. Les autres sont ignorés.
Ce qui a échoué avant d’arriver là
Embedder le corps sans pondérer le titre. La première version embarquait le corps complet sans lui préposer le titre. Résultat : les notes courtes (150 mots) étaient bien représentées, les notes longues (600 mots) étaient dominées par leur seconde moitié. Avec la pondération (titre en tête, corps tronqué à 1 500 tokens), la précision sur les requêtes “obliques” s’est améliorée de façon perceptible, requêtes où le terme de la requête n’apparaît pas dans le titre de la note.
Ne pas chunker la trilogie. Pendant une semaine, trilogie utilisait un vecteur par
fichier. Chercher “gouvernance agentique” retournait le tome 3 entier mais le score était
trop dilué pour que Claude identifie quelle section était pertinente. Le chunking par ##
a réglé ça : Claude reçoit la section exacte.
ChromaDB. Testé deux jours. Le problème n’était pas la qualité, c’était le cycle de vie. ChromaDB nécessite un processus serveur séparé. Quand le MCP redémarre (ce qui arrive à chaque nouvelle session Claude Code), ChromaDB doit aussi être démarré. Sur trois machines en rotation, c’est devenu une source d’erreurs silencieuses : MCP actif, ChromaDB inactif, embeddings servis depuis un index périmé. Le JSON plat a résolu le problème en supprimant l’état externe.
API OpenAI pour les embeddings. Fonctionnel, mais trois problèmes : latence réseau cumulée sur les sessions intensives, indisponibilité en mode offline, et dépendance à une clé API qui peut expirer ou être révoquée. Le modèle local a les mêmes performances sur ce corpus.
Tradeoffs honnêtes
Ce que le MCP gère bien :
- Requêtes conceptuelles en français sur des notes au titre contre-intuitif
- Connexions cross-collection (note × article publié × passage de la trilogie)
- Mémoire éditoriale via
list_recent_activations - Navigation structurée via
query_frontmatter(matière dormante par tags, notes non activées)
Ce qu’il ne gère pas :
- La fraîcheur intra-session : une note modifiée pendant une session active n’est pas ré-indexée avant la prochaine invocation d’un outil, le lazy reindex s’exécute à l’appel, pas en arrière-plan
- Les relations de graphe (wikilinks) : le MCP voit la sémantique des textes, pas les
[[liens]]entre notes, deux notes peuvent être sémantiquement proches sans être liées, et deux notes liées peuvent avoir un score cosinus faible - Le ranking au-delà de la similarité cosinus : deux notes avec le même score peuvent avoir une importance très différente dans le graphe (une note centrale avec 40 liens vs une note périphérique avec 2 liens)
Ce qui est prévu :
Pondération par utilisé_dans : une note activée 5 fois en production montera dans les
résultats à requête égale. Intégration des wikilinks comme signal additionnel. Ces deux
évolutions restent ouvertes, le gain marginal sur le corpus actuel n’a pas encore justifié
la complexité ajoutée.
Ce qui vient ensuite
Article 3 - Le pipeline 6Rs : comment une idée brute traverse six étapes (Record → Reduce → Reflect → Reweave → Verify → Rethink) pour devenir une note atomique correctement intégrée dans le graphe. Pourquoi le séquentiel strict produit un graphe cohérent quand le parallèle le cassait et ce que chaque étape fait que la suivante ne peut pas corriger.
Ce que ça implique si tu veux construire ça
Ce que cet article t’a donné : le QUOI et le POURQUOI de la couche sémantique pourquoi la recherche vectorielle locale bat le keyword search sur un Zettelkasten, pourquoi le JSON plat suffit en dessous de 500 notes, pourquoi le MCP l’emporte sur le pipeline RAG.
Ce qu’il ne donne pas : la séquence dans laquelle construire ça sur ta propre pile, les variantes selon ton niveau technique, comment intégrer la couche sémantique dans un flux de travail qui tient sur la durée sans maintenance active. C’est ce que la formation JARVIS construit avec toi.