YAKE! вместо нейросети: как мы заменили 600 МБ ONNX-реранкера на 400 строк статистики

от автора

YAKE вместо ONNX-реранкера

YAKE вместо ONNX-реранкера

В прошлой статье я рассказывал, что такое Yttri: local-first desktop-приложение для управления знаниями, задачами, встречами, документами и AI-контекстом.

Теперь хочу разобрать одну конкретную механику внутри приложения.

Это история о том, как мы сначала пытались решать задачу “правильным” ML-подходом, а в итоге выкинули из дистрибутива 600 МБ ONNX-модели, снизили потребление памяти примерно на 1.8 ГБ в пике и заменили всё это на небольшой статистический алгоритм.

Речь про YAKE! — Yet Another Keyword Extractor.

И да, это тот случай, когда “старая скучная статистика” оказалась практичнее нейросети.

Контекст: зачем Yttri вообще понадобился реранкер

Yttri — это локальное приложение. Не облачный SaaS, не веб-сервис, не “отправим всё на сервер и там разберёмся”.

Основная идея такая:

  • данные пользователя лежат локально;

  • поиск работает локально;

  • embeddings считаются локально;

  • RAG работает локально;

  • AI-инструменты по возможности тоже работают локально.

Технологически стек выглядит примерно так:

  • Tauri v2 + Rust на backend;

  • React + TypeScript на frontend;

  • SQLite + FTS5 + sqlite-vec для хранения, полнотекстового и векторного поиска;

  • fastembed MultilingualE5-small для embeddings;

  • гибридный поиск через BM25 + vector search + RRF.

На бумаге всё красиво.

Но у local-first desktop-приложения есть неприятное ограничение: у пользователя нет “бесконечного” сервера где-то в облаке.

У него есть ноутбук.

Иногда хороший ноутбук. Иногда старый ноутбук. Иногда ноутбук, на котором одновременно открыт браузер с 80 вкладками, IDE, Zoom и Docker.

Поэтому каждый лишний гигабайт RAM и каждые 500 МБ в дистрибутиве становятся реальной проблемой.

Как было: гибридный поиск плюс ONNX-реранкер

Изначально RAG-поиск в Yttri выглядел так:

rag_search(query)  -> BM25 search  -> vector search  -> RRF merge  -> bge-reranker-v2-m3 через ONNX  -> top results

То есть сначала мы получали кандидатов через гибридный поиск, а потом прогоняли top-N документов через cross-encoder-реранкер.

В нашем случае это был bge-reranker-v2-m3 через fastembed/ONNX.

С точки зрения качества идея нормальная. Cross-encoder действительно умеет лучше сравнивать запрос и документ, чем простой cosine similarity.

Но цена для desktop-приложения оказалась неприятной.

Реранкер: до и после

Реранкер: до и после

Практические цифры были такие:

Метрика

ONNX-реранкер

Размер модели

~600 МБ

RAM в пике

~1.8 ГБ

CPU в пике на 20 документов

до ~1055%

Латентность

50-100 мс

Первый запуск

загрузка модели + прогрев

Для backend-сервиса это ещё можно пережить.

Для desktop-приложения, которое должно ощущаться быстрым и лёгким, это уже проблема.

Особенно если рядом в приложении уже есть embeddings, OCR, транскрипция, локальные LLM и другие ML-компоненты.

В какой-то момент стало очевидно: мы не можем тащить отдельную 600-МБ модель только ради реранка.

Почему нельзя было просто выключить реранкер

Самый простой вариант — убрать реранкер вообще.

Гибридный поиск у нас уже был:

  • BM25 хорошо ловит точные совпадения;

  • vector search хорошо ловит семантическую близость;

  • RRF аккуратно объединяет оба результата.

Но без реранкера у этого подхода есть потолок.

Пример:

запрос: "postgres backup optimization"

BM25 может поднять документ, где эти слова просто часто встречаются.

Vector search может поднять семантически близкий документ, но не всегда идеально понять, что именно важнее: PostgreSQL, backup или optimization.

А хороший реранкер должен сказать:

вот этот документ не просто похож, он действительно лучше отвечает на запрос.

То есть реранкер хотелось оставить.

Но без отдельной нейросети.

Вспомнили про YAKE!

К этому моменту в Yttri уже был модуль YAKE!.

Изначально он появился не для RAG, а для другой задачи — автотегирования заметок.

YAKE! — это статистический unsupervised-алгоритм извлечения ключевых фраз из одного документа.

Он не требует:

  • ML-модели;

  • обучения;

  • внешнего корпуса;

  • GPU;

  • интернета.

