Все уроки
E-commerceСредний10 мин чтенияВ моём стеке

Поиск по товарам

От запросов с LIKE до поисковых движков: устойчивость к опечаткам, фасеты, синонимы, ранжирование и свежий индекс.

  • Algolia
  • Scout
  • Elasticsearch
  • Meilisearch
  • Typo tolerance
Шаг 1/5
Поисковый движок хранит inverted index: как указатель в конце книги — каждое слово ведёт к документам, где оно встречается.

За 30 секунд

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

Почему я это использую

  • Я использую специализированный поисковый движок, потому что строил поиск по товарам для магазина товаров для животных на Algolia через Laravel Scout — по товарам, вариантам, категориям, брендам и статьям.
  • Покупатели ошибаются в написании, используют разные слова и ждут мгновенных результатов прямо во время ввода. Обычный запрос к базе данных хорошо с этим не справляется.
  • Логи поиска — золотая жила: они показывают, чего хотят люди и какие запросы ничего не находят.
  • Поиск затрагивает каталог, остатки, акции и производительность, поэтому это хорошая проверка того, насколько чистый поток данных у платформы.

Как это работает

01Три уровня: LIKE, полнотекстовый поиск, поисковый движок

Первый уровень — запрос к базе данных с LIKE: WHERE name LIKE '%dog food%'. Это просто, но wildcard в начале не может использовать обычный индекс, поэтому таблица сканируется целиком, и такой запрос ничего не знает об опечатках, порядке слов или релевантности. Второй уровень — полнотекстовый поиск в базе данных: индексы FULLTEXT в MySQL или tsvector с GIN-индексами в PostgreSQL. Они разбивают текст на слова, выполняют стемминг («running» находит «run») и ранжируют по релевантности — этого достаточно для блогов и небольших каталогов, без дополнительного сервиса. Расширение pg_trgm в PostgreSQL добавляет нечёткое сопоставление. Третий уровень — специализированный поисковый движок, который строит инвертированный индекс, оптимизированный для мгновенного поиска с устойчивостью к опечаткам, фасетами и настраиваемым ранжированием. Переходите на следующий уровень, только когда предыдущий перестаёт справляться с задачами пользователей.

sql
-- PostgreSQL full-text search with ranking
ALTER TABLE products ADD COLUMN search tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(description, '')), 'B')
  ) STORED;
 
CREATE INDEX products_search_idx ON products USING GIN (search);
 
SELECT id, name, ts_rank(search, query) AS rank
FROM products, websearch_to_tsquery('english', 'dry dog food') AS query
WHERE search @@ query
ORDER BY rank DESC
LIMIT 20;

02Как работает поисковый движок

Поисковый движок хранит инвертированный индекс: как предметный указатель в конце книги, он сопоставляет каждому слову список документов, в которых оно встречается. Когда вы индексируете товар, движок токенизирует текст (разбивает на слова), нормализует его (нижний регистр, удаление диакритики) и может применять стемминг или языковые правила. Во время запроса он ищет каждое слово, допускает небольшое число опечаток — обычно одну для коротких слов и две для длинных, по расстоянию редактирования, — сопоставляет префиксы, чтобы результаты появлялись во время ввода, и ранжирует результаты. Ранжирование обычно сочетает текстовую релевантность (сколько слов совпало, насколько близко, в каком поле) с бизнес-сигналами, которые вы настраиваете, например популярностью, наличием или маржой. Фасеты — это количества по каждому значению фильтра («Бренд: 12, Размер: 5»), которые вычисляются вместе с результатами.

03Выбор движка

Algolia — хостинговый сервис с отличной скоростью, устойчивостью к опечаткам, инструментами мерчандайзинга и аналитикой; вы платите за поисковые запросы и записи, и это может стать дорого. Meilisearch и Typesense — open-source движки, которые можно развернуть у себя на небольшом сервере или использовать как облачные сервисы; оба быстрые, устойчивы к опечаткам и просты в настройке — идеально для большинства магазинов. Elasticsearch и OpenSearch — мощные универсальные движки для огромных каталогов, сложных запросов, агрегаций и логов, но им нужно больше памяти и опыта эксплуатации. В Laravel Scout даёт единый API поверх Algolia, Meilisearch, Typesense и драйвера базы данных, поэтому можно начать с простого и сменить движок позже.

  • Небольшой каталог, нет бюджета на ещё один сервис: полнотекстовый поиск в базе данных.
  • Большинство магазинов: Meilisearch или Typesense, у себя или в облаке.
  • Мерчандайзинг и аналитика без эксплуатации: Algolia.
  • Огромные данные, сложные агрегации или уже существующий кластер: Elasticsearch / OpenSearch.

