Привет, я — Евгений Клецов, Go-разработчик в Cloud.ru. Если вы тоже Go-разработчик, то и вам, наверняка, приходила в голову мысль добавить в свой сервис «немного AI», но казалось, что это требует погружения в незнакомый мир Python и машинного обучения. Каждый день появляются новые AI-стартапы, да и существующие сервисы не отстают с внедрением искусственного интеллекта. Еще недавно это и правда было невозможным без глубоких знаний в области ML/AI, но сейчас всё меняется. Большие текстовые модели обзавелись удобным API для работы и фактически превратились в AI as a Service. Давайте на практике убедимся, что Go тоже прекрасно подходит для разработки подобных приложений на примере RAG.

План
Немного теории
Архитектура RAG-приложения
Выбор инструментов: фреймворки
Собираем приложение
Шаг 1: Общаемся с моделью
Шаг 2: Добавляем «мозг»
Шаг 3: Наводим порядок в разговоре
Куда двигаться дальше: улучшаем PoC
Заключение
Немного теории
Про Retrieval-Augmented Generation (RAG) уже написано много, и подробнее можно почитать в интернете, и даже ChatGPT GigaChat доступно объяснит, поэтому кратко. Эта технология позволяет нам «научить» модель новым знаниям. Модифицировать саму модель мы не можем, но можем добавить в промпт всю новую информацию! Современные модели обладают весьма внушительным контекстным окном в тысячи токенов, что позволяет вместить несколько документов в дополнение к запросу пользователя.
Чтобы передавать не всю библиотеку, а только релевантные блоки текста, нам на помощь приходят специальные базы данных и Embedding-модели. Эти модели умеют преобразовывать текст в векторы таким образом, что похожие по смыслу тексты будут находиться в векторном представлении ближе друг к другу. Берем вектор от запроса пользователя и ищем ближайшие к нему векторы от кусков текстов нашей базы знаний, например штук пять самых близких, и эти куски текста уже добавляем в промпт. А большая модель уже в них будет искать ответ на вопрос.
Давайте же посмотрим, как собрать такое приложение на Go. Нам понадобится:
Большая модель для текста
Маленькая для эмбеддинга
Векторная база данных
Синяя изолента Немного кода
Архитектура RAG-приложения
Если посмотреть взглядом бэкендера на концепцию такого приложения, то можно заметить, что она довольно тривиальна и привычна. У нас есть сервер, который принимает запросы пользователя, есть базы данных для хранения истории и знаний, и есть сторонние API, которые нужно вызвать.

