Newsletter 365tipů můžete číst zdarma. Placené předplatné je dobrovolná podpora, která pomáhá webu pokračovat.
Podpořit 365tipů

TIP#3339: Jak na vlastní fulltextové hledání ve WordPressu, které nezabije web ani se 100 000 články

Vyhledávání ve WordPressu má jeden zásadní problém: databáze sice umí skutečné fulltextové hledání, ale WordPress ho standardně nepoužívá.

POZNÁMKA: Pro FeedIT.cz jsem už dlouho řešil, že tam nešlo hledat – typické pro WordPress, protože se prostě nepočítá s monstrem s více než 100 tisíci články. Až Claude Code a usilovné ladění pomohlo napsat plugin, co to řeší. Tenhle tip popisuje podrobně jak to řešit a na co přitom narazíte. Ale nepsal jsem ho, vlastně jen tento odstavec. Claude Code dala zadání. převzal to SKILL 365tipů a pak po čtyřech revizích (chyby, příliš odborné, atd) vzniklo to co si můžete přečíst). Plugin asi časem dorazí na GitHub.

Výchozí hledání ve WordPressu je jednoduché a s rostoucím počtem článků může být velmi pomalé. Pokud vám stačí hledat v titulku, perexu a obsahu příspěvků, není přitom nutné hned nasazovat Elasticsearch nebo externí službu. MySQL/MariaDB umí FULLTEXT index a WordPress lze malým vlastním pluginem přimět, aby ho skutečně používal.

WP_Query při běžném hledání pracuje s post_title, post_excerpt a post_content a skládá podmínky založené na LIKE, typicky s hledaným výrazem obaleným %. U malého webu to ničemu nevadí. U desítek tisíc příspěvků už ale databáze musí při každém hledání porovnávat dotaz s velkým množstvím řádků a hledání začne být velmi drahé.

Problém i jiné možnosti řešení už podrobněji popisuje starší tip Jak na vyhledávání na webu s WordPressem? Co vás čeká za komplikace?. Tady půjdeme jinou cestou: ponecháme hledání přímo v databázi WordPressu, vytvoříme nad wp_posts FULLTEXT index a vlastním pluginem nahradíme původní LIKE za skutečné fulltextové MATCH() AGAINST().

Pro web s přibližně 100 000 články to může být velmi dobře použitelné řešení. Má ale několik háčků, na které se při prvním pokusu snadno narazí.

Samotný FULLTEXT index nepomůže. LIKE ho nepoužije

Tohle je nejdůležitější věc z celého tipu.

WordPress běžně hledá zjednodušeně řečeno takto:

post_title LIKE '%wordpress%'

Můžete přitom mít nad tabulkou sebelepší FULLTEXT index, ale LIKE ho pro tento typ hledání nepoužije.

Nestačí tedy index vytvořit. Vlastní plugin musí změnit i SQL dotaz na fulltextové hledání:

MATCH(post_title, post_content, post_excerpt)
AGAINST('wordpress' IN NATURAL LANGUAGE MODE)

Právě MATCH() AGAINST() je mechanismus, kterým MySQL používá FULLTEXT index.

A pozor na jeden důležitý detail: sloupce v MATCH() musejí odpovídat sloupcům v příslušném FULLTEXT indexu. Pokud index obsahuje post_title, post_content a post_excerpt, musí stejnou kombinaci používat i hledání.

Jinak může SQL skončit například chybou Can't find FULLTEXT index matching the column list.

Proto vlastní plugin nemá pouze předpokládat, že správný index existuje. Má si to ověřit.

Nejdřív zjistěte, jestli už nějaký FULLTEXT index nemáte

Na živém WordPressu není dobrý nápad bez kontroly vytvářet nebo mazat indexy.

Jiný plugin může mít nad wp_posts vlastní FULLTEXT index. Například plugin pro související články může fulltextové indexy používat pro úplně jiný účel. Váš plugin se jich nesmí dotýkat.

Stejně tak může po obnově zálohy nebo migraci vlastní očekávaný index zmizet.

