org-semantic — search a tree of org-mode notes by meaning or by words
Table of Contents
- Why
- What it touches
- Install
- Use
- Searching from Emacs
- Driving it from Emacs, or anything else
- Letting an agent search for you
- Scores, and why the raw one is not worth showing
- Choosing an embedding model
- Two indexes, built separately
- Two rankings, never merged
- Vaults kept in a few large files
- Filters
- What gets indexed
- Languages
- What it writes
- Design
- Status
- Related work
- Licence
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
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 —servehas 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
targetorphaseends 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-timeoutis 10 seconds, and a reindex takes minutes. Progress notifications do not reset it — neither LSP norjsonrpc.elhas such a rule — sojsonrpc-requestgives up long before the index finishes, and you will write the$/progresshandler and still see nothing. Usejsonrpc-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;
.orgfiles 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.