04Проектирование индекса

Поисковый индекс — это не копия базы данных, а плоский документ, спроектированный под один экран. Для магазина решите, что считается результатом: обычно это товар, в который влиты атрибуты его вариантов (все цвета и размеры), чтобы работали фильтры, и минимальная доступная цена для сортировки. Включайте только то, что нужно поиску: название, бренд, путь категорий, атрибуты, ключевые слова, цену, флаг наличия, популярность и URL изображения. Объявите, какие поля участвуют в поиске (в порядке приоритета), по каким можно фильтровать (фасеты) и по каким сортировать. Используйте синонимы для слов, которые покупатели употребляют по-разному («котёнок» и «кошка», «лак для ногтей» и «лак»), и стоп-слова для шума. Отдельные индексы для категорий, брендов и статей позволяют сделать богатый выпадающий список подсказок.

php
// Laravel Scout: what goes into the search document
class Product extends Model
{
    use \Laravel\Scout\Searchable;
    public function toSearchableArray(): array
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'brand'      => $this->brand?->name,
            'categories' => $this->categories->pluck('name')->all(),
            'colours'    => $this->variants->pluck('colour')->filter()->unique()->values()->all(),
            'price_min'  => $this->variants->min('price'),       // integer minor units
            'in_stock'   => $this->variants->contains(fn ($v) => $v->available > 0),
            'popularity' => $this->sales_last_30_days,
        ];
    }
 
    public function shouldBeSearchable(): bool { return $this->is_active; }
}

05Синхронизация индекса

Индекс — это вторая копия ваших данных, поэтому он может устаревать. Обновляйте его при изменении данных: Scout делает это автоматически при сохранении и удалении модели, но отправляйте эту работу в очередь, чтобы сохранение товара никогда не ждало поисковый сервис. Остерегайтесь изменений в обход событий модели — массовых SQL-обновлений, импортов из ERP, изменений остатков в другой таблице или переименования бренда, которое затрагивает тысячи товаров, — и явно запускайте переиндексацию затронутых записей. Регулярно запускайте полную переиндексацию по расписанию, в идеале в новый индекс с последующей подменой через алиас, чтобы пользователи никогда не видели наполовину построенный индекс. Остатки и цены меняются часто; подумайте об обновлении только этих полей пакетами, а не целых документов.

  • Ставьте обновления индекса в очередь; при массовых импортах объединяйте их в пакеты.
  • Пересобирайте в новый индекс и подменяйте атомарно.
  • Поднимайте алерт, когда число проиндексированных документов сильно отличается от базы данных.

06Ранжирование, аналитика и советы для продакшена

Хорошее ранжирование настраивают, а не угадывают. Начните с настроек движка по умолчанию, задайте поля поиска по порядку (название раньше описания) и добавьте кастомный сигнал ранжирования, например популярность или продажи. Опускайте товары, которых нет в наличии, ниже, а не скрывайте их, если бизнес не предпочитает иначе. Логируйте поиски: запрос, количество результатов и клики. Список запросов без результатов показывает недостающие синонимы, товары и проблемы с написанием; популярные запросы можно использовать для подсказок. Защищайте поисковый эндпоинт ограничением частоты запросов и никогда не раскрывайте браузеру админские API-ключи — используйте ключи только для поиска, в идеале с ограниченной областью или сроком действия. Делайте debounce для поиска во время ввода на клиенте примерно на 150–300 ms и кэшируйте популярные запросы.

Когда применять

  • Каталоги из тысяч товаров или любой магазин, где покупатели чаще ищут, чем листают.
  • Вам нужны устойчивость к опечаткам, мгновенные результаты, фасеты с количествами и контроль мерчандайзинга.
  • Контент охватывает несколько типов — товары, категории, бренды, статьи — в одной строке поиска.

Когда не стоит

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

