本文以英文发布。
Hybrid Search for a Homelab: Qdrant RRF, BM25 and Custom Chunking
A self-hosted homelab assistant that chunks notes, spreadsheets, Draw.io diagrams and Loki logs, then retrieves with dense and sparse vectors, RRF and cross-encoder reranking.
Hunting through fifty markdown files, or tailing docker logs in three terminal windows, just to find one IP address is a tiring way to run a homelab. Notes are fine. Instant answers from hybrid vector search are better. This write-up builds a fully self-hosted assistant that indexes Nextcloud notes, Draw.io network diagrams and live Grafana Loki log streams, and it concentrates on the mechanics: how the data is chunked, how the embeddings behave mathematically, and how Qdrant retrieves and fuses results.
The documentation paradox
The lab in question runs a multi-node Proxmox hypervisor, an OPNsense firewall routing several VLANs, and dozens of Docker containers such as Jellyfin, Radarr, Prowlarr, Grafana and Home Assistant. Over time the documentation spread across four places:
- Network notes and guide pages in Nextcloud Collectives, stored as
.mdfiles. - Hardware inventories and IP address mappings in Excel
.xlsxsheets. - Topology diagrams drawn in Draw.io, saved as
.drawiofiles. - Live container logs collected by Grafana Loki.
Any incident or bridge change meant a manual routine: open Nextcloud, find the right note, open a diagram to check interface connections, scroll a spreadsheet, then run docker logs to inspect container errors. Frustrating, and slow.
Public chatbots can't help here. They know nothing about private subnets like 10.27.30.2, Proxmox bridges such as vmbr1 or NIC bindings like igc0, and when asked about private infrastructure they invent confident nonsense. So the knowledge has to stay local and be retrieved at question time.
Chunking each format on its own terms
Standard RAG setups split text every few hundred characters or words. That breaks homelab data. A sentence like "Proxmox Host 1 connects to Port 2" can land across two chunks, and the link between server and port is gone. Each format therefore gets its own strategy.
Markdown: split on headers
Documents are cut at structural boundaries, meaning #, ## and ###. Nextcloud Collectives stores pages as Markdown behind the scenes, so the headings are already there. Splitting on them preserves the section path, so a chunk isn't loose text but carries its full hierarchy:
Document: Home Server Setup | Section: Network > Firewalls > OPNsense
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
OPNsense VM 101 is configured with passthrough interface igc0…
Spreadsheets: rows as sentences
Using openpyxl, each hardware row is pulled out and rewritten as an explicit key-value statement. Raw sheets lose their column headers when a naive chunker turns them into text blocks, whereas a rewritten row is self-contained:
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.
Every row then becomes an independently searchable record in the vector store.
Draw.io: the hidden XML
Reading network diagrams doesn't require expensive OCR or computer vision, because .drawio files are just compressed XML strings. Python's zlib handles the first step: base64-decode the payload, then inflate it with zlib.decompress(data, -15). The resulting XML tree of <mxCell> elements yields nodes, devices and connection edges, which get written out like this:
## 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)
Connections come out exactly right, with no vision errors and no GPU load.
Loki logs: two tiers
Pouring raw Docker log lines from Loki into the vector store would flood it with millions of pointless INFO messages. A dual-tier adaptive scheme avoids that:
- Tier 1 covers incidents. Bursts of critical errors such as
ETIMEDOUT, HTTP 500s, failures and container crashes are chunked with exact timestamps, container labels like jellyfin or radarr, and stack traces. - Tier 2 covers routine activity. Normal heartbeat output gets collapsed into summaries of 15 minutes each.
[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
Two embedding spaces
Why trust a single embedding model when two mathematical spaces can be fused?
Dense vectors with all-MiniLM-L6-v2
This is a 6-layer MiniLM transformer producing 384-dimensional vectors. Relevance is measured with cosine similarity between the query vector A and the chunk vector B.
Dense vectors capture meaning. The model understands that a firewall is a conceptual neighbor of OPNsense, and that storage relates to a ZFS pool. Their weakness is numbers, which get smoothed over. 10.27.30.2 and 10.27.30.3 end up at nearly identical coordinates, which produces precision errors for IP addresses.
Sparse vectors with FastEmbed BM25
The sparse side uses term frequency weighted by inverse document frequency, and it scores keyword matches across a document collection D for query terms q.
Here exactness is the strength. Technical tokens such as IP addresses (10.27.30.2), interfaces (igc0, vmbr1), ports (8096) and error codes (ETIMEDOUT) match perfectly. The price is that a synonym in place of the literal word finds nothing.
Storage, fusion and reranking
Both representations go into a Qdrant database at localhost:6333, under one collection named homelab. A query then triggers hybrid retrieval.
Reciprocal Rank Fusion
Dense cosine scores (say 0.85) and sparse BM25 scores (say 14.2) live on entirely different scales, so adding them directly makes no sense. Qdrant merges the ranked candidate lists with Reciprocal Rank Fusion instead.
RRF rewards agreement. A document that places first in sparse search thanks to an exact match on 10.27.30.2, and second in dense search thanks to strong conceptual similarity, sees its fused score jump to the top.
Cross-encoder reranking
Bi-encoders such as MiniLM at indexing time encode query and document separately. To weed out false positives, the ten best RRF candidates go through a cross-encoder, ms-marco-MiniLM-L-6-v2, which feeds the query and document pair together through transformer self-attention.
Chunks scoring below MIN_RERANK_SCORE = -2.0 are discarded. If noise would eliminate every chunk, the pipeline falls back gracefully, using a speculative cascade to keep the best-scoring candidate.
A raw cross-encoder logit (z) becomes an intuitive probability of relevance through the sigmoid function.
Different logit values translate into probabilities like these:
At z = -2.0, a chunk has roughly an 11.9% chance of being relevant. Anything above that carries useful context or weak topical overlap that might help the language model. Anything below falls toward zero quickly, with z = -4.0 at just 1.8%, and typically reflects domain contamination, such as raw Docker log bursts mixed into a question about subnets.
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!)
Without the cutoff, the third chunk would reach the model's context, where the log error might confuse it into a wrong answer. The -2.0 threshold works as an automated bouncer at the context window's door.
Verification
An automated regression script, tests/run_regression.py, benchmarks the system against a 17-item homelab test set:
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**
Putting it together
Multimodal chunking (Markdown headers, Excel rows, inflated Draw.io XML graphs and dual-tier Loki logs), dense MiniLM and sparse BM25 embeddings, Qdrant's reciprocal rank fusion and cross-encoder reranking together form a private, self-hosted assistant. It can answer questions about every cable, IP address and Docker container error in the rack.