|
| 1 | +# sqlrite-notes — chat with your markdown notes via Claude Desktop |
| 2 | + |
| 3 | +A Node.js CLI that ingests a folder of markdown notes (Obsidian |
| 4 | +vault, Notion export, plain `~/Documents/notes`) into a SQLRite |
| 5 | +database, then exposes the database to **Claude Desktop / any MCP |
| 6 | +client** through the engine's first-party MCP server. |
| 7 | + |
| 8 | +End-user effect: drop your notes folder in, paste one block into |
| 9 | +Claude Desktop's config, and ask Claude *"what did I write about |
| 10 | +CRDTs last month?"* — it answers using your local notes. No cloud |
| 11 | +sync, no third-party indexer, the entire memory is one `.sqlrite` |
| 12 | +file on disk you can open in the REPL. |
| 13 | + |
| 14 | +> **Why this example?** Other "chat with your notes" demos build a |
| 15 | +> custom RAG pipeline and bolt it onto a model. This one shows that |
| 16 | +> when the database itself speaks the agent protocol, you don't need |
| 17 | +> a pipeline — *Claude drives the database directly* via |
| 18 | +> `sqlrite-mcp`. The Node.js side is just the ingest + glue. |
| 19 | +
|
| 20 | +## Architecture |
| 21 | + |
| 22 | +```mermaid |
| 23 | +flowchart LR |
| 24 | + Notes[/"~/notes/*.md<br/>(markdown)"/] -->|sqlrite-notes init / refresh| Ingest |
| 25 | + Ingest["Ingest pipeline<br/>(walk → chunk → embed → store)"] --> DB[("notes.sqlrite<br/>documents · chunks<br/>HNSW + FTS indexes")] |
| 26 | + DB -->|"sqlrite-mcp --read-only<br/>stdio JSON-RPC"| Claude["Claude Desktop<br/>(or any MCP client)"] |
| 27 | + Claude -->|"vector_search · bm25_search · query · ask"| DB |
| 28 | +``` |
| 29 | + |
| 30 | +The whole stack: Node.js for the **write side** (ingest pipeline, |
| 31 | +chunking, embeddings), SQLRite for **storage + retrieval primitives** |
| 32 | +(HNSW vector index, BM25 inverted index, raw SQL), and `sqlrite-mcp` |
| 33 | +for the **read side** that Claude actually talks to. The Node CLI |
| 34 | +never touches the database while Claude is connected — that's what |
| 35 | +`--read-only` is for. |
| 36 | + |
| 37 | +## Schema (v1) |
| 38 | + |
| 39 | +| Table | Purpose | Indexes | |
| 40 | +|-------------|------------------------------------------------------------------------------------------------|----------------------------------------------------------------------| |
| 41 | +| `documents` | One row per `.md` file — path, title, mtime, full body, content hash. | UNIQUE on `path`. FTS on `content` (BM25 over whole docs). | |
| 42 | +| `chunks` | One row per ~400-token slice of a document, plus a `VECTOR(384)` embedding. | HNSW on `embedding` (semantic KNN). FTS on `content` (passage BM25). | |
| 43 | + |
| 44 | +Hybrid retrieval queries `chunks` and fuses BM25 + vector cosine in a |
| 45 | +single `ORDER BY` (see [`docs/fts.md`](../../docs/fts.md) for the |
| 46 | +SQL pattern; the executor's `try_fts_probe` hook serves the top-k |
| 47 | +straight from the inverted index). |
| 48 | + |
| 49 | +## Install |
| 50 | + |
| 51 | +The example lives inside the SQLRite monorepo for now (the umbrella |
| 52 | +ticket SQLR-38 will lift it into its own repo once we've shipped a |
| 53 | +few more). |
| 54 | + |
| 55 | +```bash |
| 56 | +git clone https://github.com/joaoh82/rust_sqlite |
| 57 | +cd rust_sqlite/examples/nodejs-notes |
| 58 | +npm install |
| 59 | +``` |
| 60 | + |
| 61 | +`npm install` pulls **`@joaoh82/sqlrite`** (pinned to `^0.10.0`) with |
| 62 | +prebuilt napi-rs binaries for macOS-arm64, Linux x64/arm64, and |
| 63 | +Windows x64 — no Rust toolchain required for the Node side. |
| 64 | + |
| 65 | +`sqlrite-mcp` is a separate Rust binary. Install it once, anywhere |
| 66 | +on your `PATH`: |
| 67 | + |
| 68 | +```bash |
| 69 | +# from crates.io (~30s): |
| 70 | +cargo install sqlrite-mcp |
| 71 | + |
| 72 | +# or grab a prebuilt binary from GitHub Releases: |
| 73 | +# https://github.com/joaoh82/rust_sqlite/releases |
| 74 | +``` |
| 75 | + |
| 76 | +If you don't want to install globally, set `SQLRITE_MCP_BIN` to its |
| 77 | +absolute path — `sqlrite-notes serve` will pick it up. |
| 78 | + |
| 79 | +## Run |
| 80 | + |
| 81 | +```bash |
| 82 | +# 1. Ingest a folder of markdown into a notes.sqlrite database. |
| 83 | +node bin/sqlrite-notes.mjs init ~/Documents/notes |
| 84 | + |
| 85 | +# 2. Confirm it works locally — same retrieval shape Claude will see. |
| 86 | +node bin/sqlrite-notes.mjs search "what did I learn about CRDTs?" |
| 87 | + |
| 88 | +# 3. Wire up Claude Desktop using the snippet printed by `init` |
| 89 | +# (also available any time via `sqlrite-notes config`). |
| 90 | + |
| 91 | +# 4. Open Claude Desktop. The sqlrite-mcp tools appear in the |
| 92 | +# tool picker — `bm25_search`, `vector_search`, `query`, `ask`, |
| 93 | +# plus `list_tables` / `describe_table` / `schema_dump`. |
| 94 | +``` |
| 95 | + |
| 96 | +Once you've added the snippet to `claude_desktop_config.json` and |
| 97 | +restarted Claude Desktop, run a chat like: |
| 98 | + |
| 99 | +> *"Summarize what I've written about Postgres over the last month."* |
| 100 | +
|
| 101 | +Claude will call `bm25_search` (and/or `vector_search`) against the |
| 102 | +`chunks` table, get back the matching passages, and answer with |
| 103 | +inline citations to the file path and chunk number. |
| 104 | + |
| 105 | +## Zero-config: works fully offline |
| 106 | + |
| 107 | +The default embedder is a **deterministic hash-based bag-of-words** |
| 108 | +embedder that runs in pure JavaScript. No API key, no network, |
| 109 | +nothing to install — `sqlrite-notes init ~/Documents/notes` works |
| 110 | +on a fresh laptop. |
| 111 | + |
| 112 | +Hybrid retrieval still beats either signal alone because BM25 is |
| 113 | +already doing exact-term ranking; the hash embedder mostly carries |
| 114 | +its weight via the long-tail of co-occurring tokens. |
| 115 | + |
| 116 | +For real semantic recall, switch to OpenAI: |
| 117 | + |
| 118 | +```bash |
| 119 | +export OPENAI_API_KEY=sk-... |
| 120 | +node bin/sqlrite-notes.mjs init ~/Documents/notes --embedder openai |
| 121 | +``` |
| 122 | + |
| 123 | +Uses `text-embedding-3-small` with the `dimensions: 384` override |
| 124 | +so it matches the schema. Override the model with |
| 125 | +`--openai-model text-embedding-3-large` (and bump `--dim` if you |
| 126 | +want full-fat dimensionality). |
| 127 | + |
| 128 | +## CLI surface |
| 129 | + |
| 130 | +``` |
| 131 | +sqlrite-notes init <dir> Ingest <dir> into the notes DB (replaces the index). |
| 132 | +sqlrite-notes refresh <dir> Re-ingest only files whose mtime/hash changed. |
| 133 | +sqlrite-notes search "<query>" Hybrid retrieval against the index (debug). |
| 134 | +sqlrite-notes serve Spawn sqlrite-mcp --read-only against the DB. |
| 135 | +sqlrite-notes stats Row counts. |
| 136 | +sqlrite-notes config Print the Claude Desktop config snippet again. |
| 137 | +``` |
| 138 | + |
| 139 | +Common flags: |
| 140 | + |
| 141 | +``` |
| 142 | +--db <path> Path to the SQLRite database file. Default: ~/.sqlrite-notes/notes.sqlrite |
| 143 | +--embedder hash|openai Embedding provider. Default: hash (offline). |
| 144 | +--dim <N> Vector dimension. Default: 384. |
| 145 | +--openai-model <name> OpenAI embedding model. Default: text-embedding-3-small. |
| 146 | +--chunk-tokens <N> Target chunk size in tokens. Default: 400. |
| 147 | +--chunk-overlap <N> Chunk overlap in tokens. Default: 60. |
| 148 | +``` |
| 149 | + |
| 150 | +## Claude Desktop config |
| 151 | + |
| 152 | +`sqlrite-notes init` prints this block; you can also regenerate it |
| 153 | +any time with `sqlrite-notes config --db <path>`: |
| 154 | + |
| 155 | +```json |
| 156 | +{ |
| 157 | + "mcpServers": { |
| 158 | + "sqlrite-notes": { |
| 159 | + "command": "sqlrite-notes", |
| 160 | + "args": ["serve", "--db", "/Users/you/.sqlrite-notes/notes.sqlrite"] |
| 161 | + } |
| 162 | + } |
| 163 | +} |
| 164 | +``` |
| 165 | + |
| 166 | +Where it lives: |
| 167 | + |
| 168 | +| Platform | Path | |
| 169 | +|---|---| |
| 170 | +| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | |
| 171 | +| Linux | `${XDG_CONFIG_HOME:-~/.config}/Claude/claude_desktop_config.json` | |
| 172 | +| Windows | `%APPDATA%\Claude\claude_desktop_config.json` | |
| 173 | + |
| 174 | +Merge the snippet into any existing `mcpServers` block; don't |
| 175 | +overwrite the file wholesale. |
| 176 | + |
| 177 | +### Try it without Claude Desktop |
| 178 | + |
| 179 | +If you don't have Claude Desktop, the same MCP server works with |
| 180 | +**any** MCP client — Cursor, Codex, your own. The fastest way to |
| 181 | +verify the wiring is Anthropic's inspector: |
| 182 | + |
| 183 | +```bash |
| 184 | +npx @modelcontextprotocol/inspector sqlrite-notes serve --db ./notes.sqlrite |
| 185 | +``` |
| 186 | + |
| 187 | +Open the URL it prints, click through the tools, type JSON args. |
| 188 | +Saves a lot of "did Claude restart correctly?" debugging. |
| 189 | + |
| 190 | +## Open the DB yourself |
| 191 | + |
| 192 | +The notes index is plain SQLRite — open it in the REPL whenever: |
| 193 | + |
| 194 | +```bash |
| 195 | +$ cargo install sqlrite-engine # or grab a release binary |
| 196 | +$ sqlrite ./notes.sqlrite |
| 197 | +SQLRite v0.10.0 |
| 198 | +sqlrite> SELECT path, title FROM documents ORDER BY mtime DESC LIMIT 5; |
| 199 | +sqlrite> SELECT path, ord, substr(content, 1, 80) |
| 200 | + ...> FROM chunks |
| 201 | + ...> JOIN documents ON chunks.document_id = documents.id |
| 202 | + ...> WHERE fts_match(chunks.content, 'rust embedded') |
| 203 | + ...> ORDER BY bm25_score(chunks.content, 'rust embedded') DESC |
| 204 | + ...> LIMIT 5; |
| 205 | +``` |
| 206 | + |
| 207 | +This is the demo's whole point: **the notes index is just SQL**. You |
| 208 | +can query it, back it up, copy it between machines, or feed the same |
| 209 | +file into the Python / Go / WASM SDKs without converting anything. |
| 210 | + |
| 211 | +## How hybrid retrieval works |
| 212 | + |
| 213 | +The `search` command (and Claude, indirectly, when it composes |
| 214 | +`query` calls) runs the canonical hybrid shape from |
| 215 | +[`docs/fts.md`](../../docs/fts.md): |
| 216 | + |
| 217 | +```sql |
| 218 | +SELECT id, document_id, ord, content FROM chunks |
| 219 | + WHERE fts_match(content, 'collaborative editing CRDT') |
| 220 | + ORDER BY 0.5 * bm25_score(content, 'collaborative editing CRDT') |
| 221 | + + 0.5 * (1.0 - vec_distance_cosine(embedding, [/* query embedding */])) |
| 222 | +DESC LIMIT 5; |
| 223 | +``` |
| 224 | + |
| 225 | +Two things worth noting: |
| 226 | + |
| 227 | +1. `vec_distance_cosine` returns a *distance* (`1 - cos(a, b)`). |
| 228 | + Hybrid scoring wants "higher is better", so we invert it. |
| 229 | +2. `fts_match` pre-filters before scoring. Paraphrases with zero |
| 230 | + shared tokens never get scored — a deliberate tradeoff. If a |
| 231 | + query produces no FTS tokens at all (e.g. a single non-ASCII |
| 232 | + word), `db.hybridSearch()` falls back to vector-only so Claude |
| 233 | + always has *something* to ground on. |
| 234 | + |
| 235 | +The `-w 0..1` flag on `sqlrite-notes search` tunes the BM25 / vector |
| 236 | +balance. At `-w 1` you get pure BM25; at `-w 0` pure vector cosine. |
| 237 | +Default is 0.5. |
| 238 | + |
| 239 | +## Known limitations |
| 240 | + |
| 241 | +- **Concurrent `refresh` while `serve` is running.** SQLRite shipped |
| 242 | + `BEGIN CONCURRENT` writes in v0.10 (the SQLR-22 / Phase 11 work), |
| 243 | + so reading from `sqlrite-mcp --read-only` while `sqlrite-notes |
| 244 | + refresh` writes is supported in principle. *In practice we |
| 245 | + recommend stopping Claude Desktop's MCP connection during a |
| 246 | + refresh* — Claude Desktop caches the tool schemas at server-spawn |
| 247 | + time and won't notice newly-ingested files until you reload it |
| 248 | + anyway, so the gain from coexisting isn't worth the surprise. |
| 249 | + |
| 250 | +- **HNSW after delete + re-insert (engine bug, SQLR-8).** The |
| 251 | + engine's HNSW chunk index panics when rows are deleted and re- |
| 252 | + inserted within the same connection lifetime — `index out of |
| 253 | + bounds` inside `DistanceMetric::compute`. The ingest pipeline |
| 254 | + works around it by splitting refresh into a delete-phase → |
| 255 | + close/reopen → insert-phase, which forces a clean index rebuild |
| 256 | + on next open. We pay the close/reopen hop only when there are |
| 257 | + actual deletions (so first-time `init` skips it). Once SQLR-8 |
| 258 | + lands in the engine, `src/ingest.mjs` can drop the `db.reopen()` |
| 259 | + call. |
| 260 | + |
| 261 | +- **Aggregates limited.** The engine supports `COUNT(*)`, |
| 262 | + `SUM`/`AVG`/`MIN`/`MAX` but not arbitrary expressions in `SELECT` |
| 263 | + projection beyond aggregates (see [`docs/supported-sql.md`](../../docs/supported-sql.md)). |
| 264 | + The `stats` command sticks to `COUNT(*)`. |
| 265 | + |
| 266 | +- **No parameter binding yet.** Engine SDKs don't yet accept `?` |
| 267 | + placeholders ([Phase 5a.2](../../docs/roadmap.md) follow-up); all |
| 268 | + SQL strings inline literals via the `q()` helper in |
| 269 | + [`src/sqlutil.mjs`](src/sqlutil.mjs). That helper handles |
| 270 | + quoting + vector-literal encoding for the four value types we use |
| 271 | + (string / int / vector / null). |
| 272 | + |
| 273 | +- **Re-ingest persistence.** `refresh` requires you to pass the |
| 274 | + source directory again (we don't yet store it inside the DB). If |
| 275 | + you forget the path, run `sqlrite-notes stats --db <path>` to |
| 276 | + confirm the index exists, then re-run `init` to rebuild from |
| 277 | + scratch — it's idempotent. |
| 278 | + |
| 279 | +- **`--watch` mode** — not in v1. Re-run `refresh` manually after |
| 280 | + adding notes. |
| 281 | + |
| 282 | +- **Authentication.** None. This is a single-user local tool; the |
| 283 | + MCP server inherits the spawner's filesystem privileges. Don't |
| 284 | + point it at a database you wouldn't share with whoever can read |
| 285 | + your home directory. |
| 286 | + |
| 287 | +## Development |
| 288 | + |
| 289 | +```bash |
| 290 | +npm install |
| 291 | +npm test # offline; runs all 40 unit + integration tests |
| 292 | +``` |
| 293 | + |
| 294 | +The test suite uses `node:test` and exercises: |
| 295 | + |
| 296 | +- `chunker` (frontmatter, title derivation, overlap windowing) |
| 297 | +- `sqlutil` (every value type the engine accepts as a SQL literal) |
| 298 | +- `embeddings` (hash determinism + a mocked OpenAI HTTP call) |
| 299 | +- `db` (schema migration, upsert/delete/cascade, hybrid retrieval) |
| 300 | +- `ingest` (end-to-end against the markdown fixtures in |
| 301 | + [`test/fixtures/`](test/fixtures/)) |
| 302 | +- `serve` + `claude-config` (binary lookup, snippet shape) |
| 303 | + |
| 304 | +A few tests need the `@joaoh82/sqlrite` engine binding installed; |
| 305 | +they skip cleanly (with a message) if it isn't. |
| 306 | + |
| 307 | +## Layout |
| 308 | + |
| 309 | +``` |
| 310 | +examples/nodejs-notes/ |
| 311 | +├── package.json # @joaoh82/sqlrite pinned to ^0.10.0 |
| 312 | +├── README.md # this file |
| 313 | +├── bin/ |
| 314 | +│ └── sqlrite-notes.mjs # entry — calls src/cli.mjs |
| 315 | +├── src/ |
| 316 | +│ ├── cli.mjs # argv parsing + command dispatch |
| 317 | +│ ├── config.mjs # defaults + path resolution |
| 318 | +│ ├── sqlutil.mjs # q() + ident() — SQL literal helpers |
| 319 | +│ ├── db.mjs # schema, migrations, every SQL string |
| 320 | +│ ├── chunker.mjs # frontmatter + heading-aware chunking |
| 321 | +│ ├── embeddings.mjs # hash + OpenAI embedders |
| 322 | +│ ├── ingest.mjs # plan → delete → reopen → insert |
| 323 | +│ ├── search.mjs # hybrid retrieval driver |
| 324 | +│ ├── serve.mjs # spawn sqlrite-mcp --read-only |
| 325 | +│ └── claude-config.mjs # Claude Desktop snippet renderer |
| 326 | +└── test/ # node --test suite |
| 327 | + ├── fixtures/ # 3 short markdown notes |
| 328 | + ├── chunker.test.mjs |
| 329 | + ├── claude-config.test.mjs |
| 330 | + ├── db.test.mjs # integration — needs @joaoh82/sqlrite |
| 331 | + ├── embeddings.test.mjs |
| 332 | + ├── ingest.test.mjs # integration — needs @joaoh82/sqlrite |
| 333 | + ├── serve.test.mjs |
| 334 | + └── sqlutil.test.mjs |
| 335 | +``` |
| 336 | + |
| 337 | +The example binds only to the documented public surfaces: |
| 338 | +[`@joaoh82/sqlrite`](../../sdk/nodejs/) (`Database`, `Statement`) |
| 339 | +and [`sqlrite-mcp`](../../docs/mcp.md) as a child process. It does |
| 340 | +not reach into engine internals. |
| 341 | + |
| 342 | +## License |
| 343 | + |
| 344 | +MIT — same as the rest of the rust_sqlite repo. |
0 commit comments