Documentation
A condensed reference. For the design decisions and the benchmark numbers behind them, see the README on GitHub.
MCP tools
Every endpoint โ stdio, each /mcp/<folder> and its subfolders โ serves the same tools.
Claude calls ragdown_recall and ragdown_read_doc on its own when the notes might
help; the server's instructions tell it to. A tool failure comes back as an error result, never a dropped
connection.
| Tool | What it does |
|---|---|
ragdown_recall | Hybrid search. Returns path, line range, heading breadcrumb, tags and similarity for each hit. Takes top_k, path_prefix, tag, format (text or json) and max_chars. |
ragdown_read_doc | Reads a file, or a line range of one, straight from disk. Never clipped. Also takes a wikilink target (Note#Heading); see Obsidian. |
ragdown_context | For hooks: the sections related to a prompt as a <ragdown-context> block, or empty text. Filters by similarity, skips short prompts and slash commands, and never repeats a section for the same session_id. Takes top_k, min_score, min_ratio and max_chars to override the RAGDOWN_HOOK_* defaults. |
ragdown_stats | Folder, index size, embedder, role (primary or reader), whether a sync is running, and the last sync. |
ragdown_remember | Writes a new note (with frontmatter) under RAGDOWN_NOTES_DIR and indexes it before returning. Never overwrites a file. supersedes lists the notes this one replaces, which search then skips; session_id is recorded as provenance. |
ragdown_reindex | Syncs now; full: true re-embeds everything. |
The two write tools, ragdown_remember and ragdown_reindex, are not listed when
RAGDOWN_READ_ONLY=true.
Superseding a note
A note whose frontmatter lists supersedes: hides the notes it names from search and from hook
context. Nothing is deleted โ the old file stays on disk and ragdown_read_doc still opens it โ
but a fact that changed stops coming back next to its replacement. Paths are relative to the note's own
folder, like a Markdown link.
---
title: "Embedder"
date: 2026-09-15
supersedes: ["2025-04-02-embedder.md"]
created_by: ragdown_remember
---
Folders
serve treats every top-level directory of RAGDOWN_DOCS_DIR as a
folder, found as it appears. All folders share one index, one model and one watcher, but
each is its own MCP server at /mcp/<folder>, named ragdown-<folder>,
and sees only its own notes: paths in and out are relative to it (backups.md, not
work/backups.md), and ragdown_read_doc refuses one that leaves it.
A folder's settings live in .ragdown.json at its root, and the web UI edits them:
{ "title": "Work notes", "mcp": true }
titleis shown in the web UI and the server's instructions; it defaults to the directory name.mcpdefaults to false. A folder is human-only until someone turns it on: indexed and searchable in the web UI, but no endpoint, no recall hits and no hook context./mcp/<folder>answers a human-only folder with the same 404 as a missing one.- There is no endpoint over every folder: bare
/mcpis a 404 that says to pick one. - Markdown loose in
RAGDOWN_DOCS_DIR, outside every folder, is not indexed; the log and the web UI list it. stdioserves one folder โRAGDOWN_DOCS_DIRitself โ whatever its.ragdown.jsonsays.
The web UI creates, renames and deletes folders, and Copy MCP config on each gives the
claude mcp add line and the mcpServers entry for it. Under
RAGDOWN_READ_ONLY a folder's title and MCP switch can still change โ they are settings, not
notes โ but creating, renaming and deleting cannot.
Subfolders
/mcp/<folder>/<subfolder...> narrows a folder's server to a subfolder, following
the folder's MCP setting:
claude mcp add --transport http work-alpha http://localhost:3300/mcp/work/projects/alpha \
--header "Authorization: Bearer $RAGDOWN_TOKEN"
ragdown_recallandragdown_contextsearch only files under it, andpath_prefixnarrows further inside it. Paths are relative to the subfolder.ragdown_rememberwrites into the subfolder itself; on a folder's own endpoint it writes into<folder>/<RAGDOWN_NOTES_DIR>.ragdown_statscounts the subfolder's files and chunks, andragdown_context's per-session memory is kept separately for each endpoint.ragdown_reindexstill syncs everything.
A missing subfolder, a file, a symlink or a dot-folder is a 404.
Obsidian
Open a folder as an Obsidian vault and both see the same notes:
.obsidian/,.trash/and every other dot-folder are skipped, as is.ragdown.json.- Tags from frontmatter (
tags: [a, b]ortags: a, b) and inline#tagsin the text (not in code) are indexed per note.ragdown_recall'stagfilter matches a tag and the tags nested under it:projectfinds#project/alpha. Every hit lists its note's tags. - Aliases from frontmatter are found by keyword search and by wikilinks.
- Wikilinks.
ragdown_read_doctakes a link target as well as a path โNote,Note#Heading,sub/Note, an alias โ and resolves it the way Obsidian does, within the folder: an exact path, then a note whose name matches (the one beside the linking note, then the shortest path), then an alias. A heading narrows the text to that section.
Tags and aliases get their own columns; they are not added to the text that is embedded, which was measured and made retrieval worse.
Hooks
There is no hook command and no hook HTTP route: a hook calls ragdown_context over MCP like any
other tool, so automatic per-prompt context needs a client whose hooks call MCP tools, such as min-agent.
(There is no hook command to wire into Claude Code; there Claude calls ragdown_recall itself
when the notes might help.) A min-agent server entry
under Settings โ MCP:
{
"id": "ragdown",
"label": "Notes",
"transport": "http",
"url": "http://localhost:3300/mcp/work", // a folder with MCP on
"headers": { "Authorization": "Bearer <RAGDOWN_TOKEN>" },
"hiddenTools": ["ragdown_context"], // the model has ragdown_recall; this one is for the hook
"hooks": [
{ "id": "notes", "on": "beforeTurn", "tool": "ragdown_context", "inject": true, "maxTokens": 800,
"args": { "prompt": "{{prompt}}", "session_id": "min-agent:{{session.id}}", "max_chars": 3000 } }
]
}
Before each turn the hook sends the user's message. ragdown_context answers with the sections
whose cosine similarity reaches RAGDOWN_HOOK_MIN_SCORE and whose score is at least
RAGDOWN_HOOK_MIN_RATIO of the best hit's, wrapped in <ragdown-context> โ or
with empty text, which the client treats as nothing to add. It skips prompts under 12 characters, slash
commands, and sections it already returned for the same session_id.
Configuration
Only RAGDOWN_DOCS_DIR is required. See
.env.example.
| Variable | Default | Description |
|---|---|---|
RAGDOWN_DOCS_DIR | โ | For serve, the directory holding the folders; for stdio, the one folder. Walked recursively. Indexes .md, .markdown and .mdx; skips dot-folders and node_modules, and does not follow symlinks. |
RAGDOWN_DATA_DIR | ~/.cache/ragdown/<hash> | The index. Deleting it only costs a rebuild. serve and stdio on the same directory keep separate ones. |
RAGDOWN_MODELS | ~/.cache/ragdown/models | Model cache, shared by every folder. |
RAGDOWN_EMBEDDER | granite-small | granite-small, bge-small, embeddinggemma, openai:<model>, or hash (tests only). See below. |
RAGDOWN_EMBEDDING_URL / _API_KEY | OpenAI | For openai:<model>: any OpenAI-compatible /embeddings endpoint, such as Ollama or llama.cpp. |
RAGDOWN_THREADS | half the cores | ONNX Runtime threads for the local model. |
RAGDOWN_WATCH | true | Watch the folder; without a watcher, sync on start and on ragdown_reindex only. |
RAGDOWN_READ_ONLY | false | Hide the write tools, and refuse uploads, deletes and folder changes other than settings. |
RAGDOWN_NOTES_DIR | notes | Where ragdown_remember writes, relative to each folder (a subfolder endpoint writes into the subfolder). A relative path inside the folder, not a dot-folder. |
RAGDOWN_TEXT_LIMIT | 2000 | Characters per hit in text output. Every cut names the ragdown_read_doc call that returns the rest. |
RAGDOWN_HOOK_TOP_K | 4 | ragdown_context: most sections per prompt. |
RAGDOWN_HOOK_MIN_SCORE | 0.8 | ragdown_context: lowest cosine similarity returned. Calibrated for the default embedder. |
RAGDOWN_HOOK_MIN_RATIO | 0.95 | ragdown_context: lowest share of the best hit's similarity a hit may have; 0 disables it. |
RAGDOWN_HOOK_MAX_CHARS | 6000 | ragdown_context: most characters per prompt. |
PORT | 3000 | serve only. The HTTP port. |
RAGDOWN_TOKEN | โ | serve only. The bearer token /mcp/<folder> and the web UI's /api routes require. |
SECURE_LOCAL_NET | false | serve only. Skip the token on a trusted network. serve refuses to start with neither. |
Commands and HTTP
node src/cli.ts <command>: stdio (the MCP server a client launches) or
serve (HTTP, what the Docker image runs). Searching, indexing and stats are MCP tools, not
commands. serve exposes these routes:
| Route | Auth | Purpose |
|---|---|---|
GET /api/status | none | Liveness and index stats; ready: false while the model loads. settings holds the non-secret tuning values for the Settings page. |
/mcp/<folder>[/<sub...>] | bearer | Streamable HTTP MCP, stateless, for a folder with MCP on. 404 otherwise, and for bare /mcp. See Folders. |
GET /api/folders | bearer | Each folder's name, title, mcp, mcp_path, files and chunks, and loose_files: the Markdown outside every folder. |
POST /api/folders | bearer | Create a folder: JSON { name, title?, mcp? }. 201; 409 when it exists, 400 for a bad name. |
PATCH /api/folders/<name> | bearer | JSON { title?, mcp?, name? }: change its settings, or rename it with name (which re-indexes it). Unknown keys in .ragdown.json are kept. |
DELETE /api/folders/<name>?confirm=<name> | bearer | Delete a folder and everything in it. 400 unless confirm repeats the name. |
GET /api/docs?folder= | bearer | The indexed files, of one folder or all: path, folder, title, tags, aliases, mtime_ms, size, chunks. |
GET /api/doc?path= | bearer | One indexed file's text, read from disk, with its tags, aliases and hash (SHA-256 of its bytes). |
POST /api/doc | bearer | Write a file: JSON { path, text, overwrite?, base_hash? }, up to 4 MiB. 201 created, 200 overwritten, 409 for an existing file without overwrite: true. With base_hash it saves an edit: only over the version with that hash, else 409 with code: "changed". Answers with the new hash. |
DELETE /api/doc?path= | bearer | Delete a Markdown file. 404 when it is not there. |
GET /api/search?folder=&q=&tag=&top_k= | bearer | Hybrid search in one folder, human-only ones included. top_k defaults to 10, at most 50. |
GET /api/resolve?from=&link= | bearer | A wikilink target, resolved from the note from within its folder: { path, anchor? } or 404. |
GET /api/file?path= | bearer | Any file inside a folder โ an image, a PDF โ as raw bytes, sandboxed and nosniff. Never a dot-path or a symlink out. |
GET /* | none | The web UI from web/dist. |
Every path in /api includes the folder: work/notes/a.md. Writes answer
after the index has synced; new notes, edits, uploads, deletes, and creating, renaming or deleting a folder are a 403 under
RAGDOWN_READ_ONLY. They exist for the web UI; agents write with ragdown_remember.
An upload must be .md, .markdown or .mdx inside an existing folder,
somewhere the indexer reads โ no .., dot-folders, node_modules or symlinked
folders; missing subfolders are created.
Web UI
serve serves a web UI at /. It asks for the token once and keeps it in the
browser's local storage; its static files hold no notes, and everything it shows comes from the bearer
routes above.
- Folders โ the sidebar lists every folder, marked as served over MCP or human-only.
Opening one shows its files, filterable by title, path or tag, or a hybrid Search of it. A file
renders beside the list with its tags, size, line count and chunk count;
[[wikilinks]]are followed,![[embeds]]and images shown, and a search hit opens at its heading. The folder and file are in the URL. - New note โ a title and an optional subfolder (created if missing); the note is saved
as
<title>.mdstarting with that heading and opens in the editor. A taken name is refused, with a way to open the existing note. - Edit โ the previewed file in a Markdown editor with a preview tab; Ctrl/โ+S saves. A save goes through only over the version that was opened; if the file changed on disk meanwhile, you choose to discard yours or save it anyway. Unsaved changes hold back leaving the page.
- Upload โ pick
.md,.markdownor.mdxfiles and an optional subfolder of the open folder. A file that already exists stays in the list with an Overwrite button instead of being replaced unasked. - Delete โ removes the previewed file from disk, after a confirmation.
- Settings โ Folders โ create, retitle, rename and delete folders (deleting asks for the name), flip each one's MCP switch, and copy its MCP config. Loose files outside every folder are listed here.
- Settings โ This browser โ the theme for this browser, and whether a token is required (and a way to forget the stored one).
- Settings โ Server โ the index and search defaults: docs folder, embedder, role,
read-only, watch, and the
RAGDOWN_HOOK_*andRAGDOWN_TEXT_LIMITvalues, each with the variable that sets it. They are read from environment variables at start, so they are shown, not edited./settings?tab=serverlinks straight to it.
Choosing an embedder
RAGDOWN_EMBEDDER picks one of three local models, measured on the same benchmark of 210
questions over generated runbooks, with identical chunks:
RAGDOWN_EMBEDDER | Dim | Top-1 | Per query | MIN_SCORE |
|---|---|---|---|---|
granite-small (default) | 384 | 85.7% | 9 ms | 0.8 |
bge-small | 384 | 82.9% | 9 ms | 0.7 |
embeddinggemma | 768 | 91.0% | 260 ms | 0.6 |
granite-small is the default and the model baked into the Docker image; the others download
on first start. openai:<model> uses any OpenAI-compatible endpoint, which is the way to
index faster on a GPU.
Changing the model rebuilds the index, and RAGDOWN_HOOK_MIN_SCORE has to move with
it: cosine is on each model's own scale. Use the value in the last column.
RAGDOWN_HOOK_MIN_RATIO, being relative to the best hit, carries across embedders as it is.
How the index works
- Chunks follow headings. Each section's breadcrumb is embedded with it. Long sections
are packed paragraph by paragraph; fenced code blocks are never split. Tags and aliases are stored in
columns of their own, outside the embedded text. Line numbers refer to the original
file, so a hit is one
ragdown_read_doccall from its surroundings. - Hybrid retrieval. Dense cosine search and BM25 full-text search each return a pool, and reciprocal rank fusion (k = 60) merges them. Ranking uses the fused score; filtering uses cosine similarity.
- The index is derived data. A change of embedder or chunker version drops and rebuilds it. Syncs are diffs: unchanged size and mtime skip a file, a content hash decides re-embedding, and files gone from disk are dropped. The watcher debounces for 750 ms and falls back to polling every 60 s.
- One primary per index. The process that binds
<data dir>/primary.sockindexes and watches; others are readers that search the same table and forward syncs. A crashed primary is replaced by the next process. - Searches never wait for indexing. Until the first index finishes, searches answer from what is indexed so far.
Security notes
- One bearer token guards everything except
/api/statusand the static UI files. Generate it withopenssl rand -hex 32. - A folder is not a boundary between agents. One token opens every folder with MCP on.
Keep notes agents must never read in a human-only folder: it has no MCP endpoint, though the web UI's
/apiroutes, behind the same token, still read it. SECURE_LOCAL_NET=truedrops the token entirely: anyone who can reach the port can read and, unless read-only, write the notes. Use it only on a trusted network.- To forbid writes, mount the folder
:roand setRAGDOWN_READ_ONLY=true.
Upgrading from 4.x
5.0 splits the docs directory into folders, and nothing is migrated for you:
- Move loose Markdown into a folder. Files directly in
RAGDOWN_DOCS_DIRare no longer indexed byserve; the log and the web UI list them. - Turn MCP on for each folder agents should reach, under Settings โ Folders
or with
"mcp": truein its.ragdown.json. Every folder starts human-only. - Point clients at
/mcp/<folder>./mcpno longer serves anything. A 4.x scope URL/mcp/<folder>/<sub>keeps working once<folder>has MCP on. RAGDOWN_NOTES_DIRis relative to each folder:notesnow means<folder>/notes.
stdio is unchanged in use: it serves RAGDOWN_DOCS_DIR as one folder. Both modes
rebuild their index once on first start, for the new tag and alias columns.