Existující indexy lze zkontrolovat například přes SHOW INDEX, phpMyAdmin, Adminer nebo INFORMATION_SCHEMA.STATISTICS.

Pozor také na wp_posts. WordPress nemusí používat prefix wp_. V samotném pluginu je proto správnější získat název tabulky přes $wpdb->posts.

Vlastní plugin by měl hledat svůj konkrétně pojmenovaný index a kontrolovat také jeho sloupce. Ostatní indexy pouze nechat být.

Pokud vlastní FULLTEXT index chybí nebo neodpovídá očekávané definici, nejbezpečnější je vrátit se k původnímu WordPress LIKE hledání. Bude pomalejší, ale web kvůli chybějícímu indexu nepřestane fungovat.

Jak FULLTEXT index vytvořit

Samotné SQL může vypadat například takto:

ALTER TABLE wp_posts
ADD FULLTEXT INDEX idx_search_fulltext
(post_title, post_content, post_excerpt);

Použijte samozřejmě skutečné jméno tabulky.

Příkaz lze spustit například v phpMyAdminu, Admineru, přímo přes databázového klienta nebo pomocí WP-CLI.

Pokud používáte WP-CLI, může posloužit například:

wp db query "ALTER TABLE wp_posts ADD FULLTEXT INDEX idx_search_fulltext (post_title, post_content, post_excerpt);"

WP-CLI wp db query použije připojení k databázi z wp-config.php.

Na produkčním webu ale takový příkaz nespouštějte bez rozmyslu v době největší návštěvnosti.

U MySQL 8.4 přidání FULLTEXT indexu neumožňuje souběžné zápisy do tabulky. První FULLTEXT index navíc může vyvolat přestavbu tabulky.

U MariaDB nebo jiných verzí MySQL se podrobnosti mohou lišit, ale praktické pravidlo zůstává stejné: vytvoření indexu je databázová operace, která může chvíli trvat a může ovlivnit živý web.

Jak dlouho potrvá, nejde odhadnout jen z počtu článků. Záleží na velikosti obsahu, výkonu databáze, rychlosti disku, zatížení serveru a konfiguraci databázového systému.

Když vytvoření indexu spustí vlastní plugin

Pokud si pro tento účel napíšete plugin, může samozřejmě obsahovat vlastní administrační stránku a třeba i tlačítko pro vytvoření indexu.

To už ale není funkce WordPressu. Je to něco, co si musíte do pluginu sami naprogramovat.

A právě tady může nastat problém s timeoutem.

Plugin spustí ALTER TABLE v rámci webového požadavku a vytvoření indexu trvá déle, než webový server, proxy nebo PHP dovolí jednomu požadavku běžet. Prohlížeč pak může skončit například na 504 Gateway Timeout.

To ještě nemusí znamenat, že databázová operace skončila.

Při reálném nasazení se stalo, že webový požadavek už dávno skončil chybou, ale databáze dál pracovala a index nakonec vytvořila.

Po timeoutu tedy nespouštějte vytvoření indexu znovu naslepo. Nejprve zjistěte, zda operace ještě běží nebo zda už index vznikl.

K tomu lze použít například:

SHOW FULL PROCESSLIST;

a následně:

SHOW INDEX FROM wp_posts;

Pokud index se správným jménem a sloupci existuje, je hotovo.

Lepší vlastní plugin může vytvoření indexu spouštět jako samostatnou úlohu a administrační stránka potom jen z databáze zjišťuje aktuální stav. Jedna z možností je wp_schedule_single_event().

Podstatné je nečekat na dokončení ALTER TABLE v jednom otevřeném administračním požadavku.

Jak plugin změní hledání WordPressu

Pro vlastní implementaci jsou důležité hlavně dva WordPress filtry.

posts_search umožní změnit část SQL používanou pro hledání. Právě tady lze původní LIKE nahradit za MATCH() AGAINST().

posts_orderby pak dovolí změnit řazení výsledků.

Není nutné přepisovat celý WordPress nebo vytvářet vlastní systém načítání příspěvků. Plugin pouze zasáhne do SQL, které WP_Query sestavuje.

