LIKE skaliert nicht
Wer in SQLite schon mal nach Text gesucht hat, hat vermutlich zu LIKE '%word%' gegriffen. Bei kleinen Tabellen geht das gut, bei großen bricht es zusammen. Es gibt keinen Index, der hier helfen könnte – SQLite muss jede Zeile durchgehen, kleinschreiben und auf den Teilstring prüfen. Wortgrenzen, Ranking, Mehrwortabfragen und Präfixsuche darfst du dann auch noch selbst bauen.
Genau dafür gibt es die SQLite Volltextsuche mit FTS5. Es handelt sich um einen virtuellen Tabellentyp, der einen invertierten Index über deine Textspalten pflegt, eine kleine Abfragesprache versteht und die Treffer per BM25 sortiert. FTS5 ist standardmäßig in SQLite enthalten – du musst keine Extension installieren.
FTS5 Virtual Table erstellen
Eine FTS5-Tabelle legst du mit CREATE VIRTUAL TABLE ... USING fts5(...) an und gibst dabei die Textspalten an, die indiziert werden sollen:
Drei Dinge sind hier wichtig. Die Spalten haben keine Typen – FTS5 behandelt alles als Text. Der MATCH-Operator bezieht sich auf den Tabellennamen (posts MATCH ...), nicht auf eine einzelne Spalte. Und die Suche ist case-insensitive und tokenisiert, das heißt 'sqlite' findet SQLite in jeder beliebigen Zeile.
Die Abfragesprache des MATCH-Operators
MATCH versteht weit mehr als nur ein einzelnes Wort. Der Suchstring folgt einer eigenen, kleinen Grammatik:
Was die einzelnen Suchanfragen machen:
'fts5 AND prefix'— beide Tokens müssen vorkommen (in beliebiger Reihenfolge, an beliebiger Stelle der Zeile).'"keep fts"'— exakte Phrase in genau dieser Reihenfolge.'trig*'— Präfixsuche, trifft auftrigger,triggers,trigonometry...'index NOT trigger'— enthältindex, aber nichttrigger.
Du kannst die Suche auch auf eine bestimmte Spalte einschränken, etwa mit column:term, also zum Beispiel 'title:sqlite'. Die komplette Grammatik unterstützt zusätzlich Klammern zur Gruppierung und OR für Alternativen — also genau das, was man von einer Suchmaschine gewohnt ist.
Ranking mit BM25
Standardmäßig hängt FTS5 jeder Zeile eine versteckte Spalte rank an. Darin steckt der BM25-Relevanzwert — je kleiner die Zahl, desto besser der Treffer. Sortiere danach, um die relevantesten Ergebnisse zuerst zu bekommen:
Wenn du einzelne Spalten stärker gewichten willst als andere, übergibst du bm25() einfach passende Gewichte – eines pro Spalte, in der Reihenfolge der Deklaration:
Der erste Beitrag gewinnt, weil sqlite im title auftaucht (10-fach gewichtet) und nicht nur im body (1-fach gewichtet). Welche Gewichte sinnvoll sind, hängt davon ab, wie deine App tatsächlich ranken soll.
Den Index synchron halten
Die einfachste FTS5-Tabelle speichert eine eigene Kopie des Textes. Für reine Log-Daten, in die du nur einfügst, reicht das völlig aus. In den meisten Apps gibt es aber bereits eine richtige Tabelle, und FTS soll dieser folgen. Das saubere Muster dafür ist eine External-Content-FTS5-Tabelle in Kombination mit drei Triggern.
content='articles' weist FTS5 an, den Text nicht selbst zu speichern – stattdessen wird er bei Bedarf aus der Tabelle articles geholt. Die Trigger spiegeln dann jede Schreiboperation in den FTS-Index. Damit ist articles die Single Source of Truth, und articles_fts daneben nur noch die Suchstruktur. Das ist das klassische Muster für External Content FTS5.
Die etwas seltsam wirkende Zeile INSERT INTO articles_fts(articles_fts, ...) VALUES ('delete', ...) ist übrigens die Befehlssyntax von FTS5, mit der man dem Index mitteilt, dass eine Zeile entfernt werden soll.
Snippets und Hervorhebung von Treffern
In Suchergebnissen möchte man meist eine Vorschau anzeigen, in der die Suchbegriffe hervorgehoben sind. Genau dafür stellt FTS5 zwei Funktionen bereit:
highlight(table, column_index, open, close)liefert den vollständigen Spaltentext zurück, wobei die Treffer-Tokens umschlossen werden.snippet(table, column_index, open, close, ellipsis, token_count)liefert einen kurzen Textausschnitt rund um den Treffer.
Spaltenindizes sind nullbasiert und folgen der Reihenfolge der Deklaration. Damit hast du die Bausteine für das klassische "Treffer in Gelb"-Verhalten, das jede Such-UI früher oder später braucht.
Stolperfallen, die du kennen solltest
Ein paar Dinge, über die viele anfangs stolpern:
MATCHfunktioniert nur auf FTS-Tabellen. Auf eine normale Spalte kannst duMATCHnicht anwenden. Wenn du eine bestehende Tabelle durchsuchbar machen willst, ist der oben gezeigte Ansatz mit external content fts5 der richtige Weg.- Vergiss nicht, nach
rankzu sortieren. OhneORDER BY rankgibt FTS5 die Zeilen in Speicherreihenfolge zurück — und die hat mit Relevanz nichts zu tun. - Der Tokenizer macht den Unterschied. Der Standard-Tokenizer (
unicode61) trennt an Unicode-Wortgrenzen und schreibt alles klein. Für Stemming (also damitrunauchrunningfindet) nimmst du den Porter-Tokenizer:USING fts5(body, tokenize='porter'). - FTS5 verzeiht keine Tippfehler. Es macht Präfixsuche, keine Fuzzy-Suche. Wenn du "Meintest du …?" brauchst, ist das eine Schicht oberhalb von FTS5.
- Contentless-Tabellen (
content='') sind kleiner, aber verlustbehaftet. Du kannst darin suchen, aber den Originaltext nicht zurückbekommen — nur die rowid. Praktisch, wenn der eigentliche Text woanders liegt.
Als Nächstes: Window Functions
Damit ist das Thema Volltextsuche abgedeckt. Auf der nächsten Seite geht es um eine ganz andere Art fortgeschrittener Abfragen — Window Functions. Damit berechnest du laufende Summen, Rankings und gruppenweise Analysen, ohne dass deine Zeilen zu Aggregaten zusammenfallen.
Häufig gestellte Fragen
Was ist FTS5 in SQLite?
FTS5 ist die mitgelieferte Volltextsuch-Erweiterung von SQLite. Du legst dir mit CREATE VIRTUAL TABLE ... USING fts5(...) eine spezielle Virtual Table an und durchsuchst sie über den MATCH-Operator. Beim Insert wird der Text tokenisiert, in einem Inverted Index abgelegt und beim Suchen standardmäßig per BM25 gerankt.
Worin unterscheidet sich MATCH von LIKE?
LIKE läuft als linearer Substring-Scan durch die Tabelle und kennt keine Wortgrenzen. MATCH greift dagegen direkt auf den Inverted Index von FTS5 zu — also auch bei Millionen Zeilen schnell — und versteht echte Token, Präfix-Suchen (term*), boolesche Operatoren (AND, OR, NOT) sowie Phrasen-Suchen ("genauer ausdruck"). MATCH funktioniert dabei nur auf FTS-Virtual-Tables.
Wie halte ich den FTS5-Index mit meiner eigentlichen Tabelle synchron?
Zwei saubere Wege: Entweder du nutzt eine contentless oder external-content FTS5-Tabelle, die direkt auf deine Originaltabelle verweist, oder du legst AFTER INSERT-, AFTER UPDATE- und AFTER DELETE-Trigger an, die jede Änderung in den Index spiegeln. Mit dem External-Content-Pattern (content='posts') sparst du dir das doppelte Speichern des Texts.
Wie ranke ich Treffer der Volltextsuche?
FTS5 stellt eine versteckte Spalte rank bereit, die einen BM25-Score liefert (kleiner = besser). Du kannst direkt danach sortieren: ORDER BY rank. Alternativ rufst du den Score explizit mit bm25(table) ab, oder du gewichtest einzelne Spalten — z. B. bm25(posts, 10.0, 1.0), um den Titel deutlich höher zu gewichten als den Body.