Maßgeschneiderte FileMaker-Lösungen & AddOns. FileMaker-experts.de

# Volltext-Suche über 100 000 Mails- und sie reagiert in 50 Millisekunden

Wie wir den klassischen FileMaker-WebViewer-Suchpatterns einen modernen Such-Backend untergeschoben haben, ohne die FileMaker-Datei selbst zu berühren. Mit einer SQLite-Datenbank, die schon im System lag. Mit einer Eingebauten von SQLite, die kaum jemand kennt. Und mit einer 2-Stunden-Implementierung, die jetzt für Datenbestände von 100 000 bis 10 Millionen Mails ausreicht, bei gleichbleibend sub-100-ms-Reaktion auf jeden Tastendruck.*


Bildschirmfoto 2026-05-19 um 12.31.21.

Das Problem

Im Mail-System eines Kunden lagen nach dem großen Legacy-Import knapp 96 000 Mails. Das vorhandene Suchpattern war ein bewährter FileMaker-WebViewer-Trick: Eine gespeicherte Berechnung pro Datensatz baut ein HTML-Schnipsel (<div class="treffer" data-search="…">…</div>), eine zweite ungespeicherte Berechnung verknüpft alle Schnipsel zu einer großen HTML-Seite mit Suchfeld, der WebViewer rendert sie als data:-URL, JavaScript filtert clientseitig per data-search-Attribut.

Bei 5 000 Datensätzen ist das magisch: man tippt, die Liste filtert sich sofort, kein Server-Roundtrip, kein Lag. Bei 10 000 noch ok. Bei 92 000 stirbt es.

Konkrete Zahlen aus der Diagnose:

  • Liste() über 96 000 Datensätze: 10–30 Minuten pro Aufruf (Oder FileMaker gibt auf)
  • Generiertes HTML: ~75 MB als String
  • data:-URL: bei manchen WebKit-Versionen über 64 MB einfach gekappt
  • 92 000 DOM-Knoten + JS querySelectorAll pro Tastendruck: mehrere hundert Millisekunden zähes Laggen

Kurz: Das Pattern skaliert auf 5–10 k Datensätze. Bei 92 k bricht es.

Die Optionen

Drei klassische Wege:

A — Vorfilter. Statt alle Mails zu zeigen, immer nur eine sinnvolle Teilmenge (letzte 90 Tage, Mails eines Kontakts). Reduziert auf <2 000 Datensätze, dann funktioniert das alte Pattern wieder. Pragmatisch, aber zwingt den User in eine starre Vorfilter-Logik.

B — Klassische FileMaker-Suche. Suchfeld → ExecuteSQL → Ergebnismenge in Listenansicht. Skaliert auf Millionen, verliert aber das schöne Live-Filter-Gefühl.

C — Server-side-Index. Ein eigener Such-Endpoint im PHP-Backend, der gegen eine speziell optimierte Such-Datenbank fragt, JSON zurückliefert, vom WebViewer per Fetch konsumiert. Skaliert auf Millionen, behält Live-Filter, kostet aber ein paar Stunden Aufbau.

Variante C wäre normalerweise Cadillac-Engineering. In dem Moment, als der Kunde fragte „haben wir nicht schon eine SQLite im System?", wurde sie zur Anderthalb-Stunden-Lösung.

Der entscheidende Aha-Moment

Der PHP-Mailclient hatte schon eine SQLite-Datei. Genauer: mailclient/data/links.sqlite, eingerichtet für die kleine Verknüpfungstabelle mail_links, die Mail-Message-IDs auf FileMaker-Entitäten (Kontakte, Projekte, Konten) abbildet. PDO-Treiber konfiguriert, db()-Helper definiert, Schema-Initialisierung idempotent. Alles da.

Und dann der zweite Aha: SQLite hat seit Version 3.9 einen eingebauten Volltext-Index namens FTS5. Eine virtuelle Tabelle, die Token-basierte Volltext-Suche über Millionen Datensätze in <50 ms macht. Genau das, was Apple Spotlight nutzt. Kostenlos, ohne Plugin, ohne externen Index-Service.

Ein schneller Check, ob FTS5 in der PHP-SQLite-Installation kompiliert ist:

