Как создать ИИ Телеграм-бот с векторной памятью на Qdrant
Идея создания этого пет-проекта возникла из желания написать собственного ИИ-агента. Я сформулировал для себя минимальные технические требования: агент должен иметь несколько состояний, уметь запускать тулзы и использовать 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 получает текст и возвращает цифру:
Вопрос
Факт / утверждение
Просьба забыть
Команда для терминала
Всё остальное
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.
Генерирует эмбеддинг для text с помощью emb().
Формирует Point и отправляет PUT запрос в Qdrant.Используется для запоминания информации ботом.
delete_document(id: i32)
Удаляет документ по ID из коллекции в Qdrant.
Отправляет POST запрос на points/delete.
create_collection()
Создаёт коллекцию в Qdrant.
Читает размерность эмбеддингов из .env.
Устанавливает метрику сравнения — 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
Умный поиск релевантных документов.
Делает обычный search().
Фильтрует результаты по distance > 0.6.
Если ни один не подходит — берёт самый первый. Используется при генерации ответов.
search(query: &str, limit: usize) -> Vec
Базовый поиск документов по векторному сходству.
Генерирует вектор запроса.
Отправляет points/search запрос в Qdrant.
Возвращает отсортированные документы с distance.
Потом используя кирпичики из ai.rs и qdrant.rs я написал логику работы бота в main.rs:
main
Главная асинхронная точка входа:
Загружает .env переменные.
Инициализирует коллекцию в Qdrant и печатает документы из памяти.
Создаёт Telegram-бота.
Запускает обработку сообщений (teloxide::repl), передавая управление Finite State Machine.
enum State
enum State {
AwaitingPassword,
Pending,
ConfirmForget { info: String },
ConfirmCommand { message: String, command: String },
}
Финитный автомат состояний пользователя:
AwaitingPassword: ждет ввода пароля.
Pending: основной режим — пользователь прошёл авторизацию.
ConfirmForget: подтверждение удаления информации.
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