# Поиск по сайту

Один индекс на всё содержимое (`search_index`), а не FULLTEXT на каждой
таблице компонента: посетитель ищет по сайту, а не по товарам отдельно
и новостям отдельно. Иначе результат пришлось бы собирать UNION'ом по всем
таблицам и сортировать в PHP, теряя релевантность.

Движок — за интерфейсом `SearchEngine`. Сейчас за ним MySQL FULLTEXT
с ngram-парсером; интерфейс существует ровно ради замены, и, как показали
замеры ниже, замена понадобится раньше, чем хотелось бы.

## Индекс обновляется вместе с содержимым

Отдельной «переиндексации по расписанию» нет: поиск, отдающий то, чего
на сайте уже нет, хуже поиска, который чего-то не находит.

- товар — наблюдатель `ProductObserver` при сохранении и удалении;
- новость, страница, любой объект компонента — `ObjectWriter`, тот же писатель,
  что кладёт объект в базу (`ObjectIndexer`);
- снятый с публикации **удаляется** из индекса: найти и упереться в 404 —
  худший исход из возможных;
- импорт пишет каталог в обход моделей (одним запросом на прогон), поэтому
  после него индекс строится отдельно: `php artisan catalog:reindex {сайт}`.

## Что индексировать, решает компонент

Ядро не знает, что у новости есть анонс, а у сотрудника должность. Поле
помечается в описании компонента:

```php
Field::text('anons', 'Анонс')->searchable(),
```

`ObjectIndexer` спрашивает у компонента его поля и берёт помеченные. Отсюда
два следствия. Компонент из конструктора админки индексируется тем же кодом,
что модульный, — галочка «искать по полю» стоит в форме поля. И новый модуль
попадает в поиск, ничего не зная о поиске.

Адрес находки: карточка объекта, если у компонента есть внутренняя страница
(`hasDetailPage()`), иначе — путь раздела. Прятать из поиска содержимое,
у которого нет собственного адреса, хуже, чем привести в список.

Каталог индексируется отдельным классом (`ProductIndexer`): у товара поля
объявлены в коде, а объёмы такие, что переиндексация идёт пачками мимо моделей.
Поэтому `search:reindex` каталог пропускает и говорит об этом в выводе.

```bash
php artisan search:reindex {сайт}   # новости, страницы, объекты компонентов
php artisan catalog:reindex {сайт}  # товары
```

Полная переиндексация нужна в двух случаях: после изменения набора
индексируемых полей и как ремонт, если данные заливали мимо писателя.

Заголовок кладётся в документ дважды: FULLTEXT считает релевантность
по частоте, и совпадение в названии обязано весить больше совпадения
в середине описания. В текст документа он при этом не дублируется третий раз
полем `name` — иначе выдержка открывалась бы повтором собственного заголовка.

⚠️ **Разметка снимается до записи, и одним способом для всех** (`SearchIndexer::plain()`,
им же пользуется пачечная переиндексация каталога). Тег заменяется пробелом,
а не пустотой: `strip_tags` склеил бы `конец абзаца</p><p>Начало` в одно слово,
которого нет ни в тексте, ни в запросе. Раньше выдержка бралась из сырого
значения — на каталоге это не видно (описания простым текстом), а на первом же
поле типа «HTML» посетитель увидел в выдаче `<p>`.

## Что стоило порядка величины

Три вещи, найденные замерами на 100 тысячах документов:

| Что | Было | Стало |
|---|---|---|
| `*` в конце слова | > 3 минут | убрано вовсе |
| `chunk()` при переиндексации | 442 поз./с | 3252 поз./с (`chunkById`) |
| точный `COUNT(*)` | секунды | потолок 500, «больше 500» |

⚠️ **Звёздочку в конце слова ставить нельзя, хотя рефлекс требует.** Парсер
ngram и так режет текст на биграммы, поэтому «насос» находит «насосную
станцию» без всякой подстановки. А с `*` MySQL разворачивает префикс по всему
словарю биграмм.

