a.q.

notes · Rust · written late

notes2vec

I kept losing things in my own notes. I knew I wrote something about gradient sync months ago, I just didn’t remember the words I used. So I built a small search engine that finds notes by meaning, and it all runs on my laptop.

SourceDownload

The terminal UI. Type, get the paragraphs that mean the same thing.

The problem

My notes are a folder of Markdown files. grep is great if I remember the exact phrase, and useless if I don’t. I wanted to type something like “how did I handle GPU memory last time” and land on the right paragraph, without uploading my notes anywhere.

How it works

Two halves. Indexing turns every note into vectors once. Searching turns your question into a vector and looks for the closest ones.

Markdownon diskChunkerby headingBGE-small384 dimsredbvectors + textYour querysame modelTop matchestop kcompare

Finding the files

It walks the folder with the ignore crate, the same walker ripgrep uses, so anything in .gitignore gets skipped for free. It picks up .md, .markdown, .mdown, .mkd, .mkdn and .txt.

Chunking

If you embed a whole file, everything in it blurs into one vector. So each note gets split along its headings with pulldown-cmark, aiming for about 300 characters per chunk (never under 50 or over 500). Every chunk remembers where it lives, like Parallelism › Data parallel › Gradient sync, plus its line numbers, so a result can take you to the exact spot. Front matter goes through serde_yaml for titles and tags.

Embedding

Chunks go through BGE-small-en-v1.5, a small retrieval model (the weights are about 133 MB), running on Hugging Face’s Candle. That means no Python anywhere. notes2vec init downloads the model once. Each chunk becomes one vector: take the first token’s output (CLS pooling) and normalise it to length 1.

// embeddings: [batch, seq, hidden] → pooled: [batch, hidden]
let pooled = embeddings.narrow(1, 0, 1)?.squeeze(1)?;
let normalized = Self::normalize_l2(&pooled)?;

Searching

Your query goes through the same model, then gets compared with every stored chunk. Since everything has length 1, cosine similarity is just a dot product. I didn’t want to sort thousands of scores to keep ten, so it keeps a min-heap of size k: a new score either beats the weakest one in the heap or gets thrown away. That’s O(n log k) and flat memory, which is plenty for a notes folder without dragging in a vector database.

Not redoing work

Everything lives in redb, a small embedded database written in Rust. For every file it keeps the modification time and a SHA-256 of the contents, and a file only gets re-embedded if one of those changed. It also remembers which model built the index, so swapping models forces a clean rebuild. And notes2vec watch keeps the index fresh as you save, using a file watcher with a 2-second debounce.

Stack

Try it

notes2vec init                 # download the model once
notes2vec index ~/notes        # build the index
notes2vec                      # open the search UI