Он просто смотрит на текст и по локальным признакам пытается понять, какие слова и фразы важнее.

Оригинальная статья: Campos et al., “YAKE! Keyword Extraction from Single Documents using Multiple Local Features”, ACM TOIS, 2020.

Главная идея YAKE! простая:

важность слова можно оценить не только по частоте, но и по тому, где оно встречается, как выглядит и в каком контексте живёт.

Как YAKE! оценивает слова

В нашей реализации для каждого слова считаются пять признаков.

Как YAKE! находит ключевые фразы

Как YAKE! находит ключевые фразы

1. Casing

Если слово часто встречается с заглавной буквы или как аббревиатура, это может быть сигналом важности.

Например:

  • PostgreSQL;

  • YAKE;

  • RAG;

  • SQLite;

  • ONNX.

Для технических текстов это особенно полезно.

2. Frequency

Частота всё ещё важна.

Если слово встречается в документе много раз и не является стоп-словом, скорее всего оно несёт смысловую нагрузку.

Но голая частота опасна: иначе победят просто часто повторяющиеся общие слова.

Поэтому частота нормируется относительно статистики документа.

3. Position

Слова, которые появляются ближе к началу документа, обычно важнее.

Это хорошо работает для статей, заметок, технических описаний и задач, где тема часто задаётся в первых абзацах.

4. Relatedness

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

Если слово встречается в устойчивом контексте, оно часто является частью значимой темы.

Например, слово vector рядом с search, embedding, sqlite-vec — более специфичный сигнал, чем просто слово search само по себе.

5. Different

Если слово размазано по всем предложениям, оно может быть слишком общим.

Если встречается в нескольких важных местах, но не везде подряд, оно может быть более специфичным.

N-gram: почему нужны фразы, а не только слова

Для технических текстов одного слова часто недостаточно.

Например:

  • vector search;

  • hybrid search;

  • local first;

  • reciprocal rank fusion;

  • машинное обучение;

  • векторный поиск.

Поэтому YAKE! строит N-gram-кандидаты.

В Yttri по умолчанию используется размер до 3 слов.

Кандидат отбрасывается, если:

  • начинается со стоп-слова;

  • заканчивается стоп-словом;

  • состоит только из стоп-слов;

  • содержит только числа.

Потом для фразы считается итоговый score на основе score отдельных слов, частоты самой фразы и штрафа за слишком общие компоненты.

На выходе мы получаем список ключевых фраз с весами.

Например:

"hybrid search" -> 0.94"sqlite vec" -> 0.88"local first" -> 0.83"onnx reranker" -> 0.80

Почему мы написали свою реализацию на Rust

В Rust-экосистеме уже есть реализации keyword extraction.

Но была лицензионная проблема.

Один из подходящих крейтов использовал LGPL-3.0.

Для обычной библиотеки это не всегда страшно, но Rust-приложения часто статически линкуются. А в коммерческом desktop-продукте статическая линковка с LGPL — это мина замедленного действия.

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

Поэтому мы написали свою реализацию под MIT.

Получилось примерно 400 строк кода и 5 файлов:

frontend/src-tauri/src/modules/yake/├── mod.rs├── text_processor.rs├── candidates.rs├── features.rs└── scoring.rs

Зависимости минимальные:

  • unicode-segmentation — корректная токенизация для кириллицы и смешанных текстов;

  • stop-words — стоп-словари RU + EN.

Плюс 19 unit-тестов на токенизацию, кандидатов, scoring, дедупликацию и edge cases.

Как YAKE! заменил ONNX-реранкер

После того как YAKE! уже был в проекте, возник простой вопрос:

а что если использовать ключевые фразы из запроса как сигнал для реранка?

Новый pipeline стал таким:

rag_search(query)  -> BM25 search  -> vector search  -> RRF merge  -> yake_rerank(query, results, top_n)  -> top results

Алгоритм реранкера выглядит так:

  1. Из запроса извлекаются ключевые фразы через YAKE!.

  2. Для коротких запросов дополнительно включается обычный word-match fallback.

  3. Для каждого результата считаются совпадения по заголовку и содержимому.

  4. Заголовок получает больший вес, потому что это сильный человеческий сигнал.

  5. Итоговый score смешивает исходный RRF-score и новые keyword-сигналы.

Формула в упрощённом виде:

score = 0.45 * original_RRF      + 0.30 * title_match      + 0.15 * content_match      + 0.10 * exact_title_bonus

Почему так?

