Как создать ИИ Телеграм-бот с векторной памятью на Qdrant

Идея создания этого пет-проекта возникла из желания написать собственного ИИ-агента. Я сформулировал для себя минимальные технические требования: агент должен иметь несколько состояний, уметь запускать тулзы и использовать RAG для поиска ответов на вопросы.

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

Изначально я думал использовать LangChain. Очень хороший инструмент — позволяет подключать векторные базы данных, использовать различные LLM как для инференса, так и для эмбеддинга, а также описывать логику работы агента через граф состояний. Можно вызывать уже готовые тулзы. В целом, на первый взгляд всё выглядит удобно и просто, особенно когда смотришь типовые и несложные примеры.

Но, покопавшись немного глубже, мне показалось, что затраты на изучение этого фреймворка не оправдывают себя. Проще напрямую вызывать LLM, эмбеддинги и Qdrant через REST API. А логику работы агента описать в коде через enum, описывающий состояния, и делать match по этим состояниям.

К тому же LangChain изначально написан на Python. Я хотел бы писать на Rust, а использовать Rust-версию LangChain — сомнительное удовольствие, которое обычно упирается в самый неподходящий момент: что-то ещё не было переписано на Rust.

Для реализации магии RAG я решил использовать следующий алгоритм. Когда пользователь задаёт вопрос, из вопроса извлекаются ключевые слова при помощи LLM. Далее при помощи эмбеддинга вычисляется вектор по этим ключевым словам. Затем этот вектор передаётся в Qdrant, и ищутся ближайшие векторы от документов, которые уже есть в памяти. После этого из найденных документов формируется запрос к LLM, в который включаются найденные документы и вопрос пользователя. В итоге получаем ответ LLM, в котором учитываются данные, близкие по смыслу к вопросу. Соответственно, когда пользователь сообщает какую-то информацию боту, он её сохраняет в Qdrant, и для каждой информации указывается вектор, насчитанный через эмбеддинг. Другими словами, близкие по смыслу векторы имеют минимальное расстояние между собой. Так и работает поиск схожих по смыслу документов.

Проектирование

Сначала я придумал общую логику работы ИИ бота. Бот реагирует на команды пользователя:

Потом я расписал подробно сценарий работы ИИ бота:

1. Пользователь отправляет сообщение в Телеграм

Пользователь пишет боту что угодно — вопрос, факт, просьбу, команду — всё, что угодно.

Бот получает сообщение с Telegram Bot API.

2. Проверка пароля

Сначала бот ждёт, когда пользователь введёт пароль. Он сравнивает введённый текст с переменной окружения BOT_PASSWORD.

3. Обработка сообщения

Когда бот в состоянии Pending, он анализирует сообщение. Чтобы понять, что именно отправил пользователь, вызывается LLM:

LLM получает текст и возвращает цифру:

  1. Вопрос

  2. Факт / утверждение

  3. Просьба забыть

  4. Команда для терминала

  5. Всё остальное

4. Варианты действий в зависимости от типа сообщения

Тип 1: Вопрос

Бот просит LLM выделить ключевые слова из запроса, чтобы понять, о чём речь.

С этими ключевыми словами бот ищет наиболее подходящие документы в векторной базе Qdrant.

Затем он объединяет найденную информацию с исходным вопросом и снова обращается к LLM, чтобы получить финальный ответ.

Ответ отправляется пользователю.

Тип 2: Утверждение (сохранить информацию)

Бот создаёт эмбеддинг из текста и добавляет его в Qdrant.

Пользователю прилетает подтверждение: "Информация сохранена"

Тип 3: Просьба забыть

Бот ищет, что именно нужно забыть, используя ключевые слова.

Он уточняет у пользователя, точно ли стоит забыть это.

Тип 4: Команда в терминал

Бот просит LLM сформулировать команду для Linux по описанию.

Спрашивает пользователя, точно ли запускать команду:

Тип 5: Всё остальное

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

Написание кода

Начал писать код я для работы с LLM и embeddings. Вот список функций из ai.rs с кратким и понятным описанием:

llm(system: &str, user: &str) -> anyhow::Result

Что делает:
Отправляет запрос в чат-модель LLM (через OpenAI совместимый API).

