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:
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:
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')
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).
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)
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)
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:
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. |
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.
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.
<?php
function getMySQLZoekterm($zoekterm){
// Stripslashes, indien nodig
$zoekterm = (get_magic_quotes_gpc == 0 ? stripslashes($zoekterm) : $zoekterm);
// Vervang de spaties tussen " en " door een |
if (preg_match_all('/\"(.*?)\"/', $zoekterm, $matches, PREG_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, $matches, PREG_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], 0, 1) != "+"){
$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 > 0 && $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.
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.
16 april, 2007 - 10:57 | login of registreer om reactie te posten | |
|---|---|
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? |
1 juli, 2007 - 09:47 | login of registreer om reactie te posten | |
|---|---|
| Wouter Aantal berichten: 45 |
Mijn ervaring is tevens dat Fulltext een zeer grote performance-winst oplevert t.o.v. gebruik te maken van LIKE. |