MWCodebymw.de ↗
SQL

Volltextsuche in SQLite mit FTS5 – schneller und besser als LIKE '%wort%

LIKE '%suchwort%' kann keinen Index nutzen und liest bei jeder Suche die ganze Tabelle. FTS5 ist in SQLite eingebaut, findet auch Wortanfänge und sortiert die Treffer nach Relevanz – ohne Elasticsearch daneben.

Eine Suche über Blogbeiträge, Notizen oder Produktbeschreibungen fängt fast immer so an:

SELECT * FROM beitraege
WHERE titel LIKE '%rechnung%' OR text LIKE '%rechnung%';

Das funktioniert und ist bei zweihundert Zeilen völlig in Ordnung. Zwei Dinge stören trotzdem: Ein Index hilft hier nie (das führende % macht ihn unbrauchbar, es wird immer die ganze Tabelle gelesen), und die Treffer kommen in beliebiger Reihenfolge — ein Beitrag, in dem das Wort einmal am Rand vorkommt, steht gleichauf mit einem, der davon handelt.

SQLite bringt für genau das ein eigenes Modul mit. Ohne Zusatzinstallation.

Die Suchtabelle anlegen

CREATE VIRTUAL TABLE beitraege_fts USING fts5(
    titel,
    text,
    content = 'beitraege',      -- Inhalt bleibt in der Originaltabelle
    content_rowid = 'id'
);

content= ist der Teil, den ich immer setze: Damit speichert FTS5 den Text nicht noch einmal, sondern nur den Suchindex. Deine Datenbank wächst dadurch deutlich weniger.

Den Index aktuell halten

Weil der Inhalt in der Originaltabelle liegt, muss FTS5 über Änderungen informiert werden. Das erledigen drei Trigger, die man einmal schreibt und dann vergisst:

CREATE TRIGGER beitraege_ai AFTER INSERT ON beitraege BEGIN
    INSERT INTO beitraege_fts(rowid, titel, text) VALUES (new.id, new.titel, new.text);
END;

CREATE TRIGGER beitraege_ad AFTER DELETE ON beitraege BEGIN
    INSERT INTO beitraege_fts(beitraege_fts, rowid, titel, text)
    VALUES ('delete', old.id, old.titel, old.text);
END;

CREATE TRIGGER beitraege_au AFTER UPDATE ON beitraege BEGIN
    INSERT INTO beitraege_fts(beitraege_fts, rowid, titel, text)
    VALUES ('delete', old.id, old.titel, old.text);
    INSERT INTO beitraege_fts(rowid, titel, text) VALUES (new.id, new.titel, new.text);
END;

Das Löschen sieht seltsam aus — man schreibt 'delete' in die Tabelle hinein — ist aber genau die vorgesehene Schreibweise.

Für den bestehenden Bestand einmalig:

INSERT INTO beitraege_fts(beitraege_fts) VALUES ('rebuild');

Suchen

SELECT b.id, b.titel
FROM beitraege_fts f
JOIN beitraege b ON b.id = f.rowid
WHERE beitraege_fts MATCH 'rechnung'
ORDER BY rank;              -- die besten Treffer zuerst

ORDER BY rank ist der Punkt, an dem FTS5 LIKE deutlich überlegen ist: Ein Beitrag, in dem das Wort im Titel und mehrfach im Text steht, landet oben.

Die Suchsprache kann noch mehr:

MATCH 'rechnung*'                 -- Wortanfang: rechnung, rechnungen, rechnungsnummer
MATCH 'rechnung AND 2026'         -- beides muss vorkommen
MATCH 'titel : rechnung'          -- nur in der Titel-Spalte
MATCH '"offene rechnung"'         -- exakt diese Wortfolge

Und die Fundstelle anzeigen

SELECT snippet(beitraege_fts, 1, '<mark>', '</mark>', '…', 12) AS auszug
FROM beitraege_fts WHERE beitraege_fts MATCH 'rechnung' ORDER BY rank;

snippet liefert den Textausschnitt rund um den Treffer, mit markiertem Suchwort. Das ist die Sorte Detail, für die man sonst eine halbe Stunde Anwendungscode schreibt.

Wann es sich lohnt

Unter ein paar tausend Zeilen ist LIKE völlig ausreichend, und die drei Trigger wären nur Ballast. Sobald aber eine echte Suchfunktion für Nutzer entsteht — mit Relevanz, Wortanfängen und hervorgehobenen Fundstellen — ist FTS5 der Punkt, an dem man sich einen ganzen zusätzlichen Suchserver spart.

Du überlegst, ob dein Projekt eine richtige Suche braucht? Frag mich, ich sage dir ehrlich, ab wann sich der Aufwand lohnt.

Quellen

#SQLite#FTS5#Volltextsuche#Index#Performance

Du brauchst mehr als ein Snippet?

Ich entwickle Android-Apps in Kotlin und moderne Websites für Selbstständige und kleine Unternehmen — von der ersten Idee bis zum Release.

Projekt anfragen →