Проектирование поиска и фильтров: типичные ошибки
Поиск по каталогу часто ломается не из-за плохого SQL, а из-за неясных правил: что считать совпадением, как сочетать фильтры и что делать с пустым результатом. Разбираем архитектурные решения и ошибки, которые дорого исправлять после запуска.

Каталог с 100 000 товаров может начать тормозить уже после добавления нескольких фильтров, хотя каждый отдельный запрос работает быстро. Причина обычно не в одном медленном условии, а в неверной модели поиска: интерфейс обещает поиск по смыслу, база ищет точную подстроку, а сортировка и подсчёт количества товаров конкурируют за те же ресурсы. Ниже — практические приёмы для веб-приложения на PostgreSQL 16 и отдельного поискового движка, если одной базы уже недостаточно.
Сначала зафиксируйте правила результата
До выбора индекса опишите, что именно должен получить пользователь. Слово «айфон» может искать название и описание, «128 гб» — характеристику, а «до 50 000» — числовой диапазон. Это три разных операции, и попытка обработать их одним поисковым полем быстро превращается в набор исключений.
Для каждого поля определите четыре свойства:
- участвует ли оно в полнотекстовом поиске или фильтрации;
- нужна ли точная, префиксная или нечёткая проверка;
- может ли значение отсутствовать и как это влияет на результат;
- как поле участвует в сортировке и подсчёте количества найденных записей.
Полезно отдельно описать контракт запроса. Например: поисковая фраза ищет по названию и описанию, фильтры внутри одной группы объединяются через OR, разные группы — через AND, а отсутствие фильтра означает «не ограничивать выдачу». Такая запись важнее конкретного фреймворка: она предотвращает спор между фронтендом, API и базой.
Главная ошибка поиска — оптимизировать запрос до того, как команда договорилась, что считается совпадением.
* Фасет — отдельная группа фильтров с перечнем значений и количеством результатов для каждого значения. Например, «Бренд: Apple (42), Samsung (31)».
Выберите механизм под тип запроса
У поиска и фильтров разные требования. Фильтр по цене должен корректно работать с числовым диапазоном. Поиск по описанию должен учитывать словоформы и релевантность. Автодополнение требует короткого ответа и обычно работает по префиксу, а не по всему тексту.
| Подход | Когда подходит | Что получаете | Цена решения |
|---|---|---|---|
| Точные условия и диапазоны в PostgreSQL | Каталог, статусы, цены, даты, атрибуты | Транзакционные данные и предсказуемую фильтрацию | Нужно правильно индексировать поля и следить за планом запроса |
| Полнотекстовый поиск PostgreSQL | Поиск по названию, описанию и другим текстовым полям | Разбор текста, словоформы и ранжирование средствами базы | Меньше возможностей для сложного анализа и подсказок, чем у специализированного движка |
| Триграммный поиск в PostgreSQL | Опечатки, фрагменты слова, поиск по названию | Нечёткое совпадение и поиск по части строки | Индексы занимают место, а широкие запросы могут быть дорогими |
| Внешний поисковый движок | Большой каталог, сложное ранжирование, подсказки, синонимы | Отдельный контур индексации и богатую настройку выдачи | Появляется задержка синхронизации, отдельная инфраструктура и второй источник поисковых ошибок |
Не переносите каталог во внешний движок только потому, что запрос с фильтрами стал длинным. Сначала измерьте запросы в PostgreSQL 16, проверьте план выполнения и отделите медленный полнотекстовый поиск от обычной фильтрации. Внешний индекс оправдан, когда вам нужны функции, которые база не закрывает приемлемо: сложное ранжирование, подсказки, устойчивый поиск по опечаткам или отдельное масштабирование поисковой нагрузки.
Ошибки в фильтрах, которые видны пользователю
Смешивание OR и AND
Представьте фильтр «Бренд: Apple или Samsung» и отдельный фильтр «Память: 128 или 256 ГБ». Ожидаемый результат — товар Apple или Samsung с одной из двух ёмкостей. Если собрать все значения в один плоский список, можно получить Apple с любой памятью или Samsung с любой памятью. Группируйте условия явно и тестируйте каждую комбинацию.
Минимальный набор тестов включает: одно значение, два значения в одной группе, два значения в разных группах, снятие последнего фильтра и запрос без фильтров. Проверяйте не только список товаров, но и количество результатов в фасетах.
Неправильная работа с NULL
Пустое значение — не всегда ноль и не всегда «неизвестно». У товара без указанной диагонали нельзя автоматически подставлять 0: такой товар ошибочно попадёт в диапазон «до 6 дюймов». Разделите в модели данных отсутствие значения, нулевое значение и неизвестное значение, если бизнес-логика различает их.
Числа хранятся как текст
Цена, вес, объём памяти и дата должны иметь типы, пригодные для сравнения. Строка «100» может оказаться меньше строки «20» при лексикографической сортировке. Хранение «10 990 ₽» в одном поле с валютой и форматированием делает диапазоны хрупкими. Значение и отображаемую строку лучше разделять.
Фильтр меняет URL только визуально
Если состояние фильтров живёт только в памяти браузера, ссылку нельзя отправить другому человеку, восстановить после обновления или использовать для серверного рендеринга. Кодируйте состояние в URL с понятными параметрами и определите канонический порядок значений. Не складывайте туда внутренние идентификаторы, которые нельзя показывать пользователю, без необходимости.
Ошибки в поисковой строке
Один LIKE пытается заменить всё
Проверка вида «поле содержит строку» понятна для простого поиска по названию, но плохо подходит для длинного текста, словоформ и релевантности. Она также не отвечает на вопрос, почему один товар должен быть выше другого. Если нужен поиск по тексту, используйте полнотекстовый механизм PostgreSQL 16 или отдельный движок, а не набор условий по нескольким колонкам.
Нечёткий поиск тоже не стоит включать для каждого запроса. Он полезен для опечаток и названий, но на коротких строках вроде «мак» может вернуть слишком много нерелевантных совпадений. Введите минимальную длину запроса, ограничьте поля и проверьте результат на реальных ошибках пользователей.
Поисковая строка фильтрует не те поля
Пользователь, который вводит «красный», ожидает цвет, но не обязательно хочет найти это слово в техническом описании. Составьте карту полей: название и бренд могут иметь высокий вес, описание — меньший, а внутренний артикул — точное совпадение. Это правило должно быть одинаковым в подсказках, основном поиске и аналитике запросов.
Пустая выдача не объясняет, что делать
Сообщение «ничего не найдено» — тупик. Покажите применённые ограничения, дайте снять один фильтр и не сбрасывайте введённый текст без согласия пользователя. При этом не подменяйте нулевой результат популярными товарами так, чтобы создавалось впечатление совпадения: это ломает доверие к поиску.
Пагинация, сортировка и количество результатов
Поиск часто становится медленным не на фильтре, а на подсчёте общего количества строк. Точный COUNT для сложного запроса может стоить почти столько же, сколько получение первой страницы. Если интерфейсу не нужно знать точное число, возвращайте признак наличия следующей страницы и ограниченное количество результатов. Если точное число необходимо, измеряйте его отдельно и не рассчитывайте, что индекс автоматически сделает любой COUNT дешёвым.
Для стабильной выдачи сортировка должна быть детерминированной. Сортировки только по цене недостаточно, если у десятков товаров одинаковая цена: при изменениях данных записи могут перемещаться между страницами. Добавьте вторичный уникальный ключ как последний критерий, если это соответствует выбранному способу пагинации.
Смещение offset удобно для первых страниц, но глубокие страницы заставляют базу пропускать всё больше строк. Для длинных каталогов рассмотрите пагинацию по курсору: клиент передаёт последнее значение сортировки и уникальный ключ, а следующий запрос продолжает выборку с этой позиции. Формат курсора должен быть непрозрачным для клиента и проверяться сервером.
Данные и индекс должны обновляться согласованно
Внешний поисковый движок обычно не видит изменение товара в ту же миллисекунду, когда оно записано в основную базу. Это нормальная цена распределённой архитектуры, но её нужно показать в продукте. Иначе продавец изменит цену, пользователь увидит старое значение в поиске, а карточка уже покажет новое.
- фиксируйте событие изменения товара после успешной транзакции;
- делайте обработку событий повторяемой: повторная доставка не должна создавать неправильную запись;
- храните время последней успешной индексации и число ошибок;
- периодически сверяйте выборку из основной базы с поисковым индексом;
- заранее определите, что увидит пользователь при временной рассинхронизации.
Инвалидация кеша результатов — отдельная тема. Здесь важно не смешивать кеш поискового ответа с источником истины: кеш может ускорить выдачу, но не исправит неверно построенный индекс.
Что ломается первым и как это проверять
Проверяйте не только среднее время ответа. Для поиска важнее хвост распределения: p95 и p99, то есть время, быстрее которого завершается 95% и 99% запросов. Отдельно разбейте метрики на запросы без фильтров, с одним фильтром, с несколькими фасетами, с сортировкой по цене и с пустым результатом.
До запуска соберите набор реальных сценариев: популярные слова, опечатки, короткие запросы, длинные фразы, товары без характеристик, диапазоны на границе и комбинации фильтров. Для каждого сценария запишите ожидаемый результат и максимальное приемлемое время ответа. Без такого набора нельзя отличить регрессию поиска от изменения данных.
На ревью полезны конкретные замечания:
- «Здесь значения разных фильтров объединяются в один OR, но контракт требует AND между группами».
- «В диапазоне используется строковое поле, поэтому порядок 100 и 20 зависит от сравнения строк».
- «После обновления цены поисковый индекс может быть старым, но в интерфейсе это никак не отражено».
- «Пагинация сортирует только по цене, поэтому соседние страницы могут пересекаться».
- «Подсчёт фасетов выполняется для каждого значения отдельным запросом; нужно проверить число запросов и план на реальном наборе данных».
Что делать дальше
Составьте контракт поиска на одной странице: поля, правила AND/OR, сортировки, пустые значения, формат URL и поведение при нулевой выдаче. Затем возьмите 20–30 реальных запросов из аналитики, воспроизведите их на данных, близких к продакшену, и снимите p50, p95, p99, количество строк и план выполнения. Только после этого выбирайте между индексами PostgreSQL 16 и внешним поисковым движком. В проде следите за медленными комбинациями фильтров, долей пустых результатов и задержкой синхронизации индекса.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: PostgreSQL 16 Documentation: Full Text Search, PostgreSQL 16 Documentation: pg_trgm
Частые вопросы
Нужно ли сразу выносить поиск из PostgreSQL в Elasticsearch?
Как сочетать несколько значений одного фильтра?
Почему поиск по цене иногда возвращает неправильные товары?
Что делать, если результаты на соседних страницах дублируются?
Как поступить, если поисковый индекс не успел обновиться?
Что делать, если нечёткий поиск даёт слишком много совпадений?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.