org-semantic — search a tree of org-mode notes by meaning or by words

Table of Contents

License: MIT Rust

Search a tree of org-mode notes by meaning or by words. One static binary, no database, no Python. It runs as a one-shot command, or stays resident for Emacs — over a pipe, never a port.

The example below searches Daniel Bias's braindump, someone else's public vault of 753 org notes in English and Italian, cloned into braindump/. So you can run it as it stands; Trying it on somebody else's notes has two more.

$ org-semantic index braindump/roam --both --model e5-small
  20200924090307-elementi_di_probabilita_e_statistica.org: could not be read, so it is not indexed: stream did not contain valid UTF-8
753 org files
  256 sections were divided to fit the 350-token budget
3038 chunks · 3038 to embed · scanned in 1.5s
model loaded in 0.9s
embedded 3038 chunks in 77.8s (39/s)
wrote braindump/roam/.org-semantic/semantic/e5-small (4.7 MB of vectors) in 80.3s total
  20200924090307-elementi_di_probabilita_e_statistica.org: could not be read, so it is not indexed: stream did not contain valid UTF-8
753 org files
lexical index: 2863 chunks written in 0.4s

$ org-semantic search braindump/roam "what happens when a process is scheduled off the cpu" 2 --per-file 2

0.860 (+1.7σ)  Sistemi Operativi > Gestione Processi
       SO.org:278
       id:5c91241d-3da3-47e6-b27a-9afe7e0b4ff0
       :university:
       Componente del OS: =CPU Scheduler= - Sceglie processi in coda di ready - si attiva ogni 50/100 secondi - crea…

0.860 (+1.7σ)  Sistemi Operativi > Gestione Processi > Scheduling > Implementazione > Scheduler
       SO.org:628
       id:5c91241d-3da3-47e6-b27a-9afe7e0b4ff0
       :university:
       anche Short Term Scheduler decide quale processo in coda di ready sara' eseguito quando: 1. il processo in esecuzione passa…

0.854 (+1.5σ)  Microkernel Based Systems > Kernel Level > Scheduling > in Microkernel Based Systems
       microkernel_based_systems.org:194
       id:ad8e431b-7af6-4eb9-99a7-41af9cd0c4ce
       :erasmus:university:compsci:
       Different ideas: - Brian Ford - CPU Inheritance Scheduling + event \to mk \to root scheduler \to particular scheduler +…

0.850 (+1.4σ)  Microkernel Based Systems > Kernel Level > IPC
       microkernel_based_systems.org:29
       id:ad8e431b-7af6-4eb9-99a7-41af9cd0c4ce
       :erasmus:university:compsci:
       To send messages between threads you don't save and restore those register. The receiving end will declare beforehand to the…

[model load 733ms · query embed 8ms · search over 3038 vectors 1.0ms]

The question is in English, the note that answers it is in Italian, and the title of that note — Sistemi Operativi — shares no word with the question. An English note is ranked beside the Italian ones, in the one list. Finding what you can describe but cannot name is the whole point of org-semantic; with a multilingual model it stops mattering which language you could not name it in.

One note in that vault is UTF-16 rather than UTF-8, and index says so — once per index — rather than passing over it in silence. A search tool that drops notes without mentioning it is worse than one that finds nothing.

Most of that three-quarters of a second is the model loading, paid once per process. For anything interactive, run org-semantic serve instead: it keeps the model and the vectors resident, and answers in 7–9 ms by meaning or 3 ms by word — fast enough to search as you type.

Every timing in this manual is an order of magnitude, not a promise. All of them, the example above included, come from an Apple M2 laptop with 4 performance cores, against vaults of several hundred to a thousand notes. Embedding tracks the CPU almost exactly, so your own figures will differ — what holds everywhere is the shape of them: the word index in seconds, the embeddings in minutes, and a warm query in milliseconds either way.

Why

Existing packages either run a Python service — one popular org indexer pulls in 129 dependencies including torch, CLIP and the Azure SDK — or are built for Markdown and know nothing about org structure. Related work compares them one by one.

org-semantic is a single 34 MB program. The machinery that runs the embedding model is compiled into it, so there is nothing to install alongside it, and the only thing it ever downloads is the model itself — 129 MB, once, into your cache.

By design, it specialises in org and nothing else. Parsing one format properly buys things a format-agnostic tool cannot reach:

  • Property drawers stay out of the embedded text, so :ID: and :MODIFIED: do not dilute a chunk's meaning.
  • #+title: names the note, rather than guessing from the filename.
  • Every hit carries the outline path to the node it came from, so an editor can jump to the section, not to the file — and the enclosing :ID: as well, when the note has one.
  • Tags are parsed with org's inheritance rules and become search filters, as do TODO keywords and priorities.
  • Heading breadcrumbs (Note > Section > Subsection) are prepended to each chunk before embedding, so a passage carries the context it sits under.

If you work in Markdown, markdown-vdb reaches a very similar architecture for that format and you should use it instead.

What it touches

Indexing a vault means pointing a program at everything you have ever written down, which is worth being sure about before you do it.

It reads your notes and never writes them. Not a heading, not a property drawer, not a timestamp: nothing anywhere in the binary opens a .org file for writing. What it creates is one hidden directory — .org-semantic/ beside the notes, or somewhere else entirely if you say so — holding the two indexes and nothing besides. What it writes lists every file in it. Delete that directory and the vault is exactly as it was, because all of it is derived: index --full writes it again in one pass.

Nothing about your notes leaves the machine. There is no service to sign up to, no account, no API key, no telemetry. The embedding model runs in this process on this CPU — that is what most of the 34 MB is — so your prose and your queries are never transmitted anywhere, there being nowhere for them to go.

It reaches the network to fetch two things for itself, and for nothing else: the embedding model (see Install), and a 938 kB language classifier from dl.fbaipublicfiles.com, each once. Both land in the cache and are reused ever after, so a machine that has indexed once works entirely offline. A search in particular never downloads anything at all: asked for a model that is not there, it says so in about a millisecond rather than reaching for the network. Block both hosts after the first run and everything keeps working, short of indexing under a model you have not fetched yet.

Trying it on somebody else's notes

None of which helps if what you want is to see it work before deciding. Any public collection of org files will do, and these three need nothing installed — least of all org-roam, whose vaults are plain org files with :ID: properties and #+filetags: that org-semantic reads on their own:

vault notes what it shows off
denialbb/braindump (roam/) 753 a genuinely bilingual Zettelkasten — about a tenth of it is Italian, so lang: and a multilingual model have something to do
jethrokuan/braindump (org/) 529 org-roam's own author: main/ is written-through concept notes against reference/'s 516 paper notes, so dir: means something
Worg 293 the Org community wiki — larger, deeply nested, and unambiguously licensed (GFDL 1.3)

The first is the one to reach for. Point the vault straight at the clone; the PDFs and LaTeX beside the notes are simply not .org, so nothing looks at them. Say that it is bilingual, or nothing is classified and lang: has nothing to narrow by — one language declared is the answer for every note:

git clone --depth 1 https://github.com/denialbb/braindump
echo '{ "languages": ["en-US", "it-IT"] }' > bilingual.json

org-semantic index braindump/roam --lexical --config bilingual.json  # 2863 chunks in 0.5s
org-semantic search braindump/roam "algoritmo lang:it-IT" --lexical

The search needs no --config of its own: a policy is remembered once given, so later runs against that vault answer under it.

The semantic index over the same notes is around a minute on e5-small, which is the model to pick there — an English-only one will rank the Italian half badly. Then ask it something in English that the notes answer in Italian, which is the thing no word search can do:

org-semantic index braindump/roam --model e5-small
org-semantic search braindump/roam "what happens when a process is scheduled off the cpu"

One note in that vault is UTF-16 rather than UTF-8, so index names it and carries on without it. That is the tool saying what it left out, not a bad clone.

Install

cargo install --git https://github.com/alberti42/org-semantic

Or build it:

git clone https://github.com/alberti42/org-semantic && cd org-semantic
cargo build --release          # target/release/org-semantic

Requires a Rust toolchain, and nothing else — no Python, no libraries to install system-wide, no package manager. The default embedding model downloads by itself the first time you index, into $XDG_CACHE_HOME/fastembed — or wherever ORG_SEMANTIC_CACHE_HOME points, see What it writes.

Prebuilt binaries are on the releases page for Apple Silicon macOS, Linux (x8664 and arm64) and Windows. The macOS one is Developer ID signed and notarized. Unpack it and put org-semantic on your PATH — or, from Emacs, M-x org-semantic-binary-install fetches the one for your platform into org-semantic-install-directory, checks it against the release's own SHA256SUMS, and asks it for its version before saying it is installed.

You need not go looking for that command. The first search on a machine with no binary asks what to do about it and offers the same download, and the search carries on once it lands. Reindexing after a save is the one thing that never asks — it says what is missing and leaves you alone.

Building it yourself, from a release you can check

A binary someone else compiled is a binary you are trusting on their word, and not everyone wants to. Every release carries its own source archive for that reason — org-semantic-<version>-src.tar.gz, with its hash in the same SHA256SUMS as the binaries — so you can verify what you downloaded, read it, and build it:

V=0.3.0
BASE=https://github.com/alberti42/org-semantic/releases/download/v$V
curl -LO $BASE/org-semantic-$V-src.tar.gz
curl -LO $BASE/SHA256SUMS

# `shasum -a 256' on macOS, `sha256sum' on Linux; both read the same file.
grep "org-semantic-$V-src.tar.gz" SHA256SUMS | shasum -a 256 -c -

tar xzf org-semantic-$V-src.tar.gz && cd org-semantic-$V
cargo build --release          # target/release/org-semantic
make test                      # optional, and it all runs from the archive

Then put target/release/org-semantic on your PATH, or copy it into org-semantic-install-directory where Emacs finds it with nothing configured.

Pin this archive rather than the Source code (tar.gz) GitHub attaches by itself. GitHub generates that one on demand and has changed how it compresses it before, moving the checksum of archives already published and breaking everyone who had pinned one. Ours is built with git archive and gzip -n, so a given tag is the same bytes for ever.

It holds what builds and tests the project, and not the whole repository: the screenshots and the vendored HTML theme are left out, which is also what keeps it MIT throughout. The two test suites run from it unchanged — which is worth knowing, since reading the code and then running its tests is the point of building this way.

Intel macOS

There is no Intel macOS build, and cargo build will not make you one. The ONNX Runtime this links against is fetched as a prebuilt library by ort, and ort publishes none for x86_64-apple-darwin. The build stops with:

error: ort-sys@2.0.0-rc.13: no prebuilt binaries available for target x86_64-apple-darwin

Rosetta is not a way round it — it translates x8664 binaries, and there is no x8664 binary to translate.

There is a workaround, which is not tested here and is offered as a starting point rather than a supported path: install ONNX Runtime yourself and tell ort to link against it instead of downloading one.

