Главная / Статьи

Минимальная пайплайн-система RAG на Go с использованием эмбеддингов langchaingo, Groq и Jina

Минимальная пайплайн-система RAG на Go с использованием langchaingo: хранилище векторов в памяти, поиск по косинусу, Groq для получения ответов и модели Jina для генерации эмбеддингов, плюс демонстрация некорректного контекста.

Издатель: REACTAPP.TOP10 мин чтения2251 слов

Источники: Stop Stuffing Your System Prompts: Build a Real RAG Pipeline in Go

RAG, сокращение от Retrieval-Augmented Generation, означает, что модель отвечает на основе данных из внешних документов, загружаемых в момент запроса, а не только на основе информации, полученной во время обучения. Факты больше не обязательно встраивать в системный промпт. Самый релевантный контекст извлекается и передается модели вместе с вопросом, благодаря чему ответы остаются связанными с источниками, которые находятся под вашим контролем.

Почему ассистенту это необходимо

Любой, кто создаёт собственного ассистента, рано или поздно сталкивается с одной и той же проблемой: модель ничего не знает о вашем мире. Она никогда не видела названий ваших продуктов, внутренних правил или специфических данных вашей области. Очевидным решением кажется вставка всей информации в системный промпт, однако это нестабильно, дорого и быстро приводит к ограничению по объёму.

RAG меняет ситуацию. Вместо того чтобы включать контекст в промпт, модель получает инструмент для извлечения необходимого контекста при поступлении вопроса. Вы сами определяете, в какой «реальности» существует ассистент, не увеличивая при этом объем промпта. Кроме того, это предоставляет четкую структуру для организации знаний, специфичных для бизнеса: ваши термины, ваши документы, ваша информация, которая загружается по требованию.

Далее приведен минимальный пайплайн на Go с очень небольшим количеством зависимостей.

Используемые библиотеки

Если инструменты для работы с LLM на Go новы для вас, импорты покажутся чем-то вроде алфавитного супа, поэтому сначала расскажем, кто что делает, прежде чем показать код.

