Preskočiť na obsah
Laravel E-commerce 22. september 2026 · 12 min čítania

Fulltextové vyhľadávanie: Scout a Meilisearch

Zákazník napíše do vyhľadávania „bicikel", vy máte v databáze „bicykel" a výsledok je prázdna stránka. Vyhľadávanie postavené na LIKE nevie odpustiť preklep, ignorovať diakritiku ani zoradiť výsledky podľa relevancie — a práve na vyhľadávacom poli sa rozhoduje, či návštevník nájde produkt alebo odíde. Tento článok ukazuje, ako to vyriešiť cez Laravel Scout a Meilisearch.

DC

Dušan Chlpek

PHP vývojár, GEAR s.r.o. · 25+ rokov praxe

Prečo LIKE nestačí

Vyhľadávanie cez WHERE nazov LIKE '%výraz%' má tri problémy, ktoré sa nedajú obísť:

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:

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
Port Meilisearchu nikdy nevystavujte do internetu. V príklade je naviazaný na 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

Rýchla kontrola prínosu: porovnajte podiel vyhľadávaní s nulovým výsledkom pred a po nasadení. Pri prechode z 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

Nenachádzajú zákazníci vo vašom e-shope produkty?

Nasadím fulltextové vyhľadávanie s toleranciou preklepov, filtrami a našepkávačom — vrátane vyhodnocovania, čo zákazníci hľadajú a nenachádzajú. Funguje aj nad existujúcim e-shopom.

Ďalšie články

Zavolať E-mail Dopyt

Ochrana súkromia

Táto stránka využíva cookies pre nevyhnutné fungovanie. Rešpektujeme vaše súkromie a legislatívu GDPR.