Strona główna / Artykuły

Minimalny pipeline RAG w Go z użyciem embeddingów od langchaingo, Groq i Jina

Minimalny pipeline RAG w Go z użyciem langchaingo: pamięć wektorowa, wyszukiwanie kosinusowe, Groq do generowania odpowiedzi oraz embeddingi Jina, plus demonstracja złego kontekstu.

Wydawca: REACTAPP.TOP10 min czytania2251 słów

Źródła: Stop Stuffing Your System Prompts: Build a Real RAG Pipeline in Go

RAG, skrót od Retrieval-Augmented Generation, oznacza, że model odpowiada na podstawie danych z zewnętrznych dokumentów, które pobiera w momencie zadawania pytania, zamiast polegać wyłącznie na informacjach pozyskanych podczas szkolenia. Fakty nie muszą już być wbudowywane bezpośrednio w instrukcje systemu. Najbardziej istotny kontekst jest pobierany i przekazywany modelowi razem z pytaniem, dzięki czemu odpowiedzi pozostają powiązane z źródłami, które sam kontrolujesz.

Dlaczego asystent tego potrzebuje

Każdy, kto tworzy własnego asystenta, w końcu napotyka ten sam problem: model nic nie wie o twoim świecie. Nigdy nie widział nazw twoich produktów, wewnętrznych zasad ani faktów z danej dziedziny. Oczywistym rozwiązaniem jest wklejanie wszystkiego do instrukcji systemu, co jest nietrwałe, kosztowne i szybko napotyka ograniczenia co do wielkości.

RAG odwraca tę sytuację. Zamiast pakować kontekst do promptu, model otrzymuje narzędzie do pobierania odpowiedniego kontekstu, gdy pojawia się pytanie. Ty decydujesz, w jakiej „rzeczywistości” funkcjonuje asystent, bez konieczności rozszerzania promptu. Dzięki temu masz również uporządkowany schemat do organizacji wiedzy specyficznej dla biznesu: twoje terminy, twoje dokumenty, twoja prawda, dostępne na żądanie.

Poniżej znajduje się minimalny pipeline w języku Go z bardzo niewieloma zależnościami.

Biblioteki użyte

Jeśli narzędzia do LLM w Go są dla ciebie nowością, importy będą wyglądać jak „zupa alfabetyczna”, więc oto krótka charakterystyka wszystkich elementów przed pokazaniem kodu.