История диалога хранится в привычной базе данных, можем использовать как SQL, так и NoSQL базы, или комбинировать их для достижения целей по скорости и надежности.
База знаний хранится в векторной базе данных, взаимодействие с ней еще проще: даже не нужно писать запросы. Нужно только преобразовать текст от пользователя в вектор и передать его в метод поиска. Для такого преобразования используется API эмбеддера: текст на вход, набор чисел на выходе.
Собираем воедино все блоки текста и отправляем в API большой языковой модели, получаем ответ, сохраняем в историю и отдаем пользователю.
AI-приложение оказывается вовсе не магия, а привычный микросервис, который без особых сложностей можно написать на Go. Способность к потоковой обработке данных и высокая производительность, за которые мы так любим Go, нам в этом помогут.
Выбор инструментов: фреймворки
Для написания приложения с RAG воспользуемся готовым фреймворком для AI-приложений. В принципе можно и с нуля написать, воспользоваться низкоуровневыми библиотеками для работы с базами данных, но это будет долго. Готовые фреймворки заметно упрощают взаимодействие с векторными хранилищами и LLM. Я нашел трех кандидатов для этого:
Langchain
Eino
GenKit
Ознакомился со всеми тремя и сделал для себя следующие выводы.
Genkit
Самый новый из троих, стабильная версия 1.0 вышла в сентябре 2025. Разрабатывается компанией Google.
Плюсы:
встроенные инструменты для отладки и разработки;
Google;
поддержка строгой типизации входных и выходных данных;
есть инструменты для мониторинга.
Минусы:
плохая поддержка Go, больше нацелен на Node.js, есть интеграции с фреймворками для фронтенда, часть фич отсутствует для Go;
выбор моделей ограничен;
тесно связан с облачными сервисами самого Google, нет возможности подключить свой Prometheus для сбора метрик, зато свой облачный мониторинг включается одной строкой;
векторные хранилища тоже поддерживаются в ограниченном количестве, часть их них также облачные от Google.
Eino
Весьма функциональный фреймворк от ByteDance — создателя TikTok, одного из крупнейших разработчиков ПО в Китае.
Плюсы:
серьезный разработчик;
есть свои плагины для IDE для облегчения разработки;
поддержка строгой типизации;
ориентирован на потоковую генерацию;
можно расширять и добавлять собственные реализации для индексеров, ретриверов и других элементов;
хорошо проработаны хуки жизненного цикла;
ориентирован на разработку агентов, оркестрация на основе графов.
Минусы:
Не очень большой выбор вариантов для моделей и векторных хранилищ, в основном китайские. Можно реализовать свои адаптеры, но хотелось бы из коробки.
Скромная документация при богатом функционале, мало примеров использования, часть документации в коде на китайском без английского перевода.
Langchain
Если не самый популярный, то точно один из таковых, изначально написан на Python, но имеется и его порт на Go.
Плюсы:
Очень известен в среде AI-разработчиков, можно проконсультироваться с ними в случае сложностей и вам будет всё понятно, так как в Go версии все те же концепции, что и для Python.
Самое большое количество поддерживаемых из коробки провайдеров LLM среди трех кандидатов. Есть локальный запуск в Ollama и из бинарного файла, OpenAI-совместимые API, Google, Mistral, HuggingFace и т. п.
Поддержка известных векторных хранилищ.
Механизмы кэширования ответов моделей, поддержания памяти чата, чтения и разбиения документов.
Богатая библиотека примеров использования в различных сценариях.
Минусы:
Отсутствие возможностей для строгой типизации и структуры входных и выходных данных.
Возможность реализовать одно и то же несколькими способами разной степени очевидности и понятности.
Genkit очевидно не подходит, по крайней мере на момент написания статьи, но выглядит перспективно. Eino кажется хорош, но сложноват для входа, и некоторых вещей не хватает. В итоге я остановился на Langchain, он оказался в целом понятным, все что нужно в нем уже есть из коробки, и нагуглить решение сложного вопроса будет гораздо проще (пусть даже и на Python). В дальнейших примерах кода буду использовать упомянутую библиотеку langchaingo.
Собираем приложение
Шаг 1: Общаемся с моделью
Для начала нам нужен минимальный чат с моделью, чтобы можно было ей задавать вопросы и читать ее ответы. Модели умеют отдавать результаты генерации в потоковом режиме по мере появления, во всех современных приложениях с AI мы это можем наблюдать. Выглядит красиво, давайте и мы тоже сделаем. Полный пример кода приложения можно посмотреть по ссылке.
В качестве источника знаний я решил взять текст романа А.С. Пушкина «Евгений Онегин».
Я буду использовать llama3, запущенную локально, поскольку она довольно простенькая, а серьезные модели и без нашего RAG хорошо знакомы с содержанием книги. Так можно будет увидеть разницу с RAG и без него.
package main func main() { ctx := context.Background() slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))) llm := initOllamaModel() srv := server.NewServer(chat.NewService(llm)) if err := srv.Run(); err != nil { slog.Error(«failed to run server», «error», err) os.Exit(1) } } func initOllamaModel() *ollama.LLM { modelName := «llama3.2:3b» llm, err := ollama.New(ollama.WithModel(modelName)) if err != nil { slog.Error(«failed to create ollama llm», «error», err) os.Exit(1) } llm.CallbacksHandler = metrics.NewCallbackHandler(modelName) return llm }
Сервер будет обрабатывать сообщения чата отдельной ручкой и отдавать результат с помощью Server-Sent Events. Это проще, чем вебсокеты, особенно в части масштабирования и балансировки нагрузки. Так мы реализуем появление текста по мере генерации.
Также добавим главную страницу с простеньким фронтендом для отображения чата и отправки сообщений.
package server type ChatService interface { Chat(ctx context.Context, in chat.Message) <-chan string } type Server struct { chat ChatService } func (s *Server) Run() error { r := chi.NewRouter() r.Use(middleware.Recoverer) r.Get(«/», s.handleRoot) r.Post(«/chat/{chatID}», s.handleChat) return http.ListenAndServe(«:8080», r) } func (s *Server) handleChat(w http.ResponseWriter, req *http.Request) { chatID, err := uuid.Parse(chi.URLParam(req, «chatID»)) if err != nil || chatID == uuid.Nil { slog.Error(«invalid chat ChatID», «error», err, «chatID», chi.URLParam(req, «chatID»)) http.Error(w, «Invalid chat ChatID», http.StatusBadRequest) return } w.Header().Set(«Content-Type», «text/event-stream») w.Header().Set(«Cache-Control», «no-cache») w.Header().Set(«Connection», «keep-alive») flusher, ok := w.(http.Flusher) if !ok { http.Error(w, «Streaming unsupported», http.StatusInternalServerError) return } input, err := io.ReadAll(req.Body) if err != nil { http.Error(w, «Error reading request body», http.StatusBadRequest) } defer req.Body.Close() ch := s.chat.Chat(req.Context(), chat.Message{ ChatID: chatID, Text: string(input), }) for msg := range ch { _, _ = fmt.Fprintf(w, «data: %snn», msg) flusher.Flush() } } func (s *Server) handleRoot(w http.ResponseWriter, _ *http.Request) { w.Header().Set(«Content-Type», «text/html; charset=utf-8») data, err := embedFiles.ReadFile(«html/index.html») if err != nil { slog.Error(«error reading file», «error», err) http.Error(w, «internal server error», http.StatusInternalServerError) } else { _, _ = w.Write(data) } }
И непосредственно реализация чата с моделью и потоковым ответом.
type Service struct { llm llms.Model } func NewService(llm llms.Model) *Service { return &Service{llm: llm} } func (s *Service) Chat(ctx context.Context, msg Message) <-chan string { res := make(chan string) content := []llms.MessageContent{ llms.TextParts(llms.ChatMessageTypeSystem, «Ты специалист по творчеству Александра Сергеевича Пушкина. Тебе нужно ответить на вопрос.»), } content = append(content, llms.TextParts(llms.ChatMessageTypeHuman, msg.Text)) go s.generateContent(ctx, msg.ChatID, content, res) return res } func (s *Service) generateContent(ctx context.Context, chatID uuid.UUID, content []llms.MessageContent, out chan<- string) { defer close(out) completion, err := s.llm.GenerateContent(ctx, content, llms.WithTemperature(0.5), llms.WithStreamingFunc(func(ctx context.Context, chunk []byte) error { if ctx.Err() != nil { return fmt.Errorf(«error: %w», ctx.Err()) } out <- fmt.Sprintf(«%s», chunk) return nil }), ) if err != nil { slog.Error(«failed to generate content», «error», err) out <- «Failed to generate content» return } slog.Debug(«completion result», «result», *completion) }
Для реализации потокового ответа сервисный метод возвращает канал, из которого мы будем читать блоки текста и возвращать в ответе сервера. Если потоковый ответ не требуется, то можно просто дождаться результата вызова GenerateContent.