brew install onnxruntime
ORT_LIB_LOCATION="$(brew --prefix onnxruntime)/lib" \
ORT_PREFER_DYNAMIC_LINK=1 \
  cargo build --release

Two things to know before you try it. ORT_LIB_LOCATION must point at the lib/ subdirectory and not at the prefix, or you get a different and more confusing error about not being able to link to the build; and ORT_PREFER_DYNAMIC_LINK=1 is required, since without it ort still attempts a static link that the Homebrew keg does not provide.

What you get is no longer one self-contained binary. It loads libonnxruntime.dylib from wherever Homebrew put it, so it breaks if that is upgraded or removed, and it is not something you can copy to another machine. That is the reason this is a hint and not the build we ship.

The recipe comes from headroomlabs-ai/headroom#1797, which hit the same wall. Note that their version also sets ORT_STRATEGY=system, which does nothing here: that variable belonged to an older ort and the version pinned in this project no longer reads it. ORT_LIB_LOCATION is what does the work.

Use

usage: org-semantic <command> <vault> [options]

Two indexes are built and searched separately: a semantic one, which finds
notes by meaning, and a lexical one, which finds them by word.

  index  <vault> [--full|--rehash] [--model NAME] [--config FILE]
         Build the semantic index.  Minutes, and downloads a model once.
  index  <vault> --lexical|--both [--full|--rehash] [--config FILE]
         Build the word index (seconds), or --both in one run.
         Incremental by default; --full rebuilds, --rehash re-reads every note.

  search <vault> <query> [k] [--per-file N] [--merge-by-section] [--model NAME]
         [--json]
         Rank by meaning: describe what you are after, not its words.
         k bounds the notes shown (default 8); --per-file bounds how many
         passages any one of them may contribute (default 3).  Keeping a
         year of meetings in one meetings.org?  Raise --per-file.
         A section too long for one passage answers as several, each with
         its own lines; --merge-by-section folds those back into one hit.
  search <vault> <query> [k] --lexical [--any] [--json]
         Rank by word (BM25, over a per-language stemmed index).  Every
         term must match; --any matches notes carrying any of them.
         Phrases, AND/OR/NOT and parentheses follow tantivy's query
         syntax.  A query may carry predicates:
           tag:x  dir:x  todo:x  lang:x, and any of them negated with a
           leading -   (both rankings honour all four)

  chunks <vault> <path-substring> [--lexical] [--config FILE] [--model NAME]
         A dry run of `index`: how notes would be split, and what a
         different --config would do, without building anything.

  tokens <vault> [limit] [--model NAME]     token lengths, and what would truncate

  models [vault]                            embedding models, and which are built

  serve                                     JSON-RPC 2.0 over stdio, for an editor

  bench  <vault> [n] [config]               embedding throughput on a slice

  --version                                 the release this binary is from

Everything about how a vault is indexed is policy, not flags: which languages
it is written in, whether accents are folded, which subtrees are skipped, how
large a passage may get, and what happens to src and example blocks.  It goes
in a JSON file passed with --config, remembered afterwards so later runs need
not repeat it.  Copy config.example.json and edit it.

Each model keeps its own semantic index, so several can be built side by side;
`models <vault>` shows which are.

Searching from Emacs

The org-semantic results buffer, showing an English question answered by Italian notes and English ones ranked together

Figure 1: M-x org-semantic-find, against the same public vault as above: an English question, an Italian note answering it, and an English one ranked beside it.

The package lives in lisp/. Put it on your load-path and tell it where the binary is, if it is not already on your PATH:

