- Запрос про свидание буквальным поиском давал 1 случайный ивент, с планировщиком 12 по теме
- Вектор и полнотекст работают параллельно и сливаются по рангу через Reciprocal Rank Fusion
- Пустая выдача — не тупик, а каскад ослаблений (дата, город, ближайшее) с флагом relaxed
Задача
zdes.events собирает афишу городов из нескольких источников: концерты, лекции, вечеринки, выставки. Поисковая строка над такой базой сначала была устроена как у всех: запрос уходит в векторный индекс, при ошибке — в полнотекстовый, что нашлось, то и показали. На запросах вида «джаз» или «лекция по архитектуре» это работало. На запросах, которыми люди пользуются на самом деле, — нет.
Аудит показал два типовых провала. Первый: пользователи писали город прямо в строку («концерты в питере»), потому что не находили переключатель в шапке, и получали семантический поиск слова «питер» внутри Москвы. Второй, более важный: запрос «куда пойти на свидание» возвращал одно случайное онлайн-событие. В карточках событий слова «свидание» нет, а описания вроде «камерный концерт при свечах» или «дегустация в винном баре» буквальный матчинг не связывает с интентом. Поиск по словам не умеет отвечать на вопрос «куда сходить».
Метод
Итоговый конвейер одного хода состоит из пяти слоёв. Модель работает только на входе и на выходе, середина — детерминированная.
flowchart LR
A[Разбор интента<br/>на клиенте] --> B[LLM:<br/>план запроса]
B --> C[Гибридный<br/>retrieval]
C --> D[Каскад<br/>ослаблений]
D --> E[LLM:<br/>ответ]
classDef key stroke:#FF3600,stroke-width:2px
class B key1. Локальный разбор интента. До любого запроса к серверу клиентский парсер вытаскивает из текста город (алиасы, склонения, опечатки через расстояние Левенштейна), дату («завтра», «на выходных», «в этом месяце») и служебные слова («в моём городе»). Уверенный матч города переключает город в шапке; запрос из одного города уходит в режим просмотра ближайших событий без движка релевантности. Даты превращаются в окно фильтра и вычищаются из семантического запроса.
2. LLM-планировщик запроса. Разговорная формулировка плюс хвост истории чата отправляются в модель с одной задачей: вернуть JSON из двух полей.
{
"vector_query": "романтический вечер для двоих: камерный концерт, кино, дегустация",
"keywords": ["свидание", "романтика", "концерт при свечах", "винный бар"]
}
Векторный движок получает семантически богатую переформулировку, полнотекстовый — явные синонимы. План кэшируется на 10 минут с учётом хвоста истории, чтобы уточнение «а бесплатные?» не переиспользовало план без контекста. Если модель не ответила за 7 секунд или вернула битый JSON, оба движка получают сырой запрос. Именно этот слой превратил «1 случайный ивент» в 12 релевантных на том же запросе про свидание.
flowchart LR
Q[Запрос + хвост<br/>истории] --> P{Ответ модели<br/>за 7 секунд?}
P -->|да| J[JSON: vector_query<br/>и keywords]
P -->|нет или<br/>битый JSON| R[Сырой запрос<br/>в оба движка]3. Гибридный retrieval. Векторный индекс и полнотекстовый индекс основной базы работают параллельно на каждом запросе, а не как основной и запасной. Оценки движков несравнимы: косинус эмбеддинга живёт в диапазоне около 0.7–0.9, полнотекстовый score ничем не ограничен. Поэтому слияние идёт по рангу, через Reciprocal Rank Fusion: каждому событию начисляется сумма 1 / (60 + rank) по каждому списку, где оно встретилось. Событие, найденное обоими движками, естественно поднимается выше; точное совпадение по ключевому слову, которое эмбеддинг пропустил, всё равно попадает в выдачу. Отказ любого движка деградирует ко второму, и клиент этого не замечает. Векторный индекс делает over-fetch, точная фильтрация по городу и датам выполняется при гидрации событий из базы.
flowchart LR
P[План запроса] --> V[Векторный индекс:<br/>vector_query]
P --> F[Полнотекст:<br/>keywords]
V --> R[RRF: слияние<br/>по рангу]
F --> R
R --> H[Гидрация:<br/>город и даты]4. Каскад ослаблений. Если проход вернул ноль, система не показывает пустой экран, а последовательно снимает ограничения: сначала окно дат, затем город, затем отдаёт ближайшие события города как «ближайшее по теме». Каждый шаг помечается в ответе флагом relaxed со значением date, all_cities или nearest. Флаг прокидывается дальше в слой ответа.
flowchart LR
Z[Ноль<br/>результатов] --> D[Снять даты<br/>relaxed: date]
D -->|ноль| C[Снять город<br/>relaxed: all_cities]
C -->|ноль| N[Ближайшее по теме<br/>relaxed: nearest]5. Обоснованный ответ. Найденные события уходят во второй LLM-вызов, который пишет короткий markdown-ответ поверх карточек: что нашлось, почему это подходит, с чего начать. Ответ заземлён на конкретные карточки, и промпт при relaxed обязан открываться оговоркой («на эти даты ничего нет, вот что есть на неделе»). Модель также возвращает блок из трёх сопутствующих вопросов, роут вырезает его из текста и отдаёт массивом. Тап по вопросу продолжает диалог в контексте. Вызовы модели идут через один клиент с двумя провайдерами и взаимным фолбэком; если оба недоступны, чат показывает карточки без саммари.
Поверх конвейера стоит quality-ранжирование (релевантность, оценка качества карточки, близость даты, ручные админские ручки) и аналитика: каждый запрос пишется в лог с полями engine, relaxed, planned и хранится 180 дней. Пустые и ослабленные выдачи в этом логе показывают спрос без предложения, то есть прямой сигнал, какие источники подключать дальше.
Что пробовали и убрали
Категорийный детектор. Первая версия отображала слова запроса в жёсткий фильтр по категории («тусовки» → party). Категории агрегированных событий регулярно расходятся с тем, что имел в виду пользователь, и фильтр резал выдачу: «мск тусовки» возвращал одно событие. Детектор убран в тот же день. Тематические слова остались в тексте запроса и работают как семантический сигнал для движков, а фильтр по категориям сохранён только как серверный параметр для других вызовов.
flowchart TB
subgraph before[Было: категорийный детектор]
direction LR
A1[«мск тусовки»] --> A2[Жёсткий фильтр:<br/>категория party]
A2 --> A3[Одно событие]
end
subgraph after[Стало]
direction LR
B1[«мск тусовки»] --> B2[Слово остаётся<br/>в запросе]
B2 --> B3[Семантический<br/>сигнал движкам]
end
before ~~~ after
classDef key stroke:#FF3600,stroke-width:2px
class A3 keyПодсказка опечаток в интерфейсе. При нулевом результате сервер подбирает исправление из словаря названий предстоящих событий (кэш 5 минут). Показывать «возможно, вы имели в виду» в чате не стали: по итогам фидбека исправление осталось серверным, уходит в лог и служит сигналом для аналитики, а не для интерфейса.
Чипы фильтров и подсказки. Промежуточные версии показывали чипы города и даты, вытащенные из запроса, и уведомления о переключении города. Всё удалено: детекция работает молча, интерфейс свёлся к композеру внизу и карточкам сверху.
Защита от злоупотреблений
Каждый ход стоит двух LLM-вызовов и двух обращений к индексам, поэтому лимиты стоят на нескольких уровнях: 15 поисков и 8 ответов в минуту на IP как защита от всплесков, дневные квоты в базе (гость — 20 ходов в день по IP, авторизованный — 150), атомарный инкремент с открытым отказом. Исчерпание квоты отвечает прямо в чате, гостю — с приглашением войти. Ошибки провайдеров (квоты, биллинг, недоступность индекса) никогда не доходят до пользователя: причина уходит в серверный лог, клиент видит одно нейтральное сообщение. Локальный парсер интента покрыт 20 юнит-тестами.
Обновление, сентябрь 2026. Контракт «ошибка провайдера не доходит до пользователя» держится на клиенте модели, который никогда не бросает исключение. В сентябре у него нашлась обратная сторона. Оба провайдера отказали одновременно, один по доступу, другой — по балансу, и ночная агрегация анонсов с тем же контрактом четыре дня извлекала ноль событий из примерно 270 анонсов в день, а крон рапортовал успех. Основным стал третий провайдер, прежние остались запасными. Сбой модели и «событий нет» теперь разные ответы: первый не кэшируется и помечается в журнале прогона, второй — кэшируется.
Где ломается
Планировщик додумывает. Модель может обогатить «куда пойти на свидание» ключевыми словами, которых в афише нет, и полнотекстовый движок промахнётся. Гибрид это сглаживает, но не убирает: если оба движка получили плохой план, выдача плохая, а пользователь не видит, что именно было спрошено у индексов.
Каскад ослаблений умеет обмануть. Шаг «ближайшее по теме» показывает по сути ленту города. Оговорка в ответе обязательна, но пользователь, который её не прочитал, воспринимает результат как ответ на свой вопрос. Тонкая граница между «помог» и «подсунул что попало».
Ранговое слияние слепо к абсолютной релевантности. RRF поднимает событие, найденное обоими движками, даже если оба нашли его на десятом месте с низкой уверенностью. Порога отсечения по качеству матча нет; его роль частично играет quality-ранжирование, но это не то же самое.
Стоимость и задержка. Два LLM-вызова на ход плюс два индекса. Кэш плана на 10 минут помогает только на повторах; первый ход всегда самый долгий, и пошаговый индикатор этапов на клиенте существует именно для того, чтобы пауза не выглядела зависанием. Квота на эмбеддинги у векторного провайдера исчерпывалась в тестах, и на это время новые события выпадали из векторного пути, оставаясь только в полнотекстовом.
Обновление, сентябрь 2026. Оба вызова хода, план и ответ, переехали на самую лёгкую модель линейки: короткий JSON плана и абзац поверх карточек не требуют сильной, а цену на новом роутере определяет только выбор модели. Роут ответа получил собственную дневную квоту.
Где модель не нужна. Поиск по новостям zdes.media сделан без LLM: корпус небольшой, и хватает инвертированного индекса в памяти. Прощение ошибок там устроено ярусами: сначала — слово как напечатано, с отброшенным окончанием и таблицей синонимов вроде «ИИ ↔ AI», затем — неверная раскладка и транслит, затем — опечатки. Следующий ярус включается, только если предыдущий ничего не нашёл, поэтому правильно набранное слово не тянет шум. Слова короче пяти букв не исправляются вовсе: «гугл» с одной правкой превращается в «гул». Это тот же каскад ослаблений, только для слов, а не для фильтров.
Итог
- Поиск по смыслу над афишей — это не «подключить эмбеддинги», а переформулировка запроса моделью до retrieval и обоснованный ответ после него. Середина остаётся детерминированной.
- Два движка по рангу лучше одного с фолбэком: RRF даёт устойчивость к отказам и к слепым зонам каждого движка без сравнения несравнимых оценок.
- Пустая выдача должна быть спроектирована: каскад ослаблений с явным флагом и честной оговоркой в ответе дороже в коде, но убирает тупик.
- Жёсткие фильтры, выведенные из слов запроса, вредят, когда разметка данных не совпадает с интентом. Лучше оставить слово сигналом для семантики, чем резать выдачу по категории.
© Александр Никулин. Цитирование — с указанием автора и ссылкой · Версия для LLM