Вход:

Выход:

emb(input: &str) -> anyhow::Result>

Что делает:
Создаёт эмбеддинг для заданного текста с помощью модели эмбеддинга.

Вход:

Выход:

Далее я реализовал работу с Qdrant. Вот список функций из qdrant.rs:

add_document(id: i32, text: &str)

Добавляет документ в Qdrant.

  1. Генерирует эмбеддинг для text с помощью emb().

  2. Формирует Point и отправляет PUT запрос в Qdrant.Используется для запоминания информации ботом.

delete_document(id: i32)

Удаляет документ по ID из коллекции в Qdrant.
Отправляет POST запрос на points/delete.

create_collection()

Создаёт коллекцию в Qdrant.

  1. Читает размерность эмбеддингов из .env.

  2. Устанавливает метрику сравнения — Cosine.Полезно при первой инициализации бота.

delete_collection()

Удаляет всю коллекцию из Qdrant.
Полезно при смене модели эмбеддинга (другая размерность).

exists_collection() -> bool

Проверяет, существует ли коллекция в Qdrant.
Отправляет GET запрос — возвращает true, если есть.

last_document_id() -> i32

Находит максимальный ID среди всех документов.
Нужен, чтобы правильно инкрементировать ID при добавлении новых.

all_documents() -> Vec

Получает все документы из коллекции.
Постранично скроллит через scroll-запрос Qdrant.

search_one(query: &str) -> Document

Ищет один (наиболее релевантный) документ.
Используется при подтверждении удаления конкретной информации.

search_smart(query: &str) -> Vec

Умный поиск релевантных документов.

  1. Делает обычный search().

  2. Фильтрует результаты по distance > 0.6.

  3. Если ни один не подходит — берёт самый первый. Используется при генерации ответов.

search(query: &str, limit: usize) -> Vec

Базовый поиск документов по векторному сходству.

  1. Генерирует вектор запроса.

  2. Отправляет points/search запрос в Qdrant.

  3. Возвращает отсортированные документы с distance.

Потом используя кирпичики из ai.rs и qdrant.rs я написал логику работы бота в main.rs:

main

Главная асинхронная точка входа:

  1. Загружает .env переменные.

  2. Инициализирует коллекцию в Qdrant и печатает документы из памяти.

  3. Создаёт Telegram-бота.

  4. Запускает обработку сообщений (teloxide::repl), передавая управление Finite State Machine.

enum State

enum State {
    AwaitingPassword,
    Pending,
    ConfirmForget { info: String },
    ConfirmCommand { message: String, command: String },
}

Финитный автомат состояний пользователя:

  1. AwaitingPassword: ждет ввода пароля.

  2. Pending: основной режим — пользователь прошёл авторизацию.

  3. ConfirmForget: подтверждение удаления информации.

  4. ConfirmCommand: подтверждение выполнения команды.

State::process

Главная точка входа, которая вызывает обработчик для текущего состояния:

pub fn process(input: &str, state: &State) -> anyhow::Result<(Self, String)>

Вызывает соответствующую функцию (по сути match по состоянию).

process_password

Проверка пароля, введённого пользователем:

pub fn process_password(input: &str) -> anyhow::Result<(Self, String)>

exec_pending

Самая важная часть: определяет тип сообщения пользователя (вопрос, инфа, команда и т.п.):

pub fn exec_pending(message: &str) -> anyhow::Result<(Self, String)>

exec_answer

RAG-подход: вытаскивает релевантные документы и генерирует ответ:

pub fn exec_answer(message: &str) -> anyhow::Result<(Self, String)>

exec_remember

Просто добавляет новую информацию в Qdrant с автоинкрементом ID:

pub fn exec_remember(message: &str) -> anyhow::Result<(Self, String)>

exec_chat

Простой диалог с LLM без RAG:

pub fn exec_chat(message: &str) -> anyhow::Result<(Self, String)>

new_forget → exec_forget

Удаление информации из памяти:

new_command → exec_confirm_command

Выполнение команд:

is_condition

Универсальный способ понять: "пользователь согласен?" или "в этом сообщении есть команда?".

extract_tag, extract_number

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

init_qdrant и print_docs