(add-to-list 'load-path "/path/to/org-semantic/lisp")
(require 'org-semantic-results)
(setq org-semantic-executable "/path/to/org-semantic")

;; Optional, and explained below: reindex a vault as its notes are saved, so
;; that a note written now is searchable a couple of seconds later rather than
;; after the next `M-x org-semantic-reindex'.
(org-semantic-auto-reindex-mode 1)

Or with use-package, which also gives somewhere to put the keys. There are no default global bindings and there will not be: C-c followed by a plain letter is reserved for you rather than for a package, so a recommendation is as far as this can go.

(use-package org-semantic-results
  :load-path "/path/to/org-semantic/lisp"
  :custom (org-semantic-executable "/path/to/org-semantic")
  :bind (("C-c n s" . org-semantic-find)
         ("C-c n S" . org-semantic-find-at-point)
         ("C-c n R" . org-semantic-reindex))
  ;; Optional, and one of the nicer things here: each passage is previewed
  ;; in its note as point reaches it, so `n' and `p' read the vault rather
  ;; than a list of it.  `f' in the results buffer toggles it either way.
  :hook (org-semantic-results-mode . next-error-follow-minor-mode)
  ;; Also optional: keep the indexes up to date as notes are saved, so
  ;; `C-c n R' is for what changed outside Emacs rather than for every edit.
  :init (org-semantic-auto-reindex-mode 1))

s searches, S searches for the region or the symbol at point, and R indexes. The C-c n prefix is what org-roam's own manual suggests for its commands, and these three compose with it rather than colliding — org-roam uses f, i, l, g, c and j, and none of those. Everything else — org-semantic-cancel, org-semantic-show-status, org-semantic-show-memory, org-semantic-restart, org-semantic-quit — is rare enough to leave on M-x.

That last line is the one worth explaining, since it is off by default and easy never to find. next-error-follow-minor-mode shows each passage in its note as point arrives, without selecting that window — so the list keeps point and the note keeps up. Without use-package it is the same one hook: (add-hook 'org-semantic-results-mode-hook #'next-error-follow-minor-mode). Called from a hook a minor mode enables rather than toggling, which is why this reads as it does and f still toggles.

org-semantic-auto-reindex-mode is the other optional line, and the situation it is for is this one: you write up a meeting, search for it twenty minutes later, and it is not there — because the index is from this morning and nothing has told it otherwise. With the mode on, saving a note reindexes the vault it belongs to two seconds after saving stops, and that question stops arising.

The wait is a debounce — org-semantic-auto-reindex-delay — so writing fifty notes with save-some-buffers costs one run rather than fifty, and a save landing while a run is going waits for it rather than being refused. A run of one changed note is about 70 ms: the note is re-chunked, and only the passages whose text actually moved are embedded again.

It says nothing when it works, which is ~org-semantic-auto-reindex-quietly=; set it to nil to see each run reported. Failures are not quiet — said once per vault, since an automatic feature that has stopped working looks exactly like one that is working.

Two things it deliberately will not do. It will not build an index that does not exist: that is minutes of embedding and a decision, so a vault with nothing built is named once, with M-x org-semantic-reindex as the thing to press. And it does not see notes that changed outside Emacs — a sync, a git pull, a rename in Dired — because a save is all it hears about.

Something else in your configuration may hear about those: a file watcher, or another package indexing the same notes and watching them for its own reasons. It can pass that on with org-semantic-auto-reindex-touch, and the change is then picked up as a save would be:

(org-semantic-auto-reindex-touch "/Users/you/notes")

It takes a vault and not a file, because a run is a vault-wide incremental scan: it needs to know that something changed and not what — which is also why a rename is caught by the arrival of the new name alone, the same scan finding the old one gone. Called with no argument it asks the current buffer, which is rarely what a watcher's callback wants. It is as cheap to over-call as a save: fifty touches inside the debounce are one run.

It does not need the mode, and is not disabled by it being off. The mode is one trigger — saves — and not the policy, so a configuration whose watcher already reports saves uses the touch alone and leaves the mode off; one that has no watcher uses the mode alone. Both together is allowed and costs one run, since they share the debounce. What the touch takes from the mode is its manners: the same delay, the same silence when it works, and the same refusal to build an index that does not exist.

So there are two levels of keeping current, and the first is enough for most people:

  • org-semantic-auto-reindex-mode, above. Built in, one line, nothing else installed — and it hears about every note you write, which is most of what changes.
  • A watcher, plus the touch. For the rest: a note renamed or deleted in Dired, a git pull, a folder arriving from Dropbox. Something has to be watching the filesystem, and org-semantic deliberately is not — serve has no lifetime beyond the editor that started it, so a watcher belongs on the Emacs side where it can be switched off.

The watcher can be Emacs's own filenotify, which is enough in a dozen lines. Watch the vault, touch on anything that looks like a note:

(require 'filenotify)

(defvar my/notes "/Users/you/notes")

(file-notify-add-watch
 my/notes '(change)
 (lambda (event)
   (when (and (memq (nth 1 event) '(created changed deleted renamed))
              (string-suffix-p ".org" (or (nth 2 event) "")))
     (org-semantic-auto-reindex-touch my/notes))))

That watches one directory and not its subdirectories, which is the honest limit of a short example rather than a simplification of it: watching a tree means walking it, adding a watch per directory, and adding another for every directory that appears later. Emacs gives you no recursive watch, and writing one properly is a small package in itself.

Which is the argument for vulpea, the companion worth naming here: if you keep notes in one, that work is already done. vulpea maintains its own database of what each note is — its :ID:, its title, its tags, what links to it — and with vulpea-db-autosync-mode on it watches the whole tree, subdirectories and newly created ones included, to keep that database current. Those watches see exactly what a save hook cannot. The two indexes then answer different halves of one question over one tree, and neither knows about the other's directory: .vulpea/ and .org-semantic/ sit side by side in the vault.

Hearing about what those watches found is one add-hook and no advice. vulpea-db-updated-functions is vulpea's single data-changed signal: it is called with (PATH COUNT) once per file whose database content changed, after the write or delete transaction commits — for an edit, for a note arriving, and, with a count of 0, for a removal.

(add-hook 'vulpea-db-updated-functions
          (lambda (_path _count)
            (org-semantic-auto-reindex-touch "/Users/you/notes")))

Both arguments are ignored on purpose. A reindex is a vault-wide incremental scan, so which file changed does not matter — and neither does the count, since 0 (the notes were dropped) and 3 (they were written) call for the identical scan. Committing first is what makes this better than an after-save-hook, which fires before vulpea's own update.

It needs a vulpea newer than 2.7.0: the hook landed in August 2026, after that release. On an older one the add-hook quietly succeeds and is never called, so that is the first thing to check if changes stop reaching the index. vulpea also reports per file, so its own first sync of a thousand notes fires this a thousand times — the debounce makes that one run.

Failing both, M-x org-semantic-reindex remains what catches up. Being behind costs nothing: a search says so when the index is a version old.

A passage is org text, so it is shown with org's own faces: emphasis, verbatim, headings, block markers and links all look as they do in the note. That is org-semantic-results-fontify, on by default, and it works by inserting the passage into a hidden org-mode buffer and copying the faces back — the trick magit uses for diffs.

Only faces are copied, and the characters are never touched. The nth line of a passage is line startLine + n of the note, which is what makes each line addressable and one day writable, so nothing here may replace or move text. Org's own keymap, invisible and display properties are therefore left behind.

Links are the exception. With org-link-descriptive on — the default, and what M-x org-toggle-link-display toggles — a link shows its description and its brackets are hidden, as in a note. That hiding is ours rather than org's: org 9.8 hides links through org-fold-core, which has to be initialised in the buffer doing the hiding, and a list of passages should not have to become an org buffer for it. The characters are all still there, since invisible hides rather than deletes; a link split across two lines is left alone, because hiding part of one would leave a line nobody can point at.

It costs about 0.8 ms a passage against 0.1 ms without, and needs org loaded, which it will be. Set the option to nil for plain text.

A fragment of a document is what org is being asked to fontify, and three things that might spoil that do not. Folding: the whole passage is fontified before its tail is hidden, so a hidden #+end_src still counts. Emphasis: a paragraph too long for one passage gives every piece of itself the whole paragraph's span, so what you see is always a complete paragraph. And blocks: a block is one paragraph however many blank lines are in it, and its #+begin_ line is inside the span, so a passage never shows one marker without the other. That last one took two fixes to be true.

One setting says which vault your notes are in. With a single vault that is the whole of it:

(setq org-semantic-vault-root "~/notes")

Every buffer then belongs to that vault, including the ones that are nowhere in particular — *scratch*, an agenda — so a search works from wherever you happen to be.

With several vaults, each one says which directory it is, in its own .dir-locals.el, and that overrides the setting for the notes inside it:

((nil . ((org-semantic-vault-root . t))))

t means "the directory this declaration came from"; a string names a subdirectory of it, for notes that live below the project that declares them. Emacs applies directory-local variables when a file is opened — and when Dired opens the directory — so a note in a declared vault arrives already knowing which one it is, before anything has been indexed. Leave the global setting nil, or point it at whichever vault should answer from everywhere else.

Nothing looks for the .org-semantic directory to work out where a vault is: it holds derived data, its place is not the vault's to promise, and a vault found that way would be found on one machine and not on another that keeps its indexes elsewhere — answering with a different vault rather than with none.

When the answer has to be worked out rather than written down, the global setting may be a function of no arguments instead. That is for the case where something else in your configuration already tracks which collection of notes is current — switching between vaults, or opening one per project — so that a fixed directory is the wrong answer and the right one changes during the session:

(setq org-semantic-vault-root
      (lambda () (and vulpea-vault-directory
                      (expand-file-name vulpea-vault-directory))))

Returning nil means there is no vault here, which is a complete answer — a search then says so rather than guessing. Keep it to a variable lookup: it is asked on every question about a vault, including once per save when org-semantic-auto-reindex-mode is on. An error in it is left to signal, since it is your code and a vault that cannot be worked out is worth seeing.

A function is legal only as the global value, never in a .dir-locals.el: a directory you merely visit could otherwise run whatever it liked, so Emacs refuses to apply one and org-semantic ignores it if it is marked safe by hand. A declaration says which directory a vault is; how to work one out is a decision for your own configuration.

Then M-x org-semantic-find from any note — or from Dired, or the agenda — searches the vault that note belongs to and shows what came back. The prompt says which ranking will answer — Semantic search for: or Lexical search for: — and M-s and M-l change it while the query is being typed, carrying across whatever has been typed so far. They are the same two keys the results buffer takes. One C-u asks which ranking to start from — the prompt there names the index each one reads, since they are two indexes and not two orderings of one — and two C-u ask about the length of the list as well:

org-semantic: semantic search for "vacuum bakeout procedure"
~/notes  ·  k=8 notes  ·  3 passages per note
6 notes, 11 passages in 0.31 s

Vacuum system  ·  2 passages
  0.780 (+2.4σ)  lab / vacuum.org > Bakeout > Turbo pump > lines 142–144 :lab:vac:
    The turbo pump was baked out at 120 °C for 48 h before the
    chamber was vented.  Pressure after the bake settled at
    2e-10 mbar, which is the best we have seen on this chamber.

  0.712 (+1.9σ)  lines 181–182
    A second bake was needed after the viewport swap: the copper
    gasket had been reused once too often.

A hit's address reads left to right and every part of it is a link: the directory opens in Dired, the note opens at its top, the section goes to its heading, and the two line numbers go to where the passage starts and where it ends — either end being somewhere you might want to be, the top to read it and the bottom to carry on past it. A one-line passage says line 8 instead, and so does one the note has outgrown, which has no span left to trust.

Only the leading passage of a section carries the whole address; the ones after it name their lines alone, since the path is unchanged and only the lines have moved.

A passage is shown as the note's own lines, in order and unaltered, so RET goes to the line under point rather than to the top of the section — which matters, since a section can run to hundreds of lines. It is wired into next-error, so M-g M-n walks the hits from anywhere.

key does
RET go to the line under point
o the same, in another window
C-o show it without leaving the list
n / p next or previous passage, showing each
M-n M-p next or previous note
TAB unfold the rest of a long passage
g search again — the notes may have moved on
s search for something else
M-s M-l rank by meaning (semantic), or by word (lexical)
l join the words with AND or OR — a word search only
k / K let more or fewer notes appear
C-k that many notes exactly
+ / - more or fewer passages from each note
= that many passages exactly
R index this vault, then search again
f follow mode: show each hit as you move
q bury the list

The two pairs are two different questions: k and K widen the list, + and - deepen the notes already in it. A vault kept in a few large files answers a large k with very few hits until the second is raised too — see Vaults kept in a few large files.

A search sent while an index is running is answered from the version committed before it, and the header says so. Nothing is lost; the list is a moment behind.

If the index a search needs does not exist, the buffer says so and offers to build it — as something to press, rather than as a question asked while you were typing somewhere else. The same goes for an index whose layout is too old to read, and for a policy that has drifted since the index was built.

A prefix argument to M-x org-semantic-find asks for the ranking and the two limits instead of taking them from the settings. M-x org-semantic-reindex indexes the current vault from anywhere: plain trusts each note's timestamp, C-u re-reads and re-hashes every note, and C-u C-u rebuilds from scratch.

Settings

Everything the package exposes, in M-x customize-group RET org-semantic:

variable default what it decides
org-semantic-vault-root nil which vault your notes are in, or a function — above
org-semantic-executable org-semantic the binary, by name on exec-path or as a path
org-semantic-install-directory ~/.emacs.d/org-semantic/ where a binary of its own is kept — below
org-semantic-cache-home nil where the server downloads its models — below
org-semantic-model nil which model, when a vault has several built
org-semantic-index-mode "both" which indexes M-x org-semantic-reindex builds
org-semantic-auto-reindex-mode nil reindex a vault when one of its notes is saved — above
org-semantic-auto-reindex-delay 2.0 seconds of quiet after a save before it runs
org-semantic-auto-reindex-quietly t whether a run that worked says so
org-semantic-config nil the indexing policy to send — below
org-semantic-timeout 30 seconds to wait for a search, a status or a close
org-semantic-index-timeout 7200 seconds to wait for an index
org-semantic-results-ranking "semantic" "semantic", "lexical" or "ask" each time
org-semantic-results-connector 'and' join a word query's terms with and or or
org-semantic-results-fontify t show a passage with org's own faces — below
org-semantic-results-passage-lines 12 how much of a passage to show before folding the rest
org-semantic-results-line-numbers nil number a passage's lines as the note numbers them
org-semantic-results-reveal-function Dired how the directory part of an address is shown — below
org-semantic-results-display-action reuse a results window how the buffer asks to be shown — below

The timeouts are generous rather than tight, and exist because jsonrpc.el wants a number: a warm search is under ten milliseconds, the first one against a vault also loads the model, and the slowest measured case — a search during a full rebuild, sharing one model with it — is under two seconds. An index is minutes, and is stopped by asking rather than by waiting — M-x org-semantic-cancel.

Nothing a search does can outlast its timeout, because a search never downloads. A vault can arrive with an index but without the model that built it — copied to another machine, or a cache cleared under it — and the search then says so at once and offers to fetch it, rather than pulling hundreds of megabytes inside your query:

the bge-small-en model is not downloaded yet

  [d] Download it — fetches the weights and nothing else; minutes
  [l] Lexical search (by word) — needs no embedding model
  [q] leave it

Choice:

The question is asked in the minibuffer, once, and the results buffer keeps the sentence so there is still an account of the empty list after you have answered. d fetches the weights, reporting the size as it goes, and then runs your search again — and only that: nothing is indexed. Should the index turn out to be missing as well, that search says so itself and asks about building it, which is a separate question with its own answer.

l answers this once by word, and leaves the buffer's own ranking alone; if you would rather always search that way, say so in org-semantic-results-ranking. q or C-g declines, which costs nothing: R indexes the vault whenever you like, and M-x org-semantic-show-status says which models are cached before you ask — one it lists as absent is one that would refuse.

Each key is its label's own initial, so the menu is read rather than learnt: the same call is d where it is offered as "Download it" and b where it is offered as "Build it". A failure with nothing to decide — one the server sends no label with — is shown and not asked about, since there would be only one answer.

Two failures describe the vault rather than the request: a policy that has drifted stays drifted, and a model that is absent stays absent. Those are asked about once per results buffer and afterwards merely restated, which is what keeps a search you re-run from asking the same question on every reply.

org-semantic-install-directory is where org-semantic keeps a binary of its own, under your user-emacs-directory. M-x org-semantic-binary-install puts one there; by hand it is unpacking a release into it, and then nothing needs configuring either:

mkdir -p ~/.config/emacs/org-semantic
tar xzf org-semantic-0.3.0-bin-aarch64-macos.tar.gz -C ~/.config/emacs/org-semantic

Release assets are org-semantic-<version>-bin-<platform> for the binaries and org-semantic-<version>-src.tar.gz for the source, with SHA256SUMS covering both. They say bin rather than cli on purpose: the same binary is the server the Emacs package drives, so a name mentioning the CLI would read as the download an Emacs user does not need, when it is the only thing that makes their package work.

It is deliberately outside your package manager's tree, since a straight or elpaca rebuild repopulates the package directory and would take the binary out from under a server running from it. It is searched before exec-path, so a cargo install for shell use cannot quietly move Emacs onto a different build; to run that one on purpose, set org-semantic-executable to an absolute path, which outranks everything.

A symlink there works, and is the tidy way to run a build you are working on without installing it.

org-semantic-cache-home puts the downloads somewhere other than $XDG_CACHE_HOME — an external disk, say — for the servers this Emacs starts:

(setq org-semantic-cache-home "/Volumes/data/cache")

It is passed as ORG_SEMANTIC_CACHE_HOME (see What it writes) and expanded first, so ~/ works here even though it would not in the variable itself.

If you also run the binary from a terminal, set the variable there too. The two are separate environments, and a shell that does not have it downloads the model a second time into the default location — silently, since both runs are behaving correctly. Setting it in your shell profile alone is enough for both if Emacs is started from a shell; a daemon started by launchd or systemd is not, and needs it in the plist or unit file, or here.

org-semantic-config is the one worth care. It is the same policy the command line passes with --config, and it holds the policy itself, not the name of a file — the same object config.example.json contains, written as a plist. The key names are the JSON ones, so they keep their underscores.

Three Lisp values need care, because JSON has three things that all spell themselves nil in Emacs: an array is a vector, false is :json-false, and nil means JSON null, which the server will not parse. A list where a vector belongs is the mistake to expect.

This is the whole of the default policy — the plist form of config.example.json. Copy it and edit, as you would the file:

(setq org-semantic-config
      '(:languages ["en-US"]
        :fold_diacritics :json-false
        :blocks (:src     (:semantic "placeholder" :lexical t)
                 :example (:semantic "placeholder" :lexical t)
                 :results (:semantic :json-false   :lexical t)
                 :quote   (:semantic t             :lexical t)
                 :verse   (:semantic t             :lexical t))
        :planning_line (:semantic :json-false :lexical t)
        :chunk (:semantic_tokens 350 :lexical_chars 1500)
        :exclude_tagged ["noexport" "ARCHIVE"]
        :todo_keywords ["TODO" "DONE"]))

It is sent with searches as well as with indexing, so that an index built under older settings fails the search rather than answering from passages split by rules you no longer hold. The buffer then offers to rebuild, to search it anyway, or to show what moved — once, not on every keystroke.

A setting you leave out is not left alone — it takes its default. The policy is read whole, so (:languages ["de-DE"]) does not mean "German, and everything else as before": it means German with every other setting at the value above. If you indexed with --config and a file, say the same thing here, or the search will report drift on the settings you did not repeat.

An unknown key is an error rather than an ignored one, for the reason a mistyped flag is: a setting that does nothing looks exactly like a typo that does nothing.

Leave it nil — the default — and the index is searched as it stands, which is what the command line does and is never wrong.

Past searches come back with M-p. Every prompt — C-c n s, searching for the thing at point, and s in the results buffer — shares one history, org-semantic-search-history, so the query you typed to get here is reachable from the buffer it produced. Nothing needs configuring for it to survive restarts either: savehist-mode records whichever history a minibuffer used, so if you have it on, this is saved with the rest.

Searching for the thing at point offers it as the default rather than as text already typed — RET takes it, M-n fetches it to edit, and typing anything replaces it without deleting first.

Where the results buffer appears is yours, and this package does not touch display-buffer-alist. That is a user option, and Emacs consults it before the action a caller passes — so whatever you put there wins over org-semantic-results-display-action without either side knowing about the other. What the default asks for is a behaviour rather than a layout: reuse a window already showing results, so searching again does not open another one. With nothing to reuse it falls through to however Emacs shows any other buffer.

For a panel down the right-hand side, in your own configuration:

(add-to-list 'display-buffer-alist
             '((derived-mode . org-semantic-results-mode)
               (display-buffer-reuse-mode-window
                display-buffer-in-direction
                display-buffer-use-some-window)
               (direction . right)
               (window-width . 0.5)))

The order of those three matters, and not obviously: display-buffer-use-some-window falls back to get-largest-window and so all but always succeeds, which leaves anything listed after it unreachable — direction and window-width included. Put it last.

A hit's address is four links — the directory, the note, the section and the line — and each goes to the thing it names. The directory opens in Dired; org-semantic-results-reveal-function replaces that with anything else:

(setq org-semantic-results-reveal-function
      (lambda (directory _file) (my-file-manager directory)))

It is called with the directory and the note, both absolute; the second is for putting point on the note once the directory is shown, and may be ignored.

The buffer's faces are under M-x customize-group RET org-semantic-results. They inherit from stock faces, so a theme reaches them without being told about this package.

Driving it from Emacs, or anything else

Nothing about org-semantic is Emacs-specific: search --json returns the hits as data rather than prose, and serve speaks JSON-RPC over a pipe, so any program that can start a process and write to its standard input can drive it — another editor, a script, or an agent doing retrieval before it answers.

serve keeps the process alive, so a query costs milliseconds instead of a model load:

$ org-semantic serve        # JSON-RPC 2.0 over stdio, LSP framing
request time
first semantic query, loading e5-small 1.6 s
the same query again, model resident 7–9 ms
first lexical query 14 ms
lexical, warm 3 ms

That gap is the whole reason for a resident process: 8 ms is a keystroke, 1.6 s is not. The first figure is the model being read from disk and is paid once per process, so it scales with the model — bge-small-en takes about 300 ms where e5-small takes 1.6 s — while the warm figures do not depend on it.

Messages are framed the way LSP frames them, with a Content-Length header. That choice is for the client's sake: Emacs ships jsonrpc.el — the library Eglot runs on — so the editor needs no protocol code of its own. It starts the process with make-process, and request/response correlation and notifications come for free. No socket, no port, no authentication, and the server lives exactly as long as the editor does.

A session opens with initialize and the initialized notification, as LSP sessions do; jsonrpc.el and Eglot do this for you. Nothing is negotiated there — the package and the binary ship from one repository — but it is where the server says which version it is.

An index runs on a thread of its own, so the server keeps answering while it works. A search sent during a reindex is answered from the version committed before it, and marked indexing: true so you can say so in the UI. It is also slower — see below.

One server can hold several vaults, and the embedding model is loaded once for all of them. Nothing about embedding a query depends on which vault asked, so the weights are shared; what each vault adds is its own chunk table and vectors, a couple of megabytes for a small vault and about ten for a large one. Measured with the small English model: one vault costs 253 MB, three cost 256 MB.

When you are finished with a vault — the last buffer visiting it is gone, say — send close with its path. Its table and vectors are dropped, and the model with them if no other vault is using it.

What that buys is a ceiling rather than a refund. Opening and closing three vaults five times over settles at about 262 MB and stays there, so nothing accumulates; but the memory comes back to the system on the runtime's own schedule, which is unpredictable — sometimes seconds, sometimes not at the moment you looked. Do not wait for the number to fall.

A ceiling is the more useful thing, though, and it is why one server for all your vaults is the arrangement to prefer. One server per vault would give every byte back the moment it exited, but it pays for a whole model each time: two vaults open at once cost about 507 MB that way against 256 MB shared, three about 760 MB against the same 256 MB. Only if you visit vaults strictly one at a time do the two come out level. The protocol is identical either way, so this is the client's choice to make — but memory is not the reason to split.

Methods:

method params returns
initialize capabilities serverInfo — the release this process is
search vault, query, k, perFile, mergeBySection, mode (semantic or lexical), model, any, config {"hits": [...], "indexing": false}
index vault, mode (semantic, lexical or both), full, rehash, model, config what each index did, as numbers — and $/progress while it runs
status vault which indexes this vault has, whether it is resident, whether one is being built
reload --- drop cached indexes after a rebuild
close vault forget that vault; how many entries went
memory --- what this process holds, in bytes
$/cancelRequest id — (a notification; stops that run)
shutdown --- stop accepting work, and wait for a run in flight
exit --- end the process

Both modalities take the same request and return the same shape, so an editor can offer one command with a toggle and never branch on the reply. Each hit carries an absolute file, the heading path, the headingLine, the passage's own startLine=/=endLine, and the :ID: when there is one.

The address of a hit is path and headingLine, and there is no second way to go. path is relative to the vault root — the directory holding .org-semantic — so it names the note uniquely and survives the whole tree being moved; file is the same thing made absolute, for convenience. headingLine is a line in the real file, counted over the raw text: the parser drops drawers, keywords and block bodies from what it indexes, but never from what it counts, so a fifteen line source block collapsed to [src bash] shifts nothing.

That line is where the heading owning the passage starts — or line 1 for text that sits above every heading. So a client opens the file and goes to the line, and it is on the section. There is nothing to resolve, match or choose: org supplies the subtree, the properties and anything else from the buffer. This holds for both modalities.

heading is for showing a hit in a result list, and is part of what was embedded; it is not a navigation anchor. id is an extra for the vaults that carry them.

text is the passage, read from the note when you search rather than stored in the index — so it is the real document, code blocks and tables included, not the filtered version that was embedded. startLine and endLine give the same range, for a client that would rather read or highlight it itself. A note that has moved since it was indexed yields an empty text rather than stale lines.

One consequence of headingLine naming the section: a section long enough to be divided yields several passages reporting the same path and headingLine. They remain distinct hits, and startLine=/=endLine separate them — so you can jump to the passage that matched rather than to the top of the section it sits in.

If you would rather see one result per section, --merge-by-section (mergeBySection over JSON-RPC) folds them: the section is scored by its best passage and spans all of them. It is off by default because the spans make each passage reachable on its own, and hiding the one that matched behind the top of its section throws that away. Which you want is a question about your result list, so it is the client's to answer.

One trap if you build an org link to the heading rather than jumping yourself: org-link-search prompts "Create new heading?" when it misses, which over emacsclient blocks every frame. Bind org-link-search-must-match-exact-headline to nil and pass the heading text unescaped.

k and perFile are the two caps described under vaults kept in a few large files; an editor showing such a vault raises the second.

An empty query returns no hits rather than an error, so it is safe to send on every keystroke; debouncing is the editor's policy, not the server's.

config is optional on search, and when present it is checked against what the index was built under — see what gets indexed for what that is for and what a client should do with the error. Leaving it out searches whatever is there, which is what the CLI does.

Which binary you are talking to

The Emacs package and the binary ship from one repository, so there is nothing to negotiate — but they carry two versions, because they do not change together. The package's moves whenever anything ships, a change to one elisp file included; the binary's moves only when the Rust does.

So a client checks a floor, not a match: it needs a binary no older than the oldest one it knows how to talk to, and anything from there up is fine. A newer binary is not a problem — the protocol gains methods and fields rather than changing them — and neither is one that will not say what it is, which is a missing answer rather than an old one.

The practical effect: a release that only touches the elisp asks you to download nothing. Comparing the two for equality instead would report that one of them was stale every time one of them moved, and the only way to quiet it would be a 40 MB rebuild with no change in it.

Ask the file, before starting anything:

$ org-semantic --version
0.1.0

Ask the process, which is a different question the moment a new binary has been installed underneath a server that is still running — the file on disk no longer says what that process is. The handshake answers it, which is also the one moment a client is certain to be listening:

--> { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "capabilities": {} } }
<-- { "capabilities": {},
      "serverInfo": { "name": "org-semantic", "version": "0.1.0" } }

