Prečo LIKE nestačí
Vyhľadávanie cez WHERE nazov LIKE '%výraz%' má tri problémy, ktoré sa nedajú obísť:
- Nepoužije index. Žolík na začiatku vzoru znamená prechod celej tabuľky. Pri desaťtisíc produktoch to ešte prejde, pri státisícoch nie. Podrobnejšie o tom píšem v článku o optimalizácii pomalých MySQL dotazov.
- Nezvláda preklepy ani tvary slov. „bicikel" verzus „bicykel", „topánky" verzus „topanky", jednotné verzus množné číslo — pre
LIKEsú to úplne odlišné reťazce. - Nepozná relevanciu. Zhoda v názve produktu má väčšiu váhu než zhoda kdesi v popise, ale
LIKEvráti oboje rovnako.
Fulltextový index priamo v MySQL rieši prvý problém a čiastočne tretí, ale s preklepmi a slovenskou diakritikou si neporadí dobre. Preto sa oplatí nasadiť vyhľadávací engine.
Scout ako medzivrstva
Laravel Scout je tenká vrstva medzi Eloquentom a vyhľadávacím enginom. Napíšete kód raz a engine viete vymeniť zmenou konfigurácie:
composer require laravel/scout
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
Scout podporuje viacero ovládačov. Pre slovenský projekt prichádzajú do úvahy tri:
- Meilisearch — otvorený engine, nenáročný na pamäť, rýchly a s dobrou toleranciou preklepov už v predvolenom nastavení. Pre väčšinu e-shopov a katalógov je to správna voľba.
- Typesense — podobná kategória, tiež otvorený a jednoducho prevádzkovateľný.
- Databázový ovládač — vyhľadáva priamo v databáze bez ďalšej služby. Vhodné pre malé projekty, kde ide skôr o pohodlie než o výkon.
Spustenie Meilisearchu
# docker-compose.yml
services:
meilisearch:
image: getmeili/meilisearch:latest
environment:
MEILI_MASTER_KEY: ${MEILISEARCH_KEY}
MEILI_ENV: production
volumes:
- meili_data:/meili_data
ports:
- "127.0.0.1:7700:7700"
volumes:
meili_data:
# .env
SCOUT_DRIVER=meilisearch
MEILISEARCH_HOST=http://127.0.0.1:7700
MEILISEARCH_KEY=dlhý-náhodný-kľúč
composer require meilisearch/meilisearch-php http-interop/http-factory-guzzle
127.0.0.1. Ak beží na inom stroji, použite privátnu sieť alebo VPN — nie verejnú IP adresu s nádejou, že ju nikto nenájde.
Indexovanie modelu
Do modelu pridajte trait a popíšte, čo sa má indexovať. Kľúčové je indexovať len to, podľa čoho sa naozaj hľadá alebo filtruje — nie celý model:
<?php
namespace App\Models;
use Laravel\Scout\Searchable;
class Produkt extends Model
{
use Searchable;
public function searchableAs(): string
{
return 'produkty';
}
public function toSearchableArray(): array
{
return [
'id' => (string) $this->id,
'nazov' => $this->nazov,
'kod' => $this->kod,
'popis' => strip_tags($this->popis_kratky),
'kategoria' => $this->kategoria?->nazov,
'znacka' => $this->znacka?->nazov,
'cena_centov' => (int) $this->cena_centov,
'skladom' => $this->dostupne_mnozstvo > 0,
'aktivny' => (bool) $this->aktivny,
];
}
public function shouldBeSearchable(): bool
{
return $this->aktivny && $this->kategoria_id !== null;
}
}
Metóda shouldBeSearchable() je dôležitá — bez nej sa do indexu dostanú aj nezverejnené a nedokončené záznamy.
# prvotné naplnenie indexu
php artisan scout:import "App\Models\Produkt"
# vymazanie indexu
php artisan scout:flush "App\Models\Produkt"
Indexovanie cez frontu
Pri každej zmene modelu Scout aktualizuje index. Ak to robí synchrónne, každé uloženie produktu čaká na odpoveď vyhľadávacej služby. Zapnite frontu:
// config/scout.php
'queue' => true,
Pri hromadnom importe z dodávateľského cenníka indexovanie na chvíľu vypnite a index naplňte až nakoniec — inak vygenerujete desaťtisíce úloh vo fronte:
Produkt::withoutSyncingToSearch(function () use ($cennik) {
foreach ($cennik as $riadok) {
Produkt::updateOrCreate(['kod' => $riadok['kod']], $riadok);
}
});
Artisan::call('scout:import', ['model' => Produkt::class]);
Nastavenie relevancie pre slovenčinu
Predvolené nastavenie funguje, ale pre slovenský obsah sa oplatí doladiť tri veci: poradie dôležitosti polí, filtrovateľné atribúty a synonymá.
use Meilisearch\Client;
$klient = new Client(
config('scout.meilisearch.host'),
config('scout.meilisearch.key'),
);
$index = $klient->index('produkty');
// poradie určuje váhu pri zhode
$index->updateSearchableAttributes([
'nazov',
'kod',
'znacka',
'kategoria',
'popis',
]);
// podľa čoho sa dá filtrovať
$index->updateFilterableAttributes([
'kategoria', 'znacka', 'cena_centov', 'skladom', 'aktivny',
]);
// podľa čoho sa dá zoraďovať
$index->updateSortableAttributes(['cena_centov']);
Synonymá riešia slovenské zvláštnosti
Zákazníci hľadajú inak, než sú produkty pomenované v katalógu. Synonymá sú najlacnejší spôsob, ako to preklenúť:
$index->updateSynonyms([
'bicykel' => ['bicikel', 'bike', 'kolo'],
'mobil' => ['telefón', 'smartfón', 'smartphone'],
'notebook' => ['laptop', 'notas'],
]);
Zoznam synoným stavajte podľa reálnych dát — zo záznamov vyhľadávaní, ktoré nevrátili žiadny výsledok. To je najcennejší zdroj informácií o tom, čo zákazníci hľadajú a nenachádzajú.
Slová, ktoré nemajú vplyv
$index->updateStopWords(['a', 'aj', 'na', 'pre', 'do', 'so', 's', 'v', 'z']);
Vyhľadávanie v aplikácii
public function vyhladaj(Request $poziadavka): View
{
$vyraz = trim((string) $poziadavka->input('q'));
if (mb_strlen($vyraz) < 2) {
return view('vyhladavanie.prazdne');
}
$produkty = Produkt::search($vyraz)
->when($poziadavka->filled('kategoria'), fn ($q) =>
$q->where('kategoria', $poziadavka->input('kategoria')))
->when($poziadavka->boolean('skladom'), fn ($q) =>
$q->where('skladom', true))
->paginate(24);
ZaznamVyhladavania::zapis($vyraz, $produkty->total());
return view('vyhladavanie.vysledky', [
'produkty' => $produkty,
'vyraz' => $vyraz,
]);
}
Zapisovanie vyhľadávaných výrazov spolu s počtom výsledkov odporúčam nasadiť od prvého dňa. Zoznam výrazov s nulovým počtom výsledkov je priamy návod, čo doplniť do sortimentu alebo synoným.
Našepkávač bez zaťaženia servera
Pre našepkávanie počas písania nevolajte pri každom stlačení klávesy vlastný endpoint. Počkajte, kým používateľ prestane písať:
const pole = document.querySelector('#hladat');
const vysledky = document.querySelector('#navrhy');
let casovac;
pole.addEventListener('input', () => {
clearTimeout(casovac);
const vyraz = pole.value.trim();
if (vyraz.length < 2) {
vysledky.hidden = true;
return;
}
casovac = setTimeout(async () => {
const odpoved = await fetch(
`/api/naseptavac?q=${encodeURIComponent(vyraz)}`
);
const data = await odpoved.json();
vysledky.innerHTML = data.polozky
.map((p) => `<li><a href="${p.url}">${p.nazov}</a></li>`)
.join('');
vysledky.hidden = data.polozky.length === 0;
}, 250);
});
Endpoint našepkávača ochráňte rate limitom — je to najčastejšie volané API v celej aplikácii a zároveň vďačný cieľ pre scraping katalógu. Postup popisujem v článku o ochrane API proti botom.
Prevádzka na produkcii
- Zálohujte index alebo počítajte s jeho obnovou. Index je odvodené dáta — musí sa dať kedykoľvek vytvoriť nanovo z databázy jedným príkazom.
- Ošetrte nedostupnosť vyhľadávacej služby. Keď engine nebeží, vyhľadávanie nesmie zhodiť celú stránku. Zachyťte výnimku a degradujte na jednoduché databázové hľadanie.
- Sledujte veľkosť indexu a pamäť. Meilisearch drží dáta v pamäti namapované zo súborov — pri veľkých katalógoch to treba merať.
- Master kľúč nikdy nepoužívajte v prehliadači. Ak voláte engine priamo z frontendu, vygenerujte kľúč s obmedzenými právami len na čítanie konkrétneho indexu.
- Po každom nasadení overte, že index existuje a má očakávaný počet záznamov. Prázdny index znamená prázdne výsledky bez jedinej chybovej hlášky.
LIKE na fulltextový engine býva pokles výrazný — a je to presne tá skupina návštevníkov, ktorá predtým odchádzala.
Zhrnutie
LIKEnepoužije index, nezvláda preklepy ani relevanciu — pri väčšom katalógu je nepoužiteľný- Scout je medzivrstva, vďaka ktorej sa dá engine vymeniť bez prepisovania kódu
- Meilisearch je pre bežný e-shop správna voľba: otvorený, rýchly, nenáročný na prevádzku
- Indexujte len polia, podľa ktorých sa hľadá alebo filtruje
shouldBeSearchable()zabráni indexovaniu nezverejnených záznamov- Indexovanie púšťajte cez frontu, pri hromadnom importe ho dočasne vypnite
- Poradie polí určuje váhu pri zhode — názov pred popisom
- Synonymá stavajte podľa reálnych vyhľadávaní s nulovým výsledkom
- Ošetrite nedostupnosť enginu a po nasadení overte počet záznamov v indexe