Как стать автором
Обновить

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

Уровень сложностиСредний
Время на прочтение8 мин
Количество просмотров1.5K

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

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

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

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

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

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

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

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

  • Проверяет пароль перед началом работы.

  • Понимает, чего хочет пользователь (вопрос, утверждение, просьба забыть, команда в терминал и т.п.).

  • Работает с векторной базой Qdrant — умеет запоминать и забывать информацию.

  • Может по-человечески понять команду и выполнить её на сервере.

  • Всё это он делает, используя локальную LLM (через HTTP-запросы к API).

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

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

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

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

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

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

  • Если пароль правильный, бот переходит в состояние Pending (готов к работе).

  • Если неправильный — снова просит ввести пароль.

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

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

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

  1. Вопрос

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

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

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

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

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

Тип 1: Вопрос

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

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

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

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

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

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

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

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

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

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

  • Если да → удаляет документ из Qdrant.

  • Если нет → оставляет как есть.

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

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

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

  • Если да → запускает команду через std::process::Command и отправляет результат.

  • Если нет → команда не выполняется.

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

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

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

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

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

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

Вход:

  • system — системное сообщение (например, инструкции для бота).

  • user — сообщение от пользователя.

Выход:

  • Возвращает ответ от модели в виде строки.

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

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

Вход:

  • input — текстовая строка, которую нужно закодировать.

Выход:

  • Вектор эмбеддинга Vec<f32>.

Далее я реализовал работу с 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)>
  • Если пароль совпадает с BOT_PASSWORD из .env, переходит в Pending.

  • Иначе остаётся в AwaitingPassword.

exec_pending

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

pub fn exec_pending(message: &str) -> anyhow::Result<(Self, String)>
  • Передаёт фразу в LLM и получает ответ: "1", "2", ..., "5".

  • В зависимости от цифры вызывает нужную функцию:

    • 1 → exec_answer

    • 2 → exec_remember

    • 3 → new_forget

    • 4 → new_command

    • иначе → exec_chat

exec_answer

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

pub fn exec_answer(message: &str) -> anyhow::Result<(Self, String)>
  • Извлекает ключевые слова из сообщения.

  • Ищет документы в Qdrant.

  • Кормит всё это LLM и получает ответ.

  • Возвращает Pending.

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_forget: находит подходящий документ по ключевым словам и спрашивает подтверждение.

  • exec_forget: если пользователь подтвердил, удаляет документ.

new_command → exec_confirm_command

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

  • new_command: превращает запрос пользователя в Linux-команду и спрашивает подтверждение.

  • exec_confirm_command: если "да", запускает команду и возвращает stdout/stderr.

is_condition

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

extract_tag, extract_number

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

  • extract_tag: достаёт содержимое из тега <tag> в ответе LLM.

  • extract_number: находит первую цифру в ответе LLM.

init_qdrant и print_docs

  • init_qdrant: создаёт коллекцию, если её нет.

  • print_docs: печатает всю память при старте (удобно для отладки).

Docker & запуск

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

  • backend — сам бот.

  • qdrant — векторная база.

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:
Информация забыта.

Что в итоге

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

  • Он умеет понимать и анализировать текст.

  • Имеет состояния и переключается между ними.

  • Работает как с памятью, так и с терминалом.

  • Всё написано на Rust: быстро, стабильно и предсказуемо.

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

Теги:
Хабы:
Всего голосов 8: ↑7 и ↓1+6
Комментарии8

Публикации

Работа

Rust разработчик
10 вакансий

Ближайшие события