Hybride Suche fürs Homelab: Qdrant RRF, BM25 und eigenes Chunking
Ein selbst gehosteter Homelab-Assistent zerlegt Notizen, Tabellen, Draw.io-Diagramme und Loki-Logs in Chunks und sucht dann mit dichten und dünnbesetzten Vektoren, RRF und Cross-Encoder-Reranking.
Fünfzig Markdown-Dateien zu durchwühlen oder docker logs in drei Terminalfenstern zu verfolgen, nur um eine IP-Adresse zu finden, ist eine ermüdende Art, ein Homelab zu betreiben. Notizen sind gut. Sofortige Antworten aus hybrider Vektorsuche sind besser. Dieser Beitrag baut einen vollständig selbst gehosteten Assistenten, der Nextcloud-Notizen, Draw.io-Netzwerkdiagramme und Live-Logstreams aus Grafana Loki indexiert, und konzentriert sich auf die Mechanik: wie die Daten in Chunks zerlegt werden, wie sich die Embeddings mathematisch verhalten und wie Qdrant Ergebnisse findet und zusammenführt.
Das Dokumentationsparadox
Im betreffenden Lab laufen ein Proxmox-Hypervisor mit mehreren Knoten, eine OPNsense-Firewall, die mehrere VLANs routet, und Dutzende Docker-Container wie Jellyfin, Radarr, Prowlarr, Grafana und Home Assistant. Mit der Zeit verteilte sich die Dokumentation auf vier Orte:
- Netzwerknotizen und Anleitungsseiten in Nextcloud Collectives, gespeichert als
.md-Dateien. - Hardware-Inventar und IP-Adresszuordnungen in Excel-Tabellen (
.xlsx). - In Draw.io gezeichnete Topologiediagramme, gespeichert als
.drawio-Dateien. - Live-Containerlogs, gesammelt von Grafana Loki.
Jeder Vorfall und jede Änderung an einer Bridge bedeutete eine manuelle Routine: Nextcloud öffnen, die richtige Notiz finden, ein Diagramm öffnen, um Interface-Verbindungen zu prüfen, durch eine Tabelle scrollen und dann docker logs ausführen, um Containerfehler anzusehen. Frustrierend und langsam.
Öffentliche Chatbots helfen hier nicht. Sie wissen nichts über private Subnetze wie 10.27.30.2, Proxmox-Bridges wie vmbr1 oder NIC-Bindungen wie igc0, und auf Fragen zu privater Infrastruktur erfinden sie selbstbewusst Unsinn. Das Wissen muss also lokal bleiben und zum Zeitpunkt der Frage abgerufen werden.
Jedes Format nach eigenen Regeln zerlegen
Übliche RAG-Setups teilen Text alle paar hundert Zeichen oder Wörter. Das zerstört Homelab-Daten. Ein Satz wie „Proxmox Host 1 ist mit Port 2 verbunden“ kann über zwei Chunks verteilt werden, und die Verbindung zwischen Server und Port ist weg. Deshalb bekommt jedes Format seine eigene Strategie.
Markdown: Aufteilung an Überschriften
Dokumente werden an strukturellen Grenzen geschnitten, also an #, ## und ###. Nextcloud Collectives speichert Seiten im Hintergrund als Markdown, die Überschriften sind also schon vorhanden. Wer daran teilt, behält den Abschnittspfad, und ein Chunk ist kein loser Text, sondern trägt seine komplette Hierarchie mit:
Document: Home Server Setup | Section: Network > Firewalls > OPNsense
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OPNsense VM 101 is configured with passthrough interface igc0…
Tabellen: Zeilen als Sätze
Mit openpyxl wird jede Hardware-Zeile ausgelesen und als explizite Schlüssel-Wert-Aussage neu geschrieben. Rohe Tabellen verlieren ihre Spaltenüberschriften, wenn ein naiver Chunker sie in Textblöcke verwandelt, eine umgeschriebene Zeile dagegen ist in sich vollständig:
Row 4: Device: Proxmox Host 1 | Port: Port 2 | IP: 10.27.10.3 | Connection: Airtel Router
every single row becomes an independently searchable record in vector storage.
Jede Zeile wird so zu einem eigenständig durchsuchbaren Datensatz im Vektorspeicher.
Draw.io: das versteckte XML
Um Netzwerkdiagramme zu lesen, braucht es weder teure OCR noch Computer Vision, denn .drawio-Dateien sind einfach komprimierte XML-Strings. Den ersten Schritt erledigt Pythons zlib: die Nutzlast per Base64 dekodieren und dann mit zlib.decompress(data, -15) entpacken. Aus dem entstehenden XML-Baum aus <mxCell>-Elementen lassen sich Knoten, Geräte und Verbindungskanten gewinnen, die so ausgeschrieben werden:
## Nodes
- Node: Proxmox Host 1 (10.27.10.3)
- Node: OPNsense Firewall VM (101)
## Connections
- Connection: Airtel WAN Router -> Port 2 (igc0) -> OPNsense VM (101)
Die Verbindungen kommen exakt richtig heraus, ohne Erkennungsfehler und ohne GPU-Last.
Loki-Logs: zwei Stufen
Rohe Docker-Logzeilen aus Loki in den Vektorspeicher zu kippen, würde ihn mit Millionen nutzloser INFO-Meldungen fluten. Ein adaptives Zwei-Stufen-Schema verhindert das:
- Stufe 1 umfasst Vorfälle. Häufungen kritischer Fehler wie
ETIMEDOUT, HTTP-500-Antworten, Ausfälle und Container-Abstürze werden mit exakten Zeitstempeln, Container-Labels wie jellyfin oder radarr und Stacktraces in Chunks zerlegt. - Stufe 2 umfasst Routineaktivität. Normale Heartbeat-Ausgaben werden zu Zusammenfassungen von jeweils 15 Minuten verdichtet.
[Tier-1 Incident Event]
Timestamp: 2026–09–14 18:30:12 UTC | Service: jellyfin | Container: jellyfin
Log Event: ERR_FR_REDIRECTION_FAILURE attempting to connect to 10.27.30.2:8096
Zwei Embedding-Räume
Warum einem einzigen Embedding-Modell vertrauen, wenn sich zwei mathematische Räume kombinieren lassen?
Dichte Vektoren mit all-MiniLM-L6-v2
Das ist ein MiniLM-Transformer mit 6 Schichten, der 384-dimensionale Vektoren erzeugt. Die Relevanz wird über die Kosinusähnlichkeit zwischen dem Anfragevektor A und dem Chunk-Vektor B gemessen.
Dichte Vektoren erfassen Bedeutung. Das Modell versteht, dass eine Firewall ein konzeptioneller Nachbar von OPNsense ist und dass Speicher mit einem ZFS-Pool zusammenhängt. Ihre Schwäche sind Zahlen, die geglättet werden. 10.27.30.2 und 10.27.30.3 landen auf fast identischen Koordinaten, was bei IP-Adressen zu Präzisionsfehlern führt.
Dünnbesetzte Vektoren mit FastEmbed BM25
Die dünnbesetzte Seite nutzt die Termhäufigkeit, gewichtet mit der inversen Dokumenthäufigkeit, und bewertet Schlüsselwort-Treffer in einer Dokumentsammlung D für die Anfrageterme q.
Hier liegt die Stärke in der Exaktheit. Technische Tokens wie IP-Adressen (10.27.30.2), Interfaces (igc0, vmbr1), Ports (8096) und Fehlercodes (ETIMEDOUT) treffen perfekt. Der Preis: Ein Synonym statt des wörtlichen Begriffs findet nichts.
Speicherung, Fusion und Reranking
Beide Darstellungen landen in einer Qdrant-Datenbank unter localhost:6333, in einer Collection namens homelab. Eine Anfrage löst dann die hybride Suche aus.
Reciprocal Rank Fusion
Dichte Kosinuswerte (etwa 0,85) und dünnbesetzte BM25-Werte (etwa 14,2) liegen auf völlig unterschiedlichen Skalen, sie direkt zu addieren ergibt also keinen Sinn. Qdrant führt die gerankten Kandidatenlisten stattdessen per Reciprocal Rank Fusion zusammen.
RRF belohnt Übereinstimmung. Ein Dokument, das in der dünnbesetzten Suche dank exaktem Treffer auf 10.27.30.2 auf Platz eins liegt und in der dichten Suche dank starker inhaltlicher Nähe auf Platz zwei, springt mit seinem fusionierten Wert an die Spitze.
Cross-Encoder-Reranking
Bi-Encoder wie MiniLM kodieren Anfrage und Dokument bei der Indexierung getrennt. Um Fehltreffer auszusortieren, laufen die zehn besten RRF-Kandidaten durch einen Cross-Encoder, ms-marco-MiniLM-L-6-v2, der das Paar aus Anfrage und Dokument gemeinsam durch die Self-Attention des Transformers schickt.
Chunks mit einem Wert unter MIN_RERANK_SCORE = -2.0 werden verworfen. Würde das Rauschen jeden Chunk eliminieren, greift die Pipeline auf einen sauberen Fallback zurück: Eine spekulative Kaskade behält den Kandidaten mit dem besten Wert.
Ein roher Cross-Encoder-Logit (z) wird über die Sigmoidfunktion in eine anschauliche Relevanzwahrscheinlichkeit umgerechnet.
Verschiedene Logit-Werte ergeben etwa diese Wahrscheinlichkeiten:
Bei z = -2.0 ist ein Chunk mit rund 11,9 % Wahrscheinlichkeit relevant. Alles darüber trägt nützlichen Kontext oder zumindest schwache thematische Überschneidung, die dem Sprachmodell helfen kann. Alles darunter fällt schnell gegen null, bei z = -4.0 sind es nur noch 1,8 %, und deutet meist auf fachfremde Verunreinigung hin, etwa Bursts roher Docker-Logs, die in eine Frage zu Subnetzen gerutscht sind.
Query: "What is the OPNsense management IP?"
[Top Retrieved Chunks]
Chunk 1: "OPNsense Firewall VM 101 IP: 10.27.10.1..." ──► Logit: +4.8 (Keep)
Chunk 2: "Proxmox host bridge vmbr0 LAN 10.27.10.3..." ──► Logit: +0.4 (Keep)
Chunk 3: "Grafana Loki log error: connection refused" ──► Logit: -4.2 (Filtered out!)
Ohne den Schwellenwert würde der dritte Chunk im Kontext des Modells landen, wo der Logfehler es zu einer falschen Antwort verleiten könnte. Die Schwelle -2.0 wirkt wie ein automatischer Türsteher vor dem Kontextfenster.
Überprüfung
Ein automatisiertes Regressionsskript, tests/run_regression.py, misst das System an einem Homelab-Testset mit 17 Fragen:
Ran 3 tests in 1.307s
OK
[Qdrant Connected] Vector DB ready at http://localhost:6333 (Collection: 'homelab').
[Regression Assertion] Hit@1 Accuracy Rate: 100.0% | MRR: 1.0000
```
* **Hit@1 Accuracy Rate**: **100.0%**
* **MRR (Mean Reciprocal Rank)**: **1.0000**
* **Time-to-First-Token (TTFT)**: **640.8 ms**
Alles zusammen
Multimodales Chunking (Markdown-Überschriften, Excel-Zeilen, entpackte Draw.io-XML-Graphen und zweistufige Loki-Logs), dichte MiniLM- und dünnbesetzte BM25-Embeddings, die Reciprocal Rank Fusion von Qdrant und Cross-Encoder-Reranking ergeben zusammen einen privaten, selbst gehosteten Assistenten. Er kann Fragen zu jedem Kabel, jeder IP-Adresse und jedem Docker-Containerfehler im Rack beantworten.