Перейти к содержимому
VC
Кейс 24 из 33 · AI / Базы знаний

AI-помощник по внутренней базе знаний (RAG): бот в Telegram с уровнями доступа

Бот отвечает на вопросы по внутренним чатам, письмам, голосовым и документам — с цитатами на источник. Поиск переписан с индекса в оперативной памяти на SQLite FTS5: память упала в 20 раз, и бот уехал с ноутбука подрядчика на сервер, где отвечает круглосуточно.

Отрасль
Интернет-торговля, распределённая команда
Стек
Python · SQLite FTS5 · Telegram · MCP
Сроки
≈ 8 недель эволюции
Итог
1044 → 50 МБ, 253 → 13 мс
01 · Боль

Вся фактура бизнеса жила в переписке

Бренд товаров для активного отдыха из США, два магазина на Shopify, распределённая команда: владелец бизнеса, операционисты, специалист по рекламе, подрядчики. Ни одной системы, где хранились бы решения. Всё — в рабочих чатах: десятки тысяч сообщений, голосовые вместо документов, пересланные PDF и скриншоты, плюс почтовый корпус в более чем 100 тыс. писем.

Ответ на вопрос вида «что мы решили по этому две недели назад» стоил получаса раскопок: пролистать чат, вспомнить, в каком именно, найти голосовое, послушать его целиком. Часть решений просто терялась — их переспрашивали и принимали заново.

Требование владельца было прямое: «важно, чтобы в базе было как можно больше данных». Но в тех же чатах лежала личная переписка и персональные данные — то, что команде показывать нельзя. Задача сразу стала звучать так: «сделать поиск по своим данным так, чтобы полнота не превратилась в утечку».

02 · Решение

Полный поиск по своим данным — приватность решается до индекса

Контур односторонний: сырьё собирается, приводится к тексту, чистится от секретов, попадает в индекс с правами доступа, и только потом модель собирает ответ. Приватность решается на входе — так её нельзя обойти удачной формулировкой вопроса.

01
Сбор

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

02
Приведение к тексту

whisper расшифровывает голосовые, OCR распознаёт скриншоты — всё становится текстом со ссылкой на источник

03
Вычистка секретов

Номера карт, коды и пароли вырезаются до записи в индекс

04
Индекс и права

SQLite FTS5 хранит индекс, ранжирование своё, поверх источников — уровни доступа

05
Ответ

Модель собирает ответ и обязательно приводит цитаты; доставка в Telegram

Сбор: слияние по id сообщения

Чаты снимаются лейнами и сливаются по id сообщения. Причина простая: копирование поверх стёрло бы историю при первом же неполном съёме — источник отдаёт только последние N сообщений. Голосовые расшифровываются через whisper, скриншоты — через OCR, пересланные документы попадают в индекс как отдельные источники. На выходе всё — текст, у каждого чанка есть ссылка обратно.

Правка отправленного сообщения обновляет запись, а прежняя версия складывается в историю правок. Раньше дедупликация по идентификатору выбрасывала правку целиком: ответ сотрудницы пролежал в чате 15 часов, и по обрезанной версии был сделан противоположный вывод.

Приём материалов: что доезжает до базы

Картинки, присланные документом, уходят в OCR. PDF без текстового слоя разбирается постранично через растеризацию — с пределом в 20 страниц и честной пометкой прямо в тексте. Архивы разворачиваются рекурсивно тем же извлекателем, границы — 60 файлов и 40 МБ. Презентации поддержаны. Звуковая дорожка видео уходит в тот же распознаватель речи, что и голосовые: второй собственный вызов завёл бы вторую правду о том, чем мы распознаём речь.

Цифры первого прогона: 98 залипших вложений возвращены в очередь, 45 извлечено сразу, 76 вложений вне рабочих чатов сознательно не тронуты. Из одного архива извлечено 573 572 символа, таких архивов в очереди стояло 14. Два PDF на 9,7 и 16,3 МБ давали ровно 0 символов до постраничного OCR. Перебор вложений знал только документы и фотографии, поэтому мимо обработчика прошли 27 видео-сообщений, 15 из них — в личном чате владельца. Из живого ролика извлечено 2201 символ разбора товара.

Честные статусы: видно, что не доехало

Три из четырёх потерянных вложений стояли со статусом «готово»: выглядели доставленными и не попадали ни в один список проблем. Теперь статус называет причину — нет текста, PDF без OCR, видео, неподдерживаемый формат, превышен предел размера. Сводка печатает список того, что не доехало, а прогон завершается предупреждением.

Коммит в базу делается после каждого вложения. Раньше единственный коммит стоял после всего цикла, и любое прерывание обнуляло статусы.

