A fully local RAG pipeline over an Obsidian knowledge base.
No data leaves the machine. No cloud embedding. No vendor lock-in.
I've been using Obsidian for years — notes, technical decisions, learning logs. The problem: full-text search can't answer "what did I decide about state management when comparing BLoC vs Riverpod?" It just returns files with matching keywords.
Existing AI note tools (Notion AI, NotebookLM) solve this, but they require sending your content to a cloud server. That's a non-starter for a vault with private content.
VaultMind runs entirely on-device. Every component — embedding, retrieval, generation — runs locally via Ollama and PostgreSQL.
Ask a question in natural language. It searches your notes, finds the most relevant passages, and returns an answer with source citations.
POST /query
{ "question": "What were my reasons for choosing PGVector over Chroma?" }
→ Based on your notes from vector-db-comparison.md:
You chose PGVector because it reuses your existing PostgreSQL instance,
reducing operational overhead. Chroma required a separate process and
had less mature persistence guarantees at the time.
Source: /vault/tech/vector-db-comparison.md → "## Final Decision"
A streaming chat UI is also available at http://localhost:3000 after starting the server.
| Layer | Choice |
|---|---|
| Backend | Node.js + TypeScript + Express |
| Embedding | nomic-embed-text via Ollama |
| LLM | qwen3:14b via Ollama |
| Vector DB | PostgreSQL + PGVector |
| Keyword search | BM25 via PostgreSQL full-text search |
| Hybrid fusion | Reciprocal Rank Fusion (RRF) |
| Evaluation | RAGAS (Python) |
| Containerisation | Docker Compose |
Hardware: MacBook Pro M3 Pro, 18 GB unified memory. qwen3:14b fits comfortably.
┌──────────────────────────────────────────────────────┐
│ Ingestion (offline) │
│ │
│ Obsidian Vault (.md files) │
│ ↓ │
│ Parser → heading-aware chunking │
│ ↓ │
│ Embedder → nomic-embed-text via Ollama │
│ ↓ │
│ Indexer → PGVector upsert │
└──────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────┐
│ Query (online) │
│ │
│ Question │
│ ↓ │
│ Hybrid Retrieval → Vector + BM25, fused with RRF │
│ ↓ │
│ Generation → qwen3:14b via Ollama │
│ ↓ │
│ REST API → /query (JSON) + SSE streaming │
└──────────────────────────────────────────────────────┘
- Docker Desktop
- Ollama installed and running
- Node.js 20+
Pull the required models before first run:
ollama pull nomic-embed-text
ollama pull qwen3:14b# 1. Clone and install dependencies
git clone https://github.com/tommyshang/vaultmind.git
cd vaultmind
npm install
# 2. Configure environment
cp .env.example .env
# Edit .env: set VAULT_PATH to your Obsidian vaultKey variables in .env:
| Variable | Description |
|---|---|
VAULT_PATH |
Absolute path to your Obsidian vault |
LLM_MODEL |
Ollama model for generation (default: qwen3:14b) |
EMBED_MODEL |
Ollama model for embeddings (default: nomic-embed-text) |
WATCH_VAULT |
true (default) or false — see below |
WATCH_VAULT controls whether VaultMind watches your vault for changes:
WATCH_VAULT=true(default) — when the server starts, it automatically runs a full ingest of your vault and then watches for file changes. Any note you create, edit, or delete is re-indexed in the background without restarting the server.WATCH_VAULT=false— no watching. Run./scripts/ingest.shmanually whenever you want to re-index your notes.
# 3. Start PostgreSQL and the dev server
./scripts/start.sh
# 4. Ingest your vault
# Skip this step if WATCH_VAULT=true — the server handles it automatically on startup.
./scripts/ingest.shThe API is now available at http://localhost:3000.
Chat UI: http://localhost:3000 (open in a browser).
# Query via curl
curl -X POST http://localhost:3000/query \
-H "Content-Type: application/json" \
-d '{"question": "What did I write about Flutter state management?"}'The UI lives in client/ as a separate React + Vite app. To develop it:
- Start the backend:
npm run dev(from the project root, on port 3000) - In a second terminal, start the frontend:
npm run dev --prefix client(Vite dev server on port 5173, proxying API calls to the backend)
The Vite proxy targets http://localhost:3000 by default. If your backend runs on a different port, set BACKEND_PORT (e.g. BACKEND_PORT=4000 npm run dev --prefix client).
For production, npm run build builds both the backend and the client; the backend serves the built client from client/dist.
.
├── src/
│ ├── ingestion/
│ │ ├── parser.ts # Markdown parser, heading-aware chunker
│ │ ├── embedder.ts # Ollama embedding calls
│ │ ├── indexer.ts # PGVector upsert logic
│ │ └── watcher.ts # File watcher for auto-ingest
│ ├── retrieval/
│ │ ├── vectorSearch.ts # Cosine similarity search
│ │ ├── bm25Search.ts # BM25 keyword search
│ │ └── hybrid.ts # RRF fusion
│ ├── generation/
│ │ ├── prompt.ts # Prompt builder with source injection
│ │ └── llm.ts # Ollama LLM client (streaming)
│ └── api/
│ └── routes.ts # Express REST endpoints
├── eval/
│ ├── testset.json # 21 Q&A pairs for evaluation
│ ├── run_eval.py # RAGAS evaluation script
│ └── EVAL_LOG.md # Per-run score history
├── client/ # React + TypeScript + Vite frontend (own package.json)
│ ├── src/
│ │ ├── App.tsx # Root component
│ │ ├── api.ts # Fetch wrapper for the query endpoint
│ │ ├── hooks/ # useQueryStream (SSE streaming state)
│ │ ├── components/ # ChatPanel, TracePanel, VaultTree, StackBar
│ │ └── lib/ # buildTree, traceScores, citations helpers
│ └── dist/ # Build output, served by Express in production
├── scripts/
│ ├── start.sh # Start Postgres + dev server
│ ├── stop.sh # Stop Postgres
│ └── ingest.sh # Run ingestion
├── docker-compose.yml
└── .env.example
Uses RAGAS to score context recall, faithfulness, and answer relevancy against a hand-written test set.
# Requires the API server to be running
source eval/.venv/bin/activate
python3 eval/run_eval.py --note "describe what changed"Score history is appended to eval/EVAL_LOG.md after each run.
Heading-aware chunking — Notes are segmented by H2/H3 boundaries rather than fixed token count. This keeps chunk semantics aligned with how notes are structured. A sliding-window fallback handles sections that exceed the chunk budget.
Hybrid search (Vector + BM25) — Pure vector search fails on proper nouns and exact technical terms. BM25 covers exact keyword recall; cosine similarity covers semantic proximity. RRF fuses both rankings.
No LangChain — Each layer is implemented directly to understand how chunking affects retrieval, what metadata to store alongside embeddings, and what "faithfulness" actually measures.
| Command | Description |
|---|---|
./scripts/start.sh |
Start Postgres + dev server |
./scripts/stop.sh |
Stop Postgres |
./scripts/ingest.sh |
Run vault ingestion |
npm test |
Run test suite |
npm run typecheck |
TypeScript type check |
npm run build |
Compile to dist/ |