$pdo = new PDO("sqlite::memory:");
$pdo->exec("CREATE VIRTUAL TABLE t USING fts5(x)");
$pdo->exec("INSERT INTO t VALUES ('hallo welt'), ('foo bar')");
$r = $pdo->query("SELECT x FROM t WHERE t MATCH 'hallo'")->fetchAll(PDO::FETCH_COLUMN);
// → ['hallo welt']

Ein „FTS5 verfügbar: ja" als Antwort, und das Projekt war von „2 Tage Aufwand" auf „2 Stunden" geschrumpft.

Die Architektur

FileMaker WebViewer                  PHP-Mailclient                SQLite
+-------------------+                +-------------------+         +----------------+
| <input> + JS      | fetch(q=...)   | /api/search_mails |  SQL    | mails_fts      |
| rendert Top 100   | <------------> | sucht im FTS5     | <-----> | (92k Mails)    |
+-------------------+    JSON        +-------------------+         +----------------+
        Tastendruck                          ^
                                             |  bei jedem neuen DS
                                             |
                                       /api/index_mail
                                       (von FM aufgerufen)

Drei neue PHP-Endpoints, eine FTS5-Tabelle, ein Python-Initial-Lauf, eine neue FileMaker-Berechnung mit ein paar Dutzend Zeilen JavaScript. Mehr nicht.

Die FTS5-Tabelle

CREATE VIRTUAL TABLE mails_fts USING fts5(
    pk_mail_id     UNINDEXED,
    message_id     UNINDEXED,
    betreff,
    body,
    absender,
    empfaenger,
    datum          UNINDEXED,
    id_contacts    UNINDEXED,
    id_account     UNINDEXED,
    z_eingang      UNINDEXED,
    thread_root_id UNINDEXED,
    tokenize = 'unicode61 remove_diacritics 2'
);

Zwei Details lohnen Erklärung:

UNINDEXED-Spalten werden gespeichert, aber nicht in den Volltext-Index aufgenommen. Sie sind als reine Lookup- und Filter-Felder gedacht. So bleibt der Index kompakt: nur das, was wirklich durchsuchbar sein muss (betreff, body, absender, empfaenger), trifft den Tokenizer.

remove_diacritics 2 im Tokenizer ist der versteckte Held: er normalisiert alle Akzente und Umlaute. Eine Suche nach „muller" findet „Müller", „Mueller" und „Müller". Bei einem multilingualen Mail-Archiv mit deutschen, französischen, türkischen Namen ist das nicht Nice-to-have, sondern Funktionalität.

Der Initial-Lauf

Bei 96 000 Mails wäre ein Datensatz-für-Datensatz-Index-Update über die FileMaker-Skript-Engine vermutlich ein Stundenprozess. Python machte es in 15 Sekunden:

import sqlite3, csv
conn = sqlite3.connect('mailclient/data/links.sqlite')
conn.execute("PRAGMA journal_mode = WAL")
conn.execute("PRAGMA synchronous = NORMAL")

for csv_file in [mailausgang_csv, maileingang_csv]:
    batch = []
    with open(csv_file, encoding='utf-8') as f:
        for row in csv.DictReader(f):
            batch.append((
                row['Message_ID'],
                row['Betreff'],
                row['Body_Plain'],
                row['Absender_mailadresse'],
                row['Empfaenger'],
                # …
            ))
            if len(batch) >= 1000:
                conn.executemany("INSERT INTO mails_fts (...) VALUES (?,?,...)", batch)
                conn.commit()
                batch = []

6 100 Datensätze pro Sekunde mit Batch-Inserts und WAL-Mode. Das Ergebnis: 91 935 Mails im Index, Datei-Größe der SQLite ~250 MB, Initial-Investment zum Aufbauen einmalig 15 Sekunden, danach für immer verfügbar.

Der Such-Endpoint

PHP, gut 80 Zeilen Code. Der Kern:

$sql = "
    SELECT pk_mail_id, message_id, betreff, absender, empfaenger,
           datum, id_contacts, id_account, z_eingang, thread_root_id,
           snippet(mails_fts, 3, '<mark>', '</mark>', '…', 12) AS snippet
    FROM mails_fts
    WHERE mails_fts MATCH ?
    ORDER BY rank
    LIMIT 100
";