Метки «пусто» перепроверены на живых файлах

Ни одна из трёх меток не подтвердилась: 12 «сбоев скачивания» упирались в предел размера, 6 «видео без звука» имели звук, 20 «нет текста» скрывали разные истории. Отдельная находка — проверка на ведущую скобку выбрасывала распознанный текст целиком: таблица распозналась на 771 символ и ушла в «нет текста», потому что OCR срезал первую букву.

Уровни доступа задаются в одном месте

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

Поэтому обе стороны ходят в одну функцию is_private_source, а на дрейф между ними стоит гард-тест: если кто-то заведёт вторую копию логики приватности, падает CI.

Тот же принцип держит гард «скриншот с паролем не индексируем». Он переведён с того, как файл положил мессенджер, на тип извлечения — и правился в том же коммите, что и приём материалов: между двумя отдельными правками существовало бы окно, в котором скриншоты с ключами индексировались.

Поиск: BM25 в памяти → SQLite FTS5

Первая версия держала весь индекс в памяти процесса. Работало, пока бот жил на ноутбуке. Переписал хранение на SQLite FTS5, но с нюансом: FTS5 используется только как хранилище индекса (через fts5vocab), а ранжирование остаётся своим.

Причина конкретная: встроенная bm25() в FTS5 отдаёт по документу одно число и скрывает вклад каждого слова. Этот вклад и нужен, чтобы отличить покрытие слов запроса от веса, набранного на одном частом слове. Отдать ранжирование движку значило бы ухудшить выдачу.

Миграция сделана без риска: движок переключается одной переменной окружения, старый и новый прогоняются на одних запросах, выдача сверена ранг-в-ранг. Отличий нет — изменилась только цена.

Ответ приходит целиком

Пустой ответ рассуждающей модели лечится одним повтором с бюджетом 8000 токенов. Замер на боевом промпте в 6466 символов показал, что само рассуждение берёт 400–700 токенов, и худший ответ выдавался ровно на сложных вопросах при полностью исправной основной модели.

Что надстроено сверху базы

База знаний оказалась платформой. Поверх неё живут:

  • Дистилляция фактов — переписка сворачивается в структурированные факты
  • Утренние сводки и доски в Telegram-хабе: бизнес-пульс, пульс кампаний, инфра-пульс, дайджест трекера
  • Около двух десятков сторожей по принципу «упало — напиши в личку и в Discord»: живость CRM, зависшие заказы, срок на оспаривание платежа, важное сообщение владельца, устаревание индекса
  • MCP-сервер над базой — личные модели владельца видят ту же базу знаний, без второй такой же системы
03 · Стек

Весь поиск держится на SQLite и собственном ранкере

Python

Ядро: сбор, индекс, ранжирование, доставка — один язык на весь контур

SQLite FTS5 + fts5vocab

Хранит индекс; ранжирование остаётся своим — чтобы учитывать полноту покрытия запроса

Собственное ранжирование (семейство BM25)

Вес каждого слова и полнота покрытия запроса; движок переключается одной настройкой

Языковая модель (Claude / DeepSeek)

Ответ собирается только из найденных фрагментов, цитаты обязательны

whisper + OCR

Голосовые сообщения и скриншоты становятся полноценными источниками

Telegram Bot API

Бот в личных сообщениях и форум с отдельными ветками под сводки и оповещения

MCP-сервер

Та же база знаний доступна личным моделям владельца

systemd + Discord

Службы, таймеры и ~20 сторожевых процессов на сервере с оповещениями в канал

pytest

76 тестовых файлов, 1027 тестов — включая проверку, что права доступа не «поплыли»

PythonRAGSQLite FTS5fts5vocabwhisperOCRTelegram Bot APIMCPsystemdpytest
Масштаб кодовой базы

164 модуля на Python, около 40,8 тыс. строк, 378 коммитов. Ключевые части: движок индекса (~1300 строк), бот (~1500), вычистка секретов (~560), выжимка фактов (~430), доставка в Telegram на стандартной библиотеке, MCP-сервер, сборщик потоков и ~20 сторожевых модулей.

Масштаб индекса

15 864 чанка в поисковом индексе. На сервер индекс уезжает файлом на 32,7 МБ, приёмка сверяет контрольную сумму. Почтовый корпус — два ящика, 94 800 и 8022 письма.

04 · Результат

Одна и та же выдача за другие деньги

Пик памяти при поиске
1044 МБ 50 МБ

в 20 раз меньше — при идентичной выдаче

Латентность поиска
253 мс 13 мс