Musí přitom hlídat, aby změnu použil pouze pro skutečné vyhledávání na webu, ne například na náhodné dotazy v administraci.

A všechny hodnoty pocházející od návštěvníka musí při skládání SQL projít přes $wpdb->prepare().

Řazení podle relevance nebo podle data

FULLTEXT hledání v NATURAL LANGUAGE MODE umí kromě nalezení výsledků vypočítat i jejich relevanci.

To je další rozdíl proti původnímu LIKE.

Výsledky tak můžete řadit například:

  • podle relevance,
  • podle data,
  • nejprve podle relevance a potom podle data.

Pro běžné hledání bývá relevance praktičtější. Na zpravodajském webu ale může dávat smysl i chronologické řazení.

Vlastní plugin tedy může mít jednoduchý přepínač, jak výsledky seřadit.

FULLTEXT není jen rychlejší LIKE

Přechodem na FULLTEXT se nemění pouze rychlost. Mění se i význam hledání.

LIKE '%O2%' hledá výskyt znaků v textu.

FULLTEXT naopak text rozděluje na slova neboli tokeny, používá minimální délku slova, seznam stopwords a pravidla relevance.

Proto po nasazení nestačí změřit, že hledání trvá místo několika sekund třeba desetiny sekundy. Musíte také zkoušet, co skutečně najde a co nenajde.

„O2“ nebo „AI“ může FULLTEXT úplně ignorovat

Výchozí hodnota MySQL innodb_ft_min_token_size je 3. Kratší slova se do běžného InnoDB FULLTEXT indexu nezahrnou.

Hodnotu lze zjistit:

SELECT @@innodb_ft_min_token_size;

Pokud vrátí 3, problém jsou všechny jedno- a dvouznakové výrazy.

Na technologickém webu je to dost podstatné. AI, O2, X1 a podobné názvy mohou vracet nula výsledků, přestože se ve skutečnosti nacházejí v mnoha článcích.

To není chyba hledání. Tyto výrazy v indexu prostě nejsou.

Změna innodb_ft_min_token_size vyžaduje úpravu konfigurace databáze, restart a následné znovuvytvoření FULLTEXT indexu.

Na běžném hostingu nemusíte mít možnost to změnit vůbec.

Vlastní plugin proto může minimální délku zjistit přímo z databáze a u příliš krátkého dotazu zobrazit vysvětlení.

Vrátit se v takovém případě k LIKE sice lze, ale právě pro takový dotaz by se opět spustilo drahé hledání, kterého jste se chtěli zbavit.

Česká běžná slova jsou opačný extrém

Druhý problém je přesně opačný.

FULLTEXT používá stopwords, tedy velmi běžná slova, která nemají pro hledání velkou informační hodnotu. InnoDB má vlastní výchozí seznam stopwords.

Jenže výchozí seznam není dělaný pro český obsah.

Na webu s více než 90 000 příspěvky například hledání slova:

pro

vrátilo 89 734 výsledků.

FULLTEXT index tedy automaticky neznamená, že každý dotaz bude levný. Když hledáte slovo, které je skoro všude, databáze má pořád skoro 90 000 shod.

A právě tady se ukáže další vlastnost WordPressu.

WordPress chce kvůli stránkování spočítat všechny výsledky

WP_Query standardně potřebuje znát počet všech nalezených položek kvůli stránkování.

Pokud není zapnuto no_found_rows, může pro tento účel použít SQL_CALC_FOUND_ROWS.

WordPress například zobrazí prvních deset výsledků, ale databáze má zároveň zjistit, jestli celkově existuje 100, 1 000 nebo 89 734 shod.

U běžného dotazu to nemusí vadit.

U slova, které odpovídá prakticky celé databázi, je to přesně ten druh práce navíc, kterému se chcete vyhnout.

V reálném případě FULLTEXT hledání slova pro našlo 89 734 příspěvků a dotaz s dopočítáním všech výsledků trval přibližně 22 sekund.

Samotné nalezení prvních výsledků tedy problém nebyl. Drahé bylo zjišťování přesného počtu všech shod.