Альтернативы

  • Полнотекстовый поиск в базе данных (MySQL FULLTEXT, PostgreSQL tsvector + pg_trgm)

    Без дополнительного сервиса, с транзакционной согласованностью; слабее устойчивость к опечаткам и фасеты.

  • Meilisearch / Typesense

    Open-source, быстрые, устойчивы к опечаткам, легко разворачиваются у себя; отличный выбор по умолчанию для магазинов.

  • Algolia

    Хостинговый, очень быстрый, сильный мерчандайзинг и аналитика; оплата по использованию.

  • Elasticsearch / OpenSearch

    Самые мощные и гибкие; тяжелее в эксплуатации и настройке.

Лучшие практики

  1. Начинайте с самого простого работающего уровня и переходите выше, только когда это нужно пользователям.
  2. Проектируйте документы индекса под экран результатов: плоские, минимальные, с явными полями для поиска, фильтрации и сортировки.
  3. Ставьте обновления индекса в очередь и обрабатывайте массовые изменения, которые обходят события модели.
  4. Пересобирайте индексы в фоне и подменяйте их атомарно.
  5. Поддерживайте синонимы и регулярно просматривайте запросы без результатов.
  6. Используйте в браузере API-ключи только для поиска и ограничивайте частоту запросов к поисковым эндпоинтам.
  7. Сочетайте текстовую релевантность с бизнес-сигналами вроде популярности и наличия.

Частые ошибки

  • Использовать LIKE '%term%' на большой таблице и удивляться, почему поиск медленный.
  • Индексировать целые строки базы данных, включая приватные поля, в публичный индекс.
  • Забывать переиндексировать после массовых импортов, из-за чего поиск показывает старые цены или удалённые товары.
  • Раскрывать админский API-ключ во фронтенд-коде.
  • Никогда не смотреть логи поиска и упускать самые простые победы в конверсии.

Глубже

07Как устроена релевантность: BM25 и правила ранжирования

Универсальные движки вроде Elasticsearch и OpenSearch по умолчанию оценивают документы с помощью BM25. Если по-простому, документ получает более высокий балл, когда в нём часто встречаются слова запроса (с убывающей отдачей, поэтому повторить слово двадцать раз почти не помогает), когда эти слова редки во всём индексе («merino» весит больше, чем «the») и когда поле короткое (совпадение в заголовке из пяти слов важнее, чем в длинном описании). Бусты полей умножают этот балл. Движки для мгновенного поиска, такие как Meilisearch, Typesense и Algolia, вместо этого используют упорядоченный список правил ранжирования, или tie-breakers: число совпавших слов, опечатки, близость слов, порядок атрибутов, точность совпадения и затем ваше собственное ранжирование. Понимая, какую модель использует ваш движок, вы знаете, что настраивать: бусты и функции в движках на BM25, порядок правил и кастомные атрибуты — в остальных.

08Переиндексация без простоя с помощью алиасов и swap

Изменение настроек индекса — новые searchable-атрибуты, другой токенизатор, новый язык — часто требует перестроить индекс целиком. Никогда не делайте это на месте, иначе пользователи несколько минут будут видеть наполовину заполненные результаты. Постройте новый индекс рядом с рабочим, заполните его пачками, проверьте количество документов и прогоните несколько smoke-запросов, а затем переключите трафик одним атомарным шагом. В Elasticsearch и OpenSearch это делается алиасами: приложение всегда обращается к алиасу «products», а обновление алиаса переводит его с products_v1 на products_v2. В Typesense есть алиасы коллекций, которые работают так же. Meilisearch предлагает операцию swap индексов, которая атомарно меняет местами содержимое двух индексов. Сохраните старый индекс на короткое время, чтобы мгновенно переключиться обратно, если что-то пойдёт не так.

bash
# Meilisearch: swap the freshly built index with the live one
curl -X POST 'http://localhost:7700/swap-indexes' \
  -H 'Authorization: Bearer MEILI_ADMIN_KEY' \
  -H 'Content-Type: application/json' \
  --data '[{ "indexes": ["products", "products_new"] }]'
 
# Elasticsearch / OpenSearch: move an alias atomically
curl -X POST 'http://localhost:9200/_aliases' -H 'Content-Type: application/json' -d '{
  "actions": [
    { "remove": { "index": "products_v1", "alias": "products" } },
    { "add":    { "index": "products_v2", "alias": "products" } }
  ]
}'

09Безопасные поисковые ключи и фильтрация по пользователю