Потому что мы не хотели выбрасывать пользу векторного поиска.

Если запрос и документ семантически близки, но не имеют общих слов, vector search всё ещё должен влиять на результат.

YAKE! не заменяет embeddings.

Он добавляет дешёвый лексический сигнал поверх уже найденных кандидатов.

Результат замены

После замены ONNX-реранкера на YAKE! получили такие цифры:

Метрика

bge-reranker-v2-m3

YAKE! reranker

Размер в бандле

~600 МБ

0 байт моделей

RAM в пике

~1.8 ГБ

~1 МБ

CPU в пике

до ~1055%

< 5%

Латентность на 20 документов

50-100 мс

~100 μs

Первый запуск

загрузка + прогрев

сразу работает

Самое важное: это не просто “стало быстрее”.

Это изменило архитектурное свойство приложения.

Раньше поиск зависел от тяжёлой модели.

Теперь реранк — это обычный локальный deterministic-код без внешнего runtime, без модели и без прогрева.

Для desktop это огромная разница.

Но YAKE! не волшебный

Важно честно сказать: YAKE! не является заменой всем нейросетевым методам.

У него есть ограничения.

Короткие запросы

Для запроса из одного слова статистики почти нет.

Например:

rust

YAKE! тут не из чего делать выводы.

Поэтому в реранкере есть word-match fallback.

Семантические синонимы

Запрос:

как ускорить бэкап БД

Документ:

оптимизация дампа PostgreSQL

Смысл близкий, но лексического пересечения может почти не быть.

YAKE! сам это не поймает.

Здесь помогает vector search, поэтому original_RRF оставлен с весом 0.45.

YAKE! ищет темы, а не классифицирует

Это особенно хорошо видно на автотегировании.

Если YAKE! возвращает:

"квантование модели""apple silicon""qwen3 asr"

А ожидаемые теги в системе:

aiразработкаlocal-first

Jaccard может быть почти нулевой.

Но это не значит, что YAKE! плохой.

Это значит, что мы сравниваем разные задачи.

YAKE! нашёл темы. А теги — это уже нормализация в пользовательский словарь.

Вторая жизнь YAKE!: автотегирование

В Yttri YAKE! используется не только в RAG-реранкере.

Он также участвует в автотегировании заметок.

Автотегирование

Автотегирование

Изначальный план был такой:

текст заметки  -> YAKE! извлекает ключевые фразы  -> маленькая LLM нормализует их в теги

Это казалось логичным.

У маленькой локальной модели небольшой контекст. Значит, вместо того чтобы отдавать ей весь документ, можно дать 5-10 ключевых фраз и попросить превратить их в теги.

Но eval показал, что лучше работает другой путь.

Мы сделали embedding-first автотегирование:

текст заметки  -> embedding документа  -> cosine similarity к существующим тегам  -> если найдено >= 3 тегов, LLM не вызываем  -> иначе YAKE! генерирует новых кандидатов  -> если всё ещё мало, только тогда LLM fallback

Роли разделились так:

  • Embeddings хорошо матчят документ с уже существующим словарём тегов пользователя.

  • YAKE! хорошо помогает на холодном старте и для новых тем.

  • LLM остаётся последним рубежом, а не первым инструментом.

Практический результат на golden dataset:

Метод

Jaccard avg

Precision

Recall

Заметок с тегами

Embedding matching

0.712

1.000

0.712

7/7

YAKE! standalone

0.036

0.076

0.060

другая задача

Ключевой вывод здесь не в том, что YAKE! “хуже”.

Ключевой вывод в том, что YAKE! нельзя оценивать как классификатор тегов.

Он извлекает фразы из текста. А уже дальше их можно использовать как кандидатов, seed-слова или дополнительный сигнал.

Маленькая деталь: phrase_to_words

Чтобы связать YAKE! и embedding-подход, понадобился маленький мост.

YAKE! возвращает фразы:

"scoring service микросервис"

А теги в интерфейсе обычно короче:

scoringserviceмикросервис

Поэтому появилась простая функция:

pub fn phrase_to_words(phrase: &str) -> Vec<String> {    phrase.split_whitespace()        .filter(|w| !stop_words.contains(&w.to_lowercase())                 && w.chars().count() >= 3)        .map(|w| w.to_lowercase())        .collect()}

После этого каждое слово-кандидат можно прогнать через embedding и сравнить с embedding документа.

Так YAKE! перестаёт быть “финальным ответом” и становится генератором хороших кандидатов.

Что важно в реализации

Код YAKE! в Yttri состоит из четырёх этапов.