⚠️ **`chunk()` листает через OFFSET.** На сотой тысяче строк MySQL каждый раз
прокручивает всё, что уже отдал. `chunkById` идёт по `id >` и не деградирует.

## Подсказки при вводе

`/search/suggest?q=…` отдаёт JSON: до шести товаров, следом до четырёх
разделов. Строку рисует блок «Строка поиска» — как и вход, это блок, а не часть
шаблона темы: сайт без каталога поиском не пользуется. Форма работает без
JavaScript: выключенный скрипт отнимает только выпадающий список.

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

**Картинку и цену подсказке даёт компонент** — интерфейс `EnrichesSuggestions`.
Индекс хранит только заголовок и адрес, а в выпадающем списке товар узнают
по фотографии и цене. Ядро не знает, что у товара есть цена, а у новости нет,
и знать не должно: кто интерфейс не объявил, остаётся строкой текста.
Один вызов на компонент, а не на находку — иначе подсказка из семи товаров
стоила бы семи запросов на каждую букву. Эти данные НЕ кэшируются: цена
персональная, и общий кэш раздал бы оптовую цену всем.

⚠️ **Подсказки ищут по заголовкам, а не по документу**, и для этого есть
отдельный индекс `ft_title`. Причина не только в скорости: подсказка — переход
к вещи, а вещь опознаётся названием; совпадение, зарытое в середину описания,
даёт подсказку, которую посетитель не может связать с тем, что набрал.

Замер на 100 тысячах документов:

| Запрос | по `content` | по `title` |
|---|---|---|
| «насос» | 1,1 с | 0,39 с |
| «кран» | 27 с | 0,50 с |
| «шаровой кран» | 106 с | 1,4 с |

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

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

⚠️ **В кэше лежат массивы, а не объекты `SearchHit`.** Сериализованный объект
переживает выкладку и приезжает обратно в форме, которой в коде уже нет —
на файловом драйвере это `__PHP_Incomplete_Class` и пятисотая. Нашлось живым
прогоном: в тестах драйвер `array`, там ничего не сериализуется.

### ⚠️ Адрес в индексе строит UrlBuilder

Индексатор каталога склеивал адрес карточки руками — `путь раздела + слаг +
'/'`, то есть адрес РАЗДЕЛА вместо `слаг.html`. Каждая находка каталога вела
в 404, и на сайте это выглядело как «поиск ничего не открывает».

Прожило это долго по единственной причине: **тест повторял за кодом ту же
склейку** и проверял равенство строк. Теперь он проверяет, что по адресу
из индекса **открывается товар**, — такое утверждение неверным быть не может.

Отсюда правило: адрес объекта строит только `UrlBuilder`. Канонический вид
карточки (`.html`) — контракт, а не деталь вёрстки, и второе место, где он
собирается, однажды с ним разойдётся.

После правки индексатора старые строки индекса остаются с прежним адресом,
пока не пройдёт переиндексация: `php artisan catalog:reindex {сайт}`.

### ⚠️ Вес — ключ сортировки, а не множитель

Строка индекса несёт `weight` (у товара 150, у прочего 100), и раньше он
умножался на релевантность MySQL. Выглядело естественнее, но релевантность
считается по статистике индекса, и на индексе **из одной строки** произведение
уходит за пределы DOUBLE:

```
SQLSTATE[22003]: Numeric value out of range: 1690 DOUBLE value is out of range
in '((match `search_index`.`title` against (…)) * (`weight` / 100))'
```

То есть свежий сайт с единственным проиндексированным товаром отдавал
на первый же ввод в строку поиска пятисотую. Теперь `ORDER BY weight DESC,
score DESC` — тот же смысл («товары выше страниц»), и падать там нечему.

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

## Подсветка совпадений

Выдержка режется **вокруг первого совпадения**, а не с начала текста: искомое
слово часто стоит в середине описания, и первые двести символов не имеют
к запросу отношения.