Изначально я использовал другую модель, и она знала правильный ответ ещё до внедрения цитат из книги. Перебрал несколько разных, пока не нашёл ту, которая не знает, как же звали Ленского.
Шаг 2: Добавляем «мозг»
Настала очередь базы знаний. В langchaingo есть несколько адаптеров для популярных векторных баз данных, я буду использовать Qdrant. Почему именно его? Он довольно популярный, часто попадался мне на глаза, я захотел попробовать его. А еще его можно запустить в Docker без параметров, и в комплекте есть вебадминка, для тестовой среды очень удобно. Для эмбеддинга буду использовать модель EmbeddingGemma также локально.
func initOllamaEmbedder() embeddings.Embedder { llm, err := ollama.New(ollama.WithModel(«embeddinggemma:300m»)) if err != nil { slog.Error(«failed to create llm», «error», err) os.Exit(1) } embedder, err := embeddings.NewEmbedder(llm) if err != nil { slog.Error(«failed to create embedder», «error», err) os.Exit(1) } return embedder } func initQdrantStore(embedder embeddings.Embedder) vectorstores.VectorStore { url, err := url.Parse(«http://localhost:6333/») if err != nil { slog.Error(«failed to parse url», «error», err) os.Exit(1) } store, err := qdrant.New( qdrant.WithURL(*url), qdrant.WithCollectionName(«Onegin»), qdrant.WithEmbedder(embedder), ) if err != nil { slog.Error(«failed to create qdrant store», «error», err) os.Exit(1) } return store }
В коде можно заметить параметр CollectionName, здесь необходимо указать имя коллекции, которую мы создадим в Qdrant. Запустим его в Docker и настроим.
docker run -p 6333:6333 -p 6334:6334 qdrant/qdrant
Открываем http://localhost:6333/dashboard и сразу попадаем на страницу Коллекций. Нажимаем Create Collection, вводим имя коллекции, которое прописали в коде. Далее отвечаем на пару вопросов, выбирая подходящие опции. Я выбираю:
Global search (у нас один набор данных для всех пользователей).
Simple single embedding (сложные варианты поиска нам пока не нужны).
Последняя опция очень важная, здесь у нас задается размерность вектора. Он должен совпадать с аналогичным параметром модели, иначе мы не сможем ничего сохранить. Для EmbeddingGemma он равен 768, для вашего варианта модели должен быть указан в документации к ней. Устанавливаем его, метрика по умолчанию косинус, оставляем её. В последнем шаге нам предлагают добавить индексы по полям, но мы не будем добавлять к нашим векторам дополнительные метаданные, так что пропускаем этот шаг и сохраняем коллекцию.
Текст Онегина находим в интернете без регистрации и смс, общественное достояние как-никак. Имеющийся текст необходимо разбить на куски, которые мы векторизируем. Они должны быть не слишком большими, чтобы не перегружать модель, и не слишком маленькими, чтобы в них была полезная информация и потенциальный ответ на вопрос пользователя. Воспользуемся модулем textsplitter нашей библиотеки langchaingo, разбивать будем по 400 токенов с перекрытием в 100 токенов. У нас нет цели точно затюнить поиск, а этого хватит для теста.
func main() { ctx := context.Background() slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))) embedder := initOllamaEmbedder() store := initQdrantStore(embedder) splitter := textsplitter.NewTokenSplitter( textsplitter.WithChunkSize(400), textsplitter.WithChunkOverlap(100), ) slog.Info(«splitter created») file, err := os.Open(«evgenii-onegin.txt») if err != nil { slog.Error(«failed to open file», «error», err) os.Exit(1) } defer file.Close() loader := documentloaders.NewText(file) docs, err := loader.LoadAndSplit(ctx, splitter) if err != nil { slog.Error(«failed to load and split documents», «error», err) os.Exit(1) } slog.Info(«documents loaded and split», «count», len(docs)) ids, err := store.AddDocuments(ctx, docs) if err != nil { slog.Error(«failed to add documents to store», «error», err) os.Exit(1) } slog.Info(«documents added to store», «count», len(ids)) }
Запускаем нашу простенькую программу, через несколько минут наблюдаем в админке заполненную коллекцию. Добавляем к нашему приложению поиск в базе.
func (s *Service) Chat(ctx context.Context, msg Message) <-chan string { res := make(chan string) docs, err := s.store.SimilaritySearch(ctx, msg.Text, maxResults) if err != nil { slog.Error(«failed to search docs from vector store», «error», err) } slog.Debug(«found docs», «docs», docs) content := []llms.MessageContent{ llms.TextParts(llms.ChatMessageTypeSystem, «Ты специалист по творчеству Александра Сергеевича Пушкина. Тебе нужно ответить на вопрос.»), } if len(docs) > 0 { content = append(content, llms.TextParts(llms.ChatMessageTypeHuman, «Ты знаешь следующие документы:»)) } for _, doc := range docs { content = append(content, llms.TextParts(llms.ChatMessageTypeHuman, doc.PageContent)) } content = append(content, llms.TextParts(llms.ChatMessageTypeHuman, msg.Text)) go s.generateContent(ctx, msg.ChatID, content, res) return res }
Здесь я добавил также дополнительные инструкции для модели: системный промпт для обозначения задачи и пояснение, что вообще за текст тут добавлен. Перезапускаем приложение и повторяем наш вопрос модели.