Errors you are meant to act on

Some failures are things to show the user; others are things to offer to fix. "The vault path does not exist" is the first kind. "Your settings no longer match what this index was built under" is the second: the right response is a prompt, and the client needs to know which settings moved to write one.

Telling them apart by reading the message would mean matching English, so the ones worth acting on carry a label in the error's data member — the same place LSP puts one:

{ "jsonrpc": "2.0", "id": 7,
  "error": {
    "code": -32000,
    "message": "the semantic index was built under a different policy — todo_keywords: was [DONE, TODO], now [DONE, TODO, WAITING]\nreindex with `full` to rebuild under the new one, or restore the previous setting",
    "data": { "kind": "config-drift", "target": "semantic",
              "changed": ["todo_keywords"], "remedy": "reindex-full" } } }

The absence of data is meaningful: an error without it is one to display, with nothing to decide.

kind what happened data also carries
config-drift the policy sent differs from the one the index was built under target, changed
index-layout the index predates this version of the tool target, found, expected
no-index nothing is built for this vault yet target, and built when other models are
index-corrupt the chunk table and the vectors disagree in length target, chunks, vectors
unknown-model no such model known
ambiguous-model several indexes exist and none is the default built
indexing an index is already running; only one runs at a time remedy (wait)

Every one carries remedy, which is the call to offer rather than a sentence to parse: index means build it, reindex-full means rebuild it from scratch. changed names the settings, so a prompt can say which one moved without taking the message apart.