выдача сверена построчно, отличий нет

Приватных фрагментов на уровне «команда»
1327 0

замер на живом индексе, до и после правки прав доступа

С ноутбука уехал весь контур сбора

Падение памяти в 20 раз дало практический результат. Именно оно позволило унести бота с ноутбука подрядчика на сервер, где всего 961 МБ RAM. Хроническая болезнь «ноутбук уснул — бот молчит ночью» закрыта в корне: бот отвечает круглосуточно.

Следом уехала почта. Выкачка почтового корпуса по IMAP на ноутбуке выключена, готовый разобранный корпус забирается с сервера. Ноутбук качал 34 ГБ сырых писем ради 459 МБ разобранных; тот же корпус сервер собирает сам с конца июля, весит он 450 МБ и полнее. Диск ноутбука был занят на 98 процентов12 свободных гигабайт из 460.

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

Историю страхует отдельный гард: файл заменяется, только если он не короче локального. На первом же боевом прогоне гард отклонил второй ящик (80227985) и предотвратил тихое стирание 34 писем.

Попутно с ноутбука убрана сборка индекса на 103 МБ: он пересобирался 96 раз в сутки и не читался ни одним потребителем — время последнего чтения обоих его файлов совпадало с моментом записи.

Сторожа живут на сервере

Около двух десятков сторожевых процессов работают на сервере под systemd-таймерами. Launchd-агенты на ноутбуке выгружены и переименованы в отключённые, команда отката записана в журнал инфраструктуры.

Показательная починка оттуда же: сторож свежести данных мессенджера шесть часов писал «обновлялся 0 минут назад» при мёртвом сборщике. Разбор вывода утилиты состояния файла отдавал мусор, а мусор считался нулевым возрастом. Теперь варианты разбора пробуются по порядку, и результат проверяется на «это вообще число».

Приватность: замерили на живом индексе

Права доступа можно долго обсуждать на словах. Поэтому сделан замер на живом индексе: сколько приватных фрагментов реально видит уровень «команда». Ответ был неприятный — 1327.

После правки прав доступный этому уровню объём сжался с 6139 до 4560 фрагментов, приватных среди них — 0, при этом рабочих данных не потеряно. Отдельно поднят лог обращений: проверка подтвердила, что реального доступа к этим фрагментам никто не успел получить.

Что изменилось в работе

Вопрос «что мы решили по этому» теперь задаётся боту и возвращается ответом с цитатами на конкретные сообщения — включая те, что были голосовыми. Утренние сводки приходят сами, сторожа пишут раньше, чем проблему заметит клиент, а личные модели владельца через MCP работают с той же базой — без второй такой же системы и второй копии данных.

05 · Применимость

Где ещё ложится та же методология

За кейсом стоит типовая задача «знания компании живут в неструктурированной переписке, и часть из них показывать нельзя». Она есть почти везде, где команда старше года:

  • Поддержка и продажи — история переписок с клиентами как база ответов, при этом персональные данные не уходят в общий доступ
  • Агентства и студии — созвоны, брифы и голосовые от клиента в поиске с цитатой на источник вместо «кажется, договаривались так»
  • Производство и сервис — заявки, фото с объектов, переписка прорабов; ответ бота с цитатой заменяет обзвон
  • Ввод новых сотрудников в курс дела — уровень доступа «команда» даёт рабочий контекст, не открывая личную переписку руководства
  • Компании с ограничением на облако — весь контур, кроме синтеза, работает на своей машине: индекс лежит в SQLite-файле
Что переиспользуется на следующих проектах
  • Сбор данных со слиянием по номеру сообщения — история не затирается, если источник выгрузился не полностью
  • Вычистка секретов до попадания в индекс и единое место, где заданы правила приватности, с тестом на их «расползание»
  • Схема «FTS5 хранит индекс, ранжирование своё»: экономия памяти без потери качества выдачи
  • Переключение поискового движка одной настройкой и построчная сверка выдачи — переход проверяется
  • Сторожевые процессы и утренние сводки поверх базы — знания начинают приходить сами
Похожая задача?

Если знания компании лежат в чатах и почте и их страшно открывать всем — это решается

Начинаем с одного источника и одного уровня доступа — рабочий бот с цитатами появляется раньше, чем полная база. Дальше подключаются почта, голосовые, документы и сторожа.

Готовы начать?

Аудит за 9 900 ₽ — с конкретным отчётом и сметой

Расскажу что внедрить в вашем бизнесе в первую очередь, какая будет окупаемость, и нужен ли вообще AI для вашей задачи (иногда — нет).

Или просто напишите свой вопрос — отвечу в течение 2 часов