snippet() ist eine eingebaute FTS5-Funktion: sie nimmt das Body-Feld (Index 3), markiert die Treffer-Worte mit <mark>-Tags und schneidet 12 Tokens links und rechts ringsum raus. Das Ergebnis sieht aus wie Gmail-Suchergebnisse: man tippt „heiratsurkunde", sieht ein paar Zeilen Body-Kontext mit gelb hervorgehobenem Treffer.

ORDER BY rank sortiert nach FTS5-internem Relevanz-Algorithmus (BM25). Die häufigste/beste Übereinstimmung erscheint oben. Kein Tuning, kein TF-IDF-Selbstbau nötig.

Etwas mehr Sorgfalt verlangt das Mapping von Benutzer-Eingaben in FTS5-Query-Syntax:

$tokens = preg_split('/\s+/', $userInput);
$clean = [];
foreach ($tokens as $t) {
    $safe = preg_replace('/["\\\\:()*]/', '', $t);
    if (preg_match('/[@.\-]/', $safe)) {
        $clean[] = '"' . $safe . '"';  // E-Mail-Adresse als Phrase
    } else {
        $clean[] = $safe . '*';         // Prefix-Match für "mueller" → "muellermann"
    }
}
$ftsQuery = implode(' ', $clean);

Drei Regeln: gefährliche Sonderzeichen rausnehmen, Token mit @/. als Phrasen (sonst zerlegt FTS5 sie an Punkten), normale Wörter als Prefix-Match mit Stern (damit „mueller" sofort matcht, sobald 7 Zeichen getippt sind, ohne dass der User ENTER drücken muss).

Das Frontend

Im WebViewer:

let timer = null;
function suche(q) {
    clearTimeout(timer);
    timer = setTimeout(() => doFetch(q), 200);
}

function doFetch(q) {
    if (!q || q.trim().length < 2) {
        document.getElementById('liste').innerHTML = '<div class=empty>Mindestens 2 Zeichen…</div>';
        return;
    }
    fetch(`${API}?q=${encodeURIComponent(q)}&limit=100&token=${TOKEN}`)
        .then(r => r.json())
        .then(d => render(d));
}

Zwei Details:

Debouncing mit 200 ms. Tippt der User „mueller", wird nicht für jeden Buchstaben ein Request abgesetzt. Erst wenn 200 ms keine neue Eingabe kommt, geht der Fetch los. Das schont den Server und ist auf modernen Maschinen subjektiv noch immer „sofort".

Race-Condition-Schutz mit lastReq-ID. Wenn der User schnell tippt und der erste Request langsamer zurückkommt als der zweite, würde die Liste sonst kurz die alte Antwort zeigen. Über einen lokalen Zähler werden veraltete Antworten verworfen.

Die CORS-Falle

Beim ersten Test im WebViewer: nichts. „Load failed". Ein typisches Browser-Mysterium, das eine Stunde Diagnose verschlingt, wenn man’s nicht kennt.

Erklärung: Der WebViewer lädt seine HTML-Seite per data:-URL. Aus Browser-Sicht hat eine data:-URL als Origin den Wert null. Macht der eingebettete JavaScript-Code dann einen fetch() an einen HTTP-Server, prüft der Browser CORS (Cross-Origin Resource Sharing). Liefert der Server keine Access-Control-Allow-Origin: *-Header, wird die Antwort blockiert — der fetch() erhält einen generischen „Load failed"-Fehler ohne nähere Erklärung.

Fix: zwei Zeilen im json_response()-Helper:

header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');

Sofort lief alles. Eine dieser Sachen, die man einmal gelernt hat und nie wieder vergisst, aber bis dahin Stunden kosten kann.

Performance — die echten Zahlen

Über 96 935 Mails, gemessen direkt am Server, dann inklusive HTTP-Roundtrip aus dem WebViewer:

Such-Typ SQL-Zeit HTTP-Roundtrip
Volltext „mueller*" 4 ms 59 ms
Volltext „heiratsurkunde" 4 ms 55 ms
Volltext + Filter (z_eingang=1) 5 ms 34 ms
Phrasen-Suche „aicher.com" 4 ms 42 ms

Der HTTP-Roundtrip ist dabei der Löwenanteil. Die eigentliche FTS5-Query liegt unter 10 ms — auch bei Millionen Datensätzen würde sich daran wenig ändern.

Vergleich mit dem alten Pattern:

Liste-basierter WebViewer FTS5-Backend
Initiales Rendering 10–30 m <100 ms
Tastendruck-Reaktion 100–500 ms (DOM-Filter) 50–100 ms (HTTP)
HTML-Größe initial ~75 MB ~1 KB pro Request
Skalierungsgrenze ~10 000 DS 10 Mio+
Body-Volltext-Suche begrenzt, kein Stemming voll, mit Diacritics-Normalisierung
Sortierung nach Anlegen nach Relevanz (BM25)
Snippet-Hervorhebung nein ja, mit <mark>
Sonderzeichen/Umlaut-Toleranz nein ja

Wartung

Damit der Index nicht veraltet, muss er bei jeder neuen oder geänderten Mail aktualisiert werden:

  • Mails per IMAP abholen: das FileMaker-Skript ruft nach jedem neuen Datensatz Mail indizieren auf, der schickt den DS per POST an /api/index_mail.php
  • Mails senden: macht der PHP-Endpoint selbst nach erfolgreichem Versand
  • Manuelle Änderungen in FileMaker (Betreff editiert, Body angepasst): per Skript-Trigger OnRecordCommit → Mail indizieren

Bei Inkonsistenzen genügt ein Reset: Python-Script neu laufen lassen, leert den Index, baut ihn neu auf. 15 Sekunden.

Bilanz

Was am Ende steht:

  • Live-Volltext-Suche über 96 935 Mails mit <100 ms Antwortzeit
  • Sub-50-ms-Performance auf der DB-Ebene — würde auch bei 10 Mio Datensätzen noch unter 100 ms bleiben
  • Snippet-Hervorhebung mit Kontext-Wörtern um den Treffer herum
  • Umlaut- und Akzent-tolerant: „muller" findet „Müller"
  • Filter-Kombinationen: Volltext + Kontakt + Richtung + Thread (über zusätzliche URL-Parameter)
  • Skaliert auf jeden Datenbestand, der realistisch in FileMaker liegt
  • Wartung minimal: PHP-Endpoint indiziert neue Mails automatisch, Python-Script setzt bei Bedarf alles zurück

Das Pattern ist breit übertragbar. Überall dort, wo FileMaker mit großen Datenmengen Live-Suche bieten soll — Kontakte, Aufgaben, Dokumente, Logs — ist FTS5 in einer Helper-SQLite die richtige Antwort. Die FM-Datei selbst bleibt unangetastet, die Suche läuft daneben, der WebViewer ist die Brücke.

Was als Nächstes käme

Drei mögliche Erweiterungen, die diese Lösung von „funktional" zu „brillant" heben würden:

  1. Server-side Pagination: aktuell 100 Treffer pro Anfrage. Bei einer „1000 Treffer für muller" könnte „Mehr laden"-Button mit offset-Parameter ergänzt werden.
  2. Faceted Search: Aggregations-Endpoint, der für eine Such-Query liefert „X Treffer in Inbox, Y in Sent, Z bei Kontakt A". Bietet sofortige Verfeinerungs-Optionen.
  3. Synonyms-Wörterbuch: für Geschäftsbegriffe Synonyme definieren („Rechnung" matcht auch „Invoice"). FTS5 unterstützt das über Custom-Tokenizer.

Aber: nichts davon braucht es heute. Sub-100-ms-Volltextsuche über 96 000 Mails reicht für die nächsten Jahre. Das Pattern ist offen, falls je gebraucht.


Stack:

  • SQLite 3.x mit FTS5 (in praktisch jeder modernen PHP-Installation enthalten)
  • PHP 8.x, PDO-SQLite-Treiber
  • FileMaker WebViewer mit data:-URL + JavaScript-Fetch
  • Python 3 für den einmaligen Initial-Index-Lauf

Code-Umfang nach Implementierung:

  • lib/helper.php: +40 Zeilen (FTS5-Tabelle + index_mail()-Helper)
  • api/search_mails.php: ~100 Zeilen (komplett neu)
  • api/index_mail.php: ~40 Zeilen (komplett neu)
  • /tmp/initial_index_mails.py: ~80 Zeilen (einmalig)
  • FileMaker-Berechnung wv_html_search_fts: ~80 Zeilen JavaScript+HTML in einer FM-Calc
  • FileMaker-Skript Mail indizieren: ~12 Steps