message stays the human sentence, unchanged, and is written to be shown as-is. It never names a command-line flag, because the person reading it in an editor has no command line to type one on.

Watching an index happen

A full reindex takes minutes. While it runs, index sends $/progress notifications so you are not staring at a pipe:

{ "jsonrpc": "2.0", "method": "$/progress",
  "params": { "token": 7,
              "value": { "kind": "report", "target": "semantic", "phase": "embed",
                         "unit": "chunks", "done": 256, "total": 800,
                         "tokens": 5120, "ofTokens": 17434, "secs": 0.68 } } }

The token is the request's own id, so there is nothing to set up first. LSP would have the server ask for a token with window/workDoneProgress/create; you are waiting on the id already, so there is nothing to negotiate.

There is no begin or end. An index that fails answers with an error, and would skip its end — leaving you holding a token nothing will ever close. The rule instead:

One report per completed unit of work. A change of target or phase ends the previous run of reports. The response — result or error — ends the last.

field  
target, phase which index, and which part of building it
unit what done and total count — "files", "chunks"
done, total comparable only within one (target, phase) pair
tokens, ofTokens embedding only
bytes a download's size, if it could be asked for; never a total to climb
secs seconds into this phase
last the final report of its phase

No percentage, no rate, no estimate: those are yours to compute and yours to decide how often to show. The phases are scan, chunk, embed and download, and mode: "both" runs scan and chunk once per index — which is why every report says which one it belongs to.

Rates are chosen so as not to flood your event loop, not as a display policy: at most one report per 100 ms, whatever the phase. The first and last of a phase are never held back, so you always see one start and you never end up rendering 6,400 of 6,522.

In practice that cap only touches scanning and chunking, which count files and would otherwise fire hundreds of times a second. An embedding batch takes seconds, so every batch is reported.

A download report has a size and no total. Nothing counts up towards it: fastembed hands over no increments, and a bar frozen at nought for four minutes reads as a crash. Show a spinner and the size. bytes is absent when the size could not be asked for, which is not an error — the download is announced either way.

Reports are dropped rather than queued if you stop reading. A report is worth less than the work that delivering it would hold up, so a client that stops draining loses some and the index carries on at full speed. Do not build anything that needs every one of them — the reply is what always arrives, and it says where the run got to.

The timeout will bite you before any of this does. jsonrpc-default-request-timeout is 10 seconds, and a reindex takes minutes. Progress notifications do not reset it — neither LSP nor jsonrpc.el has such a rule — so jsonrpc-request gives up long before the index finishes, and you will write the $/progress handler and still see nothing. Use jsonrpc-async-request, or pass an explicit :timeout.

Stopping a run

Stopping the work and stopping the program are one act on a command line: Ctrl-C ends both, and there is nothing else to want. Behind an editor they are two — you want this reindex abandoned and the server still there for the next query — and separating them is what this is for.

Send $/cancelRequest with the id of the index request:

{ "jsonrpc": "2.0", "method": "$/cancelRequest", "params": { "id": 7 } }

The run stops at its next unit of work and answers under that id with LSP's -32800 RequestCancelled and a data.kind of "cancelled". The session survives: one request ended, not the server. Because the id is carried, a cancellation that arrives after its run has already finished does nothing, rather than stopping whatever came next.

Nothing is half-written. The checks sit between units and nowhere near a write, so an abandoned run leaves the previous index exactly as it was.

Two things this cannot stop. A model download has no units to check between, so it runs to the end; and shutdown waits for a run in flight rather than abandoning it. Both are answered the same way: end the process. Ctrl-C means exactly that again, and exit is its polite form.

Searching while it reindexes

An index runs on its own thread, so the server goes on answering. A search during a reindex is answered from the version committed before it, and the reply says so:

<-- { "hits": [ ... ], "indexing": true }

Grey the list, or label it; the next search after the run replies will be current. status carries the same flag for a client that wants to ask before searching.

A few things worth knowing before you build on this.

A search during a rebuild waits for one embedding batch. The server keeps one model resident, and the indexing run uses that same one — holding it for a batch at a time and releasing it in between, so a query arriving mid-batch waits for that batch and no longer. The wait is BATCH divided by chunks per second: a p90 of about 1.7 seconds on a full rebuild, against 9 ms for a warm query with nothing else running. So a rebuild does not block searching, but it is too slow to search as you type. Wait for the run, or use the word index.

The process never holds a second copy of the model. An earlier version let a long run load its own, which answered searches in 41 ms instead of 1.7 seconds. It cost 229 MB on the small English model, and more on the larger ones in proportion to their weights. That memory was never returned: the process stayed at its high mark until you restarted it. The option that declined this, conserveMemory, is gone with it. A rebuild still needs memory for the runtime's own working set, which measures at about 691 MB on the small English model against 255 MB idle.

Lexical search is unaffected either way — it uses no model — and stays at a few milliseconds throughout, which makes it the better fallback while a semantic rebuild is going.

A second index of the same vault is refused, not queued — it comes back as an error with data.kind of "indexing". Keep at most one in flight per vault and re-fire from its reply. A different vault is not refused: vaults index independently, and a one-note reindex of one never waits on a full rebuild of another.

Refusing rather than replacing is deliberate. A cancelled run writes nothing at all, and the reuse that makes a restart cheap reads the last index that was written — so cancelling a rebuild to start a newer one throws away every minute of work it had done, and a vault saved often enough might never finish indexing at all.

And only one thing may index a vault at a time, whichever process it is. If your editor's server is rebuilding a vault and you run org-semantic index on the same one, the second is refused and says which process holds it. It is a lock file, .org-semantic/index.lock, taken for the length of a run and removed at the end — including when the run fails. Two writers at once could otherwise pair the chunk table from one with the vectors from the other, which no length check can notice and which would answer every query from the wrong place.

Killing a run with Ctrl-C leaves the file behind, since nothing gets a chance to tidy up; the next run notices the process is gone and takes it over. In the one case where it cannot tell — a lock with no readable owner — it waits a minute before assuming the worst, and the error names the file so you can remove it yourself.

Asking what it is holding

memory reports what the process is using, in bytes:

--> { "jsonrpc": "2.0", "id": 1, "method": "memory" }
<-- { "rss": 265617408,
      "vaults": [ { "vault": "/home/you/notes", "model": "bge-small-en",
                    "chunks": 1023, "vectors": 1571328, "table": 268691 } ],
      "models": [ { "name": "bge-small-en", "weightFile": 133093490 } ] }

rss is the whole process. vaults and models are the parts that can be counted exactly — the vectors, the chunk table, and which models are loaded, each named once however many vaults share it. weightFile is the size of the model on disk, which is what the weights are rather than what they occupy once loaded.

There is no figure for the embedding runtime, and that is deliberate. ONNX Runtime offers no way to ask what its own arenas cost, so anything reported under that name would be a guess dressed as a measurement — and subtracting the rest from rss would not give it to you either, since the difference also holds memory the allocator is keeping back and the process's own working space. If you want that number, subtract and label it honestly.

Raw byte counts, and nothing derived: totals and units are yours to compose.

Searches, by contrast, are all answered. The server keeps no queue of its own and supersedes nothing: ten keystrokes are ten searches and ten replies, handled one at a time in the order they arrived. That is cheap when nothing is running — about 10 ms each — and expensive during a rebuild, where every one of them waits out an embedding batch.

So debounce, or better, keep one search in flight: send, and when the reply comes, send the latest query the user has typed since. That bounds the queue at one without any protocol, and it slows down by itself exactly when the server does. $/cancelRequest is no help here — it applies to an index run, and a search is answered before a cancellation for it could even be read.

A pile of stale replies is untidy but harmless: replies are correlated by id, each is a whole frame, and they arrive in the order you asked. jsonrpc.el logs a reply it is no longer waiting for and discards it. What does hurt is the timeout — ten searches queued behind a rebuild will outlast jsonrpc-default-request-timeout — and one-in-flight is what avoids that.

SIGINT is not part of this. It once was — there was a handler that stopped a run politely — but that only existed because the server could not read a cancellation while it was busy, and it can now. Ctrl-C means what it means everywhere else: the process ends. That is also the honest answer for a model download, which has no units of work to check a flag between and so was never really interruptible.

Warnings that do not stop the run

Indexing can go wrong in ways it survives: a note that will not read, a heading too long to leave its passage room, an incremental run that had to rebuild from scratch. On the terminal these go to standard error. There is no terminal behind serve, so index returns them as remarks:

{ "semantic": { "files": 951, "chunks": 6522, "embedded": 3 },
  "lexical":  { "files": 951, "chunks": 5809 },
  "remarks": [
    { "kind": "unreadable-file", "target": "lexical",
      "path": "03 Literature review/notes.org",
      "message": "could not be read, so it is not indexed: stream did not contain valid UTF-8" },
    { "kind": "heading-shortened", "target": "semantic",
      "path": "01 Projects/plan.org", "line": 672,
      "message": "heading too long to leave the passage room; shortened for embedding" } ] }

The shape is LSP's Diagnostic — a kind, a position, a message — and path is vault-relative, so it addresses a note exactly as a hit does. There is no severity: every one of these is a warning.

kind what it tells you
unreadable-file that note is missing from the index, not merely absent from your results
heading-shortened the section is filed under a cut heading path
index-rebuilt you asked for an incremental run and paid for a full one, and why
stale-policy the cached policy would not parse, so the defaults were used
unknown-configured-language a language in your policy is not one the classifier knows
unknown-declared-language a note's own # ltex: language= names one, and says where
model-downloaded why a five-second index took ninety
truncated how many of one kind were left out

The key is missing when there is nothing to report. Past fifty of any one kind the rest are counted rather than listed, since an editor that reindexes on every save should not carry the same four hundred problems each time.

remarks is a list for the whole run, not one per index: two of the kinds arise before either index is touched, and a note that will not read is one problem even when both indexes trip over it. Where it matters, target says which index a remark came from.

Reindexing happens in-process too. Spawning a CLI for it would pay the model load again, which is the cost the resident process exists to avoid — so a short run borrows the resident model and re-indexing a vault after saving one note takes under a tenth of a second, embedding only that note. A long run loads its own instead, so that searching stays quick while it works; see above.

