Text durchsuchen
Kurz gesagt: Die Volltextsuche findet Wörter im Text über einen invertierten Index, der zu jedem Wort die Zeilen speichert, die es enthalten, statt mit LIKE alles zu lesen.
Problem
Das Suchfeld
Die Seite bekommt ein Suchfeld für die Bewertungen. Die erste Idee ist LIKE. Ein Kunde sucht „äpfel“:
Nichts, obwohl Bewertung 5 „Saftige, süße Äpfel.“ lautet. In SQLite macht LIKE zwischen großen und kleinen Buchstaben nur beim lateinischen Alphabet ohne Umlaute keinen Unterschied, und Ä und ä sind verschieden. Eine andere Suche:
„rot“ findet auch „Vollkornbrot“, weil LIKE nach einem Stück des Textes sucht, nicht nach einem Wort. Und % am Anfang schließt einen Index aus: Bei einer Million Bewertungen liest die Datenbank für jede Suche eine Million Texte.
Genauer, für Neugierige
Dasselbe passiert mit Ö, Ü und im Griechischen oder Bulgarischen mit jedem großen Buchstaben: LIKE '%öl%' übersieht „Öl“. Der Volltextindex unten macht alle Buchstaben klein, auch diese, und streift außerdem die Akzente und Umlautpunkte ab.
Die Idee
Der invertierte Index
Die Volltextsuche macht das Gegenteil der Tabelle. Die Tabelle sagt „Bewertung 3 enthält die Wörter der, beste, kaffee, im, viertel“. Der invertierte Index sagt „das Wort kaffee steht in den Bewertungen 3, 6 und 13“. Ein Wort zu suchen ist ein Lesen im Index, kein Durchgehen der Texte.
Der Text wird zuerst in Wörter zerlegt (ein Tokenizer). unicode61 schneidet an Leerzeichen und Satzzeichen, macht alle Buchstaben klein, in jedem Alphabet, und entfernt die Akzente. SQLite hält den Index in einer virtuellen Tabelle mit fts4:
docid ist die Nummer der Zeile im Index. Hier ist es die Nummer der Bewertung, damit er sich mit reviews verbinden lässt. MATCH ist die Suche im Index, nicht = oder LIKE. Und jetzt wird „Äpfel“ gefunden, das LIKE übersehen hat: „äpfel“, „Äpfel“ und „apfel“ sind im Index dasselbe Wort.
So sehen die Wörter im Index aus (fts4aux ist eine Tabelle, die die Wörter zeigt und in wie vielen Dokumenten jedes steht):
Beachte kase: Das ä ist weg, „Käse“ ist im Index ohne Umlaut gespeichert.
Die Idee
Wortformen
Im Index sind „frisch“ und „Frische“ zwei verschiedene Wörter. Die einfachste Hilfe ist ein Präfix: frisch* bedeutet jedes Wort, das mit „frisch“ beginnt:
Ein Präfix ist grob: frisch* würde auch „frischgebacken“ finden. Echte Suchmaschinen haben für jede Sprache Analysatoren, die Wörter auf den Stamm zurückführen (Stemming): „Tomaten“ wird zu „Tomate“. SQLite hat so etwas nur für Englisch (tokenize=porter), Elasticsearch und OpenSearch haben es für viele Sprachen. Im Deutschen kommen die zusammengesetzten Wörter dazu: „Vollkornbrot“ ist ein Wort, und weder brot noch brot* findet es. Gute deutsche Analysatoren zerlegen es in „voll“, „korn“ und „brot“, und das kann kein Präfix.
Probier es aus
Tomaten
Finde im Index alle Bewertungen über Tomaten, in welcher Form das Wort auch steht (die Spalten docid und body).
Lösung
SELECT docid, body FROM review_search WHERE review_search MATCH 'tomate*';MATCH 'tomate*'. Nur 'tomate' findet nichts: In den Texten steht das Wort als „Tomaten“.
Die Idee
Mehr als ein Wort
Zwei Wörter hintereinander bedeuten AND: Die Bewertung muss beide haben. OR bedeutet mindestens eins davon:
Eine Wortgruppe in Anführungszeichen sucht die Wörter direkt nebeneinander und in dieser Reihenfolge. NEAR/3 sucht die zwei Wörter höchstens 3 Wörter auseinander:
NOT entfernt die Dokumente mit einem bestimmten Wort: kaffee NOT abgelaufen.
Genauer, für Neugierige
FTS4 hat zwei Abfragesyntaxen. In der Standardsyntax schreibt man „ohne das Wort“ mit einem Minus (kaffee -abgelaufen), in der erweiterten mit NOT. sql.js, das SQLite im Browser ausführt, nutzt die erweiterte, und dort ist -abgelaufen einfach das Wort „abgelaufen“. Die erweiterte erlaubt auch Klammern: (äpfel OR tomaten) NOT zerrissen. In Elasticsearch ist dasselbe eine bool-Abfrage mit must, should und must_not.
Probier es aus
Kaffee ohne Beschwerden
Finde die Bewertungen über Kaffee, die nicht „abgelaufen“ erwähnen (die Spalten docid und body).
Lösung
SELECT docid, body FROM review_search WHERE review_search MATCH 'kaffee NOT abgelaufen';MATCH 'kaffee NOT abgelaufen'. Mit -abgelaufen suchst du in sql.js die Bewertungen, die Kaffee und abgelaufen haben, also genau das Gegenteil.
Die Idee
Ausschnitte und Reihenfolge
Eine Suchmaschine zeigt einen Ausschnitt mit markiertem Wort. snippet macht das von selbst:
Die ersten Treffer sollten die relevantesten sein. Echte Suchmaschinen ordnen sie nach BM25: Je seltener das Wort in allen Dokumenten und je öfter es in diesem steht, desto höher, und ein kurzer Text mit dem Wort schlägt einen langen. Die Tabelle fts4aux aus der Karte zum Index liefert dafür die Zahlen: in wie vielen Dokumenten jedes Wort steht. FTS4 ordnet nicht von selbst (FTS5 und Elasticsearch haben BM25 eingebaut), deshalb kommt die Reihenfolge hier aus ORDER BY.
Übung
Suchen mit den Produkten
1. Der Index hält nur den Text. Der Name des Produkts und die Note stehen in den Tabellen.
Finde die Bewertungen über Kaffee mit dem Namen des Produkts und der Note (die Spalten name, rating und body), von der höchsten Note abwärts.
Lösung
SELECT p.name, r.rating, r.body
FROM review_search s
JOIN reviews r ON r.id = s.docid
JOIN products p ON p.id = r.product_id
WHERE review_search MATCH 'kaffee'
ORDER BY r.rating DESC;JOIN reviews r ON r.id = s.docid, dann JOIN products, und MATCH 'kaffee' im WHERE. Deshalb ist docid die Nummer der Bewertung. ORDER BY r.rating DESC stellt die beste nach vorn; ohne DESC kommt die Bewertung mit Note 1 zuerst.
2. Der Index ist eine eigene Tabelle und weiß nicht von selbst, wann sich reviews ändert. Ein Trigger aktualisiert ihn bei jeder neuen Bewertung:
Schreibe einen Trigger review_removed, der die Bewertung aus review_search entfernt, wenn sie aus reviews gelöscht wird.
Lösung
CREATE TRIGGER review_removed AFTER DELETE ON reviews
BEGIN
DELETE FROM review_search WHERE docid = old.id;
END;AFTER DELETE ON reviews und DELETE FROM review_search WHERE docid = old.id. Beim Löschen gibt es nur die Zeile old: new gibt es bei INSERT und UPDATE. Die Prüfung löscht Bewertung 13 und sucht noch einmal: Sie darf nicht mehr auftauchen. Vollständig braucht es auch einen Trigger für ein UPDATE des Textes.
Die Idee
Wann eine eigene Suchmaschine
Für die Suche in ein paar tausend oder Millionen Texten reicht der Volltextindex der Datenbank: FTS in SQLite, tsvector und ein GIN-Index in PostgreSQL, FULLTEXT in MySQL. Eine eigene Suchmaschine (Elasticsearch, OpenSearch, Meilisearch, Typesense) lohnt sich, wenn die Suche die Hauptaufgabe der Seite ist: Analysatoren für viele Sprachen, Korrektur von Tippfehlern („Kafee“ → „Kaffee“), Filter nach Kategorie mit den Anzahlen daneben, Vorschläge beim Tippen und viele Maschinen. Dann werden die Daten dorthin kopiert, meist aus einem Ereignisstrom, und die Wahrheit bleibt in der Datenbank.
Genauer, für Neugierige
In PostgreSQL ist dieselbe Suche WHERE to_tsvector('german', body) @@ to_tsquery('german', 'kaffee') mit dem Index CREATE INDEX ON reviews USING gin (to_tsvector('german', body)). In Elasticsearch bekommt das Feld "analyzer": "german", und die Suche ist {"query": {"match": {"body": "kaffee"}}}.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: INSERT: Zeilen einfügen · DELETE: Zeilen löschen · Trigger · Indizes
- Geht weiter in: Suchen nach Bedeutung · Wie du eine Datenbank wählst
- Aufgaben zum Thema: Arten von Datenbanken