Docker & запуск

Для запуска используется docker-compose.yml. В нём два сервиса:

Dockerfile собирает Rust-проект и запускает бинарник внутри минимального debian:bullseye.

Инструкция для запуска:

# Скопируйте .env-example и в нем укажите свои переменные
cp .env-example .env

# Соберите и запустите контейнеры
docker compose up --build

Пример чата с ботом

Evgeny Igumnov:
Привет!

Igumnov's memory extention:
Неверный пароль. Попробуйте снова.

Evgeny Igumnov:
supersecret

Igumnov's memory extention:
Пароль принят. Вы можете продолжать работу с ботом.

Evgeny Igumnov:
День рождения у Кати 24 ноября.

Igumnov's memory extention:
Информация сохранена.

Evgeny Igumnov:
Какой у меня пароль от wifi?

Igumnov's memory extention:
Ваш пароль от Wi-Fi: 4040404040. 

Evgeny Igumnov:
Запусти команду отображающую список файлов в папке текущей

Igumnov's memory extention:
Запустить команду "ls" ?

Evgeny Igumnov:
да

Igumnov's memory extention:
Результат работы команды

all-files.sh
Cargo.lock
Cargo.toml
docker-compose.yml
Dockerfile
LICENSE
README.md
src

Evgeny Igumnov:
Когда у Кати день рождения?

Igumnov's memory extention:
У Кати день рождения 24 ноября.

Evgeny Igumnov:
Забудь когда у Кати день рождения

Igumnov's memory extention:
'День рождения у Кати 24 ноября.' Забыть информацию?

Evgeny Igumnov:
да

Igumnov's memory extention:
Информация забыта.

Что в итоге

Я получил код полноценного ИИ-агента:

Исходные коды ИИ телеграм бота тут: https://github.com/evgenyigumnov/ai-agent-telegram-bot/tree/russian

@igumnov
31.03.2025 09:40 UTC
Первоисточник

Комментарии

@DreamShaded
31.03.2025 05:07 UTC
+1

Спасибо, классный проект!) А какого агента используете? На своём сервере или внешний API?

@igumnov
31.03.2025 05:21 UTC
+1

Спасибо за позитивный отклик!

Разрабатывал используя LM Studio:

CHAT_COMPLETIONS_MODEL=gemma-3-12b-it
EMBEDDINGS_MODEL=lm-kit/text-embedding-bge-m3

Что бы ИИ отвечал лучше переключился на gemma-3-27b-it, но он медленнее на моей видюхе.

Потом использовал OpenAI
CHAT_COMPLETIONS_MODEL=gpt-4o-mini
EMBEDDINGS_MODEL=text-embedding-3-small

Хотел проверить стало ли лучше работать на коммерческих моделях. В итоге чуть лучше чем квантованная gemma-3-27b-it, но не сильно, даже от OpenAI иногда фигню выдает )

@DreamShaded
31.03.2025 05:53 UTC
0

А какая видеокарта, если не секрет?) прицениваюсь к апгрейду

31.03.2025 05:59 UTC
+1

NVIDIA RTX 4070 Ti SUPER 16ГБ - брал на НГ. Лучшее соотношение скорости, цены и объёма памяти. И гамать можно и ИИ гонять для тестирования или пет проектов.

@muhachev
01.04.2025 05:37 UTC
0

Для самовыражения растомана норм. Но всё же, думаю, lang-chain + python больше подойдёт для проф разработки подобных систем. Да и для читателей, наверное, было бы интересно увидеть примеры разумного реального использования нетривиальных концепций лангчейна, прежде чем отвергать его из-за кажущейся неимоверной сложности и крутой кривой обучения.

@GiveMeFreeNickName
01.04.2025 11:59 UTC
0

Всегда хотел блокнот который для показа диалога "сохранить изменения?" будет требовать мощную видеокарту

@igumnov
02.04.2025 17:33 UTC
0

На OpenAI закиньте несколько долларов. Там модель chatgpt4 стоит 0.15$ за 1 млн токенов. Вам этих несколько долларов для этого блокнота хватит на несколько лет...

04.04.2025 08:13 UTC
0

Я просто блокнот обычный открою, вы кажется немного не поняли суть