Either way the new index is adopted, not re-read: the run already holds it in memory when it writes it, and hands it over. So the next query answers from the version just built, never from the one it replaced, and without reading the file back.

Letting an agent search for you

This tool was built so that you can find your own notes. That is the point of it, and everything above is about that. What follows is an extension some people will want, and nothing depends on it.

An AI assistant answering a question about your work has two options. It can answer from what it learnt during training — which knows nothing of your notes — or it can first go and read the relevant passages from them, and answer with those in front of it. The second is called retrieval-augmented generation, or RAG: retrieve first, then generate. The retrieval half is exactly what org-semantic does, and the assistant needs no model of its own to use it, since --json hands it the passages as data.

The awkward part is usually teaching the assistant when to search and how to read what comes back. A short instruction file does that, and one is included:

cp -R skills/org-semantic ~/.claude/skills/     # Claude Code

It tells the agent which of the two searches suits which question, that a surname is invisible to the semantic index and a paraphrase invisible to the lexical one, how to narrow with tag: and dir:, that z above about 2σ is a real match and below 1σ is not, that a lexical score means only "more than the ones below it", and that it should ask before spending minutes on an index. The file is plain Markdown — if your assistant takes instructions in some other form, it reads as documentation and translates easily.

Your notes stay where they are throughout. org-semantic never modifies them, and nothing leaves the machine: the embedding model runs locally, and the only thing the assistant sees is the passages it asked for.

Scores, and why the raw one is not worth showing

Search prints 0.883 (+2.1σ). The first number is the similarity itself; the second says how far above the ordinary background it stands. Read the second one. (--json carries them as score and z.)

The reason is that these models do not spread their vectors over the whole space: they point in nearly the same direction, whatever the text. Take two passages with nothing to do with each other and they still score 0.563 under bge-small-en, or 0.801 under e5-small. So most of any score is a fixed background common to everything, which differs from one model to the next and tells you nothing about the hit in front of you:

  raw z
bge-small-en, top hit 0.755 2.52σ
e5-small, top hit 0.883 2.17σ

The σ figure subtracts that background and measures what is left against how much scores normally vary — so +2.1σ means "well clear of the noise" in either model. The two disagree by 0.13 on the raw scale and land in the same place on this one. It also catches weak hits that look respectable: 0.826 under E5 is only +0.7σ, barely above the background.

The background is measured from the vectors themselves, by sampling 20,000 unrelated pairs when an index is loaded — about 37 ms, then kept in memory. Measuring rather than storing it means it can never disagree with the vectors it describes.

Lexical scores are left as they are, and carry no σ. BM25 has no fixed range: a score is a sum over the matched terms, weighted by how rare each is in the corpus and damped by document length, so it rises with both the rarity of what you asked for and the number of terms that hit. On the same vault, the top hit for atom scores 11.9, for LSCOLORS 21.6, and for Rabi oscillations 29.2 — which says nothing about which is the better answer to its own question.

So a BM25 number means only "more than the ones below it". It is not comparable between queries, between vaults, or with anything on the semantic side, and there is no noise floor to standardise it against — hence z: null for lexical hits in --json. Read the ordering, not the value.

Within a single note the passages show raw scores alone: they share the same offset, so the only comparison that matters there is between them.

No threshold is ever applied. z is presentation; what to do with it is the caller's business.

Choosing an embedding model

$ org-semantic models
name             dim  trained on
bge-small-en     384  English  (default)
bge-base-en      768  English
bge-large-en    1024  English
e5-small         384  100 languages
e5-base          768  100 languages
e5-large        1024  100 languages
org-semantic index ~/notes --model e5-small --full

Pick a multilingual model if your notes are not all in one language. With e5-small, an English query finds the German note it never mentions:

$ org-semantic search ~/notes "why do atoms get lost from the trap"
0.891  Trap physics            ← English
0.838  Atome in der Falle      ← German, never using those words
0.736  Ricetta                 ← unrelated

Each model keeps its own index, so you can build several and compare them without re-embedding for the one you already had:

$ org-semantic models ~/notes
name             dim  trained on      status
bge-small-en     384  English         built default
e5-small         384  100 languages   built
…

$ org-semantic search ~/notes "why do atoms get lost from the trap" --model e5-small

search --model selects between built indexes; it cannot impose a model on vectors built by another, because a query must be embedded by whatever embedded the corpus. With one index built it is used automatically; with several the default wins unless you name one. Naming a model you have not built is an error listing what you have.

Scores are only comparable within one model. BGE spreads its cosines widely (0.37–0.74 above); E5 compresses everything into roughly 0.73–0.93. Only the ranking carries meaning, never the absolute number.

fastembed offers forty models; these are the ones whose prefixes are known here. Each family expects its own — BGE prefixes only the query, E5 prefixes the indexed passage too — and the wrong convention costs retrieval quality silently, so a model is listed only once its prefixes have been checked. bge-small-en and e5-small have been run end-to-end; the base and large variants inherit their family's prefixes and have not been exercised here.

Two indexes, built separately

index follows the same convention as search: bare it builds the semantic index, --lexical builds the word index, and --both does the two in one command.

org-semantic index ~/notes                 # embeddings      ~200 s / 951 notes
org-semantic index ~/notes --lexical       # BM25             1.3 s
org-semantic index ~/notes --both          # both

languages and fold_diacritics affect only the lexical index, which is the only one that has a use for a language: they choose the stemmer.

They are separate artifacts with separate records of what they have seen, so each re-run only reads the notes that index is behind on. Both are incremental by default; --full rebuilds from scratch and --rehash re-reads every note, ignoring timestamps.

Embedding takes minutes and a 129 MB model; the lexical index takes a second and nothing but the notes. So refreshing keyword search after editing a few notes costs a second, and changing the folding or your language list rebuilds only the lexical index — those settings are hashed per index.

Two rankings, never merged

Without a flag, search ranks by meaning: your query is turned into a vector and compared against the vector of every passage.

With --lexical it ranks by words, using tantivy. That is ordinary keyword search, of the kind a search engine does: it keeps a list of which words appear where, and ranks a passage by BM25, the standard measure for this — rarer words count for more, a word appearing twice counts for less than twice, and long passages are not rewarded for their length. Words are reduced to their stem first, in the language of the note, so Sprachen finds Sprache.

It comes with a real query language — phrases, AND=/=OR=/=NOT, parentheses and field boosts — following tantivy's query syntax in full. A malformed query is reported as a syntax error rather than silently matching nothing.

Every term must match by default, since matching any of them would rank anything merely containing "oscillations" for the query "Rabi oscillations"; --any asks for that looser match when you want it.

One command, but never one merged result list: a phrase or a boolean means nothing to an embedding, so a fused list would mix hits that honoured your query with hits that could not.

The difference is not academic. Searching your notes for the surname Gehm:

$ org-semantic search ~/notes Gehm --lexical
13.187  2024-08-27 Heating rate in optical traps      ← the note citing Gehm 1998

$ org-semantic search ~/notes Gehm
0.819 (+0.5σ)  01 Deutsche Wörter 2024                  ← noise

A surname carries no meaning for an embedding model. Equally, why do the atoms heat up and get lost from the trap finds the right passage semantically and nothing at all lexically, since none of those words appear in it.

Vaults kept in a few large files

Plenty of people keep every project in one projects.org and every meeting in one meetings.org, rather than a file per note. org-semantic is built for that as much as for a file per note, but there is one number you will want to change.

A result is an outline node, not a file. A hit inside a four-thousand-line meetings.org is reported as the heading path that contains it, at the line where it starts, with the :ID: of the nearest enclosing node — so it points at the meeting, not at the file the meeting happens to live in.

You do not need :ID: for any of this. path, heading and line locate a hit on their own, and they stay correct: rename a note and the next index run sees the old path gone and the new one arrived, so the paths are rewritten — for free if the note has a #+title:, since nothing it embedded changed. Org itself only creates an :ID: when you ask (org-id-get-create, org-attach, or a package like org-roam), so most vaults have few, and a tool that hinged on them would work well for some people and badly for everyone else.

id is therefore a bonus rather than the handle: present, it is the nearest enclosing node's, and org can resolve it without consulting our index at all — useful when the index has not caught up with an edit. Absent, nothing is lost.

$ org-semantic search ~/notes "who is fixing the cryostat shaking"
0.883 (+2.4σ)  Meetings > Meeting 099 — cryostat vibration > Decisions
       meetings.org:1474
       id:mtg-0099-…

Two numbers bound the list, because two different things can crowd it out:

k (positional) how many notes may appear — default 8
--per-file N how many passages any one note may contribute — default 3

Both are applied to one list ranked by score, walked from the top. A note joins the list the moment its best passage comes up, so the k notes you get are the k notes with the highest-scoring single passage — nothing that was left out has a passage better than the best of one that got in. The passage cap then counts what has been taken from that note across all of its sections: three is three whether they come from one section or three, and a note with five sections each holding a hit shows its best three.

The consequence is worth knowing, because it is the one thing here that is not obvious: the list is not the best passages in the vault. A note already on the list may contribute a second or third passage that scores below the best passage of a note that was shut out. With k 2:

0.91  note A, passage 1     A joins the list
0.88  note B, passage 1     B joins the list
0.72  note A, passage 2     taken --- A has room
0.70  note A, passage 3     taken --- A is now full
0.69  note C, passage 1     dropped --- no room for a third note

That 0.69 loses to a 0.70 from a note you can already see, and that is the point of having two caps rather than one: without the note cap, three large files answer k 50 with everything they have and you never reach the fourth note. Raise k to see more places; raise the passage cap to go deeper into the places you already have.

The defaults suit a vault of one note per file, where a note matching in five places would otherwise spend the whole list on itself. Keep your year of meetings in a single file and that same cap becomes the thing in your way: every hit comes from one file, so three is all you will ever see, and raising k will not help — it counts files, and you have one.

# Three files, everything in them: ask for depth, not for more files.
org-semantic search ~/notes "cryostat vibration" --per-file 25

Nothing about indexing needs changing for large files. Notes are split at headings regardless of the file they sit in, and re-indexing costs what you actually edited: adding one meeting to a file of three hundred re-embeds that one meeting. Inserting it at the top, which moves every line number below it, still re-embeds only the one — a line is recorded with the passage but is not part of what was embedded.

Filters

A query may carry predicates, which narrow which chunks are searched before anything is embedded:

org-semantic search ~/notes 'tag:Literature estimating eigenvalues on hardware'
org-semantic search ~/notes 'dir:"01 Daily notes" atom sorting in a tweezer array'
org-semantic search ~/notes '-tag:Deutschlernen -tag:Computer atom heating'
predicate meaning
tag:x chunk carries tag x; repeating narrows (all must match)
dir:x note lives under directory x; repeating widens (any may match)
todo:x nearest enclosing heading has TODO keyword x
lang:x note is in language x; lang:de matches de-DE and de-AT
-… any of them negated: the chunk must not answer to it

