A single-binary CLI that builds and queries a semantic index of a local Markdown vault — primarily aimed at Obsidian vaults and other large note collections.
If you use an LLM agent to explore a large wiki or Obsidian vault, you have a token problem.
The naive approach is to hand the agent a file tree and let it figure out where to look. On a vault of any real size — a few hundred folders, a few thousand notes — that file listing alone costs hundreds of tokens before the agent has read a single word of actual content. The agent then has to reason about paths it has never seen, make guesses, open wrong files, backtrack. Each round trip burns context.
The underlying issue is that the agent is doing navigation work it shouldn't have to do. The vault's structure is stable, its semantic content changes infrequently, and the cost of navigating it is paid over and over on every conversation.
Riffle's hypothesis is: pre-compute the navigation layer offline, keep it cheap to maintain, and let agents skip straight to the relevant passage.
Instead of asking "where might information about OAuth token refresh live?", an agent asks Riffle — receives the exact chunk of security/oauth2/token-refresh.md that discusses rotation, its similarity score, and (if it's a well-connected note) a path back to the vault's most central related note — and reads from there. The semantic work was already done at index time, amortised across every future query.
Returning a whole note wastes tokens on the parts that aren't relevant; returning a bare file path makes the agent do a second round trip to actually read it. Riffle splits each note into heading- and paragraph-bounded chunks at index time, embeds each one independently, and returns the specific chunk that matched — heading context, chunk text, and its parent note's path together in one result. The agent gets exactly the passage it needs plus enough surrounding context (relevant_chunk, the note path) to go read more if it wants to.
A vault's own structure — wikilinks, shared tags, topical similarity between notes — already encodes judgements the vault owner has made about what's important and what's related. Riffle builds a graph over these signals at index time: explicit edges from [[wikilinks]], semantic edges from bounded k-NN similarity between note embeddings (not corpus-wide pairwise comparison — see docs/adr/0003-bounded-semantic-edges-via-knn.md), and IDF-weighted edges from shared frontmatter tags (rare tags carry more signal than a tag on every note). PageRank centrality is computed over that graph once per index build and folded into query ranking as a small, bounded additive nudge — never large enough to override genuine relevance, only enough to break a close tie in favour of a well-linked note over an isolated one (see docs/adr/0004-additive-pagerank-boost.md). The graph itself is directly explorable via riffle graph.
A vault changes incrementally — you update a few notes, add a new file. Re-embedding the entire vault on every change would make the tool unusable at scale. Riffle hashes each note from its metadata (path, mtime, size) and only re-parses and re-embeds notes whose hash has actually changed since the last index — editing one note costs re-embedding that note (and its chunks), not the whole vault, regardless of vault size.
Sending note content to an external embedding API at index time creates a dependency, a cost, and a privacy concern. Riffle embeds BAAI/bge-small-en-v1.5 — a 384-dimensional, ~127MB sentence-transformers model — directly into the binary at build time, run through a hand-written pure-Go forward pass. No ONNX Runtime, no CGo, no C compiler, no network call, no API key, no data leaving the machine. The binary is large (~145MB with the model embedded) but it is the complete, self-contained unit. See docs/adr/0002-pure-go-bge-small-inference.md for why ONNX Runtime was dropped in favour of this.
The default output mode is deliberately terse and machine-readable:
security/oauth2/token-refresh.md
security/oauth2/pkce-flow.md
projects/auth-service/notes.md
One path per line. No scores, no decorations, no colour codes. An agent can paste this directly into context or use it as input to a file-reading step. Human-readable output — scored tables, chunk snippets, progress bars, styled status panels — is opt-in via --pretty. The tool is designed for the machine first and the human second.
Warning
This project was designed and built almost entirely through conversational AI — prompting, reviewing, and iterating with Claude rather than writing code by hand. It is being dog-fooded: I am currently running Riffle against my own Obsidian vault to see how well the hypothesis holds in practice.
No claims are being made about the effectiveness of this approach. Whether semantic indexing meaningfully reduces token usage, improves agent navigation quality, or is worth the overhead of maintaining an index is an open question I am trying to answer through use.
No claims are being made that a better solution doesn't already exist. It probably does. This is an experiment of my own choosing, built to satisfy my own curiosity about the problem space and about what it feels like to vibe-code a non-trivial Go project from scratch.
Use accordingly.
Riffle went through a significant rebuild (see docs/riffle-architecture-v2.md and docs/adr/). If you have an index from before that rebuild, a few things to know before you reindex.
What changed. The index moved from folder-level summaries to note/chunk-level retrieval — each note is parsed, chunked at heading/paragraph boundaries, and embedded individually rather than summarised as part of its containing folder. Keyword search (BM25) now runs alongside semantic search, fused via Reciprocal Rank Fusion. A graph layer (explicit wikilinks, semantic k-NN, shared tags) and PageRank centrality now inform ranking, explorable directly via riffle graph. The embedding model changed too: all-MiniLM-L6-v2 via ONNX Runtime was replaced with BAAI/bge-small-en-v1.5 via a hand-written pure-Go forward pass — no ONNX Runtime, no CGo, no C compiler needed to build.
Clean and reindex. The on-disk index format has changed repeatedly across this rebuild (store format is now version 6). An incompatible old index is auto-detected and triggers a full rebuild on its own, so nothing will silently misbehave — but the cleanest migration path is still to start fresh rather than rely on that fallback:
riffle clean
riffle index ~/vault --fullPerformance, honestly. The pure-Go embedder is not yet optimised — roughly 14x slower per embedding than the ONNX Runtime it replaced (~154ms vs ~11ms, unoptimised), a deliberate tradeoff to drop the CGo/ONNX build dependency. A first full index of a large vault is genuinely CPU-bound and can take a while; it runs at --concurrency (default: all your CPU cores), so expect it to use everything it can get. Optimisation (float32 arithmetic, raw slice access, parallel attention heads across the forward pass) is planned but not done yet. Subsequent runs only re-embed changed notes, so this cost is mostly a one-time tax per full rebuild, not a steady-state one.
Vault hygiene. Riffle only embeds files matching --ext (default .md), but it still has to walk every directory in the tree to find them — including ones full of non-Markdown content. If you have code repositories, large binary asset dumps, or other non-Markdown projects sitting inside the vault, the most effective fix is to move them out entirely (nothing to walk, nothing to reindex). If something genuinely needs to stay, exclude it explicitly:
riffle index ~/vault --exclude my-project,large-attachments-folderExcluded directories are skipped entirely during the walk — no directory listing, no stat calls on their contents — not just filtered out afterward.
Builds or incrementally updates the semantic index for the vault rooted at <path>. The index is written to <path>/.riffle/index.bin.
riffle index ~/vaultOn first run, every note is parsed, chunked, and embedded, and a graph (explicit/semantic/tag edges) plus PageRank centrality are computed over the whole vault. On subsequent runs, only notes whose content hash has changed are re-parsed and re-embedded — everything else is carried over.
indexed path=/home/user/vault changed=14 skipped=312 ext=.md duration=2.1s
With --pretty, the progress bar tracks files (not directories), and shows the note currently being embedded plus a running chunk count:
Indexing ~/vault [ext: .md]
████████████████████░░░░ 83% 326 / 394 files
Changed: 14 Skipped: 312 Chunks: 1042 Elapsed: 2.1s
Encoding: security/oauth2/token-refresh.md
Flags:
| Flag | Default | Description |
|---|---|---|
--full |
false | Force full re-index, ignore existing hashes |
--depth <n> |
unlimited | Maximum directory depth to index |
--ext <list> |
.md |
Comma-separated file extensions (e.g. .md,.txt) |
--exclude <list> |
none | Comma-separated directory names to exclude from the walk entirely |
--concurrency <n> |
NumCPU | Goroutine count for parallel embedding |
--pretty |
false | Show progress bar |
Starts a foreground daemon that watches <path> for filesystem changes and automatically re-indexes on change. Also exposes a Model Context Protocol (MCP) Streamable HTTP server (or stdio) so LLM agents can query the live index directly.
riffle watch ~/vaultOn startup, loads the existing index from <path>/.riffle/index.bin. If no index exists, or it's incompatible with the current store format or embedding model, performs a full initial index first. Once running:
watching path=/home/user/vault listen=127.0.0.1:7424 mode=events
File changes are debounced with a 500ms quiet window before triggering a re-index. Only notes whose content hash has changed are re-embedded — the same incremental logic as riffle index.
MCP server listens on 127.0.0.1:7424 (configurable) — or run with --stdio for stdin/stdout JSON-RPC instead — and exposes three tools:
riffle_query— find semantically relevant chunks for a natural-language query, fused from keyword (BM25) and semantic (chunk HNSW) recallriffle_graph— return a note's subgraph: its neighbours out to a given depth, annotated with edge type and weightriffle_status— return index statistics, hygiene summary, and watcher health (includingmode: events|polling)
Health endpoint — GET /health for liveness checks from scripts, launchd, and systemd:
{ "ok": true, "watching": "/home/user/vault", "mode": "events" }Watcher modes:
| Mode | Meaning |
|---|---|
events |
Kernel event subscription active (inotify / FSEvents); changes delivered in real time |
polling |
Subscription lost; watcher polls every 30 seconds |
If the event subscription is lost, the daemon logs a warning and falls back to polling automatically — no restart needed.
Signals:
SIGINT/SIGTERM— graceful shutdown: finish any in-progress re-index, write the index, exit 0SIGHUP— force a full re-index (equivalent toriffle index --full)
Flags:
| Flag | Default | Description |
|---|---|---|
--listen <addr:port> |
127.0.0.1:7424 |
MCP server bind address |
--exclude <list> |
none | Comma-separated directory names to exclude from watching and indexing |
--stdio |
false | Serve MCP over stdin/stdout instead of HTTP |
MCP tool schemas:
riffle_query:
{
"q": { "type": "string", "description": "Natural-language search query" },
"top": { "type": "integer", "default": 5 },
"threshold": { "type": "number", "default": 0.0, "description": "Minimum similarity score (0.0–1.0)" }
}riffle_graph:
{
"seed": { "type": "string", "description": "Path to the seed note, relative to the vault root" },
"depth": { "type": "integer", "default": 2, "description": "Number of hops to traverse from the seed note" },
"edge_types": { "type": "array", "items": { "type": "string" }, "description": "Edge type filter: explicit, semantic, tag (default: all)" }
}riffle_status: no parameters — returns index stats, hygiene summary, and current watcher mode.
No daemonisation. riffle watch is a foreground process. For background operation, use your OS process supervisor. Sample unit files:
macOS launchd plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>com.riffle.watch</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/riffle</string>
<string>watch</string>
<string>/Users/you/vault</string>
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>StandardOutPath</key> <string>/tmp/riffle.log</string>
<key>StandardErrorPath</key> <string>/tmp/riffle.log</string>
</dict>
</plist>Save to ~/Library/LaunchAgents/com.riffle.watch.plist and run launchctl load ~/Library/LaunchAgents/com.riffle.watch.plist.
Linux systemd unit
[Unit]
Description=Riffle vault watcher
After=network.target
[Service]
ExecStart=/usr/local/bin/riffle watch /home/you/vault
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.targetSave to ~/.config/systemd/user/riffle-watch.service and run systemctl --user enable --now riffle-watch.
Finds the most relevant chunk of content for a natural-language query, combining keyword (BM25) and semantic (chunk-level HNSW) matching, fused via Reciprocal Rank Fusion, with a small additive PageRank boost toward well-linked notes. Auto-discovers the nearest .riffle index by walking up from CWD.
riffle query "OAuth token refresh"Default output (one path per line, relative to index root):
security/oauth2/token-refresh.md
security/oauth2/pkce-flow.md
projects/auth-service/notes.md
JSON output (--format json):
{
"query": "OAuth token refresh",
"root": "/home/user/vault",
"relative": true,
"results": [
{
"path": "security/oauth2/token-refresh.md",
"score": 0.91,
"relevant_chunk": "Token Rotation",
"chunk_text": "Every refresh issues a new access token...",
"token_estimate": 42,
"centrality_score": 0.26,
"reached_via": []
}
]
}centrality_score is the note's stored PageRank centrality (already factored into ranking, not just informational). reached_via is the edge path — type and intermediate notes — connecting this result to the nearest sufficiently-central note within a small hop bound; empty if the result is already central, isolated, or has no such note reachable nearby.
Pretty table (--pretty):
Query: "OAuth token refresh" root: ~/vault
0.91 security/oauth2/token-refresh.md Token Rotation
Every refresh issues a new access token without requiring the user to log in again.
0.87 security/oauth2/pkce-flow.md PKCE Flow
...
Flags:
| Flag | Default | Description |
|---|---|---|
--index <path> |
Auto-discover from CWD | Path to index root |
--top <n> |
5 | Number of results |
--threshold <f> |
0.0 | Minimum semantic similarity score (0.0–1.0) |
--format <fmt> |
plain |
plain, json, or yaml |
--relative |
true | Relative paths (vault-portable) |
--pretty |
false | Human-readable scored table with chunk snippets |
Exit codes: 0 success, 1 no results above threshold, 2 error.
Shows health and statistics for the current index, including a vault-level hygiene summary.
index=vault/.riffle/index.bin notes=684 chunks=2130 size=8.4MB stale=0 ext=.md relative=true model=bge-small-en-v1.5 built=2026-07-18T10:22:01Z hygiene=isolated=12,untagged=88
With --pretty:
Index: vault/.riffle/index.bin
─────────────────────────────────────
Notes indexed 684
Chunks indexed 2130
Stale entries 0
Index size 8.4 MB
Extensions .md
Relative paths true
Embedding model bge-small-en-v1.5
Last built 2026-07-18 10:22
Hygiene
─────────────────────────────────────
Isolated 12 (2%)
Untagged 88 (13%)
No frontmatter 4 (1%)
Short (<100 words) 31 (5%)
Broken links 2 (0%)
Stub 9 (1%)
Hygiene flags are purely informational vault-quality signals — never a warning that something needs fixing, and never used to exclude a note from retrieval. isolated notes (no inbound/outbound wikilinks) are still fully findable via BM25/semantic search, just unreachable via riffle graph traversal.
Returns a note's subgraph — its neighbours out to a given depth, following explicit (wikilink), semantic (k-NN), and tag edges in either direction, each annotated with type and weight. Useful for structured multi-hop reasoning ("what else references this note?"), not content search — use riffle query for that.
riffle graph notes/hub.md
riffle graph notes/hub.md --depth 3 --edge-types explicit,tagJSON output:
{
"seed": "notes/hub.md",
"depth": 2,
"nodes": [
{ "path": "notes/hub.md", "hop": 0 },
{ "path": "notes/leaf.md", "hop": 1 }
],
"edges": [
{ "source": "notes/hub.md", "target": "notes/leaf.md", "type": "explicit", "weight": 1.0, "context": "See leaf for detail." },
{ "source": "notes/hub.md", "target": "notes/leaf.md", "type": "semantic", "weight": 0.89 }
]
}With --pretty, a hop-grouped node list plus an edge list annotated with type and weight.
The result is an induced subgraph, not just a traversal tree: every edge between two notes that both end up within --depth hops is included, even a connection discovered via a different path than the one used to reach either note.
Flags:
| Flag | Default | Description |
|---|---|---|
--index <path> |
Auto-discover from CWD | Path to index root |
--depth <n> |
2 | Number of hops to traverse from the seed note |
--edge-types <list> |
all | Comma-separated filter: explicit, semantic, tag |
--pretty |
false | Human-readable hop-grouped view |
Removes the .riffle/ directory entirely.
riffle clean
# removed /home/user/vault/.riffle~/.config/riffle/config.toml — all settings can be overridden per-invocation with flags:
[defaults]
top = 5
format = "plain" # or "json", "yaml"
pretty = false
relative = true
[index]
exclude = ["__pycache__", ".DS_Store", ".trash"]
ext = [".md"]
depth = 0 # 0 = unlimited
concurrency = 0 # 0 = NumCPU
[watch]
listen = "127.0.0.1:7424" # bind address; "0.0.0.0:7424" for network access
debounce_ms = 500 # quiet window before triggering re-index after FS eventsThe following directories are always excluded regardless of config or flags:
| Directory | Reason |
|---|---|
.git |
Version control internals |
node_modules |
Dependency trees |
.riffle |
The index itself |
.obsidian |
Obsidian application config |
Each note is parsed for YAML frontmatter (tags, aliases), [[wikilinks]] with ±1 sentence of surrounding context, and split into chunks at heading and paragraph boundaries (roughly 50–400 words each, not fixed token counts). Each chunk is embedded independently — this is the primary retrieval unit — alongside a note-level summary embedding (title + first paragraph + tags) used for aggregation and as the seed for semantic graph edges.
A pure-Go inverted index (no SQLite, no FTS5) runs alongside vector search, indexed per note rather than per chunk. At query time, BM25 keyword recall and chunk-level HNSW semantic recall are fused via Reciprocal Rank Fusion (RRF), so an exact proper noun or idiosyncratic phrase a semantic-only search might miss still surfaces correctly.
At index time, three kinds of edges are derived from data Riffle already has:
- Explicit edges — from resolved
[[wikilinks]], weight 1.0, carrying the link's surrounding sentence as context. A wikilink that doesn't resolve to a known note produces no edge (consistent with it being flaggedbroken-linkby hygiene, not a separate, potentially-contradicting signal). - Semantic edges — a bounded k-NN query against the note-level vector index for each changed note (not corpus-wide pairwise comparison), capped at the top 10 neighbours above a 0.75 similarity threshold. This keeps semantic edge discovery — and the incremental reindex cost guarantee — bounded regardless of vault size.
- Tag edges — IDF-weighted shared-tag co-occurrence: a tag shared by only two notes carries a strong edge; a tag on every note in the vault carries none.
PageRank centrality is computed once per full reindex over the combined graph (damping factor 0.85) and stored per note as centrality_score.
centrality_score is folded into query ranking as a small, bounded additive term on top of the fused RRF score — never a multiplier, and small enough that it can only nudge a close call, never override a genuine relevance gap. reached_via traces the shortest path from a result back to the nearest sufficiently-central note within a small hop bound, reusing the same graph-traversal machinery as riffle graph.
Each note is hashed from its filename, mtime, and size — fast to compute, though it means Riffle trusts the filesystem's mtime; touching a file without changing it still triggers a re-embed. Only files matching the active extension filter contribute a hash at all, so changing a .png in a .md-only index is a no-op. A note whose hash hasn't changed since the last index has its chunks, embeddings, and BM25 postings carried over unchanged rather than reprocessed.
Riffle's vector index is hand-written pure Go — no external vector-search library. It can run as either a flat brute-force cosine similarity search or an HNSW (Hierarchical Navigable Small World) index, chosen by expected corpus size at construction time; HNSW trades exact results for sub-linear query time at larger scale.
- Go 1.26+
- Nothing else. No C compiler, no ONNX Runtime, no CGo — the embedder is pure Go.
make fetch-modelThis downloads BAAI/bge-small-en-v1.5 (~127MB safetensors weights) and its tokenizer from HuggingFace into:
internal/embedder/model/model.safetensors
internal/embedder/model/tokenizer.json
internal/tokenizer/data/tokenizer.json
All gitignored. They must be present before any build.
go build -o riffle .
# or
make buildThe dev binary does not embed the model. You must tell it where the files are at runtime via environment variables:
export RIFFLE_MODEL_PATH=internal/embedder/model/model.safetensors
export RIFFLE_TOKENIZER_PATH=internal/embedder/model/tokenizer.json
./riffle index ~/vaultmake build-releaseThis compiles with -tags embedmodel, which activates //go:embed directives for the model and tokenizer files. The result is a single self-contained binary (~145MB) that requires no environment variables and no external files at runtime.
./riffle index ~/vault # just works, no env vars neededmake install # installs to $(go env GOPATH)/bin
make install PREFIX=/usr/local/bin # or a custom locationBuilds the release (model-embedded) binary and copies it to PREFIX. make uninstall removes it from the same location.
The version string is injected at build time from the current git tag:
./riffle --version
# riffle v2.0.0Untagged builds report the nearest tag with a commit suffix (e.g. v2.0.0-3-gabcdef1) or dev if there are no tags. To cut a release:
make tag VERSION=v2.0.0 # creates an annotated git tag
git push origin v2.0.0
make build-release- Phase 1 — Note/chunk-level hybrid retrieval. Note/chunk parsing, pure-Go BGE-small embedding, chunk-first HNSW recall fused with BM25 keyword recall via RRF, Merkle-tree incremental reindexing, hygiene scoring,
riffle index/query/status. - Phase 2 — Graph layer + PageRank. Explicit/semantic/tag graph edges, PageRank centrality, additive query-time boost,
centrality_scoreandreached_viain query output, shared subgraph traversal exposed viariffle graph(CLI) andriffle_graph(MCP).
Phase 3 — Compression + knapsack token-budget control (per-result token estimation, note-summary fallback, token_budget/resolution query parameters, 0/1 knapsack-style packing) is designed but deliberately not scheduled yet. Per the architecture doc's own evaluation approach, it's gated on real dogfooding of Phase 2 first — writing a token-budgeting spec before knowing how retrieval actually behaves with the graph layer live risks solving the wrong problem. Tracked as a future spec, not a promised near-term item.
Riffle was designed and built through conversational AI — specifically through an extended session with Claude (Anthropic), using Claude Code as the development environment. The project went from a problem statement to a working, tested Go binary without writing code by hand in the conventional sense. The experience of doing this is part of what the project is testing.
Language & runtime
- Go 1.26+ — chosen for single-binary distribution and good concurrency primitives; no CGo anywhere in the stack
Embedding
- BAAI/bge-small-en-v1.5 — 384-dimensional sentence embedding model, trained for asymmetric search (short queries against longer documents); run via a hand-written pure-Go BERT forward pass (no ONNX Runtime, no CGo — see ADR-0002)
- gonum — numerical routines used in the pure-Go forward pass's matrix math
Vector & keyword search
- Hand-written pure-Go vector index — no external vector-search library; can run as flat brute-force cosine search or HNSW (Hierarchical Navigable Small World), chosen by expected corpus size at construction time
- Hand-written pure-Go BM25 inverted index — no SQLite, no FTS5 (see ADR-0001)
Watch mode
- fsnotify — cross-platform filesystem event notifications (inotify on Linux, FSEvents on macOS); used by
riffle watchto trigger incremental re-indexing
CLI & terminal UI
- Cobra — command structure and flag parsing
- Lip Gloss — styled terminal output (scored query tables, graph views, status panels)
- murli — structured agent-facing command output (JSON envelopes, error codes, tool metadata annotations) shared across every command
Configuration & testing
- BurntSushi/toml — TOML config file parsing
- testify — test assertions and requirements
Development tooling
- Claude Code — the AI coding environment in which this project was built
- Claude Sonnet 5 — the model that wrote the code