Accueil / Articles

Un pipeline RAG minimal en Go utilisant les embeddings de langchaingo, Groq et Jina

Un pipeline RAG minimal en Go avec langchaingo : un stockage vectoriel en mémoire, une recherche par cosinus, Groq pour les réponses et des embeddings Jina, ainsi qu’une démonstration de contexte incorrect.

Éditeur : REACTAPP.TOP10 min de lecture2251 mots

Sources: Stop Stuffing Your System Prompts: Build a Real RAG Pipeline in Go

RAG, abréviation de Retrieval-Augmented Generation, signifie qu’un modèle répond en s’appuyant sur des documents externes qu’il récupère au moment de la requête, plutôt que de se fonder uniquement sur ce qu’il a appris pendant son entraînement. Les faits n’ont plus besoin d’être codés directement dans le prompt du système. Le contexte le plus pertinent est récupéré et fourni au modèle en même temps que la question, de sorte que les réponses restent liées aux sources que vous contrôlez.

Pourquoi un assistant en a besoin

Tout ceux qui développent un assistant personnalisé se heurtent tôt ou tard au même problème : le modèle ne connaît rien de votre domaine. Il n’a jamais vu les noms de vos produits, vos politiques internes ni les faits spécifiques à votre secteur. La solution évidente consiste à coller tout cela dans le prompt du système, ce qui est fragile, coûteux et atteint rapidement une limite de taille.

RAG inverse cette situation. Au lieu d’intégrer le contexte dans une instruction, le modèle dispose d’un outil permettant de récupérer le contexte approprié lorsqu’une question est posée. Vous déterminez dans quelle réalité évolue l’assistant, sans alourdir l’instruction. Cela vous offre également un modèle structuré pour organiser les connaissances propres à votre entreprise : vos termes, vos documents, votre vérité, récupérés sur demande.

Ce qui suit est un pipeline minimal en Go avec très peu de dépendances.

Les bibliothèques utilisées

Si les outils de LLM en Go vous sont inconnus, les importations ressembleront à du charabia ; voici donc qui fait quoi avant même de voir du code.