Every predicate negates, with a leading -. Excluding a directory excludes everything under it, so -dir:archive also drops archive/2024/. A query may be nothing but exclusions — -todo:DONE on its own is every passage that is not done.

Quote a value containing spaces, and quote it whole: dir:"03 Literature review/Reviewed in 2024". Unquoted, the words after the first fall out as free text and the filter matches nothing. Directories are matched by whole component, so dir:"03 Lit" is not an abbreviation for 03 Literature review — it matches nothing at all.

A dir: is absolute when it starts with / or ~, and relative to the vault otherwise — the rule expand-file-name uses, so it is the one Emacs already taught you. All four of these name the same directory, which means a path pasted out of Dired works as it stands:

dir:"08 Conferences"                      # relative to the vault
dir:"./08 Conferences"                    # the same
dir:"/Users/me/notes/08 Conferences"      # spelled in full
dir:"~/notes/08 Conferences"              # and with a home directory

An absolute dir: outside the vault is an error rather than a search that finds nothing — "nothing matched" and "that directory is not in this collection" are different answers, and only one of them would be true.

What gets indexed

Two things are decided by policy: which subtrees are indexed at all, and what happens to blocks.

Subtrees tagged :noexport: or :ARCHIVE: are left out — org's own markers for "not for consumption" and "put this away". Both inherit, so the rule covers a whole subtree, children included.

Blocks are treated differently by each index. Code embedded as prose pollutes a semantic search — a shell snippet lands near queries it has nothing to do with — but exact match is precisely what you want when hunting a flag or a function name. So by default the body of a src block is not embedded, and is searchable by word:

$ org-semantic chunks ~/notes "smb" | tail -1
    tail: "…autofs will pick it up.\n\n[src bash]\n\nAfterwards the volume survives…"

$ org-semantic chunks ~/notes "smb" --lexical | tail -1
    tail: "…mount_smbfs //user@server/share /Volumes/share -o nobrowse\n\nAfterwards…"

"placeholder" is why the first one still reads properly. Dropping the block outright would glue the paragraph before it to the one after — an adjacency the note never had — and lose the fact that a snippet was there at all, which is part of what the section is about. [src bash] keeps both, without forty lines of shell drowning the prose around it.

The whole policy lives in a file you own, named with --config. Copy config.example.json — it is exactly the defaults — and edit it:

{
  "languages": ["en-US", "de-DE"],
  "fold_diacritics": false,
  "blocks": {
    "src":     { "semantic": "placeholder", "lexical": true },
    "example": { "semantic": "placeholder", "lexical": true },
    "results": { "semantic": false,         "lexical": true },
    "quote":   { "semantic": true,          "lexical": true },
    "verse":   { "semantic": true,          "lexical": true }
  },
  "planning_line": { "semantic": false, "lexical": true },
  "chunk": { "semantic_tokens": 350, "lexical_chars": 1500 },
  "exclude_tagged": ["noexport", "ARCHIVE"],
  "todo_keywords": ["TODO", "DONE"]
}

languages and fold_diacritics configure the lexical index, which is the only one that stems anything — see Languages below. semantic takes true (embed it), false (drop it) or "placeholder"; lexical is a plain boolean, since labelling something in an exact-match index would only make [src] a searchable word. Babel #+RESULTS: and bare =: = fixed-width lines count as output, not prose. Quote and verse stay in both — they are prose someone chose to set off, not machine output.

chunk is how large a passage may get, and the two numbers are in different units on purpose. An embedding has a hard context limit, so the semantic budget is counted in the model's own tokens. Counting characters instead would be a proxy that drifts with the content: chars-per-token runs about 2 in LaTeX-heavy notes and 4 in prose, and German compounds tokenize worse than English, so one figure in characters means different amounts of context in different notes of the same vault — and how coarse a hit is should not depend on which language a note happens to be in.

BM25 has no context limit, and the word index deliberately loads no tokenizer — that is what keeps index --lexical to a second's work rather than a model download. Its budget is therefore in characters, which it can measure exactly, rather than an approximation of tokens it has no way to count.

The heading is prepended to every passage before it is embedded, so it comes out of the semantic budget once. Raise semantic_tokens for more context per hit and fewer, coarser hits; lower it for finer ones and a larger index.

planning_line is a heading's DEADLINE: / SCHEDULED: / CLOSED: line, and is split by index for the same reason Babel output is. A date carries almost nothing an embedding can use, and in a project file where nearly every heading has one it would open most passages with the same shape of noise — so it is dropped there. Looking one up by word is an ordinary thing to want, though, so the lexical index keeps it. Turn either round if your notes disagree.

Only the real thing is taken: org counts a planning line only on the line immediately after the heading and before the property drawer, and matches the keywords case-sensitively. A paragraph that opens "Deadline: we agreed on the first" is prose and stays, in both indexes.

todo_keywords is this vault's org-todo-keywords. It decides what a heading says: a keyword org knows is cut off the front of the title, and one it does not know stays there and is embedded with it. The default is org's own default and nothing more — TODO and DONE — so under it * NEXT Rewire the trap is a heading titled "NEXT Rewire the trap", which is exactly what your Emacs shows you until you configure NEXT. If you have configured it, say so here:

"todo_keywords": ["TODO", "NEXT", "WAITING", "DONE", "CANCELLED"]

Order does not matter — org's sequence order drives cycling, which nothing here does, so this is read as a set. Individual files may still add their own with #+TODO:, #+SEQ_TODO: or #+TYP_TODO:, including the fast-selection keys and logging specs org allows (WAIT(w@/!)), which are stripped down to the keyword.

Those values are the defaults, so the block above describes what you get with no config at all.

cp config.example.json ~/notes/indexing.json     # then edit it
org-semantic index ~/notes --both --config ~/notes/indexing.json

The policy is sticky. Once given it is cached, so later runs need not repeat --config — forgetting it is safe, which is what makes a sticky setting tolerable. It is compared by meaning, not by bytes: key order, whitespace and duplicates all hash the same, and a file that merely restates the defaults is indistinguishable from no file at all.

Changing it is refused, not obeyed. A config can change without you doing anything — a git pull brings someone else's edit — and re-embedding a corpus takes minutes, so the tool says what moved and waits:

$ org-semantic index ~/notes --both --config ~/notes/indexing.json
Error: the semantic index was built under a different policy —
       exclude_tagged: was [ARCHIVE, noexport], now []
       pass --full to rebuild under the new one, or restore the previous setting

Unknown keys are an error rather than ignored, for the same reason unknown flags are: a typo that does nothing looks exactly like a setting that does nothing.

chunks --config applies a policy without storing it or reindexing, so you can see what a change would do before paying for it. chunks previews the semantic index and chunks --lexical the word index, faithfully in each case: each is packed in its own unit, and only the lexical one carries a language. It says which one it is showing.

Over JSON-RPC the index method takes the same policy as a config object, so an editor can keep its own source of truth in whatever format suits it — a commented .eld, in Emacs's case — and pass it already parsed. Neither side needs a reader for the other's syntax.

That is also how todo_keywords is meant to be filled in from Emacs, rather than by hand: the editor already knows the answer, so it can send it. org-remove-keyword-keys does the stripping, so nothing has to be reimplemented:

(delete "|" (mapcan (lambda (s) (org-remove-keyword-keys (cdr s)))
                    (default-value 'org-todo-keywords)))

search takes config too, and checks it. A client whose policy comes from the user's own settings should send it with every query, not only when indexing — settings change between one search and the next, and an index built under the old ones answers from passages split by rules the user no longer holds. The check is a hash comparison, so it costs nothing per query.

On a mismatch the query fails rather than answering, and the error names the setting:

the semantic index was built under a different policy —
todo_keywords: was [DONE, TODO], now [DONE, NEXT, TODO]
reindex with `full` to rebuild under the new one, or restore the previous setting

which is what a client turns into your org-todo-keywords changed since this vault was indexed — reindex? Nothing is rebuilt without that answer.

It does not have to read that sentence to do so. Over JSON-RPC the same failure arrives labelled config-drift, with the settings that moved as a list of names — see errors you are meant to act on. The message is for showing; the label and the names are for deciding.

Two things a client should get right: send the policy on search as well as index, and latch the error into one prompt — the mismatch holds until the user acts, so a client searching as you type would otherwise raise it on every keystroke.

Languages

A note declares its language the way ltex-ls-plus already asks for it:

# ltex: language=de-DE

That takes effect from its own line onward, as ltex does, so a note may switch part-way — the marker forces a chunk boundary, since a chunk carries exactly one language. Placed between sections it costs nothing; placed mid-section it splits that section in two.

The keyword is always ltex. You do not need ltex installed to use it — # ltex: language=de-DE is an ordinary org comment — and if you do use it, the line you already wrote for grammar checking is the one this reads.

A language does two jobs, and only one of them is about stemming. It selects the stemmer that makes Sprachen find Sprache, which is the lexical index's business alone — an embedding is not stemmed. But it is also a fact about the note, and lang:de is a reasonable thing to ask of either ranking, so both indexes record one and both honour the predicate.

Do not read that as multilingual semantic search. Retrieving across languages is a question about the embedding model — ask it in German and bge-small-en will not find your German notes, whatever they are labelled — and the answer is a multilingual model, e5-small or one of the large ones. lang: answers the other question, "show me only the German ones", which no model can.

Earlier versions narrowed search --lexical alone and refused lang: on the other side rather than quietly matching nothing, because the semantic index recorded no language at all. If yours does, rebuild it: index --full.

Otherwise languages names what the vault is written in, and how many you name decides everything else:

"languages": ["en-US"]                       one language: every undeclared note is English
"languages": ["en-US", "de-DE", "it-IT"]     several: each note is classified as one of these
"languages": []                              classified with no restriction, all 176

Classification is per note rather than per chunk, since a chunk can be a two-line heading. It uses fastText's lid.176 (938 kB), downloaded to $XDG_CACHE_HOME/org-semantic/ on first use — or under ORG_SEMANTIC_CACHE_HOME, see What it writes.

It is accurate on prose and guesses when there is no prose. Measured across a 951-note vault, auto placed English, German and Italian correctly; the 0.4% it got wrong were notes that are almost entirely attachment links or shell snippets, where it is classifying filenames. Listing your languages removes those — the answer is the best-ranked language among the ones you named, so a note cannot come back Portuguese because a screenshot filename looked like it. On the same vault that takes the misclassifications to zero.

Languages are matched on their primary subtag but stored as you wrote them, so de-DE stays de-DE rather than becoming fastText's bare de. The first one you name is the vault's default.

Settle the list before you first index, because changing it later is not cheap. Both indexes write a language onto every chunk, so both are refused until they are rebuilt under the new one — the word index in a second or two, the meaning index by re-embedding the corpus, which is minutes. Leaving the semantic index alone instead was the alternative and is worse: every note you did not touch keeps the label it had, so a language you have just added answers nothing, with no error and nothing in the log. The order counts as a change too, since the first entry is the default.

A misclassified chunk is stemmed wrongly and becomes harder to find, with nothing to indicate why. An explicit # ltex: language=… overrides the classifier — it wins even over a language list that doesn't mention it, because the list says what may be guessed, never what a note may state. The exception is a code the classifier doesn't know, which is a typo far more often than a language; that warns and falls back to the default. Such a marker is a line those notes want anyway, so ltex doesn't grammar-check your shell commands.

Lexical search stems each note in its own language: oscillation finds oscillations in English notes, Sprachen finds Sprache in German ones, and neither leaks into the other. Regional variants share a stemmer, so de-DE and de-AT are both German.

fold_diacritics folds accents, so eleves matches élèves. It is named for the case worth having; the filter is broader, mapping non-ASCII to ASCII generally, so æ becomes ae too. Off by default, and worth noting it does nothing for German — that stemmer already strips umlauts, so Worter finds Wörter regardless. It is French, Spanish and Portuguese that need it. Changing it rebuilds the lexical index once (0.2 s).

Tags follow org's own inheritance — #+filetags: plus every ancestor heading's tags — so a chunk under * Project :work: is found by tag:work whether or not its own subheading says so. Values with spaces take quotes; matching is case-insensitive; anything unrecognised (2:1, a URL) stays as search text.

Predicates are stripped before embedding, so query syntax never reaches the model — tag:work would otherwise have it looking for notes about the words "tag" and "work".

What it writes

Everything goes in one hidden directory — beside your notes by default, or somewhere else entirely if you say so. No note is ever modified — org-semantic only reads them.

path 951-note vault what it is
.org-semantic/semantic/<model>/chunks.json 2.4 MB per chunk: heading path, line, the lines it came from, :ID:, tags
.org-semantic/semantic/<model>/vectors.f32 10.0 MB one embedding per chunk, in the same order (384–1024 floats)
.org-semantic/semantic/<model>/manifest.json 0.2 MB what that model's index has seen: per-note hash and (mtime, size)
.org-semantic/tantivy/ 2.9 MB the lexical index
.org-semantic/lexical.json 0.2 MB the same, for the lexical index
.org-semantic/index.lock --- present only while something is indexing this vault
  15 MB  

Your prose is not copied into the index. A chunk records where it came from — the note and a range of lines — not what it said. That is a fraction of the size, and it is also truer: a preview is read from the note itself, so a code block appears in full even though the index deliberately replaced it with [src bash] before embedding.

It costs a handful of file reads per query, for the handful of hits actually being shown, and it means a preview can be missing if a note has moved since it was indexed — which is the honest answer in that case, rather than showing text that is no longer there.

One directory per model, each complete in itself. The chunk table is duplicated rather than shared because a vector is paired to its chunk by position: a shared table would silently go stale for every model you did not index in that run, and a same-count-different-content mismatch is exactly what a length check cannot catch.

An index is replaced whole or not at all. A run builds the new one beside the old and commits it at the end, so killing index halfway leaves you with the previous index, or with none — never with one that answers from mismatched pieces. If it leaves you with none, the next command says so and rebuilds, rather than quietly returning the wrong notes.

The two indexes are independent. Each carries everything its own hits need and its own record of which notes it is behind on, so either can be built, rebuilt or deleted without disturbing the other — which is what makes index --lexical a one-second operation rather than a ten-minute one.

The two chunk differently, because they are bounded by different things — see chunk in the policy above.

Add /.org-semantic/ to the vault's .gitignore. All of it is derived, so rebuilding is always available: org-semantic index --full writes it again in one pass. Deleting the directory works too, with one caveat — if this vault keeps its notes elsewhere, vault.json (below) goes with it, and the vault then looks empty rather than unindexed.

Keeping the index out of a synced folder

Your notes are in Dropbox, iCloud or Syncthing, and every reindex rewrites the index inside it — 15 MB on the reference vault, 10 MB of it a single vectors.f32 that changes wholesale. Nothing breaks; the sync client just carries derived data up and down for prose that did not change, and some of them are unhappy about the symlink you would otherwise reach for. The same wish turns up the other way round: several vaults whose indexes you would rather keep together, under one directory you can exclude from a backup, put on another disk, or delete in one go.

Both are the same answer. A vault directory is where the index lives, and the notes may be somewhere else — said in a vault.json inside it:

~/state/notes/
└── .org-semantic/
    ├── vault.json      { "version": 1, "notes": "~/Dropbox/org" }
    ├── semantic/…
    └── tantivy/…

Then org-semantic index ~/state/notes reads the notes in ~/Dropbox/org and writes nothing whatever inside it. Moving an index you already have is a mv and one file:

mkdir -p ~/state/notes
mv ~/Dropbox/org/.org-semantic ~/state/notes/
printf '{ "version": 1, "notes": "~/Dropbox/org" }\n' \
    > ~/state/notes/.org-semantic/vault.json

Every command still takes the one path it always took — the vault, now ~/state/notes — and org-semantic models ~/state/notes prints both roots, which is the only way to see what an index describes once the two can differ.

Both keys are optional and are merged over the defaults, so {} is legal and means "the notes are here". notes may be absolute, start with ~, or be relative to the vault (../org). version is what a future format would be read against; a version this binary does not know is refused by name rather than half-read. An unknown key is refused, as it is in the policy file — a misspelt setting that is silently ignored is worse than one that fails.

Two things are said out loud rather than left to be discovered:

  • a vault with no notes at all — rather than writing an index of nothing and answering every search with nothing;
  • .org files left in the vault directory when the notes are elsewhere, which are not indexed and otherwise look exactly like a chunking bug.

A vault named by notes may not name notes of its own: one hop, not a chain. (A vault.json in the notes root that says nothing about notes is allowed, and is where settings of the vault itself would be read from if there are ever any.)

From Emacs, org-semantic-vault-root names the vault~/state/notes in the example above — because that is what every request is keyed by. Nothing else changes: a hit already comes back with the absolute file it is in, and org-semantic-auto-reindex-mode asks whether a saved file is in the notes, so saving one still reindexes. M-x org-semantic-show-status names both.

Two things live outside the vault, both downloaded once and shared by every vault: the embedding model, in $XDG_CACHE_HOME/fastembed, and the language classifier, in $XDG_CACHE_HOME/org-semantic. org-semantic models prints both paths. A model is 128 MB for bge-small-en and up to 2.24 GB for the large multilingual ones; the classifier is 938 kB.

To put them elsewhere — a small system disk, an external drive, a directory shared by several accounts — set ORG_SEMANTIC_CACHE_HOME. It replaces $XDG_CACHE_HOME for this tool alone, and the layout beneath it is the same, so moving an existing cache is a mv of those two directories:

mkdir -p /Volumes/data/cache
mv ~/.cache/fastembed ~/.cache/org-semantic /Volumes/data/cache/
export ORG_SEMANTIC_CACHE_HOME=/Volumes/data/cache

Set it in the environment, not per command. It has to be the same for every invocation — yours from a shell, the serve process your editor spawns, a cron job — and there is deliberately no flag for it: a flag is per-invocation, so forgetting it once downloads the model a second time into the default location with nothing failing. Put it in your shell profile, and in the launchd plist or systemd unit if you run Emacs as a daemon, since a daemon inherits the environment it was started with and not your shell's.

Indexing is incremental by default. A file whose modification time and size are unchanged is not even read; one whose timestamp moved is read and hashed, and re-embedded only if its content actually differs. Deleted notes are dropped.

nothing changed 0.025 s
one note edited ~0.5 s
--rehash — read and hash everything 0.09 s
--full — rebuild from nothing ~4 min

--rehash is the backstop for a change that left both mtime and size untouched: a timestamp-preserving restore, rsync --times, touch -r. At 0.09 s it is cheap enough to run on every Emacs start.

Design

Every vector is compared, every time. A thousand notes come to 1.5M tokens and under 10 MB of numbers, and comparing a query against all of them takes 1.4 ms. Because nothing is skipped, the best match is always found.

Larger systems cannot afford that, so they build an approximate nearest neighbour index — FAISS, HNSW, quantisers like TurboQuant — which searches a fraction of the data and usually finds the best match. They are built for collections a thousand times bigger than a note archive, and they trade away exactness to save memory this problem has to spare.

Chunked by section, then by paragraph, to a budget counted in tokens. A token is what the model actually reads — roughly a word, or a fragment of one — and 512 of them is all it can take at once. The budget is smaller than that ceiling on purpose, 350 by default: a passage should be small enough that a hit points at something specific.

Counted in tokens rather than characters because the two are not interchangeable: this corpus runs 2.0 characters per token in LaTeX-heavy notes against 4.0 in prose, so one figure in characters means different amounts of context in different notes. Overshoot the model's limit and the embedding library drops whatever did not fit, without a word — and what it drops is the end.

The heading is prepended to every passage before it is embedded, so it comes out of the budget first. A heading path so long that nothing would be left is shortened, and the note named on standard error; the passage is what has to survive. Consecutive pieces overlap by one paragraph, so an idea cut at a boundary is still embedded whole somewhere.

Results are grouped per outline node, and bounded per note. A note that answers a query tends to answer it in several places, so a flat list of the best passages could spend every slot on one document. Two caps prevent that — how many notes, and how many passages from each — while the result itself is the section a passage sits in rather than the file it lives in, which is the difference between a useful answer and "meetings.org" nine times.

The index belongs to the vault it describes, so pointing org-semantic at another vault is a different argument, not a different configuration.

Status

Early, and useful. It indexes an org tree, searches it by meaning and by words, updates incrementally, and speaks JSON — over --json for one-shot calls and over serve for a caller holding a session open.

The Emacs package is the open piece, and is coming. Everything it needs exists: structured hits carrying the note, the line its heading starts on, the lines the passage came from and the passage itself, and a resident process that answers in ~10 ms. What is missing is the client itself — no elisp is written yet, so for now jumping to a hit is a manual find-file.

The rest of the roadmap is org depth rather than more formats: honouring :noexport: and archived subtrees, and treating #+begin_src blocks distinctly, since code embedded as prose pollutes results.

Known gaps. The two modes are not fused into a single ranking: a phrase or a boolean means nothing to an embedding, so a combined list would mix hits that honoured your query with hits that could not. Auto language detection is right on prose and guesses on notes that are almost entirely attachment links or shell snippets — 0.4% of chunks on the reference vault, and none once the languages are named in the policy. Re-split pieces of a long section share their section's line number.

Licence

MIT. Embeddings via fastembed-rs (Apache-2.0) over ort (Apache-2.0); lexical search via tantivy (MIT); language identification via the fasttext binding (MIT), whose lid.176 model is CC BY-SA 3.0 and is downloaded rather than redistributed here.

This documentation is rendered with the ReadTheOrg theme, vendored under docs/org-html-themes/ and GPL-3.0 — a documentation asset, kept under its own licence and not part of the program.

Created: 2026-08-16 Sun 17:48