Vypněte found rows a počet výsledků zastropujte

Řešením je nastavit pro vyhledávání:

$query->set( 'no_found_rows', true );

WordPress pak přesný celkový počet výsledků sám nezjišťuje.

Vlastní plugin může místo toho pustit samostatný ohraničený count, například maximálně do 500 výsledků.

Výsledkem je:

  • 47 skutečných shod -> zobrazí se 47,
  • 489 skutečných shod -> zobrazí se 489,
  • 89 734 skutečných shod -> zobrazí se 500.

Číslo 500 tedy v tomto případě neznamená, že existuje přesně 500 výsledků. Znamená dosažený strop, nad který už plugin dál nepočítá.

Technicky je důležité, aby databáze nejprve našla maximálně stanovený počet řádků a až nad nimi provedla COUNT(). Nestačí jen přidat LIMIT 500 za obyčejný COUNT(*), protože databáze by stejně musela spočítat všechny shody.

U tohoto pomocného dotazu navíc není potřeba ORDER BY. Počítáte výsledky, ne jejich pořadí.

V praktickém testu kombinace no_found_rows a zastropovaného počítání zrychlila problematický dotaz na stagingu přibližně šestkrát.

Konkrétní čísla samozřejmě závisí na databázi a serveru. Podstatný je princip: nemá smysl velmi rychle najít prvních deset výsledků a potom strávit sekundy tím, že databáze přesně dopočítá desetitisíce dalších.

Vlastní český stopword seznam? Jde to, ale má svou cenu

MySQL umožňuje použít vlastní stopword seznam.

Mohli byste tedy vytvořit český seznam obsahující například nejběžnější předložky, spojky a další slova, jejichž hledání nemá praktickou hodnotu.

Jenže změna stopwords znamená také znovuvytvoření FULLTEXT indexu.

Na velkém živém webu to není něco, co chcete dělat často.

Proto je praktičtější nejdříve vyřešit zastropování výsledků a teprve podle skutečných vyhledávacích dotazů rozhodovat, jestli vlastní český stopword seznam opravdu potřebujete.

FULLTEXT nad wp_posts neprohledá úplně celý WordPress

Index nad:

post_title
post_content
post_excerpt

hledá pouze v těchto sloupcích tabulky příspěvků.

Automaticky tím neprohledáte:

  • custom fields v wp_postmeta,
  • kategorie a štítky,
  • vlastní tabulky pluginů,
  • externí data.

Pokud potřebujete hledat například současně v názvu produktu, SKU v postmeta, taxonomiích a dalších vlastních polích, začíná být jednoduchý FULLTEXT nad wp_posts méně vhodný.

Pak může dávat větší smysl vlastní vyhledávací tabulka nebo samostatný vyhledávací systém.

Výsledky se nebudou chovat úplně stejně jako původní hledání WordPressu

WordPress a MySQL FULLTEXT nemají stejnou logiku.

Původní WordPress hledání pracuje s jednotlivými hledanými termy a má vlastní pravidla. MATCH() AGAINST(... IN NATURAL LANGUAGE MODE) používá pravidla MySQL FULLTEXT.

Výsledek tedy nebude jen „stejné hledání, ale rychlejší“.

Pro běžné vyhledávací políčko to nemusí vadit a řazení podle relevance může být dokonce lepší. Pokud ale web spoléhá na konkrétní chování původního WordPress vyhledávání, je potřeba ho při přechodu otestovat.

Plugin by měl raději selhat pomalu než shodit web

U zásahu do databázových dotazů je důležitější robustnost než elegantní kód.

Rozumný vlastní plugin by měl:

  • ověřit existenci svého FULLTEXT indexu,
  • ověřit jeho sloupce,
  • ignorovat cizí indexy,
  • při chybějícím indexu použít původní WordPress hledání,
  • zasahovat jen do skutečných vyhledávacích dotazů,
  • používat $wpdb->prepare() pro uživatelský vstup,
  • chránit administrační akce přes WordPress capability a nonce.

Naopak není dobrý nápad automaticky udělat:

