Why I built embeddings for a graph I already trusted

I built semantic search for a system that already forbids orphans.

My vault is a flat Luhmann zettelkasten — atomic idea notes connected by explicit links, no folders, no hierarchy: Markdown notes, a controlled domain taxonomy, and a creation gate that refuses to let a new note exist until it has earned at least two links to existing notes. Links are the currency. Today it's 454 notes, 1,888 wiki-links among the content notes, 20 domains. I wrote earlier about exploring the same graph from the terminal — this is the story of what I added on top of it.

The gate works — the graph is well-connected, curated, and I trust it. But the step behind the gate has a structural ceiling. Link candidates are found by keyword search: you type what you remember, the index returns what matches. That only ever retrieves what you can already name. You write "load balancing" and forget you wrote "LiteLLM routing" six months earlier in a different domain. Different words, same idea. Keyword search can't see it, and the links can't either — you never made that connection in the first place, which is exactly when you need it. That's the gap I built embeddings for: not to replace the graph, but to feed it.

The design

zk-embeddings is a small Node library plus CLI. It composes each note's embeddable text from frontmatter and body, hashes it, and stores the vector in a single SQLite file at $ZK_NOTEBOOK_DIR/.embeddings/index.sqlite — beside the vault, not in the repo, because it's derived data and should stay disposable.

Three decisions carry the design:

The model runs through Ollama, not in-process. My first assumption was transformers.js with the canonical ONNX export of nomic-embed-text on Hugging Face. That file doesn't exist — the HF repo returns 401. What exists are third-party repackages with single-digit download counts. Provenance that thin isn't something I anchor a tool on: the most-downloaded ONNX repackage had 22 downloads, the rest six or fewer. ollama pull nomic-embed-text is the canonical distribution path, and Ollama amortizes across future local models (a reranker, a local router). The cost is a daemon dependency: when it's down, semantic search degrades to empty candidates and keyword search carries the load — by design, with a distinct warning when the daemon is up but the model is missing, so a config error can't masquerade as a normal outage.

Storage is brute-force cosine over SQLite, not an ANN store. At 452 notes × 768 dimensions, the whole index is 1.4 MB of vectors and a scan is sub-millisecond. sqlite-vec, chroma, and qdrant earn their weight at ten thousand notes, not four hundred — and plain SQL keeps the interesting filter — candidates from one of the twenty domains other than the note being written — as a one-line WHERE instead of a second index.

The embedding text deliberately de-emphasizes the graph. [[wiki-links]] are rewritten to their target note's title before embedding, because link structure already biases the graph layer — embedding the raw brackets would double-count what the curated links already encode. The composed text is <title> + <summary> + <body>, prefixed with clustering:, the task-instruction prefix nomic-embed-text expects for symmetric note-vs-note similarity.

Indexing is incremental: a note is re-embedded only when its body_hash or the embedding_model column no longer matches. That keying makes the index self-healing — swap the model or the prefix convention and the next ordinary index run re-embeds what changed, instead of silently mixing incompatible vectors.

The stumble

Verification against the real vault — 407 notes back then — found 46 of them silently missing from the index. Dense markdown — notes heavy with URLs and code — exceeds nomic-embed-text's 2048-token context limit, and the embedding call was failing for those notes without surfacing. The index built "clean." Search worked. It just couldn't see 11% of the vault, and nothing would have told you except checking the row count against the note count.

The fix was two lines of honesty: truncate the composed text to 5,800 characters (~2,000 tokens) before sending, and raise num_ctx to 8192 so Ollama doesn't clip further. 407/407 clean after that. The lesson wasn't about context windows — it was that a semantic index fails silently by default, and the only reason I found this one was verifying against the real vault instead of trusting a green test run. That verification habit is now the maintenance story too: a full rebuild was 12 minutes of serial Ollama round-trips, so indexing runs with bounded concurrency (default 4) and a single transaction instead of one commit per note.

The outcome

A stale-index catch from last week, live: the index had silently fallen seven notes behind (the newest entries were never embedded). I re-ran the index — it scans the 452 content notes, the 454 files minus the generated master index and the taxonomy — and reported exactly what had changed:

{ "notesScanned": 452, "notesUpToDate": 445, "notesReembedded": 7, "durationMs": 9185 }

452 scanned, 445 already current, 7 re-embedded in 9.2 seconds — the hash-diff found precisely the new notes, nothing else. All 452 content notes now carry vectors in a 1.9 MB file, and semantic search is documented in the vault's own instructions as the agent's retrieval path alongside full-text.

The demo that made the decision real: I asked the vault "should agents keep everything in context or look it up?" The top hit, at 0.79 similarity, was a note titled Agent context must be queryable, not embedded — a note whose title shares only "agent" and "context" with my question. Full-text search for the same words returns an index file and unrelated notes; you can find that note by keyword only if you already know the word "queryable." That's the whole argument in one query: the note existed, the connection existed, and neither the links nor the keywords could surface it until I could describe it.

What I chose not to do: embeddings never write links — the gate still decides which candidate earns a link, and a human or agent still makes the call. Keyword search stays as a second source; semantic is additive. No reranker, no file-watcher indexing, no ANN store — each deferred until quality demonstrably fails at a scale that demands it.

The graph is still the arbiter of what counts as a connection. The embeddings just widened the funnel of candidates into it — and quietly made the vault retrievable by meaning instead of by memory.