Gebruikerslogin

Nu online

Er zijn momenteel 1 gebruiker en 22 gasten online.

Online gebruikers

Enquête

Wat is leuker?
White hat SEO
70%
Black hat SEO
30%
Totaal aantal stemmen: 98

Fulltext search in MySQL

Erik-Jan Bulthuis

26 november 2006

Voor de meeste sites die ik bouw die een zoekmachine behoeven, installeer ik Google. Google zoekt beter dan ik een script zou kunnen laten zoeken, en nog sneller ook. Het kan echter zo zijn dat er een reden is om geen Google te gebruiken. Een voorbeeld hiervan is een intranet applicatie welke door Google niet indexeerbaar is. In zo’n geval biedt de fulltext search de mogelijkheid om een goede zoekmachine zelf te bouwen. Fulltext search is krachtig, sorteert de resultaten redelijk tot goed en doet meer werk dan je ooit zelf zou willen programmeren. Een punt van aandacht is echter de input van de bezoeker en ook daar zullen we naar kijken. Concreet zullen we op deze vragen in gaan:

  • Wat is fulltext search?
  • Hoe werkt fulltext search?
  • Hoe verwerken we de user input?

Zoekmethode: standaard en fulltext

Als je normaal gesproken wilt zoeken in een MySQL database gebruik je iets als dit:

SELECT * FROM tabelNaam WHERE veldNaam LIKE ‘%zoekstring%’

Hoewel je op deze manier alle resultaten verkrijgt die je nodig hebt, zitten er een paar nadelen aan deze manier van zoeken:

  • Het is lastig om in meerdere velden tegelijk te zoeken (het kan natuurlijk wel).
  • Er is geen logische manier om de resultaten te sorteren naar relevantie.

Hoe werkt fulltext searching?

De fulltext search is oorspronkelijk bedoeld voor grote databases met veel data. Het idee is dat er een index wordt gemaakt van alle woorden in de tabel. Deze index wordt gebruikt bij het zoeken door MySQL. Qua code hebben we het over de MATCH() ... AGAINST syntax. MATCH() bevat een lijst met kolommen waarin gezocht kan worden. AGAINST bevat de zoekstring. Een goede query zou dus kunnen zijn:

SELECT * FROM tabel MATCH(titel, tekst) AGAINST('zoek en vind')

Hoe wordt de zoekscore bepaald?

Full-text search voorkomt deze problemen door per gevonden zoekwoord een afweging te maken tegen de grootte van het veld waar je in zocht. Als een zoekterm gevonden wordt in een kleine entry, dan wordt dit meer gewaardeerd dan wanneer de zoekterm gevonden wordt in een grotere entry. Tevens kan er makkelijk in meerdere velden worden gezocht, waarbij het ook nog zo is dat hoe vaker een zoekterm in de opgegeven kolommen gevonden, hoe hoger de zoekscore van dit resultaat. De MySQL manual zegt (vertaald):

Zoekscores zijn een niet-negatief getal. Een zoekscore van 0 betekent dat er geen resultaten gevonden zijn. De zoekscore is berekend aan de hand van (1) het aantal woorden in de rij, (2) het aantal unieke woorden in de rij (3), het aantal woorden in de verzameling en (4) het aantal rijen dat een bepaald zoekwoord bevatten.
(...)
Elk correct woord in de zoekwoordenlijst wordt gewaardeerd a.d.h.v. de significantie van dat woord in de tabel en in de query. Een woord dat aanwezig is in meerdere rijen heeft een klein gewicht, omdat het een lagere semantische waarde heeft in deze tabel. Als het woord echter zeldzaam is, heeft het een hogere waardering. De gewichten van de woorden worden gecombineerd om de relevantie van de rij te bepalen.

De zoekscore zelf heeft dus geen waarde. Het is een getal wat door een ingewikkeld algoritme samengesteld wordt en kan prima gebruikt worden voor sorteerdoeleinden. Het heeft eigen weinig nut het getal naast de zoekresultaten te vermelden (zoals toch nogal eens gebeurt).

Fulltext index maken

De fulltext index kan op een CHAR, een VARCHAR of één van de TEXT velden aangemaakt worden. Aan de hand van je tabel bepaal je op welke kolommen je wilt zoeken en daar maak je een fulltext index op aan. Dit wordt dan iets als dit:

CREATE FULLTEXT INDEX indexNaam ON tabelNaam (titel, tekst)

Normal of Boolean modus