Шаг 3: Наводим порядок в разговоре
Теперь давайте добавим сохранение истории сообщений и опробуем на более серьезной модели. Здесь также нет ничего сложного, нам нужно сохранить в базу запрос пользователя и ответ модели, а в следующем обращении загрузить предыдущие вопросы и ответы. Чтобы не перегружать модель и сэкономить платные токены, мы можем ограничить историю по количеству сообщений или токенов, или даже делать суммаризацию переписки с помощью модели. Хранить историю можем в любой базе данных, в своём примере я использую PostgreSQL.
func (h *historyStorage) Save(ctx context.Context, id uuid.UUID, role llms.ChatMessageType, content string) error { ctx, cancel := context.WithTimeout(ctx, pgQueryTimeout) defer cancel() query := `INSERT INTO history (chat_id, role, message) VALUES ($1, $2, $3)` if _, err := h.db.Exec(ctx, query, id, role, content); err != nil { return fmt.Errorf(«insert into history: %w», err) } return nil } func (h *historyStorage) Load(ctx context.Context, id uuid.UUID) ([]llms.MessageContent, error) { ctx, cancel := context.WithTimeout(ctx, pgQueryTimeout) defer cancel() query := `SELECT role, message FROM history WHERE chat_id = $1 ORDER BY created_at DESC LIMIT 4` rows, err := h.db.Query(ctx, query, id) if err != nil { return nil, fmt.Errorf(«query history: %w», err) } var res []llms.MessageContent for rows.Next() { if err = rows.Err(); err != nil { return nil, fmt.Errorf(«history row error: %w», err) } var ( role string content string ) if err = rows.Scan(&role, &content); err != nil { return nil, fmt.Errorf(«scan history row: %w», err) } res = append(res, llms.TextParts(llms.ChatMessageType(role), content)) } slices.Reverse(res) return res, nil }func (s *Service) Chat(ctx context.Context, msg Message) <-chan string { res := make(chan string) history, err := s.history.Load(ctx, msg.ChatID) if err != nil { slog.Error(«failed to load history», «error», err) } err = s.history.Save(ctx, msg.ChatID, llms.ChatMessageTypeHuman, msg.Text) if err != nil { slog.Error(«failed to save user message to history», «error», err) } docs, err := s.store.Search(ctx, msg.Text) if err != nil { slog.Error(«failed to search docs from vector store», «error», err) } slog.Debug(«found docs», «docs», docs) content := []llms.MessageContent{ llms.TextParts(llms.ChatMessageTypeSystem, «Ты специалист по творчеству Александра Сергеевича Пушкина. Тебе нужно ответить на вопрос.»), } if len(history) > 0 { for _, historyMsg := range history { content = append(content, historyMsg) } } if len(docs) > 0 { content = append(content, llms.TextParts(llms.ChatMessageTypeHuman, «Ты знаешь следующие документы:»)) } for _, doc := range docs { content = append(content, llms.TextParts(llms.ChatMessageTypeHuman, doc.PageContent)) } content = append(content, llms.TextParts(llms.ChatMessageTypeHuman, msg.Text)) go s.generateContent(ctx, msg.ChatID, content, res) return res }
Здесь я сделал простейший вариант ограничения истории — добавил лимит в запрос. Обратите внимание, мы не сохраняем в историю результаты поиска по базе знаний — они нам там ни к чему. Модель даст ответ с их учетом, и его мы запишем в историю.
func initOpenAIModel() *openai.LLM { modelName := «GigaChat/GigaChat-2-Max» client := &http.Client{ Transport: initModelAPITransport(modelName), Timeout: 300 * time.Second, } cbHandler := metrics.NewCallbackHandler(modelName) // Token set in OPENAI_API_KEY env llm, err := openai.New( openai.WithModel(modelName), openai.WithBaseURL(«https://foundation-models.api.cloud.ru/v1»), openai.WithHTTPClient(client), openai.WithCallback(cbHandler), ) if err != nil { slog.Error(«failed to create openai llm», «error», err) os.Exit(1) } return llm }
Спросим что-нибудь посложнее и посмотрим на нашу историю.


Куда двигаться дальше: улучшаем PoC
Приведенный пример приложения является лишь Proof-of-Concept, что подобные приложения можно писать на Go. Для полноценного внедрения в продакшн его ещё потребуется доработать.
Потребуются инструменты наблюдения: логи, метрики, трейсинг. Здесь в целом все как в обычном бэкенде: отслеживаем обращения к базам данных, сторонним сервисам (моделям), но добавляется один нюанс. Будет не лишним добавить метрики расхода токенов, поскольку они тарифицируются в платных моделях. Для этих целей в ответах моделей в langchaingo есть поле GenerationInfo, из которого можно получить информацию о количестве входных и выходных токенов, подсчитанных самой моделью. Также библиотека предоставляет возможность задать обработчики для событий жизненного цикла.
func (c CallbackHandler) HandleLLMGenerateContentEnd(_ context.Context, res *llms.ContentResponse) { if len(res.Choices) == 0 { return } if input, ok := res.Choices[0].GenerationInfo[«PromptTokens»].(int); ok { IncInputTokens(c.model, input) } if output, ok := res.Choices[0].GenerationInfo[«CompletionTokens»].(int); ok { IncOutputTokens(c.model, output) } }
Для улучшения поиска по базе данных можно задействовать дополнительные поля с метаданными, совместить векторный и полнотекстовый поиск, а также добавить ранжирование результатов с помощью Reranker-моделей (cross-encoder).
Стоит уделить внимание настройке таймаутов для обращений к моделям. Модель может генерировать ответ очень долго, особенно если используется размышление (thinking) или объяснение (reasoning), да и вопрос может быть нетривиальным, и тогда время до окончания генерации может исчисляться минутами. Можно ограничить количество токенов в генерации, но это не всегда приемлемо. Необходимо настроить их таким образом, чтобы генерация не прерывалась на середине, но также и не блокировать сервис в случае сетевых проблем.
Заключение
Я сам думал, что внедрить AI в приложение на Go будет непросто. На практике же оказалось, что RAG, да и не только он — это всего лишь архитектурные паттерны, а не эксклюзивная технология, и он может быть реализован на любом языке программирования. В свою очередь, Go это отличный выбор для реализации AI-приложений:
Библиотеки и фреймворки активно развиваются.
Высокая производительность и поддержка многопоточности открывают возможности для создания высоконагруженных приложений.
Легкая интеграция в существующие системы на Go.
Простота развертывания и эксплуатации (DevOps-friendly).
Ну а если хочется научить свою LLM-ку чему-то новому, но возиться с языками программирования вообще нет желания, у моих коллег из Evolution есть сервис Managed RAG. С документацией и примерами.
А вы уже пробовали писать AI приложения на Go? Расскажите, с какими проблемами столкнулись в процессе и какие инструменты показали себя лучше всего?
Источник: habr.com



























