Gute Suchfunktionen sind im Firmenumfeld rar. Schlechte sind so verbreitet, dass wir uns angewöhnt haben, dreimal anders zu formulieren, bevor wir aufgeben. Das Thema beschäftigt mich seit Jahren und heutzutage fällt dabei schnell das Stichwort RAG: Dokumente durchsuchen, Treffer an ein Sprachmodell geben, Antwort generieren. Interessanterweise redet dann alles über den zweiten und dritten Teil. Dabei steht und fällt das Ganze mit dem ersten. Wenn das Retrieval nichts Brauchbares liefert, kann das beste Modell nichts zaubern.
Ich habe mir genau diesen Teil vorgenommen. Und zwar so, dass man ihn sehen und unterschiedliche Verfahren vergleichen kann. LexSearch ist eine Demo mit einer Suchmaske und drei nebeneinander angeordneten Ergebnisspalten: lexikalische Suche (BM25), semantische Suche (Dense Retrieval über Embeddings) und die Fusion aus beiden (Hybrid Retrieval via Reciprocal Rank Fusion). Ich stelle dieselbe Frage gleichzeitig an alle drei und man sieht den Verfahren beim Auseinanderlaufen zu.

Warum Datenschutzgesetz und Verordnung
Als Korpus habe ich das Schweizer Datenschutzgesetz (DSG) und die zugehörige Verordnung (DSV) genommen. Das sind insgesamt 325 Absätze von Fedlex, der Publikationsplattform des Bundesrechts. In unseren Kundenprojekten bei Zooey tauchen Fragen zu diesen beiden Texten immer wieder auf, häufig im Umfeld der Bundesverwaltung. Die Wahl lag also nahe. Dazu kommt: Rechtstexte sind für so einen Vergleich dankbar, weil das Gesetz eine andere Sprache spricht als die Menschen, die es betrifft, das klassische Vocabulary-Mismatch-Problem.
Ein Beispiel: Die Frage "Gibt es eine Liste mit Ländern, in die man Daten schicken darf?" hat mit dem Anhang der DSV, Überschrift "Staaten, Gebiete, spezifische Sektoren", kein einziges Wort gemeinsam. Die Wortsuche landet bei der Informationspflicht, also daneben. Die semantische Spalte setzt den Anhang nach oben. Fragt man dagegen mit exakten Fachbegriffen wie "Meldung einer Verletzung der Datensicherheit an den EDÖB", sitzt BM25 sofort auf Art. 24 DSG, während die Embeddings auch thematisch Verwandtes nach oben holen.
Dieses Problem hat mich schon in meiner Projektarbeit im CAS Deep Learning an der FHNW beschäftigt. Dort habe ich BM25, SBERT und SPLADE auf dem BEIR/MS-MARCO-Benchmark verglichen und vor allem Zahlen gesehen. Mit LexSearch wollte ich dieselben Retrieval-Ideen diesmal unmittelbar sichtbar machen, nicht nur messen.
Ganz ohne Messen geht es trotzdem nicht: Der Demo liegt ein Goldset mit 42 von Hand gelabelten Fragen bei, 30 in Alltagssprache und 12 in Gesetzessprache. Wählt man eine davon aus, markieren alle drei Spalten die Musterlösung, und was eine Spalte verfehlt hat, steht unter ihr.
Wie es gebaut ist
Ich wollte so wenig bewegliche Teile wie möglich und alles lokal.
Der Stack ist bewusst einfach und ziemlich Standard im Firmenumfeld: Java und Spring Boot für Ingest und Such-API, eine PostgreSQL für alles. Volltextsuche über den eingebauten tsvector mit deutscher Konfiguration, Vektoren über pgvector. Für eine Demo hätte es auch weniger getan, aber ich wollte ein produktionsnahes Setup, ohne spezialisierte Suchinfrastruktur daneben.
Für BM25 nutze ich tsvector als lexikalische Grundlage und berechne das Ranking selbst, statt PostgreSQLs eingebautes ts_rank_cd zu verwenden. Für die Demo wollte ich explizit ein BM25-Ranking mit Term-Sättigung und Längennormalisierung verwenden, um die Dokumentlänge und wiederholte Begriffe kontrolliert in den Score einfliessen zu lassen. Eine Extension wie pg_search würde das auch lösen, die habe ich mir für später aufgehoben. Der Kern der Query ist die BM25-Formel selbst:
scored AS (
SELECT token.id,
sum(ln(1 + (corpus.docs - doc_frequency.df + 0.5)
/ (doc_frequency.df + 0.5)) -- IDF
* (token.tf * (k1 + 1))
/ (token.tf + k1 * (1 - b + b * doc_length.len
/ corpus.avg_length))) AS score
FROM ...
)
Das Drumherum ist ein halbes Dutzend CTEs Buchhaltung: Kandidaten, Termfrequenzen, Dokumentlängen, usw.
Die Embeddings rechnet ein multilinguales E5-Modell direkt in der JVM via ONNX, ohne Python-Sidecar und ohne Cloud-API. Für Umgebungen wie die Bundesverwaltung, wo Dokumente das Haus nicht verlassen dürfen, war mir das wichtig: Die semantische Suche läuft vollständig on-premises; Dokumente und Embeddings verlassen die Umgebung nicht.
Beim Chunking folge ich der Struktur des Gesetzes. Die Fedlex-Texte liegen als Akoma-Ntoso XML vor, geschnitten wird entlang der Dokumentstruktur, keine Sliding Windows mit Overlap. So bleibt jeder Treffer sauber zitierbar, mit Fundstelle und Deep-Link auf Fedlex. Gerade bei Rechtstexten macht das für mich die halbe Glaubwürdigkeit aus.
Stolpersteine
Die Fusion der beiden Ranglisten macht Reciprocal Rank Fusion (ca. zwanzig Zeilen Code). Mit dem üblichen Standardwert (k = 60) und beiden Ranglisten gleich gewichtet fiel die Hybrid-Spalte in meiner Messung unter beide Einzelverfahren. Damit hatte ich nicht gerechnet, kombiniert ist eben nicht automatisch besser. Erst nachdem ich die Parameter auf meinem Goldset angepasst hatte, lag Hybrid vor beiden Einzelverfahren. Allerdings ist das keine unabhängige Evaluation, da dasselbe kleine Goldset auch zum Tuning diente.
List<Hit> fuse(List<Hit> keyword, List<Hit> dense, int k) {
int rrfK = this.properties.search().rrfK();
double keywordWeight = this.properties.search().keywordWeight();
Map<String, Double> fused = new HashMap<>();
Map<String, Integer> keywordRanks = new HashMap<>();
Map<String, Integer> denseRanks = new HashMap<>();
Map<String, Hit> byLabel = new LinkedHashMap<>();
for (Hit hit : keyword) {
keywordRanks.put(hit.label(), hit.rank());
fused.merge(hit.label(), keywordWeight / (rrfK + hit.rank()), Double::sum);
byLabel.putIfAbsent(hit.label(), hit);
}
for (Hit hit : dense) {
denseRanks.put(hit.label(), hit.rank());
fused.merge(hit.label(), 1.0 / (rrfK + hit.rank()), Double::sum);
// A keyword hit already carries a highlighted snippet, prefer it.
byLabel.putIfAbsent(hit.label(), hit);
}
// sort by fused score, re-rank, attach per-column ranks
...
}
Die naheliegende nächste Stufe wäre ein Reranker: ein Cross-Encoder, der Frage und Absatz gemeinsam bewertet und eine Auswahl von Kandidaten neu ordnet. Der würde vermutlich mehr bringen als jedes Feintuning an den Fusionsparametern. Die Fusion müsste dann nur noch entscheiden, wer überhaupt in die Auswahl kommt. Für LexSearch wollte ich nur sehen, was die beiden Grundverfahren allein leisten.
Eine zweite Beobachtung finde ich erwähnenswert: Die semantische Suche ist beeindruckend, wenn die Frage anders klingt als das Gesetz, und erstaunlich mittelmässig, wenn jemand die exakten Begriffe kennt. Wer in meinem Korpus nur auf Embeddings setzt, verschlechtert die Suche ausgerechnet für die Leute, die am präzisesten fragen. In der Demo sieht man das Zeile für Zeile.
Auf konkrete Zahlen verzichte ich hier bewusst. Mit 30 Fragen in Alltagssprache und 12 in Gesetzessprache ist das Goldset zu klein, als dass ein paar nDCG-Punkte Unterschied mehr wären als Rauschen, und die Werte verschieben sich mit jedem Dreh am Chunking oder an der Fusion. Wer die Messung sehen will: run_eval.py schreibt Recall@5, nDCG@10 und MRR nach eval/results.md, mit einer Zeile pro Frage statt einem Mittelwert.
Der Code liegt auf GitHub, ein docker compose up genügt. Beim ersten Start lädt das Embedding-Modell (multilingual-e5, ~1.1 GB), danach läuft alles offline.
In vielen Projekten kommt die Diskussion erst beim Sprachmodell an. Die interessanteren Entscheidungen sind da längst gefallen: beim Chunking, beim Verfahren, bei der Frage, wer eigentlich sucht. Für diese Diskussion habe ich LexSearch gebaut.