Er zijn twee modussen beschikbaar voor de fulltext search. De normale modus is totaal niet spannend, maar de boolean modus biedt wat interessante mogelijkheden. Als je wilt bepalen welke zoektermen extra belangrijk zijn, kun je dit doen in de boolean modus. Er zijn nogal wat mogelijkheden hiervoor en daar zullen we zo naar kijken. Voor nu is het even belangrijk om te weten dat je de boolean modus inschakelt door IN BOOLEAN MODE op te nemen in je AGAINST. Je krijgt dan iets als dit:

SELECT * FROM tabelNaam MATCH(titel, tekst) AGAINST('zoek en vind' IN BOOLEAN MODE)

Zoekwoorden

In het AGAINST deel worden dus zoekwoorden opgenomen. Hier moeten we twee dingen over zeggen. Allereerst zijn er woorden die hier uit worden gefilterd. Ten tweede zijn er operators waarmee je MySQL kan sturen hoe hij de relevantie van bepaalde woorden moet bepalen. Maar allereerst die woorden die uit de zoekwoordenlijst worden gefilterd. Daar zijn drie groepen voor:

  1. Allereerst zal elk woord wat te kort is uit de woordenlijst gefilterd worden. Standaard betekent 'te kort' korter dan 4 karakters, maar dit kun je aanpassen in de variabele ft_min_word_len in je MySQL configuratie bestand.
  2. Er zijn zogenaamde 'stopwoorden' die dusdanig vaak voorkomend zijn en geen waarde in zichzelf hebben dat ze gewaardeerd worden. In het Engels zijn dit woorden als 'the', 'and' en 'some'. De 'stopwoorden' zou je kunnen wijzigen in een tekstfile waarheen je verwijst in de variabele ft_stopword_file in je MySQL configuratie bestand.
  3. Woorden die in meer dan de 50% van de rijen voorkomen, zullen ook niet worden meegenomen. Als we bijvoorbeeld tussen de PHP artikelen op deze site zouden zoeken op 'PHP', dan is dat een weinig zinvolle actie omdat dit woord overal wel zal voorkomen.

Operators

In de boolean modus kun je een aantal operators gebruiken die je voor of om een zoekterm zet. Ik kopieer - en vertaal - gewoon even wat de manual van MySQL ervan zegt:

Operator Betekenis
(niks) Dit is de default. Het woord is optioneel, maar als er een rij gevonden wordt waar dit woord in staat, dan zal deze rij hoger scoren dan een identieke rij waar het woord niet in staat.
+ Dit betekent dat het woord waar het voor staat in de gevonden rij moet voorkomen.
- Dit betekent dat het woord waar het voor staat in de gevonden rij niet in de rij hoeft voor te komen.
> < Deze operators kun je gebruiken om de bijdrage van de zoekterm waar het voor staat groter of kleiner te maken. De > operator betekent dat de bijdrage van de zoekterm stijgt, terwijl < de bijdrage van de zoekterm doet dalen.
() Dit zorgt ervoor dat zoekterm gegroepeerd kunnen worden.
~ De tilde werkt als een negatieve operator, oftewel, de bijdrage van deze zoekterm aan de rij is negatief. Als er dus een rij wordt gevonden met dit woord erin zal deze lager scoren dan een identieke rij waar deze specifieke zoekterm niet in zit.
* Deze fungeert als een wildcard operator. Deze moet worden geplakt achter de zoekterm. Zo zal hond* ook honden matchen.
"" Een paar woorden die zijn omgeven door " en " betekenen dat er alleen naar deze exacte string gezocht mag worden.

De MySQL manual biedt een paar voorbeelden en ook deze nemen we voor het gemak maar even over:

Zoekterm Resultaat
appel banaan Vindt de rijen die i.i.g. één van deze woorden bevatten.
+appel + banaan Vindt de rijen die beide woorden bevatten.
+appel macintosh Vindt de rijden die het woord 'appel' bevatten, maar geeft een extra waardering als de rij ook 'macintosh' bevat.
+appel -macintosh Vindt de rijen die het woord 'appel' bevatten, maar niet 'macintosh'.
+appel ~macintosh Vindt de rijen die het woord 'appel' bevatten, maar als de rij ook het woord 'macintosh' bevat, zal de zoekscore van die rij lager zijn.
+appel +(>winst <strudel) Vindt de rijen die 'appel' en 'winst' of 'appel' en 'strudel' bevatten, maar rankt de eerste optie hoger dan de tweede.
+appel* Vindt de rijen die 'appel' bevat, maar ook de rijen die woorden bevatten als 'appels', 'appelmoes', 'appelscha' enz.
"appels zijn lekker" Vindt de rijen die de exacte zin "appels zijn lekker" bevatten.

