Skip to content

tommyshang/vaultmind

Repository files navigation

VaultMind

A fully local RAG pipeline over an Obsidian knowledge base.
No data leaves the machine. No cloud embedding. No vendor lock-in.


Why

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.


What It Does

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.


Stack

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.


Architecture

┌──────────────────────────────────────────────────────┐
│                 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          │
└──────────────────────────────────────────────────────┘

Prerequisites

Pull the required models before first run:

ollama pull nomic-embed-text
ollama pull qwen3:14b

Setup

# 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 vault

Key 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.sh manually 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.sh

The 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?"}'

Frontend development

The UI lives in client/ as a separate React + Vite app. To develop it:

  1. Start the backend: npm run dev (from the project root, on port 3000)
  2. 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.


Project Structure

.
├── 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

Evaluation

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.


Key Design Decisions

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.


Scripts

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/

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages