BM25 против эмбеддингов, Protocol против наследования: как рождался фреймворк WhyTrend

от автора

В прошлой статье я рассказывал про WhyTrend — open‑source инструмент, который не просто находит аномалии во временных рядах, а пытается объяснить, почему они произошли: собирает внешний контекст (новости, Hacker News, Wikipedia) и формирует объяснение со ссылками на источники. Если коротко: идея выросла из наблюдения, что находить аномалии мы научились отлично, а вот объяснять их до сих пор приходится вручную — гуглить, листать Reddit и Slack, собирать гипотезу самому.

Эта статья — про внутреннюю кухню: почему WhyTrend в итоге оказался фреймворком, а не очередной библиотекой, и какие архитектурные решения к этому привели.

Это не одна задача, а цепочка задач

Когда идея только оформилась, первое желание было сделать максимально простой API:

report = whytrend.analyze(series)

Передаёшь временной ряд — на выходе получаешь Markdown‑отчёт с объяснением. Звучит идеально. Но примерно через полчаса стало понятно, что это плохая идея — не потому, что её невозможно реализовать, а потому что за словом analyze() скрывается слишком много разных задач.

Допустим, есть обычный временной ряд, и нужно найти аномалию. Но это лишь первый шаг. Какой алгоритм использовать — Z‑score, Prophet, Isolation Forest, Ruptures? Допустим, нашли всплеск — теперь нужно решить, какой временной диапазон считать связанным с этим событием: три дня, неделю, месяц? После этого нужно собрать внешний контекст — откуда: Google News, Wikipedia, GitHub, Hacker News, RSS, Reddit? Источники найдут несколько десятков публикаций, большинство из которых вообще не имеют отношения к нашей аномалии — их тоже нужно отсортировать. И только после этого появляется место для LLM.

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

Конвейер вместо магии

Почти все современные Python‑фреймворки используют одну и ту же идею: FastAPI строит pipeline обработки HTTP‑запроса, Airflow — pipeline обработки данных, LangChain — pipeline работы с LLM. Почему бы не сделать то же самое для анализа причин аномалий?

Time Series → Detector → Event → Collectors → Ranker → LLM → Report

Каждый этап отвечает только за свою работу. Detector ничего не знает про языковые модели. Collector ничего не знает про статистику. Ranker не знает, где были найдены документы. LLM не знает, каким алгоритмом обнаружена аномалия. Это кажется очевидным, но именно такое разделение позволило сделать систему расширяемой.

Почему это фреймворк, а не библиотека

В обычной библиотеке вы вызываете функцию: requests.get(...) или pandas.read_csv(...). Здесь всё наоборот — вы описываете конвейер:

pipeline = (    Pipeline()    .add_source(...)    .add_detector(...)    .add_collector(...)    .add_ranker(...)    .add_explainer(...))

А дальше уже WhyTrend управляет процессом: сам вызывает детектор, запускает коллекторы, объединяет результаты, выбирает, что передавать в LLM. Пользователь описывает архитектуру анализа, а жизненным циклом управляет сам WhyTrend — классический framework pattern (в духе принципа инверсии управления: «не звони нам, мы сами позвоним тебе»). Именно поэтому проект постепенно перестал ощущаться как набор функций.

Компоненты, которые не знают друг о друге

Следующий вопрос: как пользователь должен добавлять собственные источники данных? Можно было сделать десятки встроенных интеграций — Google News, Reddit, GitHub, Wikipedia, RSS, Product Hunt, StackOverflow — и постоянно расширять список. Но очень быстро стало понятно, что догнать интернет невозможно: у каждой компании свои источники — у кого‑то внутренний портал, у кого‑то Elasticsearch, у кого‑то просто SQL‑таблица с событиями.

Поэтому правильнее оказалось не писать всё самому, а сделать механизм расширения:

class MyCollector(Collector):    async def collect(self, event):        ...

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

Изначально я собирался сделать классическую иерархию через абстрактные базовые классы (class Collector(ABC) с @abstractmethod) — привычный путь для любого Python‑разработчика. Это была одна из тех развилок, которые мы обсуждали ещё в ту неделю, когда до кода дело вообще не доходило — просто гоняли архитектуру вслух. Но возник вопрос: почему пользователь вообще должен наследоваться от моего класса? Если у него уже есть объект, который умеет собирать данные, зачем заставлять его встраиваться в мою иерархию?

В итоге выбор пал на typing.Protocol. Теперь компоненту достаточно соответствовать интерфейсу — наследование не обязательно. Любой объект с методом async def collect(...) уже может работать как Collector, без дополнительных базовых классов, миксинов и регистрации. На мой взгляд, это делает API заметно питоничнее.

Почти всё асинхронное

Практически все Collectors занимаются одной и той же работой — ждут: ответа API, HTTP, сети, файловой системы. Если выполнять их последовательно, конвейер начинает тормозить буквально на пустом месте. Сегодня их уже семь — Google News, Hacker News, Reddit, GitHub Releases, Stack Overflow, RSS‑фиды и Wikipedia, каждый отвечает примерно за секунду — последовательный запуск занял бы уже около семи секунд впустую, хотя между запросами почти нет вычислений. Поэтому WhyTrend запускает Collectors параллельно: с точки зрения пользователя ничего не меняется, но общее время ожидания сокращается почти до времени самого медленного запроса. Именно поэтому практически все внешние компоненты в проекте изначально проектировались как асинхронные — не потому что это модно, а потому что это естественная модель для подобных задач.

Почему BM25 появился раньше эмбеддингов

Это решение вызвало больше всего споров среди коллег. Когда речь заходит про поиск информации, сегодня почти автоматически вспоминают эмбеддинги — и они правда отлично работают, но только тогда, когда есть что индексировать. В WhyTrend ситуация другая: количество документов обычно небольшое — не миллионы страниц, а несколько найденных публикаций на один всплеск (в реальном прогоне из первой части ranker оценивал буквально три документа). В такой ситуации классический BM25 оказался удивительно хорош: быстрый, полностью локальный, не требует GPU и отдельной модели, отлично объясняется. И главное — его легко заменить: если пользователю больше подходят sentence‑transformers или Cross Encoder — например, когда источников станет больше и документов на выходе будет действительно много — достаточно подключить другой Ranker, и фреймворк этого даже не заметит.

Почему LLM — это адаптер, а не зависимость

Мне очень не хотелось привязывать проект к одному провайдеру: сегодня все используют OpenAI, завтра — Anthropic, послезавтра — локальные модели. Поэтому внутри вообще нет понятия «ChatGPT» — есть только Explainer. Он получает структуру данных и возвращает структуру данных. Какая модель находится внутри — вопрос конкретной реализации: это может быть OpenAI, Ollama, Gemini, DeepSeek или вообще MockLLMProvider, который позволяет гонять весь pipeline вообще без сети и API‑ключей — просто для тестов или чтобы посмотреть, как работает конвейер, прежде чем платить за токены. С точки зрения Pipeline разницы нет. Именно это позволило сделать LLM обычным компонентом системы, а не её центром — в первой части я показывал, что и по смыслу конвейера LLM работает последней, уже с готовым контекстом, а не в начале, угадывая причины вслепую.

Отчёт — тоже плагин

Почти сразу выяснилось, что разные пользователи хотят разные результаты: кому‑то нужен Markdown, кому‑то JSON, кому‑то HTML, кому‑то — сразу отправить результат в Slack. Получается, генерация отчёта — такая же независимая задача, как поиск аномалий, поэтому Report оказался ещё одним расширяемым компонентом. Сегодня это Markdown, завтра может быть PDF или интерактивный дашборд — Pipeline от этого не меняется.

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

Чего пока не хватает

Список идей длинный:

  • другие алгоритмы поиска точек изменения — Ruptures, Bayesian Online Change Point Detection;

  • другие LLM‑провайдеры — Anthropic, Gemini, DeepSeek, Azure OpenAI, OpenRouter;

  • другие подходы к ранжированию — Cross Encoder, Reciprocal Rank Fusion;

  • новые форматы отчётов — HTML, PDF для постмортемов;

  • CLI, чтобы анализ запускался одной командой:

whytrend analyze --source google-trends --keyword "DeepSeek"

Конечно, это ещё только первая версия проекта. Некоторых компонентов пока нет, другие существуют в виде MVP, какие‑то идеи наверняка окажутся неудачными и будут переработаны. Но именно за это я и люблю open source — архитектура никогда не бывает законченной, она эволюционирует вместе с людьми, которые начинают пользоваться проектом.

Почему open source

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

Именно поэтому проект сразу появился как open source — хочется, чтобы вокруг него постепенно появилась экосистема: новые Collectors, Detectors, Rankers, Explainers. Какие‑то идеи наверняка окажутся неудачными, какие‑то архитектурные решения придётся переделать — это нормально и, на мой взгляд, единственный здоровый способ развития инженерных проектов.

Вместо заключения

Когда я только начинал работу над WhyTrend, мне казалось, что я пишу очередную Python‑библиотеку. В какой‑то момент стало понятно, что интереснее оказался не код, а сам вопрос: почему при таком количестве инструментов для анализа временных рядов мы до сих пор почти всегда вручную объясняем причины найденных аномалий?

Возможно, у этой задачи нет универсального решения, и WhyTrend тоже не станет серебряной пулей. Но если после запуска pipeline аналитик вместо часа потратит десять минут на подготовку объяснения — значит, вся эта история уже была не зря.

Исходный код проекта — на GitHub. Начать можно буквально с нескольких строк кода или с собственного CSV‑файла. А если у вас есть идея нового источника данных, алгоритма ранжирования или детектора аномалий — pull request будет даже интереснее, чем ещё одна ⭐ в репозитории. Особенно ценны сейчас архитектурные замечания и вопросы в духе «а почему вы сделали именно так, а не иначе» — именно такие обсуждения обычно делают open‑source проекты лучше.

Спасибо, что дочитали.

ссылка на оригинал статьи https://habr.com/ru/articles/1066000/