langchaingo (github.com/tmc/langchaingo) est la version Go de l’écosystème Python LangChain très connu. Il s’agit d’un ensemble de petits packages composables qui gèrent les parties complexes des applications basées sur des LLM, vous évitant ainsi d’avoir à écrire manuellement des appels HTTP et du parsing JSON pour chaque fournisseur. Quatre de ses sous-packages sont utilisés ici :

  • llms assure l’abstraction des modèles. Une seule interface, llms.Model, ainsi qu’un outil d’aide, llms.GenerateFromSinglePrompt, fonctionnent de la même manière que ce soit avec OpenAI, Groq, Ollama ou un modèle local. Il envoie le prompt préparé et renvoie du texte.
  • embeddings fournit l’interface embeddings.Embedder. Un embedder convertit une chaîne de caractères en une liste de nombres flottants, un vecteur qui capture le sens. Des phrases similaires produisent des vecteurs similaires, tandis que des phrases non liées n’en produisent pas, c’est sur cette logique mathématique que repose le système de récupération d’informations.
  • schema définit schema.Document, une petite structure contenant PageContent (le texte), des métadonnées ainsi qu’un champ Score pour classer les résultats.
  • vectorstores apporte principalement des types d’options et des conventions. Ses stores concrets ne sont pas utilisés, car un memStore construit manuellement montre à quel point les besoins sont en réalité limités.
  • Deux adaptateurs de fournisseurs relient ces interfaces aux API réelles :

    • openai utilise le protocole HTTP compatible avec OpenAI, comme son nom l’indique. Groq propose le même protocole à l’adresse https://api.groq.com/openai/v1 ; en dirigeant le client OpenAI vers cette adresse, on obtient des inférences rapides et gratuites sans besoin d’un SDK supplémentaire.
    • jina est l’adaptateur d’embeddings Jina. Jina propose un plan gratuit généreux pour générer des vecteurs de qualité avec jina-embeddings-v3, c’est pourquoi l’outil d’embedding l’utilise.

    Une image mentale utile : llms associé à openai représente la bouche qui produit des réponses. embeddings combiné à jina correspond à la mémoire, qui transforme le texte en données mathématiques exploitables. schema ainsi que le memStore personnalisé forment le classeur qui stocke et récupère ces données. Le reste consiste à relier ces trois éléments entre eux, sans recourir à de magie de framework, chaque interface ayant une fonction bien définie.

    Les fonctions auxiliaires

    Quelques outils d’aide interviennent avant le flux main. Ils permettent le fonctionnement du système de récupération.

    Le stockage de vecteurs en mémoire

    Les documents et leurs embeddings ont besoin d’un emplacement de stockage, ainsi que d’un moyen de recherche. Un memStore personnalisé entoure un embedder et conserve les documents à côté de leurs vecteurs.

    type memStore struct {
    	embedder embeddings.Embedder
    	docs     []schema.Document
    	vectors  [][]float32
    }
    
    func newMemStore(embedder embeddings.Embedder) *memStore {
       return &memStore{embedder: embedder}
    }
    

    Il contient trois éléments : l’embedder qui transforme le texte en vecteurs, les documents originaux, et les vecteurs float32 qui les représentent. newMemStore est un constructeur simple qui injecte l’embedder.

    La méthode AddDocuments effectue trois tâches. Elle extrait le texte brut de chaque document, embede tout en une seule opération, puis stocke à la fois les documents et les vecteurs tout en renvoyant des identifiants stables.

    First, extract the text and embed it:

    func (m *memStore) AddDocuments(ctx context.Context, docs []schema.Document, _ ...vectorstores.Option) ([]string, error) {
    	texts := make([]string, len(docs))
    	for i, d := range docs {
    		texts[i] = d.PageContent
    	}
    	vecs, err := m.embedder.EmbedDocuments(ctx, texts)
    	if err != nil {
    		return nil, err
    	}
    

    Une boucle unique collecte chaque PageContent dans une simple []string, et EmbedDocuments renvoie une tranche de vecteurs. Une erreur d’incorporation provoque une sortie anticipée.

    Deuxièmement, ajoutez et attribuez des IDs :

            start := len(m.docs)
    	m.docs = append(m.docs, docs...)
    	m.vectors = append(m.vectors, vecs...)
    	ids := make([]string, len(docs))
    	for i := range ids {
    		ids[i] = fmt.Sprintf("doc-%d", start+i)
    	}
    	return ids, nil
    }
    

    Le code indique l’endroit où commencent les nouveaux documents, ajoute des documents et des vecteurs au stockage, et génère des identifiants séquentiels tels que doc-0 et doc-1. Ces identifiants sont renvoyés à l’appelant afin qu’il puisse faire référence à ce qui a été enregistré.

    Similitude cosinus

    La récupération se résume à trouver les vecteurs les plus proches du vecteur de requête. La similitude cosinus, qui est un produit scalaire normalisé ne tenant pas compte de la magnitude, effectue cette comparaison.

    func cosine(a, b []float32) float64 {
    	n := len(a)
    	if len(b) < n {
    		n = len(b)
    	}
    	var dot, na, nb float64
    	for i := 0; i < n; i++ {
    		dot += float64(a[i]) * float64(b[i])
    		na += float64(a[i]) * float64(a[i])
    		nb += float64(b[i]) * float64(b[i])
    	}
    	if na == 0 || nb == 0 {
    		return 0
    	}
    	return dot / (math.Sqrt(na) * math.Sqrt(nb))
    }
    

    Afin de rester dans les limites prévues, la fonction utilise la plus courte des deux longueurs. Elle accumule le produit scalaire ainsi que les deux normes, puis divise le produit scalaire par la racine carrée multipliée des normes. En renvoyant 0 pour un vecteur nul, on évite la division par zéro.

    Recherche de similarité

    Avec des vecteurs stockés et une métrique définie, SimilaritySearch incorpore la requête, évalue chaque document, les classe et renvoie les N meilleurs.

    First, embed the query and score each document:

    func (m *memStore) SimilaritySearch(ctx context.Context, query string, numDocuments int, _ ...vectorstores.Option) ([]schema.Document, error) {
    	q, err := m.embedder.EmbedQuery(ctx, query)
    	if err != nil {
    		return nil, err
    	}
    	type scoredDoc struct {
    		doc   schema.Document
    		score float64
    	}
    	results := make([]scoredDoc, len(m.vectors))
    	for i, v := range m.vectors {
    		score := cosine(q, v)
    		doc := m.docs[i]
    		doc.Score = float32(score)
    		results[i] = scoredDoc{doc: doc, score: score}
    	}
    

    La requête est intégrée une seule fois. La fonction parcourt ensuite chaque vecteur stocké, calcule le score de cosinus par rapport à la requête et conserve chaque document avec son score dans une tranche scoredDoc.

    Ensuite, les résultats les plus pertinents sont triés et sélectionnés :

            sort.Slice(results, func(a, b int) bool { return results[a].score > results[b].score })
    	if numDocuments > len(results) {
    		numDocuments = len(results)
    	}
    	out := make([]schema.Document, numDocuments)
    	for i := 0; i < numDocuments; i++ {
    		out[i] = results[i].doc
    	}
    	return out, nil
    }
    

    Les résultats sont triés par score, du plus élevé au plus bas. La valeur de numDocuments est limitée au nombre réel de documents existants, et les meilleurs résultats sont copiés dans la sortie. Les documents les plus similaires apparaissent en premier.

    Aides à la configuration

    Deux petits outils facilitent l’exécution de la démonstration. envOr lit une variable d’environnement en utilisant une valeur par défaut en cas d’erreur. loadDotEnv analyse un fichier .env afin qu’il ne soit pas nécessaire d’exporter manuellement les valeurs.

    envOr d’abord :

    func envOr(key, def string) string {
    	if v := os.Getenv(key); v != "" {
    		return v
    	}
    	return def
    }
    

    Il renvoie la valeur de l’environnement si elle est définie, sinon la valeur par défaut.

    Puis loadDotEnv :

    func loadDotEnv(path string) error {
    	data, err := os.ReadFile(path)
    	if err != nil {
    		return err
    	}
    	for _, line := range strings.Split(string(data), "\n") {
    		line = strings.TrimSpace(line)
    		if line == "" || strings.HasPrefix(line, "#") {
    			continue
    		}
    		kv := strings.SplitN(line, "=", 2)
    		if len(kv) != 2 {
    			continue
    		}
    		key := strings.TrimSpace(kv[0])
    		value := strings.Trim(strings.TrimSpace(kv[1]), `"'`)
    		if os.Getenv(key) == "" {
    			os.Setenv(key, value)
    		}
    	}
    	return nil
    }
    

    La fonction lit le fichier, le divise en lignes, ignore les lignes vides et les commentaires, divise chaque ligne au premier =, supprime les guillemets entourant la valeur et définit la variable uniquement si elle n’est pas définie. Ce dernier contrôle garantit que les exportations effectuées dans le shell ont toujours la priorité. Écrire cela manuellement évite également d’installer un paquet dotenv.

    La boucle de requête

    runQuery est le cœur de RAG : récupérer des informations, assembler le contexte, générer une réponse. L’instruction fournie impose de ne répondre qu’à partir du contexte fourni, ce qui maintient l’honnêteté du modèle.

    func runQuery(ctx context.Context, store *memStore, llm llms.Model, query string) {
         fmt.Printf("\nQuery: %s\n", query)
         results, err := store.SimilaritySearch(ctx, query, 2)
         if err != nil {
            log.Fatal(err)
         }
    
         contextText := ""
         for _, res := range results {
            contextText += res.PageContent + "\n"
         }
    
         prompt := fmt.Sprintf("Answer the question based ONLY on the following context:\n\n%s\n\nQuestion: %s", contextText, query)
         completion, err := llms.GenerateFromSinglePrompt(ctx, llm, prompt)
         if err != nil {
            log.Fatal(err)
         }
         fmt.Println("\n--- RAG Response ---")
         fmt.Println(completion)
    }
    

    Il affiche la requête, récupère les deux premiers documents, combine leur texte en un seul bloc de contexte, crée une instruction interdisant l’utilisation de connaissances extérieures, génère une complétion et l’affiche.

    La fonction principale, et une manœuvre délibérée

    main relie tout ensemble, étape par étape.

    La première étape consiste en le démarrage et la configuration :

    func main() {
           ctx := context.Background()
           _ = loadDotEnv(".env")
           apiKey := os.Getenv("GROQ_API_KEY")
    
           if apiKey == "" {
              log.Fatal("Please set GROQ_API_KEY environment variable")
           }
    

    Il crée un contexte de fond, charge .env (l’erreur est ignorée afin qu’un fichier manquant ne provoque aucune panne) puis exige une clé Groq.

    La deuxième étape construit le LLM :

            llm, err := openai.New(
    	    openai.WithToken(apiKey),
    	    openai.WithBaseURL("https://api.groq.com/openai/v1"),
    	    openai.WithModel(envOr("GROQ_MODEL", "llama-3.3-70b-versatile")),
    	)
    	if err != nil {
    		log.Fatal(err)
    	}
    

    Groq utilise la structure de l’API d’OpenAI, de sorte que le client d’OpenAI est orienté vers l’URL de base de Groq avec un modèle par défaut sélectionné.

    La phase 3 permet de créer l’embedder :

          jinaKey := os.Getenv("JINA_API_KEY")
          if jinaKey == "" {
    	  log.Fatal("Please set JINA_API_KEY environment variable (free key at https://jina.ai)")
          }
    
          embedder, err := jina.NewJina(
            jina.WithModel(envOr("JINA_MODEL", "jina-embeddings-v3")),
            jina.WithBatchSize(16),
          )
    
          if err != nil {
            log.Fatal(err)
          }
    

    Jina fournit des embeddings gratuits. Le code exige cette clé, puis construit l’embedder à l’aide d’un modèle par défaut et envoie les textes par lots de 16.

    La phase 4 crée le stockage et les documents :

          store := newMemStore(embedder)
          docs := []string{
            "The Go compiler uses a Static Single Assignment (SSA) form for optimization.",
            "Go's garbage collector is a non-generational, concurrent tri-color mark-and-sweep collector that was made by Jesus",
            "The Go scheduler (G-M-P model) handles goroutines efficiently across OS threads.",
            "Go 1.21 introduced the 'slog' package for structured logging in the standard library.",
            "The 'unsafe' package allows bypassing Go's type safety for low-level memory access.",
         }
    

    Examinez de près le deuxième document. Il contient délibérément un fait absurde : celui selon lequel le collecteur de déchets de Go aurait été créé par Jésus. C’est là tout l’objectif de la démonstration. RAG ne rend pas le modèle plus intelligent ; il le pousse à répéter tout contexte qui lui est fourni, de sorte que si la couche de récupération fournit des absurdités, l’assistant les affirmera avec assurance.

    La phase 5 indexe les documents :

            fmt.Println("Indexing documents...")
    	for _, text := range docs {
    		_, err := store.AddDocuments(ctx, []schema.Document{{PageContent: text}})
    		if err != nil {
    			log.Fatal(err)
    		}
    	}
    

    Chaque chaîne de caractères est enveloppée dans un schema.Document et transmise à AddDocuments, qui l’incorpore et la stocke.

    La phase 6 exécute les requêtes :

            for _, query := range []string{
    		"How does the Go garbage collector work?",
    		"How do I access memory directly without type safety in Go?",
    		"Who made the garbage collector?",
    	} {
    		runQuery(ctx, store, llm, query)
    	}
    }
    

    Trois requêtes testent le pipeline, et c’est la troisième qu’il convient d’observer. Le modèle n’a aucune idée de qui a inventé le GC de Go. Cependant, le contexte récupéré mentionne Jésus, et l’instruction demande de répondre uniquement à partir de ce contexte, ce à quoi il se conforme. C’est la leçon la plus importante du RAG : un assistant n’est aussi fiable que les documents qu’il récupère. Du mauvais entrée, de la mauvaise sortie.

    Le lancement

    Le répertoire contient des fichiers .env pour faciliter les tests, et go run suffit car la configuration se charge automatiquement. Groq a envoyé un e-mail concernant l’invalidation de la clé fournie, il pourrait donc être nécessaire d’en utiliser une nouvelle.

    Voici la sortie brute d’une terminal Linux, y compris la surprise théologique à la fin :

    $ go run rag_v2.go
    Indexing documents...
    
    Query: How does the Go garbage collector work?
    
    --- RAG Response ---
    The Go garbage collector is a non-generational, concurrent tri-color mark-and-sweep collector.
    
    Query: How do I access memory directly without type safety in Go?
    --- RAG Response ---
    You can access memory directly without type safety in Go by using the 'unsafe' package, which allows bypassing Go's type safety for low-level memory access.
    
    Query: Who made the garbage collector?
    --- RAG Response ---
    Jesus made the garbage collector.
    

    Lectures complémentaires

  • Empêchez vos documents RAG d’être obsolètes : suivi de la fraîcheur et de l’obsolescence — Intégrez la capture des modifications, des indicateurs d’obsolescence ainsi qu’un réclassement pondéré par la fraîcheur dans un pipeline de recherche vectorielle Databricks afin que les agents privilégient les documents actuels aux documents obsolètes.
  • Arrêter les hallucinations des chatbots : RAG, recherche vectorielle, segmentation en chunks et mécanismes de contrôle — Une réponse sur la conception de systèmes pour des chatbots de support qui inventent des politiques : RAG, embeddings et recherche par ANN, taille des chunks, prompts de contextualisation, limites de contexte et surveillance.
  • Un pipeline RAG local avec Qdrant, fastembed et Gemini en Python — Créez un outil en ligne de commande RAG incrémental : divisez les documents locaux en parties, incorporez-les hors ligne, stockez les vecteurs dans Qdrant et répondez aux questions avec Gemini.