- Python 48.8%
- TypeScript 27.7%
- CSS 13.5%
- Shell 6.2%
- JavaScript 2.3%
- Other 1.5%
|
sensei
a34c480ca4
- Title/filename token boost (TITLE_BOOST=0.10): notes are now findable by their own name even when hosts frontmatter doesn't alias them - _note_body(): reconstructs the full note from its chunks in order with section headings restored; ranked notes now carry their whole body so broad questions see the complete note, not just the matching chunk - build_context() overflow guard: if the top note alone exceeds the character budget it is included truncated rather than dropped, so the model never receives an empty context Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|---|---|---|
| deploy | add sync-box deploy configs, assembly docs, and scrub real IPs | |
| pipeline | improve retrieval: title boost, full-note context, overflow guard | |
| plugin/ada | Initial commit — second-brain pipeline, deploy configs, and Obsidian plugin | |
| .gitignore | Initial commit — second-brain pipeline, deploy configs, and Obsidian plugin | |
| LICENSE | add license | |
| README.md | add scrub-work-notes.py for work-note portability | |
ada
Source for a self-hosted RAG assistant over my Obsidian vault. Two write-ups cover how everything was built and why the decisions came out the way they did:
- Building a Local RAG Assistant Over My Obsidian Vault — the indexing pipeline, retrieval API, grounded answer generation, and how the system spans three machines without any of them doing the other's job.
- Self-Hosting Obsidian Sync with CouchDB on a Rootless, Unprivileged LXC — the first half of the project: getting the vault onto hardware I control and synced across every device without handing it to a managed service.
This repo is the code; the write-ups are the reasoning. They cover the decisions behind the architecture, the obstacles that shaped it, and the failure modes that informed each design choice.
Note on authorship: The server infrastructure — the LXC setup, Podman/Quadlet configuration, Tailscale networking, and the decisions documented in the write-ups — is my own work. The application code (Python pipeline, TypeScript plugin) was generated by AI and has not been independently audited. Read it with that in mind before deploying it anywhere you care about.
What's here
pipeline/ Python RAG service — ingestion, retrieval, generation, and HTTP API
deploy/
sync-box/ Quadlet units for the CouchDB sync box (first write-up)
rag-box/ Quadlet units for the indexing/API box (second write-up)
plugin/ Obsidian plugin (Ada) — sidebar UI for asking the assistant from inside the editor
pipeline/
The core pipeline, running on a CPU-only mini PC:
ingest.py— walks the vault, chunks each note (Markdown-aware, structure-preserving), inferssourcetags from the folder path (homelab/work/personal), embeds vianomic-embed-texton a local Ollama instance, and stores vectors in a DuckDB file. Incremental: only re-embeds files whose content has changed.chunking.py— splits on heading boundaries, keeps fenced code blocks intact as single units, and prefixes each chunk with its note title and section so embedded chunks carry the context of where they came from.retrieve.py— hybrid ranking: cosine distance via DuckDB'sarray_cosine_distanceplus a boost for host/service/tag metadata matches. Single source of truth for result ordering — nothing else in the codebase reranks.server.py— thin FastAPI wrapper aroundretrieve.py. Opens DuckDB read-only per request, holding the file lock for milliseconds; the ingestion job can rebuild the index at any time without contending with the API.assistant.py— orchestration: retrieve locally, filter (empty notes, relevance floor,workpartition excluded by default), format context, call the generation model on a remote GPU node over Tailscale, return a cited answer.refresh.sh— pull-only vault sync (LiveSync CLI) followed byingest.py. Read-only throughout; never pushes back to the vault.scrub-work-notes.py— strips## Confidentialsections and<!--c-->...<!--/c-->inline spans from a folder of work notes. Run once against a copy on departure. Dry-run by default; pass--writeto apply.
deploy/sync-box/
Quadlet systemd units and configuration for the CouchDB sync box (rootless Podman under a lingering user):
couchdb.container— CouchDB 3.5, with data and config mounted from the host. Publishes to loopback only;cloudflaredreaches it over the internal network by name.cloudflared.container— Cloudflare tunnel, token-authenticated. Hostname routing is configured in the Cloudflare dashboard; no local config file is needed.obsidian.network— internal Podman network shared by the two containers.couchdb-init.sh— run once after first start to create the CouchDB system databases. Reads credentials from a siblingcouchdb.envor from the environment. Safe to re-run.etc/001-livesync.ini— CouchDB configuration (CORS, auth, request limits). Mount theetc/directory into the container at/opt/couchdb/etc/local.d/. Settings survive container recreation; credentials are not stored here.couchdb.env.example/cloudflared.env.example— copy tocouchdb.env/cloudflared.env(bothchmod 600) and fill in credentials.
deploy/rag-box/
Quadlet systemd units for the indexing/API box (rootless Podman under a lingering user):
ollama.container— embedding-only Ollama instance (nomic-embed-text). No generation.sb-api.container— the retrieval API, withada.duckdbmounted read-only. Binds loopback and the box's Tailscale address; not exposed to the LAN.secondbrain.network— internal Podman network so the API reaches Ollama by container name rather than through a host-published port.build-livesync-cli.sh— clonesvrtmrz/obsidian-livesyncat a pinned tag, buildslocalhost/livesync-cli, and removes the clone. Run once before the firstrefresh.sh.
plugin/ada/
TypeScript Obsidian plugin that consumes the /ask endpoint. See plugin/ada/README.md for build, install, and configuration details.
Dependencies
Pipeline (pipeline/requirements.txt):
| Package | Role |
|---|---|
duckdb |
vector store |
fastapi |
HTTP API |
uvicorn |
ASGI server |
requests |
Ollama and generation model HTTP clients |
Plugin (plugin/ada/package.json, dev dependencies only — bundled output is vanilla JS):
| Package | Role |
|---|---|
obsidian |
plugin API types |
esbuild |
bundler |
typescript |
type checking |
Runtime services (not in this repo):
- Ollama on the indexing box for embeddings (
nomic-embed-text) - Ollama on a GPU node for generation (
qwen3:8b, or any model you pointGEN_MODELat) - The vault materialized as plain Markdown files via the LiveSync CLI (covered in the sync write-up)
Assembly order
The components must come up in this sequence. Out-of-order steps — particularly running refresh.sh before the LiveSync CLI image exists — fail with errors that don't point at the actual cause.
-
Sync box — place the
deploy/sync-box/units under~/.config/containers/systemd/, copycouchdb.env.example→couchdb.envandcloudflared.env.example→cloudflared.env(bothchmod 600), copyetc/to~/obsidian-sync/etc/, start the stack, then runcouchdb-init.sh. Configure the public hostname route in the Cloudflare dashboard (couchdb.<your-domain>→http://couchdb:5984). -
LiveSync on devices — configure the Self-hosted LiveSync plugin on each device with the tunnel URL. Confirm the vault is syncing before continuing.
-
Indexing box LXC — stand up the unprivileged LXC with rootless Podman and Quadlet. See the RAG write-up for the uid-range, file-capabilities, and
/dev/net/tunprerequisites. -
Pull the models:
- On the indexing box, pull the embedding model into the Ollama container:
podman exec ollama ollama pull nomic-embed-text - On the GPU node, pull the generation model (use
podman exec <container>if Ollama runs containerized there):ollama pull qwen3:8b
nomic-embed-textproduces 768-dimensional vectors; swapping it requires rebuilding the index with a matchingDIMin the pipeline. - On the indexing box, pull the embedding model into the Ollama container:
-
Build the LiveSync CLI image —
refresh.shdepends onlocalhost/livesync-cli:deploy/rag-box/build-livesync-cli.shBefore the first refresh, configure the CLI's
settings.jsonto match the server's tweak values — see the RAG write-up for the required values and the remote-lock workaround. The CLI entrypoint auto-prepends the database path, so invoke it with only the command and options (e.g.--vault /vault mirror), never a leading/dataargument. -
First
refresh.sh— syncs the vault from CouchDB, mirrors it to plain files, and buildsada.duckdbfrom scratch. -
Build the API image and bring up rag-box containers. The image pins DuckDB to the host version so the container's reader matches the host's writer:
cd pipeline/ podman build -t localhost/secondbrain-api:latest \ --build-arg DUCKDB_VERSION="$(python3 -c 'import duckdb; print(duckdb.__version__)')" \ -f Containerfile .DuckDB's on-disk format is version-sensitive; the subshell reads the host version dynamically — nothing to hardcode. Then place the
deploy/rag-box/units, fill in~/second-brain/api.env(see Configuration below), and start the stack. -
Disable Tailscale key expiry — in the Tailscale admin console, disable key expiry for both always-on nodes: the indexing box and the GPU node. Keys expire after ~90 days by default; when they do, the node drops off the tailnet and the assistant silently stops working. This is an admin-console setting, not a command on the box.
-
Install the Ada plugin — build from
plugin/ada/and install into Obsidian, pointed athttp://second-brain:8000(or your node's MagicDNS name). Seeplugin/ada/README.md.
Configuration
pipeline/api.env.example is the template for the pipeline's runtime configuration. Copy it to ~/second-brain/api.env — outside the repo, where sb-api.container reads it — and fill in the two addresses: the local Ollama instance for embeddings (OLLAMA_URL) and the remote generation endpoint (QWEN_URL). Everything else has working defaults.
The ASSIST_MIN_SIM floor and the work-partition default exclusion are the two settings most likely to need tuning once the index is built. Both are explained in the write-up.
License
GPL-3.0-or-later. See LICENSE.