Поиск из браузера быстрый, потому что клиент обращается к движку напрямую, но это значит, что ключ виден всем. Используйте ключ только для поиска, никогда не admin-ключ. Когда результаты должны отличаться для разных пользователей — B2B-прайс-листы, закрытые каталоги, данные конкретного тенанта, — не полагайтесь на то, что клиент добавит фильтр, ведь он может его просто убрать. Вместо этого сервер генерирует ограниченный ключ со встроенным фильтром и сроком действия: в Algolia это secured API keys, в Typesense — scoped API keys, а в Meilisearch — tenant tokens, которые представляют собой подписанные JWT. Движок применяет встроенный фильтр к каждому запросу. Кроме того, индексируйте только публичные поля: всё, что есть в индексе, настойчивый пользователь сможет прочитать. Ставьте rate limit по ключу или IP и задавайте короткий срок действия, чтобы утёкшие ключи быстро переставали работать.

ts
// Meilisearch tenant token: the filter is enforced by the engine
import { generateTenantToken } from "meilisearch/token";
 
export async function searchTokenFor(customer: { priceListId: number }) {
  return generateTenantToken({
    apiKey: process.env.MEILI_SEARCH_KEY!,       // a search-only key
    apiKeyUid: process.env.MEILI_SEARCH_KEY_UID!,
    searchRules: { products: { filter: `price_list = ${customer.priceListId}` } },
    expiresAt: new Date(Date.now() + 60 * 60 * 1000), // 1 hour
  });
}

10Многоязычный поиск и языковые граничные случаи

Качество поиска зависит от того, как текст разбивается и нормализуется, а это зависит от языка. Английский стемминг превращает «boots» в «boot»; в русском и армянском богатые окончания слов, которым нужна обработка с учётом языка или поиск по префиксу; китайскому и японскому нужна сегментация, потому что слова там не разделяются пробелами. Встречается и смешение алфавитов: покупатели вводят названия брендов латиницей или транслитерируют местные слова. Практичный подход: храните каждый язык в отдельном поле или индексе, явно задавайте в движке настройки языка или локали, а не полагайтесь на автоопределение, добавьте синонимы между вариантами транслитерации («krem» и «крем») и нормализуйте диакритику. Тестируйте на реальных запросах из логов на каждом языке, потому что конфигурация, хорошо работающая на английском, может незаметно проваливаться на другом языке.

  • Одно поле для поиска на каждый язык, например name_en и name_ru.
  • Сначала запрашивайте индекс на языке интерфейса пользователя.
  • SKU и штрихкоды — поля точного совпадения с отключённой толерантностью к опечаткам.

Реальные ситуации

01

Поиск по кодам товаров выдаёт ерунду

Проблема

Сотрудники и B2B-клиенты ищут по SKU или штрихкоду, например AB-1042, но в результатах вместо нужного товара появляются товары с похожими названиями, а точный товар часто оказывается на второй странице.

Как решить

Против вас работают толерантность к опечаткам и токенизация: движок разбивает код на части и допускает опечатку, поэтому AB-1043 тоже совпадает. Индексируйте коды в отдельном поле, пометьте его как поле точного совпадения или отключите для него толерантность к опечаткам (большинство движков позволяют отключать опечатки для отдельного атрибута) и поставьте его первым в списке searchable-атрибутов, чтобы точное совпадение кода побеждало. Распознавайте запросы, похожие на код, в приложении с помощью regex и сначала выполняйте запрос с точным фильтром, а при неудаче переходите к обычному поиску. Индексируйте и исходный код, и нормализованную версию без дефисов и пробелов, чтобы работали и AB1042, и AB-1042.

02

Поиск показывает товары, удалённые вчера

Проблема

Покупатели кликают по результатам поиска и попадают на страницы 404. Каталог чистили массовым SQL-скриптом, который выставил is_active в false для 3 000 товаров.

Как решить

Массовое обновление обошло события моделей, поэтому поисковый индекс о нём ничего не узнал. Решите текущую проблему, переиндексировав затронутые товары, или выполните полный импорт в новый индекс и переключитесь на него. Затем закройте дыру: проводите массовые изменения через код, который ставит задачи переиндексации для затронутых ID, или вызывайте методы searchable и unsearchable из Scout на query builder-ах пачками. Добавьте ночную проверку расхождений, которая сравнивает количество и ID активных товаров в базе и в индексе и поднимает алерт при превышении небольшого порога. В качестве последней линии защиты страница товара должна редиректить неактивные товары в их категорию, а не отдавать голый 404.

03

Расходы на Algolia растут быстрее продаж

Проблема

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

Как решить