langchaingo (github.com/tmc/langchaingo) to wersja napisana w języku Go dobrze znanej ekosystemu LangChain w Pythonie. Jest to zbiór małych, komponowalnych pakietów, które obejmują skomplikowane elementy aplikacji opartych na modelach językowych, dzięki czemu nie musisz ręcznie pisać żądań HTTP ani parsować danych JSON dla każdego dostawcy. Tutaj wykorzystano cztery z jego podpakietów:

  • llms stanowi abstrakcję modeli. Jedna interfejs llms.Model oraz pomocnik llms.GenerateFromSinglePrompt działają tak samo niezależnie od tego, czy backend to OpenAI, Groq, Ollama, czy lokalny model. Wysyła on skompilowany prompt i zwraca tekst.
  • embeddings dostarcza interfejs embeddings.Embedder. Narzędzie to przekształca ciąg znaków na listę liczb zmiennoprzecinkowych, czyli wektor reprezentujący znaczenie. Podobne zdania dają podobne wektory, a niespowiązane – nie, i to właśnie na tych zasadach matematycznych opiera się funkcja wyszukiwania.
  • schema definiuje schema.Document – małą strukturę zawierającą PageContent (tekst), metadane oraz pole Score służące do rankowania wyników.
  • vectorstores dostarcza głównie typy opcji i konwencje. Jego konkretne magazyny nie są używane, ponieważ samodzielnie zbudowany memStore pokazuje, jak niewiele jest rzeczywiście potrzebne.
  • Adaptatory dostawców łączą te interfejsy z rzeczywistymi API:

    • openai korzysta z kompatybilnego z OpenAI protokołu HTTP, jak sama nazwa wskazuje. Groq używa tego samego protokołu pod adresem https://api.groq.com/openai/v1, więc skierowanie klienta OpenAI w tamto miejsce zapewnia szybkie, bezpłatne przetwarzanie bez konieczności używania dodatkowego SDK.
    • jina to adapter dla embeddingów Jina. Jina oferuje hojny plan bezpłatny do tworzenia wysokiej jakości wektorów za pomocą jina-embeddings-v3, dlatego właśnie używa go ten narzędzie do generowania embeddingów.

    Przydatny obraz mentalny: llms w połączeniu z openai to usta, które wytwarzają odpowiedzi. embeddings w połączeniu z jina to pamięć, która przekształca tekst w dane matematyczne nadające się do wyszukiwania. schema wraz z dostosowanym memStore to szuflada, która przechowuje i odzyskuje dane. Reszta polega na połączeniu tych trzech elementów, bez żadnej „magii” frameworka, przy czym każdy interfejs pełni jedną funkcję.

    Funkcje pomocnicze

    Część narzędzi pomocniczych działa przed głównym procesem main. One umożliwiają wykonywanie operacji wyszukiwania.

    Przechowalnik wektorów w pamięci

    Dokumenty oraz ich reprezentacje wektorowe wymagają miejsca przechowywania oraz sposobu wyszukiwania. Spersonalizowany memStore otacza narzędzie do generowania reprezentacji wektorowych i przechowuje dokumenty obok ich wektorów.

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

    Zawiera trzy elementy: narzędzie embedowania, które przekształca tekst na wektory, oryginalne dokumenty oraz wektory typu float32 reprezentujące je. newMemStore to zwykły konstruktor, który wprowadza to narzędzie embedowania.

    Metoda AddDocuments wykonywa trzy zadania. Wyodrębnia surowy tekst każdego dokumentu, embeduje wszystko jednocześnie, a następnie przechowuje zarówno dokumenty, jak i wektory, zwracając przy tym stabilne identyfikatory.

    Najpierw wyodrębnij tekst i zastosuj do niego embedowanie:

    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
    	}
    

    Jedna pętla zbiera każdy PageContent do zwykłego []string, a EmbedDocuments zwraca wycinek wektorów. Niepowodzenie embedowania powoduje wczesne zakończenie.

    Następnie dodaje się elementy i przypisuje ID:

            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
    }
    

    Kod określa, gdzie zaczynają się nowe dokumenty, dodaje je wraz z wektorami do magazynu oraz generuje sekwencyjne identyfikatory w postaci doc-0 i doc-1. Te identyfikatory są zwracane do użytkownika, aby mógł odwołać się do zapisanych danych.

    Podobieństwo kosinowe

    Proces wyszukiwania sprowadza się do znalezienia wektorów najbliższych wektorowi zapytania. Porównanie to przeprowadza podobieństwo kosinowe – znormalizowany iloczyn skalarny, który ignoruje wartość bezwzględną wektorów.

    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))
    }
    

    Aby pozostać w granicach, funkcja wykorzystuje krótszą z dwóch długości. Akumuluje iloczyn skalarny oraz obie normy, a na końcu dzieli iloczyn skalarny przez iloczyn pierwiastków kwadratowych norm. Zwracanie wartości 0 dla wektora zerowego unika dzielenia przez zero.

    Wyszukiwanie podobieństwa

    Gdy wektory są już przechowywane, a metryka zdefiniowana, SimilaritySearch wstawia zapytanie, ocenia każdy dokument, sortuje je i zwraca N najlepszych.

    Najpierw należy wstawić zapytanie i ocenić każdy dokument:

    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}
    	}
    

    Zapytanie jest wstawiane tylko raz. Następnie funkcja przegląda każdy przechowywany wektor, oblicza wynik kosinusowy w odniesieniu do zapytania i przechowuje każdy dokument wraz z jego wynikiem w slice’u scoredDoc.

    Następnie sortuje się i wybiera najlepsze wyniki:

            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
    }
    

    Wyniki są sortowane według oceny od najwyższej do najniższej, numDocuments jest ograniczony do rzeczywistej liczby dokumentów, a najlepsze wyniki są kopiowane do wyniku końcowego. Najbardziej podobne dokumenty pojawiają się na początku.

    Pomocniki konfiguracyjne

    Dwa małe narzędzia ułatwiają uruchomienie demonstracji. envOr odczytuje zmienną środowiskową z opcją awaryjną. loadDotEnv解析uje plik .env, dzięki czemu nie trzeba niczego ręcznie eksportować.

    envOr najpierw:

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

    Zwraca wartość z środowiska, jeśli jest ustawiona, w przeciwnym razie wartość domyślną.

    Następnie 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
    }
    

    Funkcja czyta plik, dzieli go na linie, pomija puste linie i komentarze, dzieli każdą linię przy pierwszym =, usuwa cudzysłowy otaczające wartość i ustawia zmienną tylko wtedy, gdy jest niezdefiniowana. To ostatnie sprawdzenie oznacza, że eksporty wykonane w shellu zawsze mają pierwszeństwo. Ręczne napisanie tego kodu pozwala również uniknąć używania pakietu dotenv.

    Pętla zapytań

    runQuery jest sercem RAG: pobieranie danych, tworzenie kontekstu, generowanie odpowiedzi. Prompt zawiera ścisłą instrukcję, by odpowiadać wyłącznie na podstawie dostarczonego kontekstu, co zapewnia uczciwość modelu.

    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)
    }
    

    Wydrukuje zapytanie, pobiera dwa najważniejsze dokumenty, łączy ich tekst w jeden blok kontekstowy, tworzy instrukcję zakazującą korzystania z wiedzy z zewnątrz, generuje uzupełnienie i je wydrukuje.

    Główna funkcja i celowy trik

    main łączy wszystko ze sobą, krok po kroku.

    Krok 1 to uruchomienie i konfiguracja:

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

    Tworzy kontekst tła, ładuje plik .env (błąd jest ignorowany, więc brakujący plik nie powoduje awarii) i następnie wymaga klucza Groq.

    Etap 2 buduje model 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 wykorzystuje strukturę API OpenAI, więc klient OpenAI jest skierowany na podstawową adres URL Groq z wybranym domyślnym modelem.

    Na etapie 3 budowany jest mechanizm embedowania:

          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 dostarcza darmowe embeddingi. Kod wymaga tego klucza, następnie tworzy narzędzie do generowania embeddingów przy użyciu domyślnego modelu i wysyła teksty partiami po 16.

    Etap 4 tworzy sklep oraz dokumenty:

          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.",
         }
    

    Przyjrzyj się uważnie drugiemu dokumencie. Zawiera on celowo absurdalny fakt: że kolektor śmieci w Go został stworzony przez Jezusa. To właśnie jest sedno tej demonstracji. RAG nie czyni modelu mądrzejszym – sprawia, że model powtarza wszystko, co mu zostanie podane, więc jeśli warstwa wyszukiwania dostarcza bzdur, asystent przedstawi je z pewnością siebie.

    Etap 5 indeksuje dokumenty:

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

    Każda ciąg znaków jest umieszczana w obiektu schema.Document i przekazywana do AddDocuments, który ją włącza i przechowuje.

    Na etapie 6 wykonywane są zapytania:

            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)
    	}
    }
    

    Trzy zapytania testują ten proces, a to trzecie jest najważniejsze do obserwacji. Model nie ma pojęcia, kto wynalazł GC w Go. Jednak uzyskany kontekst mówi o Jezusie, a instrukcja nakazuje odpowiadać wyłącznie na podstawie tego kontekstu, więc model się do tego stosuje. To najważniejsza lekcja z RAG: asystent jest tak wiarygodny, jak dokumenty, które pobiera. Co włożysz, to i dostaniesz.

    Uruchamianie

    Repozytorium zawiera pliki .env, dzięki czemu testowanie jest proste, a wystarczy polecenie go run, ponieważ konfiguracja ładowuje się automatycznie. Groq wysłał e-mail dotyczący unieważnienia dołączonego klucza, więc może być potrzebny nowy.

    Oto surowy wyjście z terminala Linux, włączając teologiczne zaskoczenie na końcu:

    $ 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.