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

План
Немного теории
Про 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: %s\n\n", 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? Расскажите, с какими проблемами столкнулись в процессе и какие инструменты показали себя лучше всего?