How BlackHole works
The mechanism, the numbers, and the tradeoffs we take on purpose. Each part opens with the short version; expand a block for the details.
The one idea
Filing is cosmetic. Where a note lands doesn't decide whether you can find it, and a query never reads your whole vault. Connections come from a note's content, and retrieval is a bounded lookup against an index, not a scan.
Why that matters
The usual failure mode for a notes app is taxonomy rot: if you don't categorize correctly on the way in, things get orphaned or misfiled and you can't find them later. BlackHole avoids that by separating two things people usually conflate. The folder is for your own browsing. Findability comes from the embedding index, the keyword index, and the knowledge graph, none of which look at the folder.
Drop something in
You drop a URL, PDF, image, audio, tweet thread, or text. A cheap model turns it into one clean Markdown note with a title, summary, reuse-first tags, auto [[wikilinks]], and a folder. It lands in your local Obsidian vault.
The pipeline, stage by stage
Extract clean text → repair encoding and detect language → write frontmatter (one model call, handed your existing tags and told to reuse before inventing) → pick a folder (one model call, constrained to folders that exist, returns a confidence) → link to related notes → write the file → embed into the index → update the graph. Indexing and graphing are incremental and never block the write. Every model call degrades to something reasonable with no key.
What if it files something wrong?
The filing step is confidence-gated. If the model clears 0.65 it files the note; if it doesn't, the note stays in your inbox where you can see it. So the worst case is “waiting in the inbox,” not “buried in the wrong folder.” And because links don't depend on the folder, a misfiled note still connects to the right things.
Connections come from content
Related notes are found through four channels and fused: exact title/alias matches, semantic similarity, shared graph entities, and shared tags. Strong matches become inline [[links]]; softer ones become a ## Related list.
The four channels and their weights
Lexical 0.45, vector 0.35, graph 0.12, tag 0.08. Lexical leads because an exact title match is almost always a real connection. Vector is close behind because it's the only channel that catches conceptual overlap with no shared words. Graph and tags are tie-breakers. A shortlist of 15 candidates gets one structured model call to decide placement (inline ≥0.65, related ≥0.55), and it still works without a model, just more bluntly. None of the channels reads the folder, so moving a note never breaks its links. The weights are a reasoned judgment call, not a tuned result.
Search is a lookup, not a scan
Each note has one embedding and one keyword entry, both in the same SQLite file. A query embeds once, looks up nearest neighbors, runs a keyword search, and fuses them. Cost scales with the index, not the size of your vault.
The retrieval router (the interesting part)
Hybrid search (vector + BM25, fused with reciprocal-rank fusion) returns the top 12. That set expands one hop along wikilinks and backlinks into a working set capped at 60 notes. If those notes fit a 100k-token budget, it loads the whole notes, in order, not snippets. This is the Karpathy idea: chunked RAG fragments context, so if a coherent set fits the window, just load it. Only when the set overflows does it fall back to summaries, then to 1500-character excerpts. So chunking happens at query time and only on overflow. It never happens at intake, and most queries never trigger it.
Why fuse vector and keyword
Vectors miss exact terms and names; keyword search misses paraphrase. Running both covers the gaps. Reciprocal-rank fusion merges them on rank rather than raw score, so you don't have to calibrate a cosine similarity against a BM25 number. It also degrades cleanly: lose either index and the other still answers.
Why sqlite-vec and not LanceDB / pgvector / Pinecone
Everything already lives in one SQLite file (jobs, note metadata, keyword index, audit log). sqlite-vec puts the vectors in that same file: one store, one connection, no extra process, nothing to operate. For a desktop app that installs clean and runs offline, that's the right call. The tradeoff is that it does exact nearest-neighbor search, not approximate. Exact is great for a personal vault of tens of thousands of notes; for millions of documents you'd want an ANN index and probably a dedicated vector DB. We're the former, so we don't carry the weight of the latter.
It keeps itself organized
A knowledge graph rebuilds in the background. A daily digest resurfaces a few notes worth revisiting. The system proposes links between notes that should be connected, and clusters related notes into concept pages. You confirm the judgment calls; it does the rest.
What runs, and when
The graph updates after each note and re-extracts fully overnight (deterministic, local, no model). A digest scheduler resurfaces notes each morning through four deterministic strategies (anniversaries, still-unread, buried gems, the current thread). A suggestion engine flags note pairs that share entities. All of it runs inside the app process on simple schedulers, no external cron.
Local-first, and you can prove it
Pick your privacy level at setup: cloud (your key), local (no cloud AI, runs on Ollama), or air-gapped (nothing leaves the machine except a license check). In local and air-gapped mode a network guard blocks any non-local call, and an Activity page shows a cloud-call counter that reads zero.
How the guard works
In local and air-gapped mode, a process-wide guard patches socket.getaddrinfo (the name-resolution step every outbound call passes through) and blocks anything off the allowlist. A forgotten or future cloud call can't connect; it's blocked and logged. The Activity counter is the user-visible proof. In cloud mode, a redaction pass strips secrets (API keys, tokens, private keys) before any text is sent; PII is opt-in.
Defaults and tradeoffs
Some numbers here are reasoned starting points, not benchmarked results: the seed count (12), the scope cap (60), the fusion constant and weights, the 0.65 filing threshold, and 768 dimensions. They're all configurable. The real tradeoffs we take on purpose: exact KNN doesn't scale to millions; local-mode quality is capped by your local model; retrieval quality rides on embedding quality. None of that is the “perfect filing or full scan” dilemma, which simply doesn't apply here.
That is the whole machine. Drop things in; it does the rest.
Get the launch build