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.
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 :
llmsassure 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.embeddingsfournit l’interfaceembeddings.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 :
openaiutilise le protocole HTTP compatible avec OpenAI, comme son nom l’indique. Groq propose le même protocole à l’adressehttps://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.jinaest l’adaptateur d’embeddings Jina. Jina propose un plan gratuit généreux pour générer des vecteurs de qualité avecjina-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
- Notes pratiques : Arrêtez de surveiller votre agent de codage : créez un système en qui vous pouvez avoir confiance — Guide pas à pas des Notes pratiques : Arrêtez de surveiller votre agent de codage : créez un système en qui vous pouvez avoir confiance : contrats, vérifications et blocs de code à intégrer pour les équipes utilisant ce modèle.
- Notes pratiques : Pourquoi votre pipeline RAG a besoin d’un ré-rangérateur (et comment) — Guide pas à pas des Notes pratiques : Pourquoi votre pipeline RAG a besoin d’un ré-rangérateur (et comment) : contrats, vérifications et blocs de code à intégrer pour les équipes utilisant ce modèle.