User input

Tot nu toe heeft er veel informatie in dit artikel gestaan wat ook elders beschikbaar is. Waar ik echter op internet een stuk minder over kon vinden was de overgang van user input naar MySQL input. Laten we even een voorbeeld pakken om duidelijk te maken waarom dit een probleem is. Stel iemand zoekt op een database entry waar zowel het woord kat als hond in voorkomt. In dat geval kan hij dit invoeren:

kat AND hond

Als we dit direct in een MATCH() ... AGAINST stoppen, gebeurt er iets raars. Sowieso wordt AND gezien als stopwoord en verwijderd. Je krijgt de query MATCH() … AGAINST(kat hond). Dit zal echter resulteren in alle entries van de database waarin het woord kat en/of hond voorkomt. Dit terwijl je juist zocht naar alle entries waarin beide woorden voorkomen. De correcte zoekquery voor MySQL was +kat +hond geweest. Oftewel, de user input moet herschreven worden tot correcte MySQL input. Dit valt uiteraard weg als de gebruiker prima MySQL input intypt, maar van die gebruikers zijn er helaas te weinig.

User input herschrijven

Om dit op te lossen, zullen we als toetje van dit artikel over MySQL eens een mooie PHP code schrijven. De volgende vier voorbeelden zouden moeten werken op een intuïtieve manier:

User input Gewenste resultaten MySQL input
kat AND hond Resultaten waarin zowel 'kat' als 'hond' voorkomt. +kat +hond
kat OR hond Resultaten waarin of 'kat' of 'hond' voorkomt (of beiden). kat hond
(kat OR hond) AND varken Resultaten waarin i.i.g. 'varken' voorkomt en 'kat' en/of 'hond'. +(kat hond) + varken
"kat hond" Resultaten waarin de letterlijke string 'kat hond' voorkomt. "kat hond"

Omdat dit artikel in eerste instantie niet over PHP, maar over MySQL gaat, zal ik niet de hele code uitleggen. De code staat hieronder (klik hier voor een werkend voorbeeld), maar misschien is het wel goed om een paar gemaakte keuzes toe te lichten.

  1. Allereerst was het een uitdaging om alles tussen ( en ) of tussen " en " als één term te zien. Dit heb ik voor elkaar gekregen door alle witruimte tussen ( en ) te vervangen door een | en alle witruimte tussen een " en een " door een ~. Deze twee tekens (| en ~) kunnen hiermee niet als input gegeven worden. Erg handig is het misschien niet om de ~ te gebruiken hiervoor, omdat dit ook een standaard MySQL operator is. Maar we gingen er van uit dat onze bezoekers de MySQL syntax niet kenden, dus dan is het weer geen probleem.
  2. Vervolgens is het belangrijk te beseffen dat als iemand hond kat invoert hij bedoelt hond AND kat. Oftewel, tussen elke twee termen waar geen operator staat, moet de operator AND geplaatst worden.
  3. Indien iemand per abuis een zoekterm als hond AND OR kat invoert, is dit uiteraard corrupte invoer. Om echter hier toch iets mee te doen, wordt elke tweede (derde, vierde, enz.) operator verwijderd. Beter is het natuurlijk de bezoeker te vermelden dat hij rare dingen invoert, maar omdat ik in dit geval geen werkende module maak, maar slechts een stuk code presenteer heb ik dat vanwege de overzichtelijkheid achterwege gelaten.
  4. In de meeste gevallen zal het niet nodig zijn om de operators AND of OR weg te halen, maar er zijn gevallen te bedenken waarin het wel nodig is. Daarom halen we aan het eind deze woorden maar weg, ook al hebben we daar officieel de stopwoordenlijst voor.

