Минимальная пайплайн-система RAG на Go с использованием эмбеддингов langchaingo, Groq и Jina
Минимальная пайплайн-система RAG на Go с использованием langchaingo: хранилище векторов в памяти, поиск по косинусу, Groq для получения ответов и модели Jina для генерации эмбеддингов, плюс демонстрация некорректного контекста.
Источники: 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 нужен повторный ранжер (и как это сделать) — Пошаговое руководство по применению «Практических заметок: Почему вашему каналу RAG нужен повторный ранжер (и как это сделать)», включающее контракты, проверки и готовые блоки кода для команд, использующих эту схему.