Section-scoped semantic search over a markdown corpus. The index holds references, never bodies.
A folio is a numbered leaf reference — a pointer to where text sits, not the text. That is what this index stores: for every markdown heading section, a path, a line range, the heading trail that names it, and the document's frontmatter. Ask it a question and it ranks the sections you should read. Reading them is your next step, and it reads the file, so an index that has fallen behind costs you a wasted candidate rather than a wrong quotation — and where that would cost more than a candidate, a query notices and catches the index up first.
grep fails on the words you did not think of. Searching a corpus for
independent finds nothing when every document writes independence; searching
for commit returns six directories and buries the one that answers the
question among five plausible decoys. Picking wrong there is how an agent reads
the wrong document and misunderstands a project.
Semantic ranking fixes that much. What it does not fix — and makes worse — is
precedence. A superseded statement and the statement that replaced it are
semantically alike, so a vector index surfaces them side by side: in a two-line
fixture the retired definition of revenue ranks at 0.763 directly under the live
one at 0.842. Corpora that record their own lifecycle already carry the answer in
frontmatter, as a status or a supersedes. folio indexes those fields as
filters, so a query can say which of two similar passages still holds.
cargo install folio-cliThe crate is folio-cli, because the name folio is held on crates.io by a
placeholder. The command it installs is folio. To build from a clone instead,
run cargo install --path ..
folio calls an OpenAI-compatible embeddings endpoint and contains no inference
code, so the model is yours to choose. Any server exposing /v1/embeddings
works. One that has been measured:
llama-server -hf keisuke-miyako/gte-modernbert-base-gguf \
--hf-file gte-modernbert-base-Q8_0.gguf \
--embeddings --pooling cls -c 8192 -b 8192 -ub 8192 --port 8080Two flags there are not optional. --pooling cls is what this model wants, and
the default is wrong for it — the Qwen3-Embedding family wants --pooling last
instead. And -b/-ub must be at least your longest section: an encoder needs
its whole input in one physical batch, so at the default 512 a longer section
comes back as an HTTP 500 rather than a truncated vector.
Another that has been measured is Hugging Face's text-embeddings-inference,
which is Rust on Candle and reads safetensors, so the model loads directly and
no GGUF conversion has to be trusted:
text-embeddings-router --model-id Alibaba-NLP/gte-modernbert-base --port 8080 \
--auto-truncate false --max-batch-tokens 8192Two flags there are not optional either, and one of them fails worse than
anything llama-server does. --auto-truncate defaults to true, and a section
past the model's input length then comes back as a vector of its beginning with
a 200 rather than as an error: folio's budget calibration sees nothing to react
to, and the index records that nothing was cut while the tail of that section is
in no vector. false restores the refusal, and it then wants
--max-batch-tokens to be at least the model's own maximum input length, or the
server declines to start and says so. Pooling is not passed here because this
server chose cls for this model on its own; one wanting last would still
have to be told.
folio unit --backend tei prints a service file for it.
Switching between the two costs no re-index. An index built by either is answered from the other with nothing re-embedded, because folio compares what the endpoint returns rather than what it is called.
On Apple Silicon it is the slower and heavier of the two by a wide margin, and
the reason is worth knowing before you size a corpus: without flash attention
its Metal backend materializes the attention matrix, so what it holds follows
the square of the longest section rather than the count of tokens. That is this
backend and not this server — a CUDA build fuses the matrix instead, and none of
it has been measured there.
--max-chars is the first thing to keep down, ahead of any flag on the server.
Prefer a model whose repository carries safetensors, too: without them the
server converts a pickle at load instead of mapping tensors, which is a heavier
path than anything measured here. docs/measurements/ has the figures.
Preparing a server to run a command-line tool is a strange shape for a CLI, and folio does not manage one. It prints a service file for whatever manages services on your machine, and installing it is yours to do:
folio unit > ~/Library/LaunchAgents/dev.folio.embeddings.plist
launchctl bootstrap gui/$UID ~/Library/LaunchAgents/dev.folio.embeddings.plistfolio unit --systemd > ~/.config/systemd/user/folio-embeddings.service
systemctl --user enable --now folio-embeddingslaunchctl bootout gui/$UID/dev.folio.embeddings stops it again. On macOS
before Ventura, launchctl load and unload are the pair to use instead.
folio unit writes nothing and starts nothing. --backend tei prints
text-embeddings-router's command instead, with --auto-truncate false in it
because folio's own accounting is wrong without it; --backend llama.cpp is the
default, so an invocation written before that flag existed prints what it always
did. A flag belonging to the other server is refused rather than dropped. It
fills in the port your configuration already points at, and the absolute path to
the server,
because a service manager starts a job with a bare environment and will not find
an unqualified name. The model and its pooling arrive as flags:
folio unit --hf Qwen/Qwen3-Embedding-0.6B-GGUF \
--hf-file Qwen3-Embedding-0.6B-Q8_0.gguf --pooling lastThe service runs from login until you stop it. llama-server binds its own
socket rather than accepting one, so neither launchd nor systemd can start it on
demand, and it is not free while it waits.
What it holds is set by the longest section folio sends it, not by the model file: the server allocates for the largest input it is asked to embed, and keeps that allocation until it restarts. A corpus of shorter sections is therefore a smaller server as well as a more precise index. Waking it after idle hours costs one slow query, because the machine has paged most of it out by then.
docs/measurements/ has the figures, and they belong there rather than here.
They describe llama.cpp's allocation rather than folio's, so they move when it
changes, and a number on this page would ask for a release every time it did.
If you already run an embeddings endpoint, ignore all of this and name it:
folio config set endpoint http://your-host:port/v1/embeddings. For an endpoint
that needs an API key, set FOLIO_API_KEY (or OPENAI_API_KEY when pointing at
api.openai.com) in the environment, or run folio config set api_key <token>
to store it in your user config. Credentials never live in folio.yaml, and
travel off the loopback only over TLS unless --allow-insecure is passed.
folio config set endpoint http://127.0.0.1:8080/v1/embeddings
folio index # every .md under the working directory
folio query "does unfinished work count as a failure"
folio statusOnly folio index needs to be told where the endpoint is. A query reads the
endpoint and model the index recorded, because a vector space belongs to one of
each and the recorded pair is the only correct answer for that corpus.
A question does not have to be asked from the corpus root. Given no --root,
folio query, status and doctor walk upward from the working directory to
the first .folio/ or folio.yaml and answer from the corpus they find, so
working inside a subdirectory costs no cd. folio.yaml ends the walk as well
as .folio/, the way Cargo.toml ends cargo's and target/ does not: the
index is derived and disposable, while the model a corpus needs is not, so a
corpus that has declared itself and has not been indexed yet is named in the
refusal instead of being passed on the way to an unrelated index above it.
Where the corpus is not the working directory a reference is printed so that it
opens from where you are.
folio index takes the root it is given and does not walk for one, because
indexing a tree is not the same request as asking it a question. What it does
look upward for is folio.yaml, so a subtree of a corpus embeds with the model
that corpus chose rather than with whatever the machine is configured for.
--endpoint beats FOLIO_ENDPOINT, which beats folio.yaml beside the corpus,
which beats the user's ~/.config/folio/config.yaml, which beats
http://127.0.0.1:8080/v1/embeddings. folio config prints what won and where
each file is. Both are YAML with two keys, so editing one by hand is fine.
Every number here was measured on English. A corpus in another language wants a model trained for it, and that choice belongs to the corpus rather than to the machine indexing it:
folio config set --project model bge-m3
folio config set --project endpoint http://127.0.0.1:8081/v1/embeddingsThe budget follows. folio counts characters because it cannot see the model's tokenizer, and the default 8,000 was set below an 8,192-token context on English, which runs about 3.7 characters per token. Korean prose on the same tokenizer runs 0.66, so the same 8,000 characters are about 12,000 tokens.
folio index does not guess at that. Before it embeds anything it sends the
longest section it is about to send, and halves the budget until the endpoint
accepts it:
$ folio index
the endpoint refused the longest section; trying 4000 characters
budget for this run: 4000 characters, not 8000
indexed 1 files · 1 sections · dim 768
1 sections truncated at 4000 characters
A section past that budget is divided rather than cut. It becomes consecutive records that each fit, all carrying the same heading trail, so the tail of a long section is a vector rather than nothing:
$ folio status
sections 11
truncated 0
That is one MDN reference page whose largest section is 46,529 characters. It
divides at a paragraph break where one fits and at a line otherwise, because the
alternative to an untidy piece is not a tidier one but a tail no query can
reach. Measured on mdn/content, giving cut tails their own records took
retrieval of a sentence drawn from them from 3 of 12 at mean rank 6.0 to 6 of 12
at mean rank 1.5.
What no boundary divides — one line longer than the budget, a table row or a
generated block — is still cut, marked, and counted, and
folio status --truncated names those.
That writes folio.yaml at the corpus root. Commit it, and everyone who indexes
that corpus embeds it the same way. It is deliberately not inside .folio/: the
index there is derived and disposable, while which model a corpus needs is
neither.
It reaches every subtree. folio index in a subdirectory reads the nearest
folio.yaml at or above the root it was given, so indexing part of a corpus
embeds that part the same way as the whole, and the two indexes can be read
together by one query. A subtree that needs different weights writes its own
folio.yaml, and the nearest one is then its own.
Indexing inside a corpus that is already indexed is allowed and said out loud:
$ cd docs && folio index
this is inside the index at /home/you/corpus, which also holds these files
Both indexes then hold those files and both re-embed them on every edit, which
is a cost worth accepting deliberately rather than by accident. An ignore entry
in the parent is how you decline it. That line is the only chance folio has to
mention it — a parent's walk skips a child's .folio/ as a hidden directory and
never learns the child exists.
A declaration says where new embeddings are sent. It never describes what an
index already holds, which is settled by the fingerprint that index recorded. A
corpus carried from another machine can therefore disagree with the file beside
it, and folio status shows both rather than waiting for a re-index to discover
it:
$ folio status
model gte-modernbert @ http://127.0.0.1:8080/v1/embeddings
declared bge-m3 @ http://127.0.0.1:8081/v1/embeddings (./folio.yaml)
An index holds vectors from one model, and folio decides which model that is by
asking rather than by reading a name. It embeds a fixed text when it builds an
index and keeps the vector as a fingerprint; a run about to embed asks again and
compares. Weights that answer differently discard the index, and folio index
says so with the number that decided it:
$ folio index
the endpoint at http://127.0.0.1:8081/v1/embeddings no longer returns what this
index was built with (fingerprint 0.016570 against the one it carries, and
0.999 is where the same weights sit) — re-embedding every section
A server restarted on another model keeps its URL and whatever --model you
pass, so a name cannot catch that; the fingerprint can. It works the other way
too — moving the same weights to another port or calling them something else
keeps the index, where a name would have thrown away every vector in it.
A query never discards. It says the same thing and stops, because it was asked to read the index, not to rebuild it.
Output names sections, with the heading trail beneath each:
#1 0.693 s21-does-an-incomplete-session-count-as-a-failure/README.md:6-6
Does an incomplete session count as a failure?
Two ways a server disappoints folio are silent. A section longer than the server's physical batch comes back as an HTTP error rather than a short vector, and a pooling mode the model was not trained for returns vectors that rank badly while looking like vectors.
$ folio doctor
endpoint http://127.0.0.1:8080/v1/embeddings (user config)
model default
reachable yes, 768 dimensions, 43 ms for one input
long input 8000 characters accepted, the longest section in ./docs/measurements/2026-09-05-public-corpora.md, cut to the budget
structure paraphrase 0.900, unrelated 0.352 — ok
The batch question is asked with your own longest section, because characters are not tokens: repeated filler tokenizes several times more cheaply than prose, and a corpus that is not written in English packs more tokens into the same characters. The structure question is asked with an English triple, so it says less about a corpus in another language; it catches a space that is inverted or collapsed, not one that is merely mediocre.
folio doctor exits 1 when a question fails, and prints the server's own
sentence with it.
Leave --max-chars alone unless you are testing a budget you mean to index
with. The server allocates for the largest input it is asked to embed and keeps
that allocation, so a probe larger than your budget makes it hold memory that
indexing would never have needed.
folio status counts the sections that hit the character budget before they
were embedded — after dividing, only the ones no boundary could divide.
folio status --truncated names them:
$ folio status --truncated
1 of 5 sections were cut at 8000 characters, and ranked on what was left:
reference.md:7-7
Reference > Compatibility table
That is a single generated table row longer than the budget, which is what a cut looks like now that a section over the budget divides instead. The row was ranked on its first 8,000 characters, so the rest of it cannot be found by asking, and no heading will help because there is no boundary inside one line. A larger budget would take it, at the cost of every section's memory — the server allocates for the largest input it is ever asked to embed.
Sections that touch and rank alike come back as one row covering them:
#1 0.763 facilities.md:3-25
Facilities (6 sections)
A result is a range to read, and five rows naming lines 3-6, 7-10, 11-14, 15-18 and 19-22 of one file describe one read that the caller would have to work out. The score is the mean over lines, so it reads as relevance per line: a merged range falls as it grows and dilutes, and a tight pointer outranks a broad one that contains it.
Touching is not enough on its own. A section that answers a question often sits
beside one that merely surrounds it, and joining those two would trade a precise
pointer for a vague one, so a section joins its neighbour only while the two
rank alike. --limit counts rows a reader would open rather than sections.
An index can be cut. folio extract writes the rows under a path prefix into a
corpus of its own, keeping their vectors:
cp -R corpus/web/svg/. ./svg-docs
cd corpus && folio extract web/svg --into ../svg-docsextracted 300 files · 2177 sections into ../svg-docs
model gte-modernbert @ http://127.0.0.1:8080/v1/embeddings
It embeds nothing and calls no endpoint. On that slice of mdn/content it took
2.9 s, against 43.8 s to embed the same 2,177 sections again, and the 43.8 s is
a server holding the model for the length of it. The slice answers the way its
source answered — the same scores, not equivalent ones — because it carries the
vectors rather than recomputing them.
folio moves the index and not the text: --into names a directory that already
holds the files, and every row is checked against the hash the index recorded
for its file, so a slice is refused rather than written half-true. A destination
that already holds an index is refused too. The source is only read.
The slice carries its source's model, budget and fingerprint, so a query can
read both at once, and either can be proven against whatever endpoint answers
where it lands. A copy loses modification times and keeps contents, so the first
folio index in the new corpus reads every file once and embeds none of them.
$ folio query "when is revenue recognised" --paths-only
finance/revenue.md:42-57
finance/policy.md:8-31One reference per line and nothing else, for a caller that reads files rather than prose. The notices and a moved row's warning go to stderr, so a pipe on stdout stays parseable. On the three-row query above this is 63 bytes against 248, which is the whole argument for it: the same references, a quarter of the context.
Frontmatter is flattened to dotted keys and stored as written — no field is built in, so any producer's schema is queryable.
folio query "who owns a decision's status" --where status=live
folio query "the workspace layout" --where supersedes
folio query "current guidance" --where type!=deprecatedkey=value matches, and reads as membership when the value is a list, so
--where tags=alpha works. key tests presence and !key tests absence.
key!=value also passes when the key is absent, so a filter never silently drops
the documents nobody has annotated yet — !key is how you ask for those on
purpose.
| joins alternatives. = is a comparison, not an assignment, so it binds
tighter the way it does anywhere else — and the two levels never need
parentheses, because | inside one argument is the or and the boundary between
arguments is the and:
# status is live or draft, and the type is guide
folio query "current guidance" --where "status=live | status=draft" --where type=guideThat is the whole grammar. There are no comparisons of magnitude, and folio refuses what it cannot express rather than guessing:
$ folio query "current guidance" --where "version>=7"
--where version>=7: reads `version>` as the key, and `>` in a key is not
something you meant. folio compares text, and the whole grammar is `key`,
`!key`, `key=value`, `key!=value`, joined by `|`
Precedence brings one trap with it, the same one if (x == 1 | 2) has in C:
--where "status=live|draft" is status=live or the presence of a key named
draft. folio says so and still does it, because a presence test beside an or
is a real thing to ask for:
$ folio query "current guidance" --where "status=live|draft"
--where status=live|draft: `draft` beside `|` is a presence test for the key
`draft`. A value needs its key, as in `<key>=draft`
A value is literal text otherwise, so it cannot contain a bare | — the cost
every grammar pays before it has quoting.
When a filter keeps nothing, folio names the predicate that emptied it rather than leaving you to bisect:
$ folio query "current guidance" --where "status=live | status=draft" --where '!version'
no section passed the filter
!version matched none on its own
A --where predicate reads one record. Precedence does not live in one record:
the pointer sits on the successor, and the record you want gone is the one it
points at. That needs a join.
folio query "when is revenue recognised" --exclude-pointed-by supersedesAny section whose id appears in any other section's supersedes stops being a
candidate, and the count of what went is printed so the drop is never silent.
Neither key is built in — --exclude-pointed-by names the pointer and
--identity names the key holding a record's own identity, which defaults to
id only because most schemas spell it that way.
The values are collected from the whole index rather than from what the other filters leave, because a superseded record is superseded whether or not the record that replaced it also answers this query.
Defaults belong to the query, not the index. The
Open Knowledge Format
reads an absent status as stable; folio stores what the file says and leaves
that reading to you.
--root is repeatable, and a query ranks every index named as one answer:
folio query "when is revenue recognised" --root . --root ../decisionsThey have to be one vector space — the same weights and the same character budget — because scores from two models are not comparable and nothing in a ranked list would say so. folio does not take that on the endpoint's word: each index carries the text of its own fingerprint, a query embeds all of them in the request it was already sending, and each has to match the vector its index stored. A root with no index, or one that cannot be shown to belong with the others, is refused by name rather than quietly left out of an answer that then reads as complete.
Where several are read, each reference names the root it came from, so the paths still open. Roots may cover the same files, and a section two indexes hold is returned once:
$ folio query "when is revenue recognised" --root . --root ./decisions
(1 section(s) held by more than one of these indexes, returned once)
#1 0.712 decisions/live/revenue.md:4-9
Revenue
That overlap costs an embedding in each index on every edit of the file, for as
long as it lasts, and no folio index is in a position to warn you: a parent's
walk skips a child's .folio/ as a hidden directory and never learns the child
index exists. An ignore entry in the parent is the fix, and it is yours to
write.
folio index lists every file and re-embeds only the ones whose contents
changed. Listing is what makes it cheap — a file whose length and modification
time are what the index recorded is never opened — so on 14,616 files a re-index
with nothing changed is 0.30 s, and one changed file is 0.44 s.
An index commits in batches of whole files, so a run that is interrupted keeps
what it embedded. Killed partway through a 300-file corpus, folio index left
86 files in the index and the next run embedded the remaining 214 and nothing
else. The batch is whole files because a file's record of being current is
written only once every section of it is in.
A file that only moved is embedded again by neither. Its content hash arrives under a name the index has not seen while the name it had is gone, so its records take the new path and keep their vectors:
$ folio index
indexed 14616 files · 119565 sections · dim 768
0 re-embedded, 0 replaced or removed, 0 dead row(s)
1 moved, keeping the vectors they had
Renaming one seven-section file of 14,616 costs 0.33 s and writes no vector at all, against 1.11 s and seven dead rows when the same rename was a delete and a create. A copy is not a move: the file it copied is still there and still answers, so the copy is embedded.
There is no watcher and no daemon. A query checks itself instead. Before returning a row it stats the file behind it, and if that file has moved since it was indexed, folio brings the index up to date and answers again:
$ folio query "when is revenue recognised"
(1 of the files behind this result had changed; 1 file(s) re-embedded before answering)
#1 0.812 finance/revenue.md:42-57
Recognition > Timing
This is the one place a stale index does harm rather than waste. A line range is not a candidate; it is an instruction to read lines 40 to 55, and once two lines are inserted above that section, following the instruction reads the wrong lines.
It costs one stat per returned row, which is nothing: over 119,565 sections a query with nothing changed is 0.12 s either way. It checks the rows it returns and not the corpus — a file that changed without surfacing still costs you a candidate, which is the trade this index makes everywhere else too. Walking the whole tree to close that would cost 0.27 s on every query, twice what the query costs.
--no-refresh turns off the writing, not the checking. A row whose file has
moved is still marked, because the caller who asked to be answered from the
index as it stands is the one who most needs to know where it does not:
$ folio query "when is revenue recognised" --no-refresh
#1 0.812 finance/revenue.md:40-55 (stale)
Recognition > Timing
A refreshing query takes the same write lock folio index takes, and declines
rather than waits when another folio holds it, since that one is already
producing an index at least as fresh. It refreshes at most once: a row still
stale afterwards means the files are moving while folio reads them, and saying
so beats looping.
skill/SKILL.md is the same surface written for an agent to act on: the
commands, the filters, and when to reach for rg instead. It sits in its own
directory because that is the shape a skill loader expects, and folio skill
prints it, so a machine with the binary and no clone has it too:
folio skill > ~/.claude/skills/folio/SKILL.mdIt carries only what folio cannot tell you at the moment you need it. folio
explains its own refusals — a filter it cannot express, the predicate that
emptied a result, an index whose weights no longer match — so the document does
not explain them in advance, and it stays short enough to be worth loading every
time. Where it belongs is yours to decide, as with folio unit.
- Exact matching. Use
rg. It is exhaustive and folio is not, and a lexical route mixed into the ranking measurably buried correct answers. - Code structure. folio indexes prose sections, not symbols or call graphs.
- Anything but markdown. PDF, office documents and source files are skipped.
One f32 matrix, mapped and scanned end to end. No approximate index and no
recall parameter, on the two public corpora benchmarks/run.py pins:
| Corpus | Sections | Index | Vectors | Query |
|---|---|---|---|---|
rust-lang/book |
555 | 39 s | 1.7 MB | 25 ms median, 40 ms worst |
mdn/content |
119,565 | 1,976 s | 367.3 MB | 135 ms median, 170 ms worst |
Measured 2026-09-06 by benchmarks/run.py, model gte-modernbert, 400 queries at top 10, corpora pinned by commit.
About 100 ms of the larger query is the scan itself; the rest is one embedding round trip and reading the list of rows that are still live. Three quarters of a query is therefore the exhaustive arithmetic — which is the part an approximate index replaces, and it would replace 102 ms with a graph to build, a recall parameter to defend, and more bytes to load beside the matrix.
Headings buy two things, and the second one is memory: a corpus of shorter sections asks the server for smaller batches, and the server sizes itself to the largest batch it is asked for.
Pointer precision is set by your headings, not by the model. A file whose long
sections carry ### subheadings returns 12-line ranges; the same content under
one ## returns a 133-line range. If a result feels too coarse, add a heading
before you change models.
MIT. See LICENSE.