Index neexistuje? Tak ho právě teď vytvořím.

Po obnově zálohy nebo migraci by se tím mohl nečekaně spustit drahý ALTER TABLE přímo na živém webu.

Pokud vlastní plugin obsahuje administrační stránku, je praktičtější tam pouze zobrazit:

FULLTEXT index: chybí

a vytvoření nechat jako vědomou samostatnou operaci.

Hodí se jednoduchá diagnostika

U vlastního pluginu je velmi praktické mít administrační stránku, která ukáže například:

FULLTEXT index: OK
Sloupce: post_title, post_content, post_excerpt
innodb_ft_min_token_size: 3
Řazení: relevance
Limit výsledků: 500

A ideálně umožní zadat testovací výraz a zobrazit počet výsledků a dobu dotazu.

Tak rychle poznáte, zda hledání stále funguje například po migraci webu nebo obnově databáze.

Užitečné může být také logování hledaných výrazů a pomalých dotazů. Právě reálný provoz dokáže odhalit problémy, na které při testování výrazů typu wordpress, iphone nebo google vůbec nenarazíte.

Pokud log obsahuje IP adresy nebo User-Agent návštěvníků, musí být chráněný a neměl by být volně dostupný přes web.

Co se ukázalo až na skutečných datech

Tento postup vznikl při řešení webu s více než 90 000 příspěvky.

Původní COUNT(*) založený na LIKE tam trval přibližně 5,7 sekundy.

FULLTEXT vyřešil základní problém, ale až testování nad skutečnými daty odhalilo další tři:

  • O2 a další krátké výrazy nebyly kvůli innodb_ft_min_token_size v indexu,
  • běžné české pro odpovídalo prakticky celé databázi,
  • WordPress kvůli dopočítávání celkového počtu výsledků dokázal z rychlého hledání znovu udělat velmi drahý dotaz.

Plugin vznikal iterativně pomocí Claude Code, ale právě tady se ukázalo něco důležitějšího než způsob jeho naprogramování: databázově náročnou funkci musíte testovat na skutečných datech.

Kód může být správný a několik zkušebních hledání rychlých, ale až reálná databáze ukáže výrazy a kombinace, které jste při psaní vůbec nepředpokládali.

Kdy je vlastní FULLTEXT dobrý nápad

Tohle řešení dává smysl hlavně tehdy, pokud:

  • máte desítky tisíc až nižší stovky tisíc příspěvků,
  • hlavním problémem je pomalé standardní hledání WordPressu,
  • stačí hledání v titulku, obsahu a perexu,
  • nechcete provozovat další vyhledávací službu,
  • nepotřebujete opravy překlepů, složitou jazykovou analýzu nebo našeptávání.

Výhodou je jednoduchost. Data už jsou v MySQL/MariaDB a není potřeba provozovat další službu.

Pokud ale potřebujete hledat přes řadu vlastních polí a tabulek, řešit překlepy, pokročilou práci s jazykem nebo velmi sofistikované řazení výsledků, bude vhodnější specializované řešení. Další možnosti najdete v tipu Jak na vyhledávání na webu s WordPressem? Co vás čeká za komplikace?.

Co tedy vlastní plugin musí udělat

Celé řešení má několik hlavních částí:

  1. Zjistit, zda existuje správný FULLTEXT index.
  2. Umožnit jeho bezpečné a vědomé vytvoření.
  3. Nahradit WordPress LIKE hledání za MATCH() AGAINST().
  4. Případně řadit výsledky podle relevance.
  5. Při chybějícím indexu se vrátit k původnímu hledání.
  6. Zohlednit minimální délku fulltextových tokenů.
  7. Vypnout drahé dopočítávání všech výsledků a jejich počet zastropovat.
  8. Otestovat běžná, krátká i extrémně častá slova na skutečných datech.

A hlavně: FULLTEXT index není optimalizace původního LIKE. Je to jiný způsob hledání a SQL dotaz se musí změnit spolu s indexem.

Bez této změny můžete vytvořit velký a dokonale funkční FULLTEXT index, který WordPress při hledání nebude používat vůbec.