Разметка ставится метками **до** экранирования, а `<mark>` подставляется
после. Иначе пришлось бы искать подстроку в тексте, где `&` уже стал `&amp;`,
и запрос «amp» подсветил бы половину мнемоник. Длинные слова размечаются
первыми: иначе «кран» разорвал бы уже размеченное «крановый» меткой внутри
метки.

Готовит выдержку контроллер, а не шаблон: подсветка — это HTML, и собирать
его в Blade значило бы печатать через `{!! !!}` строку, которую шаблон
не экранировал сам.

## Поиск в админке

`/admin/search?q=…` — отдельный механизм, а не тот же индекс. Причина простая:
в индексе витрины лежит только опубликованное, а в админку заходят как раз
за черновиком.

Источники — реестр `AdminSearchProviders`: разделы и содержимое знает ядро,
товары — каталог, заказы — магазин. Упавший источник не роняет поиск целиком:
у модуля может не быть таблиц, и это не повод отказывать в поиске по разделам.

Что ищется чем:

| Источник | Как | Цена |
|---|---|---|
| Разделы | по названию и пути, в памяти (дерево уже в кэше) | 0 |
| Содержимое | `name LIKE '%…%'` по таблицам компонентов | сотни строк — бесплатно |
| Товары | сначала точный артикул, потом название | 2 мс / 1–2 с на 200 тыс. |
| Заказы | номер и телефон, точным совпадением по индексу | 2 мс |

⚠️ **Однобуквенный запрос запрещён — кроме цифры.** Буква совпадает со всем
и стоит скана; цифра — это точное попадание по номеру заказа, и запретить её
значило бы не находить заказ №1. Провайдеры товаров и содержимого при этом
однобуквенный запрос всё равно игнорируют: заказ ищут не они.

⚠️ **Пустая вложенная `where(function …)` — это отсутствие фильтра.** Провайдер
заказов не добавлял ни одного условия, когда запрос не был ни номером,
ни телефоном, и отдавал ВСЕ заказы сайта на любое слово. Нашлось живым
прогоном: тест искал по номеру и по телефону, то есть ровно там, где условия
есть.

## Ограничение, из-за которого понадобится другой движок

Замеры на 100 000 документов (каталог, реальные названия из словаря):

| Запрос | Время |
|---|---|
| `насос` | 0,5 с |
| `ART-777` | 1,1 с |
| `шаровой кран` | **41 с** |

Причина: в булевом режиме ngram-запрос превращается в фразовый поиск
по биграммам, и MySQL проверяет фразу в каждой строке-кандидате. Два слова —
два набора кандидатов, и каждый совпадает с половиной базы.

Естественный режим (`AGAINST (?)` без `IN BOOLEAN MODE`) те же два слова
отдаёт за 1,6 с, но применять его нельзя: он молча выбрасывает слова,
встречающиеся более чем в половине строк. На каталоге, где «насос» есть
в 60 % названий, поиск по слову «насос» вернул бы пустоту — это хуже, чем
медленно.

**Вывод.** Текущий движок годится для каталога в тысячи позиций (там те же
запросы укладываются в десятки миллисекунд) и не годится для сотни тысяч.
Следующий шаг — Meilisearch за тем же интерфейсом; менять придётся один класс.

## Тесты поиска не транзакционные

`SearchTest` и `ContentSearchTest` используют `DatabaseTruncation`,
а не `RefreshDatabase`:
InnoDB обновляет полнотекстовый индекс **при коммите**, и внутри транзакции
теста поиск не видит только что вставленных строк. Тест, написанный обычным
способом, показывал бы ноль находок при исправном коде.

## Страница

`/search?q=…` — адрес ядра в оболочке `PageShell`, как корзина и кабинет.
Запрос короче двух символов не даёт ни одного ngram-токена, и страница
говорит об этом прямо, вместо того чтобы отдавать всю базу.

## Чего ещё нет

| Что | Когда |
|---|---|
| Meilisearch за тем же интерфейсом | до первого каталога на сотню тысяч |
| Поиск в админке по большому каталогу быстрее скана | вместе с Meilisearch |
