Le développement illustré ici s'inscrit dans la catégorie « RAG » (Retrieval-Augmented Generation) de l'intelligence artificielle. Le terme « RAG » désigne généralement la génération augmentée par récupération, un cadre d'IA dans lequel un modèle de langage étendu (LLM) récupère d'abord les informations pertinentes d'une source de connaissances privées avant de générer une réponse. Ce processus permet à l'IA de fournir des réponses adaptées au contexte. Cette source sera quelques fichiers Word dont le contenu correspond à une base de connaissances interne.
Ce développement nécessite l'installation d'un serveur Qdrant qui sera utilisé comme VectorStore pour stocker le contenu de la base de connaissance.
Ceci requiert l'installation des plugins MercatorAI.OpenAI ainsi que MercatorAI.Qdrant, et ne fonctionne qu'avec une version Core de Mercator. (Le code est toutefois compilable dans une version classique).
Ensuite, deux codes C# distincts seront mis en place, le premier pour populer ou mettre à jour le VectorStore et le second pour chatter avec OpenAI sur base de ce contenu.
Ces deux codes utilisent cette classe, qui servira de modèle du VectorStore :
public sealed class DocChunk
{
[MercatorAI.FactoryQdrant.VectorStoreKey]
public Guid Id { get; set; } = Guid.NewGuid();
[MercatorAI.FactoryQdrant.VectorStoreData(IsIndexed = true)]
public string SourceId { get; set; }
[MercatorAI.FactoryQdrant.VectorStoreData]
public int Chunk { get; set; }
[MercatorAI.FactoryQdrant.VectorStoreData(IsFullTextIndexed = true)]
public string Content { get; set; } = "";
// Mode "auto" : on stocke une propriété string marquée comme vecteur.
// La valeur est le texte dont l’embedding sera généré automatiquement.
[MercatorAI.FactoryQdrant.VectorStoreVector(Dimensions = 1536)] // 1536 pour text-embedding-3-small
public string Embedding => Content;
}
Le propriétés SourceId est marquée comme indexée. Cela permettrait de placer un filtre préalable pour limiter la recherche à un fichier de documentation source détermine. Ceci est optionnel.
Le contenu de la propriété Content sera vectorisé et placé dans Embedding.
1. Populer ou mettre à jour le VectorStore
Dans l'exemple repris, nous allons charger une série de fichiers Word (extension DOCX) qui se trouvent dans la base de données de Mercator. Ces contenus vont être découpés en morceaux (chunks) de taille idéale pour l'usage qui va en être fait. Pour calculer ces chunks, Mercator fait usage Microsoft.SemanticKernel. On voit dans le code que les chunks vont être transformés en embeddings (vecteurs) par OpenAI, donc un service externe, même s'ils seront stockés sur un serveur Qdrant privé.
Note : le découpage en chunks est simple. Un processus plus complexe pourrait avantageusement être mis en place, tenant compte notamment de la structure du document.
Comme la collection contient un identifiant aléatoire via Guid.NewGuid(), pour la mettre à jour, elle est toujours supprimée. Il est aussi possible de mettre un identifiant qui reprend une information connue afin de ne mettre à jour que les enregistrements ajoutés ou modifié. (Par exemple avec Api.DeterministicGuidFromString).
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MercatorUi;
using MercatorApi;
using MercatorExtensions;
// <CompileWithRoslyn />
namespace MyAssembly
{
public static class ChatGPT
{
private const string tutosCollectionName = "Tutos";
public async static Task VectorizeTutos()
{
if (!Dialogs.AnswerYesNo("Réellement vectoriser le contenu des tutoriels ?"))
return;
MercatorAI.FactoryOpenAI factoryOpenAI = new MercatorAI.FactoryOpenAI();
MercatorAI.Interfaces.IOpenAiEmbeddingGeneratorClient openAiEmbeddingGeneratorClient = factoryOpenAI.CreateOpenAiEmbeddingGeneratorClient<string>("text-embedding-3-small", out string error);
if (!string.IsNullOrEmpty(error))
{
Dialogs.Stop("Impossible d'initialiser le générateur d'embeddings d'OpenAI : " + error);
return;
}
MercatorAI.FactoryQdrant factoryQdrant = new MercatorAI.FactoryQdrant();
using (MercatorAI.Interfaces.IQdrantClient qdrantClient = factoryQdrant.CreateQdrantClient("http://192.168...:6334", out error))
{
if (!string.IsNullOrEmpty(error))
{
Dialogs.Stop("Impossible d'initialiser le client Qdrant : " + error);
return;
}
MercatorUi.Wait.WaitStatic.WaitWindow(Api.Iif_langue(Globals.Langue, IifLangueEnum.DataUpdate));
await qdrantClient.CollectionDeleteAsync<Guid, DocChunk>(tutosCollectionName);
using (var embeddingGenerator = openAiEmbeddingGeneratorClient.EmbeddingGenerator)
using (var collection = await qdrantClient.CreateCollectionAsync<Guid, DocChunk>(tutosCollectionName, embeddingGenerator))
{
foreach (string docxFile in Api.GetFiles("<Other\\Tutos", "*.docx"))
{
string sourceId = Api.JustFName(docxFile);
string fullText = Api.DocxToText(docxFile);
List<string> chunks = qdrantClient.TextChunkerSplitPlainTextLines(fullText, maxTokensPerLine: 400);
IEnumerable<DocChunk> records = chunks.Select((text, i) => new DocChunk
{
Chunk = i,
SourceId = sourceId,
Content = text
});
foreach (DocChunk[] arrayOfDocChunks in records.Chunk(500)) // on envoie par bloc de 500
{
await qdrantClient.CollectionUpsertAsync<Guid, DocChunk>(collection, arrayOfDocChunks);
}
}
}
MercatorUi.Wait.WaitStatic.WaitClear();
Dialogs.Stop(Api.Iif_langue(Globals.Langue, IifLangueEnum.UpdateAchieved));
}
}
}
}
Ce code peut être appelé, par exemple, depuis le ruban (Exécuter code C #async) :
public static class Script
{
public static async Task ExecAsync(DevComponents.DotNetBar.ButtonItem clickedButtonItem)
{
// enter your customized code here
await MyAssembly.ChatGPT.VectorizeTutos();
}
}
ou être exécuté depuis Mercator en mode console. Dans ce cas, il faut remplacer les Dialogs.Stop(...) par MercatorUi.Globals.MercatorTasksToMain.Log(..., isError: true) et retirer toutes les lignes commençant par MercatorUi.Wait.WaitStatic.
2. Chatter avec OpenAI sur base de ce contenu
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using MercatorUi;
using MercatorApi;
using MercatorExtensions;
// <CompileWithRoslyn />
namespace MyAssembly
{
public static class ChatGPT
{
private const string tutosCollectionName = "Tutos";
public static async Task SearchInTutos()
{
MercatorAI.FactoryOpenAI factoryOpenAI = new MercatorAI.FactoryOpenAI();
MercatorAI.Interfaces.IOpenAiEmbeddingGeneratorClient openAiEmbeddingGeneratorClient = factoryOpenAI.CreateOpenAiEmbeddingGeneratorClient<string>("text-embedding-3-small", out string error);
if (!string.IsNullOrEmpty(error))
{
Dialogs.Stop("Impossible d'initialiser le générateur d'embeddings d'OpenAI : " + error);
return;
}
MercatorAI.FactoryQdrant factoryQdrant = new MercatorAI.FactoryQdrant();
MercatorAI.Interfaces.IQdrantClient qdrantClient = factoryQdrant.CreateQdrantClient("http://192.168...:6334", out error);
if (!string.IsNullOrEmpty(error))
{
Dialogs.Stop("Impossible d'initialiser le client Qdrant : " + error);
return;
}
MercatorUi.Wait.WaitStatic.WaitWindow(Api.Iif_langue(Globals.Langue, IifLangueEnum.AIisThinking));
if (!await qdrantClient.CollectionExistsAsync(tutosCollectionName))
{
qdrantClient.Dispose();
MercatorUi.Wait.WaitStatic.WaitClear();
Dialogs.Stop($"La collection \"{tutosCollectionName}\" n'existe pas sur le serveur Qdrant !");
return;
}
MercatorUi.Forms.Other.ChatForm chatForm = MercatorUi.Forms.Other.ChatForm.SimpleOpenAiChat("gpt-4o", tutosCollectionName, systemMessage: null, question: "", false, chatOptions: null, standardStreamingAction: MercatorUi.Forms.Other.ChatForm.StandardStreamingActionEnum.RawStreamShow);
MercatorUi.Wait.WaitStatic.WaitClear();
if (chatForm == null)
{
qdrantClient.Dispose();
return;
}
chatForm.Disposed += (s, e) =>
{
qdrantClient.Dispose();
};
MercatorUi.Forms.Other.ChatForm.BeforeAskEventHandler beforeAskEventHandler = null;
beforeAskEventHandler = (s, e) =>
{
if (string.IsNullOrWhiteSpace(chatForm.TextBoxQuestion.Text))
{
_Divers.FocusError(chatForm.TextBoxQuestion);
return;
}
(double? score, DocChunk docChunk)[] hits = null;
MercatorUi.Wait.WaitStatic.WaitWindowBaseThread(Api.Iif_langue(Globals.Langue, IifLangueEnum.AIisThinking));
MercatorUi._BaseClasses.ExclusiveBackgroundWorkerAsync exclusiveBackgroundWorkerAsync = new MercatorUi._BaseClasses.ExclusiveBackgroundWorkerAsync(async () =>
{
float[] questionAsVectors = await openAiEmbeddingGeneratorClient.GenerateEmbeddingAsync(chatForm.TextBoxQuestion.Text);
hits = await qdrantClient.ClientSearchAsync<Guid, DocChunk>(tutosCollectionName, questionAsVectors, top: 5);
}, formWhereToCenterLoadingCircle: chatForm);
MercatorUi.Wait.WaitStatic.WaitClearBaseThread();
if (exclusiveBackgroundWorkerAsync.ExceptionDuringDoWork != null)
{
Dialogs.Stop(exclusiveBackgroundWorkerAsync.ExceptionDuringDoWork.Message, chatForm);
return;
}
if ((hits == null) || !hits.Any())
{
Dialogs.Stop("Aucune correspondance trouvée dans la documentation !", chatForm);
return;
}
var sb = new StringBuilder();
foreach ((double? score, DocChunk docChunk) hit in hits)
{
sb.AppendLine(hit.docChunk.Content);
}
chatForm.SimpleChatSystemMessage = "Tu es un assistant. Réponds en t'appuyant uniquement sur le CONTEXTE qui est extrait de tutos expliquant certaines procédure comptables dans notre entreprise. Si l'info n’y est pas, dis-le clairement. \nCONTEXTE:\n" + sb;
chatForm.BeforeAsk -= beforeAskEventHandler; // SimpleChatSystemMessage uniquement lors de la première interaction dans le chat
};
chatForm.BeforeAsk += beforeAskEventHandler;
}
}
}
Ce code va montrer une ChatForm de Mercator. L'essentiel se passe dans beforeAskEventHandler, c-à-d avant l'exécution du code quand on cliquer pour la première fois sur le bouton "Interroger". A cet endroit, on voit que le code va interroger le VectorStore pour obtenir quelques résultats jugés en rapport avec la question posée. Ces informations sont concaténées et simplement passées dans le message système du chat. On voit dans le code que la question posée va être transformée en embeddings (vecteurs) par OpenAI, donc un service externe, même si la configuration repose sur un serveur Qdrant privé.
Ce code peut être appelé, par exemple, depuis le ruban (Exécuter code C #async) :
public static class Script
{
public static async Task ExecAsync(DevComponents.DotNetBar.ButtonItem clickedButtonItem)
{
// enter your customized code here
await MyAssembly.ChatGPT.SearchInTutos();
}
}
Les informations sont envoyées à un tiers (OpenAI). Il ne faut donc en aucun cas transmettre des données revêtant un caractère confidentiel. Cette documentation indique clairement que l'ensemble de données est soumis à OpenAI pour générer les embeddings (vecteurs).
Les réponses données par ce code sont générées automatiquement à l’aide d’un modèle d’intelligence artificielle (IA). Bien que conçu pour fournir des réponses utiles et pertinentes, ces réponses peuvent être incomplètes, inexactes, partiellement ou totalement.
L’utilisateur reste seul responsable de l'évaluation et de l’utilisation de ces réponses. En aucun cas Mercator ou le fournisseur du service IA ne saurait être tenu responsable des conséquences qui découleraient de l'utilisation de ces réponses.
L'usage de ce service doit être rendu compatible avec les normes relatives au RGPD qui sont en place dans l'entreprise.