Ein minimaler RAG-Pipeline in Go mit langchaingo, Groq und Jina-Embeddings
Ein minimaler RAG-Pipeline in Go mit langchaingo: ein In-Memory-Vektorlagert, Kosinus-Suche, Groq für Antworten und Jina-Embeddings, sowie eine Demonstration eines schlechten Kontexts.
Quellen: Stop Stuffing Your System Prompts: Build a Real RAG Pipeline in Go
RAG, abgekürzt für Retrieval-Augmented Generation, bedeutet, dass ein Modell zu einer Anfrage aus externen Dokumenten antwortet, die es zum Zeitpunkt der Anfrage abruft, anstatt sich nur auf das zu verlassen, was es während des Trainings aufgenommen hat. Fakten müssen nicht mehr fest in einen Systemprompt eingebettet werden. Der relevanteste Kontext wird abgerufen und zusammen mit der Frage dem Modell übergeben, sodass die Antworten weiterhin auf von Ihnen kontrollierten Quellen beruhen.
Warum ein Assistent es benötigt
Jeder, der einen benutzerdefinierten Assistenten entwickelt, stößt letztendlich auf dasselbe Problem: Das Modell weiß nichts über Ihre Welt. Es hat noch nie Ihre Produktnamen, internen Richtlinien oder fachspezifischen Fakten gesehen. Die offensichtliche Lösung besteht darin, alles in den Systemprompt einzufügen – doch das ist anfällig, kostspielig und erreicht schnell eine Größenbegrenzung.
RAG kehrt das um. Anstatt den Kontext in einen Prompt zu packen, erhält das Modell ein Werkzeug, mit dem es bei einer Frage den richtigen Kontext abruft. Sie entscheiden, in welcher „Realität“ der Assistent agiert, ohne den Prompt zu überladen. Zudem bietet es ein übersichtliches Muster zur Organisation von branchenspezifischem Wissen: Ihre Begriffe, Ihre Dokumente, Ihre Fakten – alle auf Anfrage abrufbar.
Im Folgenden finden Sie einen minimalen Go-Pipeline mit sehr wenigen Abhängigkeiten.
Die verwendeten Bibliotheken
Falls die Nutzung von LLM-Werkzeugen in Go für Sie neu ist, werden die Importe wie alphabetischer Kauderwelsch erscheinen – daher erklären wir hier vorab, wer was ist.
langchaingo (github.com/tmc/langchaingo) ist die Go-Portierung des bekannten Python-LangChain-Ecosystems. Es handelt sich um eine Sammlung kleiner, kombinierbarer Pakete, die die komplizierten Teile von LLM-Anwendungen abdecken, sodass man für jeden Anbieter keine HTTP-Aufrufe und JSON-Parser manuell schreiben muss. Hier werden vier seiner Unter-Pakete verwendet:
llmsdient als Abstraktion für Modelle. Eine Schnittstelle,llms.Model, sowie ein Hilfsfunktion,llms.GenerateFromSinglePrompt, funktionieren unabhängig davon, ob der Backend-Provider OpenAI, Groq, Ollama oder ein lokales Modell ist. Es werden die zusammengestellten Anfragen gesendet und anschließend Text zurückgegeben.embeddingsstellt dieembeddings.Embedder-Schnittstelle bereit. Ein Embedder wandelt einen String in eine Liste von Fließkommazahlen um – einen Vektor, der die Bedeutung erfassen soll. Ähnliche Sätze ergeben ähnliche Vektoren, während unverwandte Sätze andere Vektoren erzeugen; darauf beruht die Suchfunktion.
schema definiert schema.Document, eine kleine Struktur mit PageContent (dem Text), Metadaten sowie einem Score-Feld zur Rangfolge der Ergebnisse.vectorstores liefert hauptsächlich Optionstypen und Konventionen. Seine konkreten Speicher werden nicht verwendet, da ein selbst erstellter memStore zeigt, wie gering die tatsächlichen Anforderungen sind.Zwei Anbieteradapter verbinden diese Schnittstellen mit echten APIs:
openaiverwendet das OpenAI-kompatible HTTP-Protokoll – wie der Name schon andeutet. Groq bietet dasselbe Protokoll unterhttps://api.groq.com/openai/v1an, wodurch eine Verbindung des OpenAI-Clients dorthin schnelle, kostenlose Inferenz ohne zusätzliches SDK ermöglicht.jinaist der Adapter für Jina-Embeddings. Jina bietet eine großzügige kostenlose Version zur Erstellung hochwertiger Vektoren mitjina-embeddings-v3, weshalb der Embedder diese verwendet.
Eine nützliche Vorstellung: llms zusammen mit openai ist der Mund, der Antworten erzeugt. embeddings zusammen mit jina ist das Gedächtnis, das Text in suchbaren mathematischen Strukturen umwandelt. schema zusammen mit dem benutzerdefinierten memStore ist der Aktenschrank, der Daten speichert und abruft. Der Rest besteht darin, diese drei Komponenten miteinander zu verbinden, ohne Framework-Magie – wobei jede Schnittstelle eine bestimmte Aufgabe übernimmt.
Die unterstützenden Funktionen
Einige Hilfsfunktionen laufen vor dem main-Fluss ab. Sie sorgen dafür, dass das Abrufen funktioniert.
Der Vektor-Speicher im Arbeitsspeicher
Dokumente und ihre Embeddings benötigen einen Speicherort sowie eine Suchmöglichkeit. Ein benutzerdefinierter memStore umhüllt einen Embedder und speichert die Dokumente neben ihren Vektoren.
type memStore struct {
embedder embeddings.Embedder
docs []schema.Document
vectors [][]float32
}
func newMemStore(embedder embeddings.Embedder) *memStore {
return &memStore{embedder: embedder}
}
Es enthält drei Elemente: den Embedder, der Text in Vektoren umwandelt, die ursprünglichen Dokumente sowie die float32-Vektoren, die sie darstellen. newMemStore ist ein einfacher Konstruktor, der den Embedder einfügt.
Die AddDocuments-Methode erledigt drei Aufgaben. Zunächst wird der Rohtext jedes Dokuments extrahiert, anschließend wird alles in einer einzigen Batch-Verarbeitung eingebettet, und schließlich werden sowohl die Dokumente als auch die Vektoren gespeichert, wobei stabile IDs zurückgegeben werden.
Zuerst wird der Text extrahiert und eingebettet:
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
}
Eine Schleife sammelt jeden PageContent in einen einfachen []string ein, und EmbedDocuments gibt einen Teilmenge von Vektoren zurück. Ein Versagen bei der Einbettung führt zu einem vorzeitigen Beenden.
Zweitens: Fügen Sie hinzu und weisen Sie IDs zu:
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
}
Der Code gibt an, wo die neuen Dokumente beginnen, fügt Dokumente und Vektoren zum Speicher hinzu und erzeugt sequenzielle IDs wie doc-0 und doc-1. Die IDs werden an den Aufrufer zurückgegeben, damit dieser auf das Gespeicherte verweisen kann.
Kosinusähnlichkeit
Die Suche reduziert sich auf das Finden der Vektoren, die dem Abfragenvektor am nächsten liegen. Die Kosinusähnlichkeit, ein normalisierter Skalarprodukt, der die Länge ignoriert, führt den Vergleich durch.
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))
}
Um im Rahmen zu bleiben, verwendet die Funktion die kürzere der beiden Längen. Sie addiert das Skalarprodukt sowie beide Normen und teilt schließlich das Skalarprodukt durch die multiplizierten Quadratwurzeln der Normen. Die Rückgabe von 0 für einen Nullvektor vermeidet eine Division durch Null.
Ähnlichkeitssuche
Sobald Vektoren gespeichert und eine Metrik definiert ist, fügt SimilaritySearch die Abfrage ein, bewertet jedes Dokument, sortiert sie und gibt die besten N Ergebnisse zurück.
Zuerst wird die Abfrage eingefügt und jedes Dokument bewertet:
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}
}
Die Abfrage wird einmal eingebettet. Anschließend durchläuft die Funktion jeden gespeicherten Vektor, berechnet den Kosinus-Wert im Vergleich zur Abfrage und speichert jedes Dokument zusammen mit seinem Wert in einem scoredDoc-Slice.
Zweitens werden die besten Ergebnisse sortiert und ausgewählt:
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
}
Die Ergebnisse werden nach Punktzahl von hoch zu niedrig sortiert, numDocuments wird auf die tatsächlich vorhandene Anzahl begrenzt, und die besten Einträge werden in die Ausgabe kopiert. Die am ähnlichstenen Dokumente kommen zuerst.
Konfigurationshilfen
Zwei kleine Hilfsprogramme sorgen dafür, dass die Demo einfach ausführbar bleibt. envOr liest eine Umgebungsvariable mit einer Ersatzlösung. loadDotEnv parset eine .env-Datei, sodass nichts manuell exportiert werden muss.
envOr zuerst:
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
Es gibt den Wert der Umgebungsvariable zurück, wenn diese gesetzt ist, andernfalls den Standardwert.
Dann 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
}
Die Funktion liest die Datei, teilt sie in Zeilen auf, überspringt leere Zeilen und Kommentare, teilt jede Zeile an der ersten =-Zeichen auf, entfernt die Anführungszeichen um den Wert und setzt die Variable nur, wenn sie undefiniert ist. Diese letzte Überprüfung bedeutet, dass in der Shell erstellte Exporte immer Vorrang haben. Die manuelle Implementierung vermeidet außerdem das Einbinden eines dotenv-Pakets.
Die Abfrageschleife
runQuery ist das Herzstück von RAG: Abrufen, Zusammenstellen des Kontexts, Erstellen. Die Anweisung besagt ausdrücklich, dass nur aus dem bereitgestellten Kontext geantwortet werden darf, was dafür sorgt, dass das Modell ehrlich bleibt.
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)
}
Es gibt die Abfrage aus, holt die beiden besten Dokumente, fügt ihren Text zu einem einzigen Kontextblock zusammen, erstellt eine Anweisung, die das Verwenden von externem Wissen verbietet, generiert eine Ergänzung und gibt sie aus.
Die Hauptfunktion – und ein bewusster Trick
main verbindet alles Schritt für Schritt miteinander.
Phase 1 ist das Initialisieren und Konfigurieren:
func main() {
ctx := context.Background()
_ = loadDotEnv(".env")
apiKey := os.Getenv("GROQ_API_KEY")
if apiKey == "" {
log.Fatal("Please set GROQ_API_KEY environment variable")
}
Es erstellt einen Hintergrundkontext, lädt .env (der Fehler wird ignoriert, sodass das Fehlen einer Datei nichts zum Absturz bringt) und verlangt anschließend nach einem Groq-Schlüssel.
In Phase 2 wird das LLM erstellt:
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 verwendet die API-Struktur von OpenAI, wodurch der OpenAI-Client auf die Basis-URL von Groq mit einem voreingestellten Modell verweist.
In Phase 3 wird der Embedder erstellt:
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 stellt kostenlose Embeddings zur Verfügung. Der Code verlangt nach diesem Schlüssel, erstellt anschließend den Embedder mithilfe eines Standardmodells und sendet die Texte in Gruppen zu 16 Stück.
In Phase 4 werden der Speicher sowie die Dokumente erstellt:
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.",
}
Betrachten Sie das zweite Dokument genau. Es enthält absichtlich eine absurde Tatsache: dass der Go-Garbage-Collector von Jesus entwickelt wurde. Das ist der ganze Zweck der Demo. RAG macht ein Modell nicht intelligenter. Es lässt das Modell einfach alles wiederholen, was ihm zur Verfügung gestellt wird; wenn also die Abrufschicht Unsinn liefert, wird der Assistent dies selbstbewusst wiederholen.
In Phase 5 werden die Dokumente indiziert:
fmt.Println("Indexing documents...")
for _, text := range docs {
_, err := store.AddDocuments(ctx, []schema.Document{{PageContent: text}})
if err != nil {
log.Fatal(err)
}
}
Jeder String wird in einen schema.Document eingebettet und an AddDocuments übergeben, der ihn einbettet und speichert.
In Phase 6 werden die Abfragen ausgeführt:
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)
}
}
Drei Abfragen werden an das Pipeline gestellt, wobei die dritte besonders wichtig ist. Das Modell hat keine Ahnung, wer Go’s GC erfunden hat. Der abgerufte Kontext nennt jedoch Jesus, und die Anweisung besagt, ausschließlich auf Basis des Kontexts zu antworten – daher befolgt das Modell diese Anweisung. Das ist die wichtigste Lektion bei RAG: Ein Assistent ist nur so wahrheitsgetreu wie die Dokumente, die er abruft. Schlechte Eingaben führen zu falschen Ergebnissen.
Ausführung
Das Repository enthält .env-Dateien, sodass das Testen einfach ist, und go run reicht aus, da die Konfiguration automatisch geladen wird. Groq hat per E-Mail mitgeteilt, dass der mitgelieferte Schlüssel ungültig sein könnte, weshalb ein neuer benötigt werden könnte.
Das ist die unverfälschte Ausgabe aus einer Linux-Terminal, einschließlich der theologischen Überraschung am Ende:
$ 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.
Verwandte Artikel
- Praktische Hinweise: Hören Sie auf, Ihren Code-Agenten zu beobachten – Bauen Sie ein System, dem Sie vertrauen können — Detaillierte Anleitung zu den Praktischen Hinweisen: Hören Sie auf, Ihren Code-Agenten zu beobachten – Bauen Sie ein System, dem Sie vertrauen können: Verträge, Überprüfungen sowie Code-Blöcke für Teams, die dieses Muster einsetzen.
- Praktische Hinweise: Warum Ihre RAG-Pipeline einen Neu-Rankierer braucht (und wie) — Detaillierte Anleitung zu den Praktischen Hinweisen: Warum Ihre RAG-Pipeline einen Neu-Rankierer braucht (und wie): Verträge, Überprüfungen sowie Code-Blöcke für Teams, die dieses Muster einsetzen.