Offline hybrid search over a personal case archive
Semantic similarity and full-text search, fused by reciprocal rank, running entirely on my own machine with two dependencies and no network calls at all.
The problem
Over a year of support work I accumulated a few hundred pages of my own case notes. The knowledge was real and it was mine, and it was completely inaccessible — I could remember that I had solved something like this before without being able to find which case, or what the answer turned out to be.
Full-text search alone did not work, because the useful match is rarely a shared keyword. Two cases about the same root cause routinely describe it in different vocabulary. Semantic search alone did not work either: it happily returns five notes that are thematically adjacent and none that contain the exact error string I actually typed.
The approach
Run both, and combine them properly. The two retrieval methods fail in opposite directions, so the design goal was to let each cover the other's blind spot without letting either dominate.
-
Semantic ranking over embeddings generated locally with
BAAI/bge-small-en-v1.5through ONNX. Cosine similarity against a plain NumPy array — at this corpus size a vector database would be infrastructure with nothing to do. - Lexical ranking with SQLite FTS5, which is already in the Python standard library and needs no service to run.
- Reciprocal rank fusion to merge them. RRF combines by position in each result list rather than by score, which matters because a cosine similarity and an FTS5 relevance score are not on comparable scales and normalising them is guesswork. RRF sidesteps the question entirely.
Documents are chunked at 900 characters with 150 characters of overlap, then results are regrouped by case so one long note cannot flood the top of the list with six of its own fragments.
Architecture
Four subcommands over one flow. build walks the archive, parses
each note's sections, chunks them, embeds them, and writes three artifacts: a
NumPy array of vectors, an FTS5 index, and a JSON catalogue.
search runs both rankers and fuses them. catalog and
stats report on the corpus.
The deliberate constraint was no network, ever. Embeddings run on-device, the index is local files, and there is no API key to leak or rate limit to hit. Two third-party dependencies total. That was not minimalism for its own sake — the corpus is my own work notes, and the cheapest way to guarantee they are never transmitted anywhere is to build something structurally incapable of transmitting them.
Tradeoffs
- A local embedding model is weaker than a hosted one.
- A small ONNX model does not match a frontier embedding API on retrieval quality. I took the loss deliberately: the data must not leave the machine, and lexical search covers a meaningful share of what the smaller model misses.
- Cosine over a flat array does not scale.
- It is a linear scan. At a few thousand chunks that is imperceptible; at a million it would be unusable, and the answer would be an approximate index. Building that now would have been solving a problem I do not have.
- The parser is coupled to my own note format.
- It reads a specific heading structure. That made it fast to write and means it is not a general-purpose tool. For a personal utility that is the right trade; for anything shared it would need a real document abstraction.
- No tests.
- Honestly, this is the weak spot. The parsing and fusion logic are the two places a silent regression would be most costly and least visible, and they are exactly what a small test suite should cover. It is the next thing I will add.
Outcome
It works, and I use it. The index covers 49 cases as roughly 3,400 chunks, and a query returns useful results in well under a second. The behaviour I was actually after — typing a half-remembered symptom and getting back the case where I had seen it before — is the part that turned out to be worth the build.
What I would do differently
Write the tests first, for the two functions above. Separate the note parser from the retrieval core behind a small interface, so the interesting half is reusable and the format-specific half is replaceable. And keep the generated index somewhere outside the project directory from the start — colocating derived data with source code is how derived data eventually gets committed by accident.
On the source. The code is publishable and the index is not — it is built from my own support case notes, which contain customer information that cannot leave my machine under any circumstances. If you want to look at the retrieval logic, ask me and I will walk you through it.