Когда RAG на Go свистнет: собираем прототип чата за вечер. ai.. ai. Go.. ai. Go. golang.. ai. Go. golang. Natural Language Processing.. ai. Go. golang. Natural Language Processing. rag.. ai. Go. golang. Natural Language Processing. rag. Блог компании Cloud.ru.. ai. Go. golang. Natural Language Processing. rag. Блог компании Cloud.ru. искусственный интеллект.

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

Когда RAG на Go свистнет: собираем прототип чата за вечер - 1

План

Немного теории

Про Retrieval-Augmented Generation (RAG) уже написано много, и подробнее можно почитать в интернете, и даже ChatGPT GigaChat доступно объяснит, поэтому кратко. Эта технология позволяет нам «научить» модель новым знаниям. Модифицировать саму модель мы не можем, но можем добавить в промпт всю новую информацию! Современные модели обладают весьма внушительным контекстным окном в тысячи токенов, что позволяет вместить несколько документов в дополнение к запросу пользователя.

Чтобы передавать не всю библиотеку, а только релевантные блоки текста, нам на помощь приходят специальные базы данных и Embedding-модели. Эти модели умеют преобразовывать текст в векторы таким образом, что похожие по смыслу тексты будут находиться в векторном представлении ближе друг к другу. Берем вектор от запроса пользователя и ищем ближайшие к нему векторы от кусков текстов нашей базы знаний, например штук пять самых близких, и эти куски текста уже добавляем в промпт. А большая модель уже в них будет искать ответ на вопрос.

Давайте же посмотрим, как собрать такое приложение на Go. Нам понадобится:

  • Большая модель для текста

  • Маленькая для эмбеддинга

  • Векторная база данных

  • Синяя изолента Немного кода

Архитектура RAG-приложения

Если посмотреть взглядом бэкендера на концепцию такого приложения, то можно заметить, что она довольно тривиальна и привычна. У нас есть сервер, который принимает запросы пользователя, есть базы данных для хранения истории и знаний, и есть сторонние API, которые нужно вызвать.

Прыгать с парашютом совсем не страшно. Открываешь дверь самолета — а там Google Maps. Вы ведь не боитесь Google Maps?

Прыгать с парашютом совсем не страшно. Открываешь дверь самолета — а там Google Maps. Вы ведь не боитесь Google Maps?

История диалога хранится в привычной базе данных, можем использовать как 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.

llama3 не очень хорошо знакома с творчеством Александра Сергеевича, но она старалась

llama3 не очень хорошо знакома с творчеством Александра Сергеевича, но она старалась

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

Шаг 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  
}

Спросим что-нибудь посложнее и посмотрим на нашу историю.

Когда RAG на Go свистнет: собираем прототип чата за вечер - 5
Когда RAG на Go свистнет: собираем прототип чата за вечер - 6

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

Автор: warmaris

Источник

Rambler's Top100