Menu
Im Playground testen

SQLite Volltextsuche mit FTS5: MATCH & BM25

So baust du eine schnelle Volltextsuche in SQLite mit FTS5 — Virtual Tables anlegen, mit MATCH suchen, Ergebnisse per BM25 ranken und den Index sauber synchron halten.

Diese Seite enthält ausführbare Editoren — bearbeiten, ausführen und Ausgabe sofort sehen.

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 auf trigger, triggers, trigonometry...
  • 'index NOT trigger' — enthält index, aber nicht trigger.

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:

  • MATCH funktioniert nur auf FTS-Tabellen. Auf eine normale Spalte kannst du MATCH nicht anwenden. Wenn du eine bestehende Tabelle durchsuchbar machen willst, ist der oben gezeigte Ansatz mit external content fts5 der richtige Weg.
  • Vergiss nicht, nach rank zu sortieren. Ohne ORDER BY rank gibt 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 damit run auch running findet) 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.

Coddy programming languages illustration

Lerne mit Coddy zu programmieren

LOS GEHT'S