Каждое нажатие клавиши — это оплачиваемый поисковый запрос. Добавьте debounce ввода примерно на 200 мс, требуйте минимум два символа и отменяйте запросы в полёте, когда пользователь продолжает печатать. Кэшируйте одинаковые запросы на клиенте и на вашем edge на короткое время, а популярные подсказки для пустого состояния отдавайте из заранее рассчитанного списка, а не запросом. Проверьте нежелательный трафик: боты, prefetch и дублирующиеся запросы из-за повторных рендеров. Если расходы всё равно высоки относительно пользы, оцените self-hosted Meilisearch или Typesense; когда Laravel Scout спрятан за интерфейсом, миграция сводится в основном к конфигурации и повторной настройке ранжирования, а качество можно сравнить на залогированных запросах.

Шпаргалка

Импортировать все записи в индекс (Scout)
php artisan scout:import "App\Models\Product"
Удалить все записи из индекса (Scout)
php artisan scout:flush "App\Models\Product"
Применить настройки индекса из конфига (Scout)
php artisan scout:sync-index-settings
Обновления Scout через очередь
SCOUT_QUEUE=true # in .env; index writes go through the queue
Переиндексация после массового изменения (Scout)
Product::where('brand_id', $id)->searchable();
Поменять индексы местами (Meilisearch)
POST /swap-indexes [{ "indexes": ["products", "products_new"] }]
Направить алиас на новую коллекцию (Typesense)
PUT /aliases/products { "collection_name": "products_v2" }
Нечёткий поиск в PostgreSQL
CREATE EXTENSION pg_trgm; CREATE INDEX ON products USING GIN (name gin_trgm_ops);
Debounce поискового ввода
setTimeout(() => search(q), 200) // clear the previous timer on each keystroke

В моих реальных проектах

  • На платформе товаров для животных я построил поиск на Algolia через Laravel Scout, с индексацией товаров, вариантов, категорий, брендов и статей, с подсказками, популярными запросами, синхронизацией синонимов, логами поиска и ограничением частоты запросов.
  • Аналитика той же платформы включает отчёты по поиску, а задачи ранжирования и ассоциаций питают рекомендации, «купить снова» и «недавно просмотренные».

Вопросы и ответы

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

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

Q2Что такое инвертированный индекс?

Это основная структура данных поисковых движков. Вместо того чтобы хранить документы и сканировать их, движок хранит отображение каждого термина в список документов, где он встречается, часто с позициями и частотами. Запрос находит список для каждого термина и пересекает или объединяет их, что очень быстро. Текст перед индексацией токенизируется и нормализуется, поэтому разные формы слова сводятся к одному термину. Устойчивость к опечаткам и поиск по префиксу работают на словаре терминов.

Q3Как держать поисковый индекс в синхронизации с базой данных?

Инкрементальные обновления при каждом создании, изменении и удалении, отправленные через очередь, чтобы основной запрос оставался быстрым. Laravel Scout делает это через события модели. Изменения в обход событий — массовый SQL, импорты из ERP, переименования связанных моделей — должны явно запускать переиндексацию. Сверху этого полная переиндексация по расписанию строит свежий индекс в фоне и атомарно подменяет его через алиас. Мониторинг сравнивает количество документов с базой данных, чтобы ловить расхождения.

Q4Algolia, Meilisearch, Typesense или Elasticsearch?

Algolia — когда клиент хочет хостинговый сервис с сильным мерчандайзингом и аналитикой и согласен на оплату по использованию. Meilisearch или Typesense — для большинства магазинов: open source, быстрые, устойчивы к опечаткам, легко настраиваются, дёшево разворачиваются у себя или используются в их облаке. Elasticsearch или OpenSearch — когда данных очень много, запросы и агрегации сложные или у компании уже работает кластер. С абстракцией вроде Laravel Scout переключение между первыми тремя — это в основном конфигурация.

Q5Что такое фасеты и как они работают?

Фасеты — это фильтры с количествами рядом с результатами поиска, например «Бренд: Acme (12)» или «Размер: Large (5)». Вы объявляете, по каким полям можно фильтровать, а движок считает количества для каждого значения в пределах текущего набора результатов. Выбор значения фильтрует результаты и обновляет остальные количества. Для магазина атрибуты вариантов обычно вливают в документ товара, чтобы товар появлялся во всех своих цветах и размерах. Дизъюнктивные фасеты сохраняют видимыми количества для других значений после выбора одного.

