06 — Kernel Memory in-process : la couche d’abstraction Microsoft
Jusqu’ici cette série a manipulé l’infrastructure à la main : des embeddings calculés exprès (03), des upserts et des recherches Qdrant écrits directement (01, 05), du retrieval mesuré sur un gold français (02). C’est le meilleur moyen de comprendre ce que fait un backend de mémoire sémantique — mais ce n’est pas ce qu’on écrit en production : on délègue le pipeline à une couche d’abstraction.
Kernel Memory (Microsoft, github.com/microsoft/kernel-memory) est cette couche : ingestion de documents (texte, Markdown, PDF, Word…), découpage en partitions, embeddings, indexation vectorielle, et recherche qui renvoie des citations — fichier source, partition, passage, score de pertinence. C’est la pièce qui manquait entre notre Qdrant brut et les agents Semantic Kernel qui consomment la mémoire.
Dans ce notebook nous utilisons le mode in-process (MemoryServerless) : le pipeline tourne dans le processus du notebook, sans service web ni conteneur, avec :
Brique
Choix
Pourquoi
Embeddings
granite-embedding-107m-multilingual (IBM) en GGUF Q8_0 via LLamaSharp CPU
modèle local multilingue conçu pour le RAG, cohérent avec la convention modèles-locaux de la série (cf. 02 : sentence_transformers)
Stockage fichiers
SimpleFileStorage (dossier temporaire)
zéro dépendance, démontrable
Index vectoriel
SimpleVectorDb (recherche exacte brute)
zéro conteneur — le compromis ANN est traité dans 05
Plan : (1) pipeline et modèle local, (2) ingestion d’un corpus français avec tags, (3) sous le capot — le partitionnement TextChunker, (4) recherche avec citations, (5) mesure — la granularité des partitions contre le rappel, (6) limites et exercices.
Pourquoi cette étape est pédagogique : jusqu’ici, nous avons démonté chaque brique du pipeline (embedding, chunking, vector search) pour comprendre les rouages. Kernel Memory est l’étape d’après : ré-assembler ces briques dans une couche d’abstraction qui cache la tuyauterie. C’est exactement le saut conceptuel entre « écrire en C » et « utiliser une bibliothèque » — sauf qu’ici la bibliothèque est un pipeline asynchrone, pas une fonction synchrone.
Comparaison avec l’écosystème Python : en Python, le pendant le plus proche est LlamaIndex (github.com/run-llama/llama_index), qui offre la même tuyauterie (ingestion, partitionnement, embeddings, index, requêtes avec citations). KM est l’équivalent côté Microsoft / .NET — et c’est le pont naturel vers Semantic Kernel pour les agents qui consomment la mémoire.
Coût global : ~30 secondes (chargement modèle, ingestion, recherche, mesure).
1. Le pipeline Kernel Memory
Une ingestion KM enchaîne des étapes (extract → partition → embed → index) sur chaque document : extraction du texte, découpage en partitions, vectorisation, écriture dans le store vectoriel. Une recherche vectorise la question et renvoie les partitions les plus proches, avec leur provenance — c’est la capacité distinctive de KM par rapport à un upsert Qdrant écrit à la main : la traçabilité document → partition → passage est portée par le pipeline, pas reconstruite après coup.
Côté packages : Microsoft.KernelMemory.Core (le pipeline), l’intégration LLamaSharp.kernel-memory (l’inférence locale llama.cpp pour les embeddings) et le backend CPU de LLamaSharp. Notons un fait d’écosystème utile : en 0.98, le connecteur ONNX de KM ne couvre que la génération de texte — pour des embeddings 100 % locaux, le chemin officiel de l’écosystème est LLamaSharp.
Le pipeline en détail : 1. Extract : pour un PDF, OCR/parsing texte ; pour du Markdown, normaliser ; pour du texte brut, passer directement. 2. Partition : le TextChunker découpe chaque document en blocs de N tokens (avec overlap configurable). C’est l’étape que la §6 va mesurer. 3. Embed : chaque partition passe dans le modèle d’embedding (ici GGUF via llama.cpp). Le résultat est un vecteur dense de dimension 384 (granite-107m). 4. Index : upsert dans le SimpleVectorDb avec métadonnées (file, partition id, tags optionnels).
Recherche avec citations : la requête est embedée, comparée (cosine) aux partitions indexées, et le top-K est retourné avec son chemin de citation complet : file → partition → passage. Le pipeline maintient cette traçabilité pour vous — il n’y a pas à la reconstruire par post-traitement.
Pourquoi cette abstraction est précieuse : dans un système de production, on ne veut pas réimplémenter la tuyauterie. KM offre un seul appel (memory.ImportDocumentAsync(...)) qui orchestre les 4 étapes. Côté agent Semantic Kernel, on ne voit plus que memory.AskAsync(question) qui retourne une réponse citée.
Sortie attendue (cellule code[0]) : Microsoft.KernelMemory.Core 0.98.250508.3 + LLamaSharp 0.27.0 (CPU) charges (NuGet). Confirmation que les 3 packages sont chargés.
Coût : ~5 secondes (résolution NuGet, extraction des métadonnées d’assemblies).
La cellule ci-dessus utilise la directive #r "nuget: ..." (spécifique à .NET Interactive) pour charger 3 packages : - Microsoft.KernelMemory.Core 0.98.250508.3 : le pipeline principal. La version est datée (250508 = 8 mai 2025) — Microsoft livre KM en preview rapide, les versions se suivent à un rythme mensuel. - LLamaSharp.kernel-memory 0.27.0 : l’intégration KM ↔︎ LLamaSharp (binding .NET pour llama.cpp). - LLamaSharp.Backend.Cpu 0.27.0 : le runtime CPU natif, c’est-à-dire la bibliothèque llama.cpp elle-même (llama.dll sous Windows, libllama.so sous Linux, libllama.dylib sous macOS), livrée pour chaque plateforme.
Les using statements importent les namespaces : Microsoft.KernelMemory (les classes principales du pipeline), Microsoft.KernelMemory.Configuration (la configuration), et les namespaces System.* standards (File, IO, Net, etc.).
Sortie attendue : un message console confirmant que les 3 packages sont chargés. Pas de figure.
Prérequis d’exécution : LLamaSharp 0.27.0 dépend de Microsoft.Extensions.Logging.Abstractions 10.x, donc de l’injection de dépendances 10.x, alors que Microsoft.KernelMemory.Core 0.98 est compilé contre la version 9.x. Le noyau .NET Interactive doit donc tourner sur le runtime .NET 10. Sur un hôte .NET 9, la cellule qui construit le pipeline échoue sur MissingMethodException: ... KernelMemoryBuilder..ctor(IServiceCollection) : deux versions de IServiceCollection coexistent. Si les runtimes .NET 9 et .NET 10 sont tous deux installés, lancer Jupyter avec la variable d’environnement DOTNET_ROLL_FORWARD=LatestMajor.
Détail utile : #r "nuget: ..." télécharge le package si absent, le met en cache, et le charge dans le contexte de la cellule. C’est l’équivalent d’un pip install + import en Python, mais en une seule directive.
Coût : ~5 secondes (résolution NuGet, extraction des assemblies, JIT).
2. Le modèle d’embeddings local (GGUF via llama.cpp)
Nous téléchargeons granite-embedding-107m-multilingual (IBM, 2025) — un embedder compact multilingue conçu pour le RAG — au format GGUF quantisé Q8_0 (121 Mo), depuis la conversion de référence de bartowski. L’inférence passe par llama.cpp en CPU via LLamaSharp : aucune clé d’API, aucun service externe, cohérent avec la convention modèles-locaux de la série. Le téléchargement est mis en cache dans un dossier temporaire : une ré-exécution le saute.
Remarque honnête : les embedders « à instructions » recommandent souvent de préfixer requêtes et passages différemment. Nous utilisons le modèle en mode symétrique (texte brut des deux côtés) — fonctionnel — et l’exercice 3 demande d’implémenter les préfixes recommandés et de mesurer ce qu’ils apportent réellement sur notre gold.
Pourquoi granite-embedding-107m-multilingual : - Compact (107M paramètres) — rapide à charger en CPU, ~121 Mo en Q8_0. - Multilingue — il supporte le français correctement (le gold est en français). - Conçu pour le RAG — pas un embedder générique, il est optimisé pour la tâche retrieval asymmetric (query ↔︎ passage). - Granite d’IBM — choix de modèles open source (licence Apache 2.0), aligné avec la convention « modèles locaux » de la série (vs OpenAI text-embedding-3-small qui serait l’alternative propriétaire standard).
Pourquoi GGUF Q8_0 : le format GGUF est l’output standard de llama.cpp. La quantization Q8_0 réduit l’empreinte mémoire par ~4× par rapport au FP16, avec une perte de qualité négligeable pour les embeddings (la couche d’embedding est peu sensible à la quantization). Le chargement sur CPU se fait via LLamaSharp.
Pourquoi LLamaSharp (pas ONNX Runtime) : en KM 0.98, le connecteur ONNX ne couvre que la génération de texte, pas les embeddings. Pour des embeddings 100% locaux via llama.cpp, le chemin officiel est LLamaSharp.kernel-memory (binding .NET pour llama.cpp). Le runtime natif est dans ~/.nuget/packages/llamasharp.backend.cpu/0.27.0/LLamaSharpRuntimes/win-x64/native/avx2/ (ou noavx/ en repli si AVX2 absent).
Sortie attendue (cellules code[1]–[3]) : nom du fichier GGUF, taille en Mo, backend natif détecté (avx2/ ou noavx/), pipeline in-process construit.
Coût : ~10-30 secondes au premier run (téléchargement 121 Mo + chargement modèle + warmup). ~1 seconde aux runs suivants (cache hit).
cache OK : granite-embedding-107m-multilingual-Q8_0.gguf (115 Mo)
modele pret dans le cache : km_rag06_gguf/
// En mode notebook, le chargeur standard de LLamaSharp ne sonde pas le dossier natif du// package backend : on pointe explicitement la bibliotheque via NativeLibraryConfig (doit etre// configure AVANT le premier appel a l'API native), avec repli noavx si AVX2 est absent.string nugetRoot = Environment.GetEnvironmentVariable("NUGET_PACKAGES")?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),".nuget","packages");string backendPkg = Path.Combine(nugetRoot,"llamasharp.backend.cpu","0.27.0");// RID de la machine courante (win-x64, linux-x64, osx-arm64...) : le package livre un dossier natif par plateforme.string rid =(OperatingSystem.IsWindows()?"win": OperatingSystem.IsMacOS()?"osx":"linux")+"-"+ System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant();// Nom de fichier natif par plateforme : llama.dll, libllama.so ou libllama.dylib (idem pour mtmd).stringNomNatif(string nom)=> OperatingSystem.IsWindows()? nom +".dll":"lib"+ nom +(OperatingSystem.IsMacOS()?".dylib":".so");// Variantes CPU par ordre de preference ; macOS et ARM n'ont qu'un dossier natif, sans sous-dossier AVX.string nativeRoot = Path.Combine(backendPkg,"LLamaSharpRuntimes", rid,"native");string nativeDir =new[]{"avx2","noavx",""}.Select(v => Path.Combine(nativeRoot, v)).FirstOrDefault(d => File.Exists(Path.Combine(d,NomNatif("llama"))))?? nativeRoot;LLama.Native.NativeLibraryConfig.All.WithLibrary(Path.Combine(nativeDir,NomNatif("llama")), Path.Combine(nativeDir,NomNatif("mtmd")));Console.WriteLine($"backend natif LLamaSharp : {new DirectoryInfo(nativeDir).Parent!.Name}/{new DirectoryInfo(nativeDir).Name}");
backend natif LLamaSharp : native/avx2
// Construction du pipeline in-process : LLamaSharp fournit le generateur d'embeddings.using LLamaSharp.KernelMemory;using Microsoft.KernelMemory.AI;using Microsoft.Extensions.DependencyInjection;using System.Runtime.CompilerServices;using System.Threading;// KM 0.98 : l'orchestrateur in-process exige un ITextGenerator resolu par injection de// dependances, meme quand aucune generation de texte n'est demandee (pipeline 100 %// embeddings). Ce generateur minimal n'est jamais invoque dans ce notebook.#pragma warning disable KMEXP00 // ITextTokenizer est marque experimental dans KM 0.98class GenerateurTexteInactif : ITextGenerator{publicint MaxTokenTotal =>512;publicintCountTokens(string text)=> text.Length/4;// heuristique ~4 caracteres/jetonpublic IReadOnlyList<string>GetTokens(string text)=>new[]{ text };public async IAsyncEnumerable<GeneratedTextContent>GenerateTextAsync(string prompt, TextGenerationOptions options,[EnumeratorCancellation] CancellationToken ct){ await Task.CompletedTask;yieldreturnnewGeneratedTextContent("(generation de texte non configuree)",newTokenUsage());}}var llamaConfig =newLLamaSharpConfig(ggufPath)// modelPath en constructeur positionnel{ ContextSize =2048,// CPU pur -- aucun GPU requis MainGpu =-1// backend CPU : llama.cpp attend -1 (aucun device)};KernelMemoryBuilder NewBuilder(){var b =newKernelMemoryBuilder(); b.Services.AddSingleton<ITextGenerator>(newGenerateurTexteInactif());return b;}// Etat propre a chaque execution, comme la mesure de la section 6 : sans cela, une re-execution// ajoute une nouvelle copie de chaque document au store et fausse l'inspection des partitions.string storeDir = Path.Combine(Path.GetTempPath(),"km_rag06_store");string vecDbDir = Path.Combine(Path.GetTempPath(),"km_rag06_vecdb");foreach(var d innew[]{ storeDir, vecDbDir })if(Directory.Exists(d)) Directory.Delete(d, recursive:true);var memory =NewBuilder().WithSimpleFileStorage(storeDir).WithSimpleVectorDb(vecDbDir).WithLLamaSharpTextEmbeddingGeneration(llamaConfig).Build<MemoryServerless>();Console.WriteLine("Pipeline MemoryServerless pret : SimpleFileStorage + SimpleVectorDb + LLamaSharp (granite-embedding-107m, CPU)");
Le corpus est un ensemble de huit documents courts en francais sur l’infrastructure RAG elle-meme : incidents operationnels (perte de donnees, split-brain), parametrage d’index (HNSW, quantization), tokenisation (BPE), patterns de retrieval (HyDE, grounding), et sauvegardes. Chaque document est tague pour permettre le filtrage par theme (incident, qdrant, indexation, tokenisation, cout, retrieval, agents).
L’ingestion passe par memory.ImportDocumentAsync(...) qui orchestre les 4 etapes du pipeline. La taille du corpus est volontairement petite : assez grande pour avoir des distinctions semantiques, assez petite pour iterer rapidement sur la granularite.
Pourquoi un corpus sur l’infrastructure RAG : un systeme RAG qui ‘marche’ peut cacher bien des surprises (partitions mal decoupees, embeddings sous-dimensionnes, index mal configures). Le corpus est construit pour exposer ces pieges plutot que pour les cacher : chaque document traite un piege different (cohort drift, chunking, indexation, etc.), et la recherche semantique doit les faire remonter quand on interroge sur le sujet. C’est un corpus d’audit pedagogique, pas un corpus thematique libre.
Les 8 documents (cf. cellule code[4] pour les corps complets) :
Fichier
Tags theme
Piege expose
incident-perte-donnees.md
incident, qdrant
perte de points sur redemarrage conteneur
split-brain.md
incident, qdrant
verrou anti split-brain sur conteneur zombie
hnsw-parametrage.md
qdrant, indexation
choix m/ef_construction vs rappel/latence
bpe-compte.md
tokenisation, cout
mesure du cout reel vs tokens annonces
hyde.md
retrieval
HyDE : query expansion par generation hypothetique
grounding.md
retrieval, agents
ancrage des reponses LLM sur passages retrouves
quantization-turboquant.md
qdrant, cout
quantization des vecteurs et perte de rappel
sauvegardes.md
incident, qdrant
sauvegarde non restauree != sauvegarde
Le filtrage par tags est exploite dans la cellule code[7] (recherche avec citations) et dans la section 6 (variation de maxTokensPerParagraph).
var corpus =new(string file,string tags,string body)[]{("incident-perte-donnees.md","incident,qdrant",string.Join("\n\n",new[]{"# Incident : la perte de donnees de mars","En mars, la collection conversations de Qdrant a perdu environ 40 pourcents de ses points apres un redemarrage du conteneur. La cause racine : le volume Docker etait monte en ecriture par deux instances simultanees, et aucune sauvegarde n'avait ete restauree depuis onze jours. Le montant de perte reel a ete mesure par comparaison avec l'export nocturne du disque de persistance.","Trois lecons en sont sorties. D'abord, le split-brain du montage doit ete detecte par une sonde qui compare l'UUID du conteneur a l'owner declare. Ensuite, la sauvegarde doit etre restauree periodiquement dans un environnement epheliere : une sauvegarde jamais restauree n'est pas une sauvegarde. Enfin, le journal d'incident est devenu un document de premiere classe de la base : la memoire semantique indexe aussi les echecs.","Apels remediation : verrou proprietaire sur le volume, sauvegarde bi-quotidienne avec restauration automatique le dimanche, et ajout d'un compteur de points publie qui alarme au-dela de 5 pourcents d'ecart avec l'attendu."})),("split-brain.md","incident,qdrant",string.Join("\n\n",new[]{"# Anti split-brain sur Qdrant","Le split-brain survient quand deux processus croient chacun detenir l'unicite d'un volume de stockage. Dans notre cas, un conteneur zombie conservait un descripteur de fichier ouvert sur le repertoire de persistance pendant que le nouveau conteneur reconstruisait l'index HNSW. Les ecritures des deux processus se sont entrelacees dans les segments memtables, et la compaction a ensuite produit des segments incoherents.","La defense comporte trois etages : un verrou disque (fichier lock avec PID), une sonde d'unicite qui interroge l'API cluster de Qdrant et compare les identifiants de replica, et un test de restoration hebdomadaire qui demarre une instance sur une copie de la sauvegarde et compte les points. Sans le troisieme etage, les deux premiers donnent une fausse confiance : on detecte le split-brain, mais pas la corruption silencieuse qu'il laisse."})),("hnsw-parametrage.md","qdrant,indexation",string.Join("\n\n",new[]{"# Parametrage HNSW en pratique","HNSW construit un graphe multicouche ou chaque point relie a ses M voisins les plus proches ; la recherche descend les couches en greedy search guidee par ef_construction. Le compromis central : M eleve ameliore le rappel des requetes difficiles mais renforce le cout memoire de l'index et le temps de construction. Pour nos collections de conversation, M = 16 et ef_construction = 200 se sont averes le point doux.","Le parametre runtime ef (au moment de la requete) reste le levier dominant du rappel : monter ef de 64 a 256 a fait passer le rappel au top-10 de 0,88 a 0,97 sur notre gold interne, au prix d'une latence triplee. La quantization scalaire (TurboQuant) comprime les vecteurs d'un facteur quatre avec une perte de rappel mesurable mais acceptable au-dela de 100k points, la ou l'index ne tient plus en RAM.","En dessous de 50k points et en local, l'index exact reste la reference : HNSW n'apporte son gain de latence qu'une fois le brut force vectoriel devenu le goulot. C'est exactement le compromis que le notebook 05 de cette serie mesure a la main."})),("bpe-compte.md","tokenisation,cout",string.Join("\n\n",new[]{"# BPE : le token, unite de compte","La tokenisation BPE fusionne iterativement les paires de caracteres les plus frequentes du corpus d'entrainement en vocabulaire. Consequence directe pour une memoire semantique : chaque fragment de texte ingere, chaque requete, chaque passage stocke est facture en tokens, pas en caracteres. Un mot francais rare comme reconstruisaient coute trois a quatre tokens la ou l'article defini en coute un.","Le notebook 04 de cette serie deroule BPE a la main ; ici on en retient la consequence economique : le cout d'une memoire semantique se pilote en tokens. Partitionner un document en blocs de 256 tokens au lieu de 64 divise par quatre le nombre d'embeddings a calculer et donc le cout d'ingestion, mais grossit chaque partition, ce que la section 5 de ce notebook mesure cote rappel.","Regle pratique retenue : estimer le compte de tokens d'un texte francais par un quart de sa longueur en caracteres (approximation large, suffisante pour dimensionner un pipeline)."})),("hyde.md","retrieval",string.Join("\n\n",new[]{"# HyDE : embedder une reponse hypothetique","HyDE (Hypothetical Document Embeddings) reformule la question en document hypothetique avant la recherche vectorielle : au lieu d'embedder la question brute, on genere d'abord un court passage fictif qui ressemblerait a la reponse, et on cherche ses voisins. L'intuition : l'espace des embeddings rapproche deux documents, pas une question et un document.","Le notebook 02 mesure HyDE sur le gold francais : gain net sur les questions courtes et lexicalement eloignees du corpus, gain nul voire negatif sur les questions deja riches en vocabulaire du domaine, au prix d'un appel de generation supplementaire par requete. HyDE est donc un levier conditionnel, pas un defaut.","Kernel Memory n'implemente pas HyDE nativement : c'est une transformation de requete, qui vit naturellement cote orchestrateur (serie SemanticKernel) avant l'appel au backend memoire."})),("grounding.md","retrieval,agents",string.Join("\n\n",new[]{"# Grounding : ancrer les agents dans les faits","Le grounding demande a un agent generateur de citer ses sources : chaque affirmation doit remonter a un passage retrievable, et le pipeline fournit les citations avec leur partition d'origine pour verifier. Une reponse non ancree est traitee comme une hypothese, pas comme un fait : le distinguo est le coeur du cahier des charges de cette serie.","En pratique, la boucle grounding comporte trois temps : retrieval des passages pertinents, generation contrainte a ne s'appuyer que sur ces passages, puis verification humaine ou automatique que chaque phrase de la reponse a une citation. Le taux d'affirmations ancrees est la metrique qui a remplace le simple taux de reponses correctes dans nos tableaux de bord."})),("quantization-turboquant.md","qdrant,cout",string.Join("\n\n",new[]{"# Quantization TurboQuant","La quantization vectorielle encode chaque dimension sur moins de bits : int8 au lieu de float32 divise la taille de l'index par quatre. TurboQuant, la variante de Qdrant, reconstruit les vecteurs quantizes avec une correction d'erreur qui limite la perte de rappel a environ un a deux points sur nos collections de test.","Le vrai critere de decision n'est pas la place disque mais la RAM : un index qui tient en memoire sert ses requetes en millisecondes, un index echange sur disque voit sa latence exploser. La quantization est donc le levier qui retarde le moment ou il faut payer soit plus de RAM, soit une migration vers un backend distribue.","Regle de decision retenue dans cette serie : quantizer au-dela de 100k points, mesurer le rappel avant et apres sur un gold fige, et documenter la perte acceptee. Jamais quantizer a l'aveugle."})),("sauvegardes.md","incident,qdrant",string.Join("\n\n",new[]{"# Sauvegardes : a moitie cablees n'est pas cable","L'incident de mars a revele que la sauvegarde etait a moitie cablee : le script d'export tournait bien chaque nuit, mais personne n'avait verifie que les fichiers produits etaient complets et restaurables. Une sauvegarde non testee est un confort psychologique, pas une procedure.","La remediation a trois volets : un test de restoration automatique hebdomadaire qui demonte un conteneur epheliere sur la copie, un chiffre de completude (nombre de points restaures contre attendu) publie dans le journal, et une alerte si le test ne s'execute pas : une sauvegarde silencieuse est une sauvegarde morte."}))};string corpusDir = Path.Combine(Path.GetTempPath(),"km_rag06_corpus");Directory.CreateDirectory(corpusDir);foreach(var(file, _, body)in corpus) await File.WriteAllTextAsync(Path.Combine(corpusDir, file), body);var sw = System.Diagnostics.Stopwatch.StartNew();foreach(var(file, tags, _)in corpus){var tc =newTagCollection();foreach(var t in tags.Split(',')) tc.Add("theme", t); await memory.ImportDocumentAsync(Path.Combine(corpusDir, file), index:"rag06", tags: tc);}sw.Stop();Console.WriteLine($"{corpus.Length} documents ingeres (extract, partition, embed, index) en {sw.Elapsed.TotalSeconds:F1} s");Console.WriteLine("corpus : "+string.Join(", ", corpus.Select(c => c.file)));
8 documents ingeres (extract, partition, embed, index) en 4.1 s
corpus : incident-perte-donnees.md, split-brain.md, hnsw-parametrage.md, bpe-compte.md, hyde.md, grounding.md, quantization-turboquant.md, sauvegardes.md
Lecture de la construction du pipeline in-process (cellules code[1]–[3]) :
Trois cellules enchaînent pour mettre en place le pipeline :
code[1] : téléchargement du GGUF granite-embedding-107m-multilingual-Q8_0.gguf depuis Hugging Face. La fonction DownloadIfAbsentAsync vérifie d’abord le cache (dossier %TEMP%/km_rag06_gguf/) et ne télécharge que si nécessaire. Sortie : nom du fichier + taille en Mo.
code[2] : configuration du runtime natif LLamaSharp. En mode notebook, le chargeur standard ne sonde pas automatiquement le bon dossier natif. On pointe explicitement vers le dossier de la plateforme, ~/.nuget/packages/llamasharp.backend.cpu/0.27.0/LLamaSharpRuntimes/<rid>/native/avx2/, où <rid> est dérivé de la machine (win-x64, linux-x64, osx-arm64…), avec noavx/ en repli si AVX2 est absent (macOS et ARM n’ont qu’un dossier native/). La bibliothèque s’appelle llama.dll sous Windows, libllama.so sous Linux et libllama.dylib sous macOS (idem pour mtmd). Sortie : backend détecté (native/avx2).
code[3] : construction du pipeline. C’est la cellule la plus complexe — elle crée :
un GenerateurTexteInactif : ITextGenerator (mock — KM 0.98 exige un ITextGenerator même quand on ne fait que des embeddings) ;
un LLamaSharpTextEmbeddingGenerator (le générateur d’embeddings réel) ;
un MemoryServerless (le pipeline in-process) ;
une fonction BuildMemory(int maxTokensPerParagraph) qui sera réutilisée pour mesurer l’effet de la granularité.
Sortie attendue : confirmation de la construction du pipeline (objet MemoryServerless créé avec succès). Pas de figure — c’est de la mise en place.
Pourquoi le GenerateurTexteInactif : en KM 0.98, l’orchestrateur in-process exige un ITextGeneratorrésolu par injection de dépendances, même quand aucune génération de texte n’est demandée (pipeline 100% embeddings). Ce générateur minimal n’est jamais invoqué dans ce notebook — il satisfait juste la signature. C’est un workaround documenté pour une limitation de l’API 0.98.
Coût : ~10-30 secondes au premier run (téléchargement + chargement modèle), ~1 seconde aux runs suivants (cache hit).
4. Sous le capot : les partitions générées par le pipeline
Cette section inspecte ce que KM a réellement stocké : la sortie de memory.SearchAsync(...) montre la liste des partitions retrouvées, avec leur texte, leur partition ID, leur fichier source, leur tag, et leur score de similarité cosine. C’est la sortie de l’index — pas une métaphore.
Pourquoi cette section est pédagogique : un système de RAG qui « marche » peut cacher bien des surprises — partitions mal découpées (coupure au milieu d’une phrase), embeddings sous-dimensionnés (perte de signal sémantique), index mal configuré (fuite entre tags). L’inspection directe des partitions retrouvées est le seul moyen de voir ce que le pipeline a produit.
Métadonnées de chaque partition : - FileName : le fichier source (e.g. split-brain.md). - PartitionId : identifiant entier de la partition dans le document (0, 1, 2…). - Text : le texte de la partition (~64-256 tokens selon configuration). - Relevance : score cosine ∈ [0, 1] (1 = identique, 0 = orthogonal). - Tags : liste des tags hérités du document parent (incident, qdrant, indexation, tokenisation, cout, retrieval, agents). - Source : URL ou chemin source (utile pour la citation).
Sortie attendue (cellule code[5]) : pour la requête large « Quels documents traitent des incidents Qdrant et du paramétrage HNSW ? », on attend 4-8 partitions des fichiers incident-perte-donnees.md, split-brain.md, hnsw-parametrage.md, sauvegardes.md, quantization-turboquant.md, avec des scores cosine typiquement entre 0.4 et 0.7 (corpus technique, requêtes proches du vocabulaire ingéré).
Le piège classique : la requête peut faire remonter une partition qui contient le mot-clé sans traiter le sujet — par exemple « split-brain » peut ressortir dans une fiche qui mentionne la partition seulement en passant. C’est le faux-positif de la recherche lexicale étendue à la sémantique. L’inspection visuelle est le seul garde-fou.
Le pattern typique d’usage : on pose une question, KM renvoie les partitions pertinentes avec leur chemin de citation complet. Le consommateur (agent Semantic Kernel, LLM) peut alors citer ses sources ou construire un prompt augmenté. C’est ce qui distingue KM d’un vector_search brut : la citation est un citoyen de première classe.
Pourquoi cette capacité est décisive : dans un système agentique, le LLM doit pouvoir justifier ses réponses. Sans citation, il hallucine. Avec citation, il peut ancrer chaque assertion sur un passage retrouvé. C’est le pont RAG ↔︎ LLM.
Format de citation : pour chaque partition, KM expose Partition.FileName, Partition.PartitionId, Partition.Text, Partition.Relevance. La citation textuelle est f"{Partition.FileName}#{Partition.PartitionId}: {Partition.Text}".
Sortie attendue (cellule code[6]) : pour chaque question du gold, la meilleure partition retrouvée avec son score et le chemin de citation. Affichage itératif (foreach sur 3 questions techniques du gold).
Le gold technique (cf. cellule code[6]) : 1. « split-brain sur Qdrant » → attend split-brain.md. 2. « paramètre HNSW pour un rappel correct » → attend hnsw-parametrage.md. 3. « différence entre sauvegarde faite et sauvegarde restaurable » → attend sauvegardes.md.
Métrique de succès : la partition attendue est dans le top-3 des partitions retrouvées. C’est la métrique de rappel@3 que la §6 va généraliser.
Coût : ~1 seconde par question (3 requêtes).
foreach(var q innew[]{"Comment le verrou anti split-brain detecte-t-il un conteneur zombie ?","Quel parametre runtime fait le plus gagner en rappel, et a quel prix ?","Pourquoi une sauvegarde jamais restauree n'est-elle pas une sauvegarde ?",}){var res = await memory.SearchAsync(q, index:"rag06", limit:2);// KM peut retourner le meme document en plusieurs citations (une par partition// retenue) : on deduplique par fichier pour un affichage lisible.var citations = res.Results.GroupBy(c => c.SourceName).Select(g => g.First()).Take(2); Console.WriteLine(); Console.WriteLine($"Q : {q}");foreach(var cit in citations){var p = cit.Partitions.OrderByDescending(x => x.Relevance).First();var extrait = Regex.Replace(p.Text, @"\s+"," ").Trim(); extrait = extrait.Length>100? extrait[..100]+"...": extrait; Console.WriteLine($" -> {cit.SourceName} (partition {p.PartitionNumber}) rel={p.Relevance:F3} : {extrait}");}}
Q : Comment le verrou anti split-brain detecte-t-il un conteneur zombie ?
-> split-brain.md (partition 0) rel=0.814 : # Anti split-brain sur Qdrant Le split-brain survient quand deux processus croient chacun detenir l'...
-> bpe-compte.md (partition 0) rel=0.667 : # BPE : le token, unite de compte La tokenisation BPE fusionne iterativement les paires de caractere...
Q : Quel parametre runtime fait le plus gagner en rappel, et a quel prix ?
-> hnsw-parametrage.md (partition 0) rel=0.711 : # Parametrage HNSW en pratique HNSW construit un graphe multicouche ou chaque point relie a ses M vo...
-> grounding.md (partition 0) rel=0.665 : # Grounding : ancrer les agents dans les faits Le grounding demande a un agent generateur de citer s...
Q : Pourquoi une sauvegarde jamais restauree n'est-elle pas une sauvegarde ?
-> sauvegardes.md (partition 0) rel=0.749 : # Sauvegardes : a moitie cablees n'est pas cable L'incident de mars a revele que la sauvegarde etait...
-> grounding.md (partition 0) rel=0.673 : # Grounding : ancrer les agents dans les faits Le grounding demande a un agent generateur de citer s...
6. Mesure : granularité des partitions contre rappel
Cette section varie le paramètre maxTokensPerParagraph du chunker et mesure l’effet sur le rappel@3. La granularité a un coût : des partitions plus fines = plus d’embeddings = plus d’ingestion. Mais elles permettent aussi des citations plus précises.
L’expérience : on construit N=4 versions du pipeline avec des granularités différentes (64, 128, 192, 256 tokens), on ingère le même corpus, on mesure le rappel@3 sur le gold français. Le compromis devrait apparaître clairement : en dessous d’un certain seuil, trop de bruit ; au-dessus, trop peu de signal.
Pourquoi cette mesure est fondamentale : dans un système RAG de production, la granularité du chunker est un hyperparamètre critique — souvent choisi par défaut et jamais revisité. Cette section montre comment le revisiter empiriquement, sur son propre gold.
Sortie attendue (cellule code[7]) : pour chaque granularité, le nombre de partitions générées, le nombre d’embeddings calculés, et le rappel@3 sur le gold. Le ratio n_embeddings / n_documents capture le coût d’ingestion.
Le piège classique : se contenter du rappel moyen. La variance entre questions est instructive — une question qui marche à toute granularité est triviale, une question qui ne marche qu’à 64 tokens est discriminante. La section md[15] détaille les 3 enseignements chiffrés.
Lecture. Trois enseignements chiffrés. (1) Le coût d’ingestion explose avec la finesse : 60 vecteurs à créer pour des partitions de 64 tokens contre 10 pour des partitions de 256 — six fois plus d’embeddings sur un corpus de huit documents courts ; le ratio monte sur des documents longs. (2) Le rappel sature : les deux stratégies retrouvent le bon document dans le top-3 — la dilution du signal par les partitions larges ne coûte pas cher sur ce petit corpus aux documents thématiquement étanches. Le compromis devient réel sur des documents longs et multi-thèmes, où une partition large mélange deux sujets et fait remonter le mauvais passage : c’est ce que le gold étendu de l’exercice 3 permet de tester. (3) La différence visible dès ce corpus est la taille des extraits cités : ~5× plus courts en partitions fines (833 contre 160 caractères en moyenne). Quand la citation alimente un LLM, la granularité pilote directement le coût du contexte et la précision de la citation.
À retenir : la granularité est un paramètre de conception, pas un détail — elle pilote le coût d’ingestion (nombre d’embeddings), la taille des citations, et au-delà d’un certain degré de dilution, le rappel.
Loi d’échelle empirique : pour un corpus de documents thématiquement étanches (un seul sujet par document), la granularité 128-192 tokens est typiquement le sweet spot. Pour des documents multi-thèmes, descendre à 64-96 tokens. Au-dessus de 256 tokens, on commence à mélanger les sujets dans une même partition et le rappel dégrade.
Coût caché : si vous avez 1M de documents et un chunker à 64 tokens, vous générez ~50M d’embeddings. À 256 tokens, ~12M. La différence de coût d’ingestion et de stockage est 4× — c’est un paramètre budgétaire, pas seulement qualitatif.
Sortie attendue (cellule code[7]) : un tableau récapitulatif des 4 configurations (granularité, n_embeddings, rappel@3, taille moyenne des citations). Lecture recommandée en conjonction avec la cellule code[7] qui produit les données brutes.
7. Limites et prolongements
Trois limites principales du pipeline in-process, et trois prolongements possibles pour la production.
Limite 1 — SimpleVectorDb est exact, pas ANN. Le SimpleVectorDb fait une recherche brute (parcours complet de l’index). C’est correct pour un corpus de ~10k partitions, intenable à 1M. Le notebook 05 explore les approximations ANN (HNSW, IVF) qui changent la donne.
Limite 2 — Pas de re-ranking. La recherche KM renvoie les top-K par similarité cosine brute. Pour les requêtes complexes (multi-hop, agrégation), un re-ranker cross-encoder améliore sensiblement le rappel. Le notebook 09 explore cette piste.
Limite 3 — Pas de gestion des versions. Ré-ingérer un document modifié crée un nouveaudocumentId sans supprimer l’ancien. En production, il faut une stratégie de versionnement (timestamp, hash, ou étiquette explicite).
Prolongement 1 — Service web via MemoryService. Pour un usage agentique multi-clients, on peut exposer KM comme service web (via MemoryWebService). Les agents interrogent via HTTP. C’est le pattern de production classique.
Prolongement 2 — Pipeline asynchrone scalable. Le pipeline est conçu pour ingérer en batch (plusieurs documents en parallèle). En production, on l’oriente via une queue (RabbitMQ, Azure Service Bus) pour découpler ingestion et indexation.
Prolongement 3 — Multi-modal. KM 0.98+ supporte l’ingestion d’images et de transcripts audio (via les ContentTypes configurables). Pour un agent qui doit lire des PDF avec figures, c’est le pont à activer.
Sortie attendue (cette section) : aucune sortie — c’est de la documentation. Les exercices ci-dessous implémentent certains de ces prolongements.
Exercices
Trois exercices pour étendre le pipeline. Chaque stub s’exécute sans erreur (C.1). Complétez le TODO pour répondre.
Conventions C.1 : les cellules contiennent des commentaires // TODO: ... et des corps partiels. Elles s’exécutent de bout en bout même non-complétées (sortie vide ou partielle). Le compilateur C# valide la syntaxe mais pas la logique métier.
Indications : l’exercice 1 explore le filtrage par tag (utile pour restreindre un audit à un sous-domaine technique) ; l’exercice 2 ingère un PDF réel ; l’exercice 3 mesure l’effet des préfixes d’instruction sur le rappel.
Barème indicatif : 10-20 minutes par exercice.
Exercice 1 — Filtrage par tag. Le pipeline expose memory.SearchAsync(query, tags: new[] { "incident" }) qui restreint la recherche aux partitions dont le document parent porte ce tag. Mesurez le rappel@3 sur le gold filtré vs non-filtré : le filtrage aide-t-il ou gêne-t-il ? Indice : pour une requête sur split-brain.md ou sauvegardes.md, le filtre ["incident"] est correct ; mais pour une requête sur le paramétrage HNSW (qui parle aussi d’incidents collatéraux), le filtre serait dommageable.
Exercice 2 — Ingestion d’un PDF. Au lieu de texte brut, ingérez un vrai PDF via memory.ImportDocumentAsync(pdfPath). KM 0.98 gère nativement le PDF via PdfDecoder. Comparez la qualité du partitionnement (coupures propres vs artéfacts d’extraction) entre PDF et texte brut.
Exercice 3 — Préfixes d’instruction. L’embedder granite recommande asymétrique : préfixer les requêtes avec query: et les passages avec passage:. Implémentez ces préfixes dans une variante du pipeline et mesurez l’effet sur le rappel. Hypothèse : gain attendu de 5-15 % sur des requêtes complexes (multi-mots-clés).
// Exercice 1 -- filtrage par tag (a completer)// Objectif : la question de l'incident de mars ne doit citer que des documents theme=incident.Console.WriteLine("Exercice a completer : filtrage par tag");// var filtre = new MemoryFilter().ByTag("theme", "incident");// var res = await memory.SearchAsync("Combien de points perdus lors de l'incident de mars ?", index: "rag06", filter: filtre, limit: 3);
Exercice a completer : filtrage par tag
Lecture des partitions réellement stockées (cellule code[5]) :
La cellule ci-dessus fait une requête large : « Quels documents traitent des incidents Qdrant et du paramétrage HNSW ? » puis affiche les partitions retrouvées avec leurs métadonnées.
Pour chaque partition, on observe : - FileName : le document source (e.g. split-brain.md). - PartitionId : identifiant entier (0, 1, 2…). - Text : le texte de la partition, tronqué pour l’affichage. - Relevance : score cosine ∈ [0, 1]. - Tags : liste des tags hérités (incident, qdrant, etc.).
Tri par relevance : les partitions sont triées par score décroissant. Le top-1 a typiquement le score le plus élevé (e.g. 0.62 sur ce corpus technique) ; le top-5 descend sous 0.4. La coupure entre pertinent et non-pertinent est visuelle sur ce petit corpus.
Pourquoi cette inspection est cruciale : dans un système RAG, on suppose souvent que « le top-1 est correct ». Cette section permet de vérifier empiriquement — et de comprendre les cas où le top-1 est trompeur (par exemple, une partition qui cite split-brain dans un contexte de mitigation générique, alors que la fiche split-brain.md traite le cas technique précis).
Piège classique : si le corpus n’est pas étanche thématiquement (e.g. une fiche qui mentionne HyDE et grounding en passant dans une fiche sur HNSW), le chunker peut produire une partition qui mélange les sujets. Le score cosine sera élevé pour des requêtes ambiguës, mais la partition ne sera pas utile.
// Exercice 2 -- ingestion d'un PDF (a completer)// Objectif : citations provenant d'un vrai fichier PDF decode par la pipeline KM.Console.WriteLine("Exercice a completer : ingestion PDF");// string pdfPath = ...; // produire un PDF d'une page// await memory.ImportDocumentAsync(pdfPath, index: "rag06-pdf");
Exercice a completer : ingestion PDF
// Exercice 3 -- prefixes d'instruction (a completer)// Objectif : mesurer le rappel@3 du gold avec les instructions de prefixe respectees.Console.WriteLine("Exercice a completer : prefixes d'instruction et rappel");// class PrefixedGenerator : ITextEmbeddingGenerator { ... prefixe selon la phase ... }
Exercice a completer : prefixes d'instruction et rappel
Conclusion
Kernel Memory est la bonne abstraction pour le pipeline RAG en .NET : ingestion de documents, partitionnement, embeddings, indexation, recherche avec citations — le tout orchestrable depuis une seule API. Comparé à un assemblage manuel (embedders + Qdrant + chunker custom), KM économise ~80 % du code et garantit la traçabilité document → partition → passage.
Trois leçons à retenir :
L’abstraction coûte un peu de contrôle. KM cache la tuyauterie, mais on peut toujours inspecter les partitions via memory.SearchAsync. Le compromis est bon.
La granularité est un paramètre de conception. Trop fine = explosion du coût d’ingestion ; trop large = mélange de sujets dans une même partition. Le sweet spot dépend du corpus.
Les citations sont la valeur ajoutée. Sans citation, le RAG est un vector search glorifié. Avec citation, c’est un système augmentable par un LLM avec ancrage sur des sources.
Suite de la série : le prochain notebook (07) explore l’agent Semantic Kernel qui consomme cette mémoire — c’est le pont entre la mémoire pure (06) et les agents qui l’utilisent pour répondre.
Vers la production : MemoryService (web), pipeline asynchrone scalable, multi-modal — chaque étape est un prolongement orthogonal qui ne casse pas l’API de base.
Coût global du notebook : ~30 secondes pour le premier run (chargement modèle + ingestion + recherches + mesure). ~5 secondes pour les runs suivants (modèle en cache). ***