<?php
function getMySQLZoekterm($zoekterm){

   
// Stripslashes, indien nodig
   
$zoekterm  = (get_magic_quotes_gpc == stripslashes($zoekterm) : $zoekterm);

   
// Vervang de spaties tussen " en " door een |
   
if (preg_match_all('/\"(.*?)\"/'$zoekterm$matchesPREG_SET_ORDER)) {
        foreach(
$matches as $match) {
           
$zoekterm str_replace($match[0], str_replace(' ''|'$match[0]), $zoekterm);
        }
    }

   
// Vervang de spaties tussen ( en } door een ~
   
if (preg_match_all('/\((.*?)\)/'$zoekterm$matchesPREG_SET_ORDER)) {
        foreach(
$matches as $match) {
           
$zoekterm str_replace($match[0], str_replace(' ''~'$match[0]), $zoekterm);
        }
    }

   
// Stop de zoektermen in een array
   
$zoektermTemp preg_split("/[\s,]+/"$zoekterm);

   
// Doorloop de zoektermen, op zoek naar dubbele keywords achter elkaar
   
$aantalAND     0;
   
$aantalOR   0;
   
$zoekterm = array();
   
$i 0;
    while(list(
$key$val) = each($zoektermTemp)){

        if (
strtoupper($val) == "AND" OR strtoupper($val) == "OR"){

           
// Als de term hiervoor ook al een operator is, deze verwijderen
           
if ($key != 0){
                if (!(   
strtoupper($zoektermTemp[$key-1]) == "AND" OR
                       
strtoupper($zoektermTemp[$key-1]) == "OR")){
                   
$zoekterm[$i] = strtoupper($val);
                   
$i++;

                   
// Tel aantal AND en OR's die overblijven
                   
if (strtoupper($val) == "AND"){
                       
$aantalAND++;
                    }else{
                       
$aantalOR++;
                    }
                }
            }

        }else{

           
// Als de vorige term geen operator was, moet er nu een AND tussen
           
if ($i 0){
                if (
$zoekterm[$i-1] != "AND" AND $zoekterm[$i-1] != "OR"){
                   
$zoekterm[$i] = "AND";
                   
$i++;
                }
            }

           
// Zoekterm toevoegen
           
$zoekterm[$i] = $val;
           
$i++;

        }

    }

   
// Doorloop de zoektermen, op zoek naar een AND
   
while(list($key$val) = each($zoekterm)){

        if (
strtoupper($val) == "AND"){

           
// De term voor en na deze term zijn verplicht
           
if ($key != 0){
               
// Voorkom een dubbel plusje
               
if (substr($zoekterm[$key-1], 01) != "+"){
                   
$zoekterm[$key-1] = "+" $zoekterm[$key-1];
                }
            }
            if (
$key != count($zoekterm) - 1){
               
$zoekterm[$key+1] = "+" $zoekterm[$key+1];
            }

        }

    }

   
// Als er AND én OR in de zoektermen voorkomt, moeten er ronde haken om het AND-deel
   
if ($aantalAND && $aantalOR 0){

       
reset($zoekterm);

        while(list(
$key$val) = each($zoekterm)){

           
// Openingshaak: (
           
if ($key != count($zoekterm) - 1){
                if (
$zoekterm[$key+1] == "AND"){
                   
$zoekterm[$key] = "(" $zoekterm[$key];
                }
            }

           
// Sluithaak: )
           
if ($key != 0){
                if (
$zoekterm[$key-1] == "AND"){
                   
$zoekterm[$key] = $zoekterm[$key] . ")";
                }
            }

        }

    }

   
// Haal de | en ~ weer weg
   
$zoekterm str_replace("|",     " ",     $zoekterm);
   
$zoekterm str_replace("~",     " ",     $zoekterm);

   
// Haal handmatig de AND en OR weg
   
$zoekterm str_replace("AND",     "",     $zoekterm);
   
$zoekterm str_replace("OR",     "",     $zoekterm);

   
// Plak de zoekterm weer aan elkaar
   
$zoekterm implode(" "$zoekterm);

    return 
$zoekterm;

}
?>

PS: Het is bekend dat deze code voor complexere queries fout kan gaan. Een betere code wordt gewaardeerd.

Conclusie

Aan de hand van de fulltext search mogelijkheid van MySQL en de PHP code zoals deze hierboven staat, is het mogelijk een goed werkende zoekmachine te maken. In de praktijk zul je nog wat haken en ogen tegen komen, maar ik hoop dat dit artikel je het nodige aan houvast geeft.

DennisWijnberg
Avatar van DennisWijnberg
Aantal berichten: 207

Erg goed artikel. Duidelijk als je even snel wat wil maken.

Echter, de wildcardoptie vertoont bij mij weinig leven. Als ik zoek op "Product*" en het record heeft als waarde "Producten" dan krijg ik nul terug als waarde. Als ik op "Producten" zoek, dan krijgt hij wel een waarde terug.

Iemand ervaring hiermee?

Wouter
Aantal berichten: 45

Mijn ervaring is tevens dat Fulltext een zeer grote performance-winst oplevert t.o.v. gebruik te maken van LIKE.