Q6Как со временем улучшать качество поиска?

С помощью данных. Логируйте каждый запрос, количество результатов и клики. Каждую неделю просматривайте запросы без результатов и исправляйте их синонимами, недостающими товарами или лучшими атрибутами. Смотрите на запросы, по которым есть результаты, но нет кликов, — они указывают на плохое ранжирование. Настраивайте порядок полей поиска, добавляйте бизнес-сигналы ранжирования вроде популярности и опускайте товары, которых нет в наличии. Популярные запросы можно использовать для подсказок. Небольшая регулярная настройка лучше большой разовой конфигурации.

Вопросы уровня senior

S1Как бы вы спроектировали поиск для каталога с миллионами SKU и персональными ценами для клиентов?

Я бы отделил то, что меняется медленно, от того, что зависит от клиента. Контент товаров, категории и атрибуты попадают в индекс как документы — по одному на товар, с объединёнными атрибутами вариантов для фасетов. Персональные цены не должны множить документы; вместо этого индексируйте ID прайс-листов или ценовые уровни, по которым можно фильтровать, и применяйте фильтр клиента через secured key или tenant token. Точные цены можно запрашивать у pricing-сервиса для видимой страницы результатов. Обновления остатков и цен — это пакетные частичные обновления. Шардировал бы по рекомендациям движка, измерял бы p95 latency под нагрузкой и держал бы процедуру полной пересборки с алиасами. Компромисс — дополнительный вызов pricing-сервиса на страницу в обмен на гораздо меньший и более дешёвый индекс.

S2Как объективно оценить качество поиска до и после изменения?

Мнениям о поиске доверять нельзя, поэтому я опираюсь на данные. Офлайн: собираю judgement set из реальных запросов из логов вместе с товарами, которые должны быть наверху, и считаю метрики вроде precision на первых результатах или NDCG, который поощряет релевантные результаты ближе к началу; прогоняю его до и после каждого изменения конфигурации. Онлайн: отслеживаю долю запросов без результатов, CTR, позицию первого клика, уходы из поиска и конверсию из поиска, в идеале в A/B-тесте между конфигурациями. Совмещаю оба подхода, потому что офлайн-наборы дёшево ловят регрессии, а онлайн-метрики измеряют реальное поведение. Компромисс — трудозатраты: judgement set собирается несколько дней, но он не даёт сделать настройку, которая улучшает один запрос и незаметно ломает пятьдесят других.

S3Когда стоит оставить поиск внутри PostgreSQL, а не добавлять отдельный движок?

Когда каталог небольшой, команда маленькая, а транзакционная свежесть данных важна. Полнотекстовый поиск PostgreSQL с tsvector, GIN-индексами и ранжированием плюс pg_trgm для нечёткого поиска хорошо покрывает десятки и сотни тысяч товаров — без конвейера синхронизации, без второй копии данных и с правами, которые обеспечиваются теми же запросами. Его слабые места — мгновенный префиксный поиск с толерантностью к опечаткам, богатые фасеты на больших объёмах, многоязычная настройка и инструменты мерчандайзинга. Я перехожу на выделенный движок, когда эти ограничения проявляются в реальных метриках — медленные фасетные запросы, плохая доля запросов без результатов, маркетингу нужен контроль над ранжированием, — а не по умолчанию. Компромисс операционный: выделенный движок добавляет инфраструктуру и сложность синхронизации, но даёт гораздо лучший UX поиска.

Глоссарий

Инвертированный индекс
Отображение каждого термина на документы, которые его содержат; основная структура поисковых движков.
Токенизация
Разбиение текста на единицы для поиска, обычно на слова.
Стемминг
Приведение слов к общей основе, чтобы «running» и «run» совпадали.
BM25
Стандартная формула релевантности, учитывающая частоту термина, его редкость и длину поля.
Редакционное расстояние
Число односимвольных изменений между двумя словами; используется для измерения опечаток.
Фасет
Фильтр с количеством результатов для каждого значения, например бренд или размер.
Синоним
Слова, настроенные так, чтобы совпадать друг с другом, например «котёнок» и «кошка».
Алиас индекса
Постоянное имя, которое указывает на реальный индекс и может быть атомарно переключено.
Tenant token
Подписанный поисковый ключ с ограниченным сроком действия и встроенными фильтрами, которые применяет движок.

Есть идея продукта?Давайте создадим его.

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