A local RAG (Retrieval Augmented Generation) system for querying personal markdown notes using local LLMs. No cloud, no data leaving your machine.
Built from scratch in Python — no heavy abstractions, just the pipeline.
- Ingests markdown files (Obsidian notes, Telegram exports, anything
.md) - Chunks, embeds, and indexes them in a local Qdrant vector database
- At query time: retrieves → reranks → applies MMR diversity → stuffs neighboring context → streams an answer via a local LLM
- Exposes a web UI and a REST API
| Layer | Tool |
|---|---|
| Embeddings | bge-m3 (multilingual, via Ollama) |
| Vector DB | Qdrant |
| Reranker | BAAI/bge-reranker-v2-m3 (CrossEncoder) |
| LLM | Ollama (Qwen3 9B default, 4B lite mode) |
| API | FastAPI + SSE streaming |
| History | SQLite via aiosqlite |
query → embed → Qdrant ANN (top 20) → dedup → CrossEncoder rerank
→ MMR diversity (λ=0.7, top 5) → fetch neighbor chunks
→ lost-in-middle reorder → stream via Ollama
- Python 3.11+
- Ollama running locally
- Docker (for Qdrant, or run Qdrant manually)
pip install uv
uv pip install -r requirements.txtollama pull bge-m3
ollama pull dmtx/qwen3.5-9b-abliterated # default
ollama pull richardyoung/qwen3-4b-instruct-2507-abliterated # litedocker run -p 6333:6333 qdrant/qdrantCopy .env.example to .env and fill in values:
cp .env.example .envRelevant settings:
GENERATION_MODEL=dmtx/qwen3.5-9b-abliterated
GENERATION_MODEL_LITE=richardyoung/qwen3-4b-instruct-2507-abliterated
IDLE_TIMEOUT_MINUTES=10
Put your markdown files in data/ (any subdirectory structure works), then:
cd src
python ingest.pyResumable — already-indexed chunks are skipped on re-run.
CLI:
python query.py "your question here"
python query.py --lite "your question here" # use 4B model
python query.py --prose "your question here" # narrative answer formatWeb UI:
python api.py
# open http://localhost:8000Runs Qdrant + API server together. Ollama must be running on the host.
docker compose up --buildThe API is available at http://localhost:8000. Data directory is mounted read-only; history.db is mounted for persistence.
To ingest a Telegram channel into data/telegram/:
- Get your API credentials at my.telegram.org
- Set
TG_API_ID,TG_API_HASH,TG_CHANNELin.env - Run:
cd src
python scrape_telegram.pyTo find a channel's numeric ID:
python list_dialogs.pyThen re-run ingest.py to index the new files.
src/
api.py # FastAPI app, SSE streaming
query.py # full retrieval pipeline, CLI
ingest.py # chunk, embed, upsert to Qdrant
config.py # all settings
db.py # SQLite history
scrape_telegram.py
list_dialogs.py
static/index.html # web UI
data/ # your markdown files (gitignored)
All settings are in src/config.py and can be overridden via environment variables:
| Variable | Default | Description |
|---|---|---|
QDRANT_URL |
http://localhost:6333 |
Qdrant address |
EMBED_MODEL |
bge-m3 |
Ollama embedding model |
GENERATION_MODEL |
dmtx/qwen3.5-9b-abliterated |
Default LLM |
GENERATION_MODEL_LITE |
richardyoung/qwen3-4b-instruct-2507-abliterated |
Lite LLM |
RERANKER_MODEL |
BAAI/bge-reranker-v2-m3 |
HuggingFace reranker |
IDLE_TIMEOUT_MINUTES |
10 |
API idle shutdown |