1. Нормализация и токенизация

Мы используем unicode-segmentation, потому что приложение должно нормально работать с русским, английским и смешанными техническими текстами.

Обычный split по пробелам быстро ломается на реальных данных.

Нужно корректно обрабатывать:

  • кириллицу;

  • английские термины;

  • дефисы;

  • пунктуацию;

  • переносы строк;

  • смешанные фразы вроде sqlite-vec, Qwen3, Rust backend.

2. Генерация кандидатов

За один проход по предложениям собираются:

  • N-gram-кандидаты;

  • occurrences слов;

  • левые и правые соседи;

  • карта для дедупликации.

Фильтры применяются сразу, чтобы не раздувать HashMap мусорными кандидатами.

3. Feature scoring

Для каждого слова считаются пять признаков: casing, frequency, position, relatedness и different.

Потом score нормируется так, чтобы дальше с ним было удобно работать в API.

4. Дедупликация

На выходе часто появляются похожие фразы:

машинное обучениемашинное обучение модельобучение модели

Чтобы не отдавать пользователю почти одно и то же, используется Levenshtein-дедупликация.

Если similarity выше 0.8, оставляем более сильный кандидат.

Отдельный нюанс: Levenshtein должен работать по char, а не по байтам.

Иначе кириллица превращается в боль.

Неожиданный урок: плохая метрика может скрывать хороший use case

В начале YAKE! выглядел не очень убедительно.

Standalone eval для автотегирования показывал Jaccard около 0.036.

Первое желание — сказать: “алгоритм не работает”.

Но проблема была не в алгоритме, а в постановке задачи.

Мы сравнивали:

YAKE!: "квантование qwen3 asr", "apple silicon", "локальная транскрипция"

с ожидаемыми тегами:

ai, разработка, local-first

Это не одно и то же.

YAKE! не обязан угадывать канонический словарь тегов. Он обязан находить значимые фразы в документе.

Когда мы перестали оценивать его как классификатор и начали использовать как генератор кандидатов и сигнал для реранка, алгоритм внезапно стал очень полезным.

Что в итоге получилось

Один небольшой статистический модуль сейчас закрывает в Yttri несколько задач:

  • помогает реранжировать результаты RAG-поиска;

  • генерирует кандидатов для автотегирования;

  • уменьшает количество вызовов LLM;

  • помогает на холодном старте, когда словарь тегов пользователя ещё пустой;

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

И всё это без отдельной ML-модели.

Почему это важно архитектурно

Главный вывод для меня такой:

не каждая AI-задача требует нейросети.

Иногда нейросеть нужна.

Embeddings у нас остались. LLM fallback тоже остался. Vector search тоже на месте.

Но если задачу можно закрыть дешёвым статистическим сигналом, его стоит попробовать до того, как тащить в приложение ещё одну модель.

Особенно в local-first desktop-приложении.

Потому что у локального AI есть три врага:

  • размер дистрибутива;

  • RAM;

  • latency.

YAKE! оказался хорош именно потому, что почти ничего не стоит.

Не “почти ничего” в маркетинговом смысле, а буквально:

  • 0 байт модели;

  • микросекунды выполнения;

  • минимум памяти;

  • deterministic-поведение;

  • нет прогрева;

  • нет зависимости от внешнего сервиса.

Паттерн, который мы оставили в архитектуре

После этой истории в Yttri закрепился простой паттерн:

сначала дешёвый локальный сигналпотом embedding / vector searchпотом LLM fallback

Не наоборот.

LLM — это мощный инструмент, но плохой первый слой для всего подряд.

Если 80% кейсов можно закрыть без неё, архитектура должна это позволять.

Вывод

Мы начинали с классического ML-подхода: гибридный поиск плюс нейросетевой реранкер.

Это работало, но плохо подходило под ограничения desktop local-first приложения.

YAKE! не стал “заменой искусственного интеллекта”.

Он стал маленьким локальным механизмом, который делает ровно свою работу:

  • находит важные фразы;

  • даёт дешёвый сигнал релевантности;

  • помогает не звать LLM без необходимости;

  • снимает зависимость от тяжёлой модели там, где она не обязательна.

Иногда хорошая архитектура — это не добавить ещё одну модель.

Иногда хорошая архитектура — это вовремя её удалить.


Ссылка на предыдущую вводную статью про Yttri: https://habr.com/ru/articles/996446/

Сайт проекта: https://yttri.online/

Подписывайтесь на мои каналы для получения информации от ИТ-архитектора с более чем 20-летним стажем.

Telegram, MAX, Setka

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