Hoe gefacetteerd zoeken in Elasticsearch echt werkt (en waar het misgaat)
Een facet is twee vragen, niet één
Een gefacetteerde zoekpagina stelt twee verschillende vragen tegelijk. De eerste is voor de hand liggend: welke documenten komen overeen met de huidige zoekopdracht en de al toegepaste filters. De tweede wordt één keer gesteld voor elke beschikbare filterwaarde: hoeveel documenten zouden overeenkomen als die waarde ook werd geselecteerd. Het vakje “Rood (12)” is het antwoord op de tweede vraag, berekend terwijl de eerste wordt beantwoord.
Het meeste van wat gefacetteerd zoeken moeilijk maakt, in Elasticsearch zoals elders, komt voort uit deze tweede vraag. De lijst met overeenkomende documenten opvragen is niet ingewikkeld. Een eerlijke telling naast elke waarde van elk filter krijgen, bij elke paginaweergave, zonder dat de responstijd explodeert naarmate het aantal facetten groeit: dat is het echte technische probleem.
De basis: een terms-aggregatie op een gefilterde verzameling
Elasticsearch beantwoordt beide vragen met één enkele query. De querycomponent beperkt de verzameling documenten (volledige-tekstovereenkomst plus de al actieve filters), en een reeks aggregaties die aan diezelfde query is gekoppeld, berekent voor elk facetveld hoeveel documenten van die gefilterde verzameling in elke waarde vallen. Een terms-aggregatie op een veld color geeft één bucket terug per unieke waarde met het aantal documenten; een range- of histogram-aggregatie doet hetzelfde voor numerieke velden zoals de prijs. Dit alles gebeurt in dezelfde uitvoering van de query, op dezelfde gefilterde verzameling, in één enkele heen-en-terugreis.
Dit is het punt dat gemakkelijk wordt gemist als u alleen SQL hebt gebruikt: een aggregatie is hier geen tweede query op het resultaat van de eerste. Ze draait op de doc values en de postings van de index tijdens dezelfde query, en daarom betekent twintig facetten extra op een pagina niet twintig extra volledige doorlopen van de overeenkomende documenten. Het betekent twintig extra berekeningen van buckets op gegevens die de zoekmotor toch al moest raadplegen om de query te beantwoorden.
Filtercontext, niet querycontext
Facetten, en de filters die door erop te klikken worden toegepast, horen bijna altijd in de filtercontext te staan, niet in de querycontext. Een term-filter op brand: acme hoeft geen relevantiescore te berekenen, het heeft een ja-of-nee-antwoord nodig, en Elasticsearch cachet filtercomponenten veel agressiever dan queryclausules met score. Een gefacetteerde zoekpagina alleen opbouwen met bool-clausules must die allemaal een score krijgen, is een gebruikelijke manier om haar trager te maken dan nodig zonder enig voordeel: niemand sorteert een categoriepagina op “relevantie ten opzichte van het filter Rood”. De filters horen in filter, het zoeken in volledige tekst in must of in een aparte match-query, en deze scheiding is niet cosmetisch: ze verandert wat wordt gecachet en wat een score krijgt.
De valkuil van meervoudige selectie
Het detail dat meer implementaties van gefacetteerd zoeken breekt dan enig ander, is dit: wanneer u de telling van een facet berekent, moet u het filter van dat facet zelf uitsluiten van de verzameling documenten waarop u telt, terwijl u alle andere actieve filters behoudt. Anders toont elk facet waarop u al hebt gefilterd een telling gelijk aan het aantal resultaten dat nu op het scherm staat, omdat u de doorsnede telt met een voorwaarde die al volledig is toegepast. Klik op “Rood”, en elke andere waarde van “Kleur” zou dan ten onrechte hetzelfde cijfer tonen als het totaal, in plaats van het aantal producten dat echt rood is en ook die waarde heeft.
De gebruikelijke oplossing is een aggregatie per facetveld te bouwen, elk verpakt in een eigen filter-aggregatie die alle actieve filters opnieuw toepast behalve dat van dit veld, allemaal op hetzelfde niveau onder dezelfde hoofdquery. Concreet: de basisquery bevat al de zoekopdracht in volledige tekst en alle filters die niet worden geteld, en de aggregatie van elk facet voegt de andere geselecteerde filters toe, min het eigen filter. Dat zijn meer aggregaties per query, niet meer queries, en dit is het deel dat de moeite waard is om expliciet te testen in plaats van op het oog goed te keuren, omdat een pagina in een demo met drie producten perfect kan lijken en stilletjes onjuiste tellingen kan tonen zodra echte gegevens met echte overlappingen worden geladen.
Er is een tweede beslissing, verbonden met de eerste, die u beter bewust neemt: wat een groep facetten betekent wanneer meerdere waarden onder hetzelfde label samen worden geselecteerd. Als een webwinkel “Karmozijn”, “Scharlaken” en “Bordeaux” onder één filter “Rood” samenvoegt, betekent het selecteren van twee van die subwaarden dan “heeft dit EN dat” of “heeft dit OF dat”? Een telling die de EN-vraag beantwoordt terwijl de klik de OF-vraag toepast (of andersom) is de meest voorkomende oorzaak van een filter dat het ene cijfer toont en het andere oplevert. Beide moeten met exact dezelfde logica worden berekend, wat meestal neerkomt op het behandelen van de groep vanaf het begin als één enkele vereniging van voorwaarden, in plaats van tellingen per waarde op te tellen die elkaar kunnen overlappen of hetzelfde product twee keer kunnen tellen.
Waarom alles instort zonder aggregatiemotor, met een echt cijfer erbij
De bestaansreden van gefacetteerde zoekmotoren, in plaats van alles in SQL te doen, wordt duidelijk zodra u exacte tellingen per waarde probeert te krijgen op een grootschalige relationele database. Op een testcatalogus van 200.000 producten met de ingebouwde zoekmotor van PrestaShop, beperkt tot de database, met ongeveer 2.250 groepen attributen en kenmerken, kostte het berekenen van een exacte telling “hoeveel resultaten per waarde” met een COUNT(DISTINCT ...)-query per facet een categoriepagina met één actief filter 43 seconden. Het uitschakelen van de exacte tellingen voor die facetten (de lijst met waarden tonen zonder cijfer) bracht dezelfde pagina terug tot net onder 12 seconden, wat laat zien welk deel van de oorspronkelijke kosten aan het pure tellen lag, en niet aan het zoeken zelf.
Een verwante fout, aangetroffen bij een interne codereview van een module voor gefacetteerd zoeken, bestond uit het berekenen van het bereik van een numeriek facet (voor een schuifregelaar “Prijs” of “Gewicht”) door alle overeenkomende waarden in het geheugen van de applicatie te laden en daar het minimum en maximum te berekenen, in plaats van de database of de zoekmotor dat te laten doen. Op een catalogus waar dit ongeveer twee miljoen rijen betrof, nam alleen deze stap per query 11 seconden en 965 MB geheugen in beslag, wat op sommige configuraties volstond om de geheugenlimiet van een shared hosting uit te putten en een foutpagina terug te geven in plaats van een schuifregelaar. Dezelfde berekening verplaatsen naar een GROUP BY van de database, of naar de range-aggregatie van de zoekmotor, maakte er weer een normale, snelle query van, omdat dit precies het soort geaggregeerde berekening is dat deze systemen zijn ontworpen om in één keer uit te voeren op geïndexeerde gegevens, en niet achteraf in de applicatiecode.
De twee cijfers beschrijven dezelfde fundamentele fout op verschillende niveaus: “hoeveel, voor elke mogelijke waarde” één keer per waarde vragen in plaats van één keer voor alle waarden samen. Het aggregatieframework van Elasticsearch bestaat precies om deze vraag in één keer te beantwoorden op gegevens die al zijn geïndexeerd. Dat is de echte waarde ervan, los van de rangschikking op relevantie in volledige tekst, en daarom is gefacetteerde navigatie een van de meest voorkomende redenen om Elasticsearch te kiezen, ook wanneer het zoeken zelf geen geavanceerde relevantiefuncties nodig heeft.
Een korte checklist voordat u gefacetteerd zoeken live zet
- De filters horen in de filtercontext, de volledige tekst in de querycontext. Bereken geen score voor een selectievakje.
- De telaggregatie van elk facet moet het actieve filter van dat facet uitsluiten en alle andere behouden, anders gaan de facetten bij meervoudige selectie liegen zodra twee filters samen worden gebruikt.
- Beslis vanaf het begin, en test expliciet, of een groep waarden onder hetzelfde label met EN of met OF werkt, en laat de telling en de klik exact dezelfde logica delen.
- Beperk de grootte van de aggregaties en let op
doc_count_error_upper_boundbij velden met veel unieke waarden; een terms-aggregatie zonder limiet die duizenden buckets teruggeeft is een zelfstandig prestatieprobleem, los van de hierboven besproken kwestie van meervoudige selectie. - Bereken de cijfers van een facet nooit door ruwe rijen in het geheugen van de applicatie te laden. Betrapt u zich erop dat u een lus schrijft over de resultaten van een query om een telling of een bereik op te bouwen, dan hoort die lus thuis in de aggregatie, niet in uw code.
Wanneer Elasticsearch niet het antwoord is
Niets hiervan betekent dat elke catalogus Elasticsearch nodig heeft. Een webwinkel met enkele duizenden producten en een handvol facetten zal zelden het verschil merken dat een aparte zoekmotor maakt, en een cluster draaiend houden heeft eigen beheerkosten: geheugen, schijf, en iemand die weet hoe de heropbouw van de index moet worden beheerd wanneer het schema verandert. De cijfers hierboven gaan pas meetellen wanneer het aantal facetten, de omvang van de catalogus of beide groot genoeg worden om een naïeve telling in seconden in plaats van in milliseconden te meten. Bent u daar nog niet, dan is een goed geoptimaliseerde databasequery een volkomen redelijke keuze, en de checklist hierboven geldt daar ook: de EN/OF-kwestie en de valkuil van meervoudige selectie zijn hetzelfde probleem, welke zoekmotor het ook beantwoordt.
Kort samengevat
Gefacetteerd zoeken is geen query, het is een query plus een aggregatie per filter, en bijna alle echte bugs komen voort uit een afwijking van dit model: een score berekenen voor wat een filter hoort te zijn, een facet tellen met inachtneming van zijn eigen, al toegepaste voorwaarde, of de telling en de klik beantwoorden met twee verschillende definities van een groep. Het aggregatieframework van Elasticsearch lost het schaalprobleem netjes op, in één keer op geïndexeerde gegevens in plaats van één query per facetwaarde, en dat is de concrete reden waarom de cijfers van 43 en 11 seconden hierboven zijn ontstaan op een zoekmotor die beperkt is tot de database en niet op een zoekmotor die hiervoor is ontworpen. Wij doen dit werk dagelijks in cll_essearch, onze module voor gefacetteerd zoeken voor PrestaShop, zowel op de eigen driver als op Elasticsearch, OpenSearch en Meilisearch (zie de installatiegids), als u dezelfde logica wilt zien toegepast op een webwinkel in plaats van op een ruwe index.
Elasticsearch-zoekfunctie & gefacetteerde navigatieRazendsnelle zoekfunctie met typfouttolerantie en moderne gefacetteerde navigatie in een venster, met de…€ 99,99Bekijk product