langchaingo (github.com/tmc/langchaingo) — это реализация в языке Go известной экосистемы LangChain для Python. Это набор небольших взаимосвязанных пакетов, которые обернули сложные части приложений на основе больших языковых моделей, так что вам не нужно вручную писать HTTP-запросы и парсить JSON для каждого поставщика. Здесь используются четыре из его подпакетов:

  • llms представляет собой абстракцию моделей. Один интерфейс llms.Model и вспомогательная функция llms.GenerateFromSinglePrompt работают одинаково независимо от того, является ли бэкенд OpenAI, Groq, Ollama или локальной моделью. Он отправляет сформированный запрос и возвращает текст.
  • embeddings предоставляет интерфейс embeddings.Embedder. Этот инструмент преобразует строку в список чисел с плавающей точкой — вектор, отражающий смысл. Похожие предложения дают похожие векторы, а несвязанные — нет, и именно на этой математической основе осуществляется поиск информации.
  • schema определяет schema.Document — небольшую структуру, содержащую PageContent (текст), метаданные и поле Score для ранжирования результатов.
  • vectorstores в основном предоставляет типы опций и правила использования. Его конкретные хранилища не используются, поскольку самостоятельно созданное memStore показывает, насколько мало на самом деле требуется.
  • Два адаптера-провайдера соединяют эти интерфейсы с реальными API:

    • openai использует HTTP-протокол, совместимый с OpenAI, как и следует из названия. Groq предоставляет тот же протокол по адресу https://api.groq.com/openai/v1, поэтому направление клиента OpenAI туда обеспечивает быстрый и бесплатный анализ без необходимости использования дополнительных SDK.
    • jina — это адаптер для эмбеддингов Jina. U Jina имеется щедрый бесплатный тариф для генерации качественных векторов с помощью jina-embeddings-v3, и именно поэтому эмбеддер его использует.

    Удобное визуальное представление: llms вместе с openai — это рот, который формирует ответы. embeddings вместе с jina — это память, преобразующая текст в структуры, поддающиеся поиску. schema вместе с пользовательским memStore — это шкаф для хранения и извлечения данных. Остальное заключается в соединении этих трех компонентов без использования каких-либо магических функций фреймворка, причем каждый интерфейс выполняет одну задачу.

    Вспомогательные функции

    Некоторые вспомогательные компоненты работают до основного потока выполнения. Они обеспечивают работу механизма поиска.

    Хранилище векторов в памяти

    Документам и их векторным представлениям необходимо место хранения, а также способ поиска. Пользовательский memStore оборачивает модуль генерации векторов и хранит документы рядом с их векторами.

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

    В нём хранятся три элемента: модуль встраивания, преобразующий текст в векторы, оригинальные документы и векторы типа float32, представляющие их. newMemStore — это обычный конструктор, который встраивает модуль встраивания.

    Метод AddDocuments выполняет три задачи. Он извлекает необработанный текст каждого документа, встраивает всё сразу в одной партии, затем хранит как документы, так и векторы, возвращая при этом стабильные идентификаторы.

    Сначала извлекаем текст и встраиваем его:

    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
    	}
    

    Один цикл собирает каждый PageContent в обычную строку типа []string, а функция EmbedDocuments возвращает отрезок векторов. Ошибка при встраивании приводит к досрочному завершению.

    Во-вторых, добавляются элементы и присваиваются 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
    }
    

    Код указывает место начала новых документов, добавляет документы и векторы в хранилище, а также генерирует последовательные идентификаторы вида doc-0 и doc-1. Эти идентификаторы возвращаются вызывающему коду, чтобы тот мог обращаться к сохранённым данным.

    Косинусное сходство

    Поиск сводится к нахождению векторов, наиболее близких к вектору запроса. Для сравнения используется косинусное сходство — нормализованный скалярный произведение, игнорирующее величину векторов.

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

    Чтобы оставаться в пределах допустимых значений, функция использует меньшую из двух длин. Она накапливает скалярное произведение и обе нормы, а затем делит скалярное произведение на произведение квадратных корней этих норм. Возвращение значения 0 для нулевого вектора позволяет избежать деления на ноль.

    Поиск по сходству

    После хранения векторов и определения метрики функция SimilaritySearch встраивает запрос, оценивает каждый документ, сортирует их и возвращает топ-N результатов.

    Сначала встраивается запрос, а затем оценивается каждый документ:

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

    Запрос встраивается один раз. Затем функция проходит по каждому сохранённому вектору, вычисляет коэффициент косинуса совпадения с запросом и сохраняет каждый документ вместе с его оценкой в массиве scoredDoc.

    Во-вторых, результаты сортируются и выбираются лучшие:

            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
    }
    

    Результаты сортируются по оценке от высокой к низкой, значение numDocuments ограничивается фактическим количеством документов, а лучшие записи копируются в результат. Самые похожие документы показываются в первую очередь.

    Вспомогательные инструменты настройки

    Два небольших утилиты облегчают запуск демо-версии. envOr считывает переменную окружения с возможностью использования запасного значения. loadDotEnv парсит файл .env, что позволяет избежать ручного экспорта данных.

    envOr сначала:

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

    Он возвращает значение из окружения, если оно установлено, в противном случае — значение по умолчанию.

    Затем 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
    }
    

    Функция читает файл, разделяет его на строки, пропускает пустые строки и комментарии, разделяет каждую строку на уровне первого символа =, удаляет кавычки вокруг значения и устанавливает переменную только в том случае, если она ранее не была определена. Благодаря этой последней проверке экспорты, сделанные в оболочке, всегда имеют приоритет. Реализация этой логики вручную также позволяет избежать использования пакета dotenv.

    Цикл обработки запросов

    runQuery является ядром технологии RAG: получение данных, сбор контекста, генерация ответа. В промпте указано строгое требование отвечать исключительно на основе предоставленного контекста, что обеспечивает честность работы модели.

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

    Он выводит запрос, загружает два лучших документа, объединяет их текст в один блок контекста, формирует промпт, запрещающий использование знаний извне, генерирует результат и выводит его.

    Основная функция и преднамеренный трюк

    main постепенно связывает всё вместе, этап за этапом.

    Этап 1 — запуск и настройка:

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

    Он создаёт фоновый контекст, загружает файл .env (ошибка игнорируется, поэтому отсутствие файла ничего не ломает), а затем требует наличия ключа Groq.

    На втором этапе создаётся 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 использует формат API OpenAI, поэтому клиент OpenAI настроен на базовый URL Groq с выбранным по умолчанию моделью.

    На третьем этапе создается модуль встраивания:

          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 предоставляет бесплатные эмбеддинги. Код требует наличия этого ключа, затем создаёт модуль генерации эмбеддингов с использованием стандартной модели и отправляет тексты пакетами по 16 штук.

    На четвёртом этапе создаются хранилище и документы:

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

    Внимательно посмотрите на второй документ. В нем намеренно приведен абсурдный факт: что коллектор мусора Go был создан Иисусом. В этом и заключается суть демонстрации. RAG не делает модель умнее. Он заставляет модель повторять любой предоставленный ей контекст, поэтому если слой поиска предоставляет бессмыслицу, ассистент с уверенностью её изложит.

    На пятом этапе происходит индексация документов:

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

    Каждая строка оборачивается в объект schema.Document и передаётся в функцию AddDocuments, которая встраивает её и сохраняет.

    На этапе 6 выполняются запросы:

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

    Три запроса применяются к данной системе обработки данных, причем третий из них наиболее важен. Модель не имеет ни малейшего представления о том, кто изобрёл механизм сбора мусора в Go. Однако в полученном контексте упоминается Иисус, а в инструкциях сказано отвечать исключительно на основе этого контекста, поэтому модель следует этим правилам. Это самый важный урок работы с RAG: ассистент настолько же достоверен, насколько достоверны документы, которые он использует. Что введено — то и получено.

    Запуск

    В репозитории предусмотрены файлы .env, что облегчает тестирование; достаточно просто выполнить команду go run, поскольку конфигурация загружается автоматически. Компания Groq отправила письмо о необходимости аннулирования встроенного ключа, поэтому, возможно, потребуется новый ключ.

    Это необработанный вывод из терминала Linux, включая теологическое удивление в конце:

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

    Связанные статьи

  • Как не позволить системе RAG использовать устаревшие документы: отслеживание актуальности и старения — Внедрение механизмов фиксации изменений, флагов устаревания и переранжирования с учётом актуальности в пайплайн поиска векторов Databricks, чтобы агенты отдавали предпочтение актуальным документам перед устаревшими.
  • Как предотвратить галлюцинации у чат-ботов: RAG, поиск векторов, разбиение на фрагменты и механизмы контроля — Решение проблемы галлюцинаций у чат-ботов, создающих ложные правила, с использованием технологий RAG, векторного поиска, формирования фрагментов, управления контекстом и мониторинга.
  • Локальная пайплайн-система RAG с использованием Qdrant, fastembed и Gemini на Python — Создание инкрементального инструмента командной строки для RAG: разбиение локальных документов на части, встраивание информации оних офлайн, хранение векторов в Qdrant и ответы на вопросы с помощью Gemini.