MICROSOFT AGENT FRAMEWORK · 0.16.0-rc.1
Index vectoriel et preuves retrouvées
Construire un index dense versionné et contrôler les preuves retrouvées dans SQLite.
Objectifs
- Découpage et embeddings réels
- Microsoft.Data.Sqlite et cosinus exact C#
- Manifeste modèle/dimension et reconstruction transactionnelle
- Sélection des passages et citations vérifiées
Du document à la réponse contrôlée
Le RAG ajoute à une question un contexte retrouvé dans un corpus externe. Il faut d’abord choisir les sources, puis préparer le texte, le découper, calculer ses embeddings et conserver les métadonnées. Au moment de répondre, la question est vectorisée dans le même espace ; l’hôte filtre les candidats, les classe et ne transmet que les extraits admissibles. La génération reste une étape distincte, suivie d’un contrôle de preuve.
- Corpus validé → découpage versionné → embeddings denses réels
- Transaction SQLite → manifeste + BLOB float32 + métadonnées
- Question → embedding → filtres → cosinus exact → fusion et seuil
- Extraits → outil ou provider → proposition typée → contrôle des citations
La rénovation utilise Microsoft.Data.Sqlite sans extension et le cosinus exact en C#. Les procédures historiques Qdrant/Docker sont remplacées ; aucun service vectoriel à installer ou lancer. Le classement lexical actuel est une branche complémentaire réelle, pas un faux score d’embedding. Le chat et les embeddings possèdent des profils indépendants ; changer de chat ne reconstruit pas automatiquement l’index.
Préparer et versionner l’index
CorpusPolicy normalise CRLF et découpe à 600 caractères avec recouvrement de 80 ; la version chars-600-overlap-80-v1 identifie ce choix. Les identifiants sourceId#part suivent l’ordre ordinal des sources. Le corpus livré possède 15 documents courts et deux faits produit. Pour de longs documents, une règle et son exception doivent rester compréhensibles ; le découpage en caractères est ici un compromis pédagogique, sans reconnaissance des sections métier.
EmbeddingIndex génère des lots de 16 avant d’ouvrir la transaction SQLite. Il contrôle des vecteurs finis, de même dimension et de norme non nulle ; le store vérifie aussi le nombre par rapport aux chunks. Le manifeste associe fournisseur, endpoint, modèle, dimension, Cosine, découpage et SHA-256 du corpus normalisé, faits produit inclus. Une différence impose rebuild. Même dimension ne signifie pas même espace sémantique.
public async Task<float[][]> EmbedAsync(IEnumerable<string> texts, CancellationToken ct = default)
{
var result = new List<float[]>();
foreach (var batch in texts.Chunk(16))
{
var generated = await embeddings.GenerateAsync(batch, cancellationToken: ct);
result.AddRange(generated.Select(v => v.Vector.ToArray()));
}
if (result.Count == 0 || result[0].Length == 0 || result.Any(v => v.Length != result[0].Length
|| v.Any(x => !float.IsFinite(x)) || !v.Any(x => x != 0)))
throw new InvalidDataException("Invalid embedding vectors.");
return result.ToArray();
Schéma et remplacement transactionnel
CREATE TABLE IF NOT EXISTS RagManifest (Id INTEGER PRIMARY KEY CHECK(Id=1), Json TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS RagChunks (
Id TEXT PRIMARY KEY, SourceJson TEXT NOT NULL, Text TEXT NOT NULL, Vector BLOB NOT NULL,
Scope TEXT NOT NULL, Status TEXT NOT NULL, Canonical INTEGER NOT NULL,
ValidFrom INTEGER NOT NULL, ValidUntil INTEGER, Product TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS RagProducts (Product TEXT PRIMARY KEY, Json TEXT NOT NULL);
Code source · Maf.Rag · Dossier
RagChunks conserve le texte, SourceJson, Scope, Status, Canonical, dates, Product et les coordonnées float32 little-endian en BLOB. RagManifest stocke le contrat d’index ; RagProducts conserve les faits structurés. rebuild remplace les lignes de ces trois tables dans une transaction, sans supprimer le fichier ni les tables d’un autre module. Une erreur d’embedding avant transaction laisse l’index précédent ; une annulation pendant la transaction annule son remplacement. Le démarrage ordinaire ne réinitialise rien.
La lecture utilise un instantané transactionnel, vérifie manifeste, nombre de chunks, identifiants, texte et longueur des BLOB. Un document retiré ou raccourci ne laisse pas ses anciennes parties après rebuild. Ces vérifications détectent des incompatibilités et corruptions courantes ; elles ne signent pas une base contrôlée par un attaquant.
Mesurer la proximité sans lui attribuer une vérité
Le cosinus est le produit scalaire divisé par le produit des normes. La somme utilise des accumulateurs double ; le résultat est borné entre -1 et 1. Le code refuse dimension incompatible, vecteur nul et coordonnées non finies. Tous les vecteurs admissibles sont parcourus : coût O(N × dimension), adapté au petit corpus et sans index ANN de grande échelle.
public static float Cosine(float[] left, float[] right)
{
ValidateVector(left, left.Length);
ValidateVector(right, left.Length);
double dot = 0, a = 0, b = 0;
for (int i = 0; i < left.Length; i++)
{
dot += (double)left[i] * right[i];
a += (double)left[i] * left[i];
b += (double)right[i] * right[i];
}
return (float)Math.Clamp(dot / Math.Sqrt(a * b), -1, 1);
Code source · Maf.Rag · Dossier
RetrievalPolicy exige cosinus >= 0,60 et recouvrement lexical significatif. RRF combine les rangs dense et lexical avec constante 60 et retient au plus trois extraits. Rank n’est ni un cosinus ni une probabilité. Ce seuil est calibré sur la fixture livrée, pas une garantie pour tout domaine ou modèle ; une paraphrase sans terme commun peut être refusée malgré sa proximité.
public static SearchHit[] Rank(string question, IEnumerable<SearchHit> candidates, float minCosine = MinCosine)
{
if (!float.IsFinite(minCosine) || minCosine < -1 || minCosine > 1)
throw new ArgumentOutOfRangeException(nameof(minCosine));
var all = candidates.OrderByDescending(h => h.Cosine).ThenBy(h => h.Chunk.Id, StringComparer.Ordinal).ToArray();
var terms = Terms(question);
var lexical = all.Select(h => (h.Chunk.Id, Overlap: Terms(h.Chunk.Source.Title + " " + h.Chunk.Text).Intersect(terms).Count()))
.Where(h => h.Overlap > 0).OrderByDescending(h => h.Overlap).ThenBy(h => h.Id, StringComparer.Ordinal)
.Select((h, i) => (h.Id, Rank: i + 1)).ToDictionary(h => h.Id, h => h.Rank);
return all.Select((h, i) => h with { Rank = 1.0 / (60 + i + 1)
+ (lexical.TryGetValue(h.Chunk.Id, out int rank) ? 1.0 / (60 + rank) : 0) })
.Where(h => h.Cosine >= minCosine && lexical.ContainsKey(h.Chunk.Id))
.OrderByDescending(h => h.Rank).ThenBy(h => h.Chunk.Id, StringComparer.Ordinal).Take(3).ToArray();
Recherche explicite et TextSearchProvider
La recherche hôte est obligatoire dans toutes les variantes. Demo190 demande en plus au modèle d’appeler retrieve_knowledge ; l’outil retourne les extraits déjà retrouvés et un compteur prouve son invocation. Sans appel, la réponse est refusée. Demo200 injecte ces mêmes hits par TextSearchProvider avec BeforeAIInvoke et RecentMessageMemoryLimit=0 : il ne relance pas une recherche autonome et ne conserve pas six messages comme l’ancien script. Les réponses sont sans session conversationnelle.
if (mode == "provider")
{
options.AIContextProviders = [new TextSearchProvider((_, cancellation) =>
{
cancellation.ThrowIfCancellationRequested();
retrievalCalls++;
return Task.FromResult<IEnumerable<TextSearchProvider.TextSearchResult>>(retrieval.Hits.Select(h =>
new TextSearchProvider.TextSearchResult { SourceName = h.Chunk.Id, Text = h.Chunk.Text }).ToArray());
}, new TextSearchProviderOptions
{
SearchTime = TextSearchProviderOptions.TextSearchBehavior.BeforeAIInvoke,
RecentMessageMemoryLimit = 0,
ContextPrompt = "Untrusted retrieved excerpts for the current question only:",
CitationsPrompt = "Return exact SourceId and Quote pairs in Evidence."
Code source · Maf.Rag · Dossier
L’outil rend le passage visible dans les événements ; le provider place l’intégration dans le cycle natif. Les deux gardent la politique de sélection et le contrôle final côté hôte. Un nom de provider n’est donc pas une délégation de la gouvernance au modèle.
Ce que prouve la citation
AnswerProposal contient CanAnswer et au plus trois Evidence, chaque entrée avec SourceId et Quote. AnswerGate exige un identifiant réellement retrouvé, une source encore admissible et une citation exacte d’au moins 20 caractères après trim. Une source inventée, une citation modifiée ou une absence de preuve produit Insufficient evidence. Aucun hit admissible arrête le service avant la création du client de chat.
public static ControlledAnswer Validate(AnswerProposal? proposal, Retrieval retrieval)
{
if (retrieval.Hits.Length == 0 || proposal is not { CanAnswer: true, Evidence.Length: > 0 and <= 3 })
return ControlledAnswer.Refuse("no supported answer.");
var sources = retrieval.Hits.ToDictionary(h => h.Chunk.Id, StringComparer.Ordinal);
foreach (var evidence in proposal.Evidence)
{
if (evidence is null || string.IsNullOrWhiteSpace(evidence.SourceId)
|| !sources.TryGetValue(evidence.SourceId, out var source)
|| !CorpusPolicy.Eligible(source.Chunk.Source, retrieval.AsOf, source.Chunk.Source.Scope)
|| string.IsNullOrWhiteSpace(evidence.Quote) || evidence.Quote.Trim().Length < 20
|| !source.Chunk.Text.Contains(evidence.Quote, StringComparison.Ordinal))
return ControlledAnswer.Refuse("invalid citation or quotation.");
}
return new(true, string.Join("\n", proposal.Evidence.Select(e => $"[{e.SourceId}] {e.Quote}")), proposal.Evidence);
Code source · Maf.Rag · Dossier
Le rendu accepté est composé uniquement des citations exactes ; la prose libre du modèle n’est pas publiée comme conclusion. Ce contrôle prouve l’appartenance textuelle aux extraits récupérés, pas leur vérité, leur exhaustivité ni leur capacité à répondre à toute la question. Une citation tronquée avant une exception peut être trompeuse tout en passant le contrôle. L’adéquation métier et les contradictions entre sources exigent une revue distincte.
Ingestion, inspection, puis refus
$env:MAF_STATE_ROOT = Join-Path $env:TEMP ('maf-rag-' + [guid]::NewGuid().ToString('N'))dotnet run --project demos/Demo180_VectorIngestion/Demo180.App.csproj -c Release --no-build -- --Rag:Action rebuilddotnet run --project demos/Demo190_RetrievedEvidence/Demo190.App.csproj -c Release --no-build -- --Rag:Action answer --Rag:AsOf 2026-10-23 --Rag:Question "Where can I export CSV in Reporting?"Conserver la même racine pendant tout l’essai. rebuild demande le fournisseur d’embeddings configuré ; le profil livré utilise embeddinggemma:300m, indépendant du chat granite4:3b. probe donne un diagnostic dense avant seuil/fusion ; answer exerce le refus et demande le chat uniquement avec des hits retenus. Poser une question hors corpus puis examiner la liste de hits et le refus. Ne pas recopier les scores datés du guide comme des mesures nouvelles.
dotnet test tests/Maf.Rag.Tests/Maf.Rag.Tests.csproj -m:1 -c Release --no-build --no-restoreManipulation de citation fabriquée sans modèle : exécuter la commande filtrée ci-dessous, puis lire FabricatedCitationsAndAlteredQuotesAreRejected dans RagTests.cs. Trois propositions sont refusées : INVENTED#0, une phrase modifiée autorisant le remboursement sans reçu et une citation trop courte. Comparer avec ValidEvidenceRendersOnlyTheExactRetrievedQuotation, qui accepte seulement le passage réel. La console RAG ne propose pas de commande pour injecter une fausse Evidence ; ce contre-exemple est exercé directement à la frontière AnswerGate par le test.
dotnet test tests/Maf.Rag.Tests/Maf.Rag.Tests.csproj -m:1 -c Release --no-build --no-restore --filter "FullyQualifiedName~FabricatedCitationsAndAlteredQuotesAreRejected|FullyQualifiedName~ValidEvidenceRendersOnlyTheExactRetrievedQuotation"Observation du 24 octobre 2026 : ingestion réelle via Ollama embeddinggemma:300m, dimension 768, puis relance de probe sur la même base. SQLite contient 15 BLOB de 3072 octets et deux faits produit. Pour Where can I export CSV in Reporting?, le premier hit diagnostique est ACME-EXPORT#0, cosinus 0,7675654. Le mode answer de Demo220 sur GX-999 a renvoyé Insufficient evidence: no admissible retrieved excerpts. Aucun appel chat n’a été nécessaire. Ces mesures décrivent le corpus livré et ce profil local ; les 20 tests RAG emploient séparément des vecteurs connus. Réception Lot-008 et logs privés conservés.
Modes exercés et dépendances
Les tests déterministes injectent de petits vecteurs connus dans de vrais fichiers SQLite et calculent réellement le cosinus ; ils ne testent pas un fournisseur d’embeddings. L’ingestion dense live suit les commandes ci-dessus et reste distincte de cette preuve logicielle. Aucune réponse ni score de modèle n’est inventé. Exécuter les fiches depuis la distribution complète déjà compilée, avec Maf.Rag et Maf.DemoConnection, pas depuis un dossier de démo isolé.
Approfondir l’admissibilité
Prérequis
Pour poursuivre
Précédent
Suivant
