ROOT
Ask questions across all your knowledge. Get cited answers.
Turn your Obsidian vault, meeting notes, and emails into a queryable intelligence layer.
Read the story behind it: My knowledge graph got fifteen stars
Quick Start • What You Can Ask • How It Works • Tools • Running Costs • Comparison
You wrote it down. You know you did. It was in a meeting note, or a chat thread you pasted into Obsidian, or a document you made before the planning session. Now you are searching and finding nothing, or finding five things that contradict each other, and you are holding the whole mental model in your head again.
ROOT connects your notes, meetings and emails into one queryable layer. You ask in plain English, the way you would ask a colleague who had read everything you ever wrote.
Quick Start
# Install. A venv or pipx keeps the embedding model's dependencies out of your system Python
pip install root-kg
# Answer the setup wizard: where your notes live, which LLM backend
root-kg init
# Index your notes. Embeddings run locally and cost nothing
root-index
# Extract entities into the graph. This step calls an LLM
root-index --extract
# Register ROOT with Claude Code, using the absolute path to the server
claude mcp add root "$(which root-server)"
root-kg init writes config.yaml, data/ and logs/ to ~/.root-kg. Set ROOT_KG_HOME to put them somewhere else. A git clone keeps them in the repo root instead, see Development.
Indexing prints what it touched, so you can see the incremental behaviour on the second run:
ROOT indexer started at 2026-09-11T08:30:04Z
Scanning vault: ~/Documents/My Vault
Embedding 23 notes (9 new, 14 updated)...
Embedded 118/118 chunks...
Done. 9 new, 14 updated, 412 unchanged, 3 removed, 0 errors.
Extraction complete: 23 processed, 147 entities, 96 relations, 0 errors
Now ask ROOT something from Claude Code: root_search("your topic"), root_ask("your question"), root_graph("person name", 2). Two more worth knowing: root-kg stats prints what the index holds, and root-index --extract-only --limit 10 tries extraction on ten notes before you pay for the rest.
ROOT talks to Claude Code as an MCP server. If Claude Code is new to you, claudecodeguide.dev gets you set up in under an hour.
What You Can Ask ROOT
The questions that otherwise mean opening six tabs and rebuilding the story from memory:
- "What did I commit to Maya last week?"
- "How did the pricing decision evolve over the last month?"
- "Who has been working on Project X, and through what?"
- "What action items from last quarter are still open?"
- "Brief me on this person before my 1:1, everything we have discussed."
You ask in plain English. ROOT synthesizes an answer and cites the notes it came from:
> root_ask("What decisions were made about the Search Redesign?")
# ROOT Answer
Leadership APPROVED the Search Redesign project on March 17, 2026.
Scope was locked at the kick-off meeting on March 23: consolidate
1,200 product categories down to 85 across 12 groups, following the
industry taxonomy. Owner: Alex Chen. Sprint start: April 7.
*Based on 5 search results and 2 entity matches.*
That answer came from five notes and two separate meetings. ROOT also knows that "Alex" in the kick-off note and "Alex Chen" in the planning doc are the same person, so you can walk the connections without opening a file.
How It Works
Four steps, in order.
Ingest. The vault adapter scans your markdown and hashes each file with SHA-256. Meetings, emails and anything else arrive through root_ingest, which takes content from any other MCP server.
Embed. Notes are split at heading boundaries and embedded with all-MiniLM-L6-v2, a local model that runs on your CPU. Nothing leaves the machine and nothing is billed. Vectors go into SQLite through sqlite-vec.
Extract. For each new or changed note, an LLM pulls out entities (person, project, decision, event, concept, organization, tool, document, skill) and typed relations (works_with, owns, decided, attended, discussed, blocked_by, depends_on, manages, created, reviewed). Each relation carries a confidence score: 0.9 and above for explicit statements, 0.7 for implied, 0.5 for weak signals. Aliases are captured too, so "Fredrik" and "Frederick" resolve to one entity.
Query. root_ask runs semantic search for the most relevant chunks, pulls the graph neighborhood of the entities it finds, and hands both to the synthesis model for a cited answer. Graph traversal is a breadth-first walk in Python over indexed SQLite reads, with a visited set for cycle detection.
The two-model split
Extraction runs on every changed note, so it uses Haiku (llm.extraction_model in config.yaml). Synthesis only runs when you ask a question, so it uses Sonnet (llm.synthesis_model). Embeddings never call an API at all.
What is in the repo
root-kg/
├── root_kg/
│ ├── server.py # MCP server: exposes the 18 tools over stdio
│ ├── indexer.py # Reads configured sources, embeds them, orchestrates extraction
│ ├── extractor.py # Incremental, hash-tracked entity and relation extraction
│ ├── db.py # SQLite + sqlite-vec storage and entity graph with BFS traversal
│ ├── llm.py # Multi-backend LLM client for extraction and synthesis
│ ├── embeddings.py # Local embeddings via sentence-transformers, zero API cost
│ ├── chunker.py # Splits long notes at heading boundaries
│ ├── cli.py # Setup wizard, stats, and cron-callable search and note ingest
│ ├── paths.py # Where config, data and logs live: ROOT_KG_HOME, the checkout, or ~/.root-kg
│ ├── config.example.yaml # Template that root-kg init copies into config.yaml
│ ├── .env.example # Template that root-kg init copies into .env
│ ├── query.py # Calls any ROOT tool from a shell or another agent
│ ├── rootd.py # Warm daemon: keeps DB and embedder loaded for fast local search
│ ├── merge_cli.py # Folds duplicate entity shards into one canonical entity
│ ├── adapters/vault.py # Markdown scanner for the vault and any extra root
│ └── tools/ # search, patterns, correlations, graph, intelligence
├── tests/ # 70 tests over the DB, extractor and multi-root paths
├── templates/root-instructions.md # Drop-in usage instructions for an agent
├── .github/workflows/ # tests.yml runs pytest on every PR, release.yml publishes to PyPI on a v* tag
└── run-indexer.sh # Wrapper that activates the venv and runs an incremental pass
Design principles worth knowing before you read the code: one SQLite file and no other server, no vendor SDK for LLM calls (stdlib urllib only), incremental everything through content hashing, and per-root safety guards so an unmounted drive skips its stale sweep instead of purging your index.
The 18 MCP Tools
| Tool | What it does |
|---|---|
| Search and discovery | |
root_search(query) |
Semantic search across all indexed knowledge |
root_search_folder(query, folder) |
Semantic search scoped to one vault folder |
root_note(path) |
Read the full content of a note by path |
root_connections(path) |
Notes that are related but live in a different folder |
root_themes(scope) |
Recurring themes, found by clustering similar notes |
root_gaps(topic) |
What is mentioned but never explored, and which domains are absent |
root_stats() |
Notes, chunks, sources, top folders, last indexed time |
| Multi-source intelligence | |
root_ingest(source_type, title, content, path) |
Ingest a meeting, email or message from another MCP |
root_ingest_batch(items) |
Ingest many items in one call |
root_about(person) |
Everything ROOT knows about a person across all sources |
root_open_loops(scope) |
Things discussed or promised but never followed up |
root_project_pulse(project) |
Recent activity for a project across every source |
| Entity graph and GraphRAG | |
root_graph(entity, depth) |
An entity's neighborhood up to N hops |
root_influence_map(project) |
Who influenced a project, through which actions |
root_decision_trail(topic) |
How decisions around a topic evolved over time |
root_blind_spots() |
Entities gone quiet: 30+ days inactive after 3+ mentions |
root_ask(question) |
Free-form Q&A over search, graph and synthesis |
root_weekly_digest() |
New entities, new relations, and activity by source |
Indexing More Than One Folder
Most people start with one vault. Your knowledge usually is not in one place: there is the vault, and then there is the project docs folder, and the notes your coding agent writes, and the memory store some tool keeps in a dotfile directory.
Add them as roots:
vault:
path: "~/Documents/My Vault"
roots:
- name: project-docs
path: "~/code/myproject/docs"
extract: true
- name: agent-memory
path: "~/.config/agent/memory"
extract: false
Each root is indexed separately and keeps its own identity. root_stats breaks the counts out per root, root_search can filter to one of them, and each root's stale sweep only ever removes its own notes. If a root goes missing, say an external drive is unmounted, that root is skipped with a warning and its notes are left alone.
extract is the part worth understanding, because it is where the money is.
Indexing is free. It chunks your notes and embeds them with a local MiniLM model on your CPU, so a root becomes semantically searchable at zero cost. Entity extraction is the step that calls an LLM once per note to pull out people, projects and relationships for the graph.
Those are now separate decisions. extract: false gives you a root you can search but never pay for. Point a large, repetitive, low-entity corpus at it and your bill does not move. In my own setup a folder of agent memory files went in that way. Every file is searchable, and the extraction queue barely moved.
One detail if you already have an index: note paths are unique, and two folders can both contain index.md, so each extra root namespaces its paths with a prefix (its name, by default). The main vault.path keeps no prefix, which means adding roots never re-embeds the vault you already indexed.
Running Costs
Setup costs a few dollars. After that you pay for changed notes and for questions you actually ask.
| Activity | When it runs | Cost |
|---|---|---|
| Indexing and embedding | Every run | $0, local model on CPU |
| Entity extraction | New and changed notes only | about $0.003 per note on Haiku |
root_ask |
On demand | about $0.01 per query on Sonnet |
| Scheduled incremental pass | Every 2 hours | $0.01 to $0.05 per day |
A root with extract: false |
Every run | $0, searchable, never sent to an LLM |
Those are ranges measured on my corpus with the Anthropic backend. Mine settles between $1 and $3 a month. Yours will land somewhere else, depending on how long your notes are and how many of them change each day. On Ollama the LLM column goes to zero and the quality drops with it.
LLM Backends
Three backends handle entity extraction and Q&A synthesis:
| Backend | Cost | Quality | Setup |
|---|---|---|---|
| Anthropic (default) | about $0.003 per note | Best | ANTHROPIC_API_KEY in .env |
| OpenRouter | Free $1 credit to start | Good | OPENROUTER_API_KEY in .env |
| Ollama | Free, runs locally | Lower | ollama pull llama3.1 |
Set it in config.yaml:
llm:
backend: "anthropic" # or "openrouter" or "ollama"
Keeping the index fresh
Run root-index --extract on a schedule and ROOT stays current. Only changed notes are reprocessed, so a typical incremental run finishes in well under a minute.
Cron and launchd both need the absolute path to root-index, which which root-index prints. Cron is the simpler option on macOS. A launchd agent cannot read ~/Documents or iCloud paths without a Full Disk Access grant, and cron sidesteps that:
crontab -e
# Runs at :30, every two hours during the day
30 8,10,12,14,16,18,20,22 * * * ANTHROPIC_API_KEY=your-key /path/to/root-index --extract >> ~/Library/Logs/root-indexer.log 2>&1
If your vault sits outside those protected folders, launchd works and survives reboots. Save this as ~/Library/LaunchAgents/com.root-kg.refresh.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.root-kg.refresh</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/root-index</string>
<string>--extract</string>
</array>
<key>StartInterval</key><integer>7200</integer>
</dict>
</plist>
Load it with launchctl load ~/Library/LaunchAgents/com.root-kg.refresh.plist. On Linux the equivalent is a systemd timer calling the same command, and a contribution there is welcome. One scheduling tip: if a meeting-notes tool syncs into your vault, offset ROOT by half an hour so the fresh notes are on disk before ROOT reads them.
Comparison
| Feature | ROOT | Obsidian Graph | Mem.ai | Khoj | Rewind |
|---|---|---|---|---|---|
| Entity extraction | LLM-powered | None | None | None | None |
| Typed relations | Yes, 10 types | Backlinks only | No | No | No |
| GraphRAG | Yes | No | Basic RAG | Basic RAG | No |
| Multi-source | Notes, meetings, email | Notes only | Yes | Notes only | Everything |
| MCP native | Yes | No | No | No | No |
| Self-hosted | Yes | Yes | No | Yes | No |
| Single file DB | Yes | N/A | Cloud | Postgres | Cloud |
| Free embeddings | Yes, local | N/A | No | Yes | No |
The ROOT column is checked against this repo. The other columns come from each product's public description, not from code I have read.
Requirements
- Python 3.11 or newer
- Disk space for the local embedding model and its dependencies, downloaded on first run
- One of: an Anthropic API key, an OpenRouter key (free $1 credit), or Ollama running locally
- Four runtime dependencies:
sentence-transformers,sqlite-vec,mcp,pyyaml. No Postgres, no Neo4j, no Docker
Development
Clone, install in editable mode with the dev extra, and run the suite from the repo root:
git clone https://github.com/mshadmanrahman/root-kg.git
cd root-kg
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest
70 tests pass, covering the database layer, the extraction pipeline, the multi-root indexing paths and where config lives. In a checkout, root-kg init writes config.yaml, data/ and logs/ next to pyproject.toml rather than to ~/.root-kg.
Releases go out from a tag: bump the version in pyproject.toml and root_kg/__init__.py, merge, then git tag vX.Y.Z && git push origin vX.Y.Z. The release workflow builds the sdist and wheel, checks the tag against the version, and publishes through PyPI trusted publishing.
What went wrong and what I learned
clear_extraction_for_note() had a docstring saying it removed all entities and relations sourced from a note. It deleted relations, note links, and the extraction record. It never deleted a single entity row.
Extraction runs through an LLM, so it is nondeterministic. Re-index a note and the fresh pass returns a slightly different entity set. Everything the old pass found and the new one missed stayed in the database forever, holding no relation and no note link. A few per note, every run, compounding.
When I finally counted, 11,172 of 21,696 entities were unreachable. That is 51% of the graph. Search could not return them, traversal could not reach them, and root_stats() counted every one as real. That last part is why it took months to notice. The graph was reporting roughly double its true size and I believed it.
The obvious fix was wrong. I swept entities holding a note link to the cleared note, and a three-note re-extraction still leaked two orphans. _extract_note() resolves a relation's endpoints through resolve_entity(), which matches an entity that already exists elsewhere and does not link it to the current note. That entity is held up by the relation alone. Both sets have to go: entities linked to the note, and both endpoints of every relation sourced from it.
Fixing that exposed a second bug that had been unreachable until then. entity_aliases declares ON DELETE CASCADE, but SQLite ignores foreign keys unless the connection sets PRAGMA foreign_keys = ON, and this code never did. Deleted entities left their alias rows behind. alias is UNIQUE, so the dead row squats the name: add_alias() for a new entity hits INSERT OR IGNORE, does nothing, and that alias resolves to None permanently. It could not happen before, because nothing was ever deleted. The fix created the conditions for it.
Last one, and it is the least technical. I fixed this in the copy I run on 2026-08-07 and did not push it here until 2026-08-10. Anyone who cloned in between got the orphan factory. A fix that only exists in the copy you run is not a fix.
Contributing
PRs welcome. Read CONTRIBUTING.md first. The codebase stays deliberately plain: Python 3.11+, no frameworks, small files.
Areas where help goes furthest:
- Adapters: LogSeq, Notion, Apple Notes, Google Docs
- Backends: Google Gemini, local models via llama.cpp
- Visualization: a web UI for exploring the entity graph
- Platforms: a systemd timer for Linux, Task Scheduler for Windows
See also
Other things I have built for the same workflow:
- pm-pilot: Claude Code configured for PMs. Meeting prep, PRDs, market sizing, 25 skills.
- morning-digest: morning briefing automation. Calendar, email and news in one digest.
- discovery-md: AI product discovery for PMs.
- ceremonies: agile ceremonies that do not waste the hour.
- claudecode-guide: a friendly guide to Claude Code, also at claudecodeguide.dev.
- riff: LinkedIn engagement assistant. Extracts posts and comments for AI-drafted replies.
License
MIT. See LICENSE.
Support
Questions and bugs go to GitHub Issues; ideas and setups go to Discussions.
Built by Shadman Rahman
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file root_kg-2.0.0.tar.gz.
File metadata
- Download URL: root_kg-2.0.0.tar.gz
- Upload date:
- Size: 79.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
daca14faa40d77df8b45e3a2c5ece05132b7892da095806ef19953db26ed5d9f
|
|
| MD5 |
75d5ecc505bae9d0ee0dbfbb5f443c96
|
|
| BLAKE2b-256 |
408f199bcc86543dcf57014b2c08f4bd8d06b53a1cab82637b024074f51baa18
|
Provenance
The following attestation bundles were made for root_kg-2.0.0.tar.gz:
Publisher:
release.yml on mshadmanrahman/root-kg
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
root_kg-2.0.0.tar.gz -
Subject digest:
daca14faa40d77df8b45e3a2c5ece05132b7892da095806ef19953db26ed5d9f - Sigstore transparency entry: 2797758395
- Sigstore integration time:
-
Permalink:
mshadmanrahman/root-kg@3a01fde0fc01c005d97dd0a16e9b6350c2d69a1b -
Branch / Tag:
refs/tags/v2.0.0 - Owner: https://github.com/mshadmanrahman
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3a01fde0fc01c005d97dd0a16e9b6350c2d69a1b -
Trigger Event:
push
-
Statement type:
File details
Details for the file root_kg-2.0.0-py3-none-any.whl.
File metadata
- Download URL: root_kg-2.0.0-py3-none-any.whl
- Upload date:
- Size: 71.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
84e101dbedc376446e1e8c4df044c2a7d3743fae0df30875479b7ee69787b201
|
|
| MD5 |
37dc2032e0ea51c02ceb45016ced78df
|
|
| BLAKE2b-256 |
e786b36e4f57ab06066e8c92e2d36aafbaecc0291ab545bdd1a0362c2f34627f
|
Provenance
The following attestation bundles were made for root_kg-2.0.0-py3-none-any.whl:
Publisher:
release.yml on mshadmanrahman/root-kg
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
root_kg-2.0.0-py3-none-any.whl -
Subject digest:
84e101dbedc376446e1e8c4df044c2a7d3743fae0df30875479b7ee69787b201 - Sigstore transparency entry: 2797758506
- Sigstore integration time:
-
Permalink:
mshadmanrahman/root-kg@3a01fde0fc01c005d97dd0a16e9b6350c2d69a1b -
Branch / Tag:
refs/tags/v2.0.0 - Owner: https://github.com/mshadmanrahman
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3a01fde0fc01c005d97dd0a16e9b6350c2d69a1b -
Trigger Event:
push
-
Statement type: