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.

ToolWhat it does
ragdown_recallHybrid 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_docReads a file, or a line range of one, straight from disk. Never clipped. Also takes a wikilink target (Note#Heading); see Obsidian.
ragdown_contextFor 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_statsFolder, index size, embedder, role (primary or reader), whether a sync is running, and the last sync.
ragdown_rememberWrites 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_reindexSyncs 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 }
  • title is shown in the web UI and the server's instructions; it defaults to the directory name.
  • mcp defaults 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 /mcp is 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.
  • stdio serves one folder โ€” RAGDOWN_DOCS_DIR itself โ€” whatever its .ragdown.json says.

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_recall and ragdown_context search only files under it, and path_prefix narrows further inside it. Paths are relative to the subfolder.
  • ragdown_remember writes into the subfolder itself; on a folder's own endpoint it writes into <folder>/<RAGDOWN_NOTES_DIR>.
  • ragdown_stats counts the subfolder's files and chunks, and ragdown_context's per-session memory is kept separately for each endpoint.
  • ragdown_reindex still 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] or tags: a, b) and inline #tags in the text (not in code) are indexed per note. ragdown_recall's tag filter matches a tag and the tags nested under it: project finds #project/alpha. Every hit lists its note's tags.
  • Aliases from frontmatter are found by keyword search and by wikilinks.
  • Wikilinks. ragdown_read_doc takes 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.

VariableDefaultDescription
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/modelsModel cache, shared by every folder.
RAGDOWN_EMBEDDERgranite-smallgranite-small, bge-small, embeddinggemma, openai:<model>, or hash (tests only). See below.
RAGDOWN_EMBEDDING_URL / _API_KEYOpenAIFor openai:<model>: any OpenAI-compatible /embeddings endpoint, such as Ollama or llama.cpp.
RAGDOWN_THREADShalf the coresONNX Runtime threads for the local model.
RAGDOWN_WATCHtrueWatch the folder; without a watcher, sync on start and on ragdown_reindex only.
RAGDOWN_READ_ONLYfalseHide the write tools, and refuse uploads, deletes and folder changes other than settings.
RAGDOWN_NOTES_DIRnotesWhere 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_LIMIT2000Characters per hit in text output. Every cut names the ragdown_read_doc call that returns the rest.
RAGDOWN_HOOK_TOP_K4ragdown_context: most sections per prompt.
RAGDOWN_HOOK_MIN_SCORE0.8ragdown_context: lowest cosine similarity returned. Calibrated for the default embedder.
RAGDOWN_HOOK_MIN_RATIO0.95ragdown_context: lowest share of the best hit's similarity a hit may have; 0 disables it.
RAGDOWN_HOOK_MAX_CHARS6000ragdown_context: most characters per prompt.
PORT3000serve only. The HTTP port.
RAGDOWN_TOKENโ€”serve only. The bearer token /mcp/<folder> and the web UI's /api routes require.
SECURE_LOCAL_NETfalseserve 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:

RouteAuthPurpose
GET /api/statusnoneLiveness and index stats; ready: false while the model loads. settings holds the non-secret tuning values for the Settings page.
/mcp/<folder>[/<sub...>]bearerStreamable HTTP MCP, stateless, for a folder with MCP on. 404 otherwise, and for bare /mcp. See Folders.
GET /api/foldersbearerEach folder's name, title, mcp, mcp_path, files and chunks, and loose_files: the Markdown outside every folder.
POST /api/foldersbearerCreate a folder: JSON { name, title?, mcp? }. 201; 409 when it exists, 400 for a bad name.
PATCH /api/folders/<name>bearerJSON { 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>bearerDelete a folder and everything in it. 400 unless confirm repeats the name.
GET /api/docs?folder=bearerThe indexed files, of one folder or all: path, folder, title, tags, aliases, mtime_ms, size, chunks.
GET /api/doc?path=bearerOne indexed file's text, read from disk, with its tags, aliases and hash (SHA-256 of its bytes).
POST /api/docbearerWrite 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=bearerDelete a Markdown file. 404 when it is not there.
GET /api/search?folder=&q=&tag=&top_k=bearerHybrid search in one folder, human-only ones included. top_k defaults to 10, at most 50.
GET /api/resolve?from=&link=bearerA wikilink target, resolved from the note from within its folder: { path, anchor? } or 404.
GET /api/file?path=bearerAny file inside a folder โ€” an image, a PDF โ€” as raw bytes, sandboxed and nosniff. Never a dot-path or a symlink out.
GET /*noneThe 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>.md starting 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, .markdown or .mdx files 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_* and RAGDOWN_TEXT_LIMIT values, each with the variable that sets it. They are read from environment variables at start, so they are shown, not edited. /settings?tab=server links 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_EMBEDDERDimTop-1Per queryMIN_SCORE
granite-small (default)38485.7%9 ms0.8
bge-small38482.9%9 ms0.7
embeddinggemma76891.0%260 ms0.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_doc call 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.sock indexes 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/status and the static UI files. Generate it with openssl 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 /api routes, behind the same token, still read it.
  • SECURE_LOCAL_NET=true drops 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 :ro and set RAGDOWN_READ_ONLY=true.

Upgrading from 4.x

5.0 splits the docs directory into folders, and nothing is migrated for you:

  1. Move loose Markdown into a folder. Files directly in RAGDOWN_DOCS_DIR are no longer indexed by serve; the log and the web UI list them.
  2. Turn MCP on for each folder agents should reach, under Settings โ†’ Folders or with "mcp": true in its .ragdown.json. Every folder starts human-only.
  3. Point clients at /mcp/<folder>. /mcp no longer serves anything. A 4.x scope URL /mcp/<folder>/<sub> keeps working once <folder> has MCP on.
  4. RAGDOWN_NOTES_DIR is relative to each folder: notes now 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.

Read the full README โ†—