mindfork mindfork

Where vector search earns its keep

The fourth in a short series on how mindfork is put together. Earlier: the overview, the engine and the self-model.

Search by meaning

An embedding model turns a piece of text into a vector — a long list of numbers — so that texts with similar meaning land close together, even when they share no words. Ask about «деплой на сервер» and a chunk about "rolling out to production" is nearby. That one trick, search by meaning instead of by substring, is what this article is about: where mindfork uses it, and what it took to make it boring and reliable.

A second, smaller server

mindfork never asks the chat model for embeddings. An autoregressive chat model embeds poorly; a dedicated embedding model — the reference here is bge-m3, multilingual and comfortably good in Russian — does it well and runs in a fraction of the memory. So embeddings come from a separate process on its own port, speaking the same OpenAI-compatible dialect as the engine:

llama-server -m bge-m3-Q8_0.gguf --embeddings \
    --host 0.0.0.0 --port 8001 -ngl 99 -c 8192 -ub 8192 -b 8192

The -ub/-b flags matter: embedding models read their whole input in one physical batch, and llama-server's default of 512 tokens makes long chunks fail outright. In managed mode — the Embeddings tab of the settings, point it at the binary and the GGUF — the app launches the server with the right flags itself. External servers and the cloud providers with an embeddings endpoint work too; a chat on Claude or Grok simply pairs with a local embedder, since those clouds offer none.

What it powers

The knowledge base. /rag add <file-or-folder> ingests txt, Markdown, HTML, PDF and DOCX into a per-profile library; the model searches it with rag_search. Files are cut into overlapping chunks of roughly 800 characters — characters, not bytes, so Cyrillic text is measured honestly — with Markdown split along its headings, each chunk carrying its heading as a semantic anchor. At search time the overlap is sewn back: adjacent hits from one document merge into a single contiguous passage, so the model never sees the duplication the index pays for.

Large attachments. /file attach puts a file into the current chat in full — no embedder needed. But a file too big to inline switches to by-reference: it is indexed in the background, and the model gets two complementary tools. attachment_search finds the relevant passages; attachment_read walks the file page by page — pages, not offsets, because pages are enumerable, and the model can know it has read everything. Retrieval is the shortcut; paging is the guarantee.

Notes and the self-model. Saved notes are embedded, so note_recall finds them by meaning. The same vectors power the quieter machinery: before saving a note or an observation, close existing ones are shown back — revise, don't duplicate; and each turn, the self-model surfaces the observations relevant to what you just said, not merely the newest ones.

Web search, finally, reranks results by how close each page's content sits to your query.

One thing deliberately not on this list: cross-chat and history search run on a plain full-text index. An embedding server is optional equipment, and exact identifiers — the first thing a summary loses — are precisely what substring search is better at.

Swap the model, and the app notices

The uncomfortable property of embeddings is that vectors from different models are mutually meaningless — and two models can share a dimension, so no size check catches a swap. mindfork detects it behaviourally: it stores the vector of a fixed canary phrase and re-embeds it on the next run. The same model scores 1.0 against its stored vector; a different one lands far away, whatever its dimension.

On a detected change nothing is deleted. Every stored vector is stamped with a generation number; one counter bump retires them all while the text stays put. Notes quietly re-embed themselves on the next search. The knowledge base — too large to heal on a read path — refuses to search and asks for /reindex, one command that re-embeds everything, every profile, from the text already stored, resumable if interrupted. Refusing beats warning: results against a foreign index would be noise dressed up as answers.

The similarity thresholds move with the model, too. A cut-off tuned on bge-m3 sits below the unrelated-text baseline of some other models, so the app measures each new embedder against a small built-in probe corpus and rescales its thresholds — and any failure in that calibration degrades to changing nothing.

If there is no embedder

Everything degrades, nothing breaks. Notes fall back to substring search, attachments to page reading, web results keep the provider's order, and the knowledge base says plainly that it needs an embedding server. The status bar shows a small emb chip with the truth. You can run mindfork for months without an embedder and never hit an error — you are just choosing to search by letters instead of meaning.

And as with everything else in mindfork, the vectors live in the app's own SQLite database (via sqlite-vec), per profile, next to the binary. Your library is indexed on your disk, by a model on your machine.

The decision records — ADR 0002 on the dedicated embedding server, the RAG journal with every measurement — live in the repository: github.com/vshylov/mindfork-rs.