paperless-rag — Ask About Our Documents#
Own-tooling RAG over the Paperless library: the read side of the
paperless-curate family. Instead of an off-the-shelf
RAG app, a small Python package (pkgs/paperless-rag) with full control over
chunking, metadata, and — the part that matters for the family UX — citations
that link straight into Paperless.
Architecture#
family browser ──CF Access──> cloudflared ──> cf-origin-ask vhost (loopback)
└─> paperless-ask :8015
paperless-ask ── embed query ──> loopback ollama :11435 (nomic-embed-text)
── vector search ─> qdrant :6333 (paperless pod, loopback)
── generation ────> ollama failover proxy :11434 (M4 → mokou)
paperless-index (hourly timer) ─> Paperless API → chunks → embeddings → qdrant
- Embeddings never fail over. Indexing and query embedding use the
always-on loopback ollama (
nomic-embed-text, with thesearch_document:/search_query:task prefixes the model is trained for). - Generation rides the failover proxy — the family gets M4-quality answers when the laptop is home, mokou-quality when it isn’t, and never an outage.
- The qdrant volume is derived data.
paperless-index --fullrebuilds it from Paperless; it is deliberately excluded from backups. Only the tiny cursor state (/var/lib/paperless-rag) is backed up.
Tools#
paperless-index#
Incremental Paperless → qdrant sync, run hourly by systemd timer:
- Cursor on the Paperless
modifiedfield ($STATE_DIR/cursor.json) — only changed documents are re-embedded; a modified document is deleted-by-filter and re-upserted, so re-OCR/backfill improvements replace stale chunks. - Chunks ~1,200 chars with 200 overlap, breaking at paragraph/sentence
boundaries, each prefixed with
title — correspondent — createdso retrieval can match on metadata the OCR body doesn’t repeat. --full(rebuild),--reconcile(weekly sweep deleting index entries for removed documents),--dry-run,--limit N.
sudo systemctl start paperless-index.service # manual tick
sudo paperless-index --dry-run --limit 5 # what would happen
curl -s http://127.0.0.1:6333/collections/paperless | jq .result.points_count
paperless-ask#
Loopback Flask app (:8015) with a deliberately minimal one-box UI:
POST /api/ask: embed the question → qdrant top-8 (score floor) → numbered-source prompt → streamed answer (SSE) with[n]citations. Accepts an optionalfiltersobject (see Scoped Q&A below).- Sources render as links to
https://paperless.inaba.network/documents/<id>/. GET /api/correspondents: the scope dropdown’s source — live non-empty correspondents (name + count), TTL-cached from the Paperless API.- Identity comes from CF Access via the
X-Remote-Userheader (set by the cf-origin vhost) — the app itself does no auth, which is why it must stay on loopback. GET /healthzchecks qdrant + embeddings.
Scoped Q&A (“spaces”)#
The one-box UI has a scope row — a correspondent dropdown and a date range — so you
can ask questions within one person’s letters (the “space” pattern: pull the
whole thread from one correspondent and interrogate it against your own timeline).
The request carries a filters object:
{"q": "what did she write about the move?",
"filters": {"correspondent": "Nyssa Craton",
"created_from": "1998-01-01", "created_to": "2004-12-31",
"tags": ["letter"]}}
The server validates it (bad dates → HTTP 400), translates it into a qdrant
payload filter (indexes on correspondent, tags, created, doc_id are created
idempotently on startup), and — because a scoped pool is already all-relevant —
favours recall: RAG_TOP_K_SCOPED (default 12) and RAG_SCORE_FLOOR_SCOPED
(default 0.30). A scoped query with no hits gets a scope-aware “nothing in this
scope” reply rather than a bare miss. The active scope shows above the answer and
persists in localStorage between visits. The filters object is shaped to grow,
so a future saved space serializes to {name, filters} with no API break.
Correspondent renames don’t bump doc modified, and chunk vectors embed the
correspondent name in their header, so after a correspondent collapse the
incremental indexer can’t heal the change — run paperless-index --full.
Not in v1 (deliberate): saved/named spaces, multi-turn chat history, RRF fusion with Paperless’ own full-text search, and a reranker (no good local cross-encoder path through Ollama today). RRF is the marked next step if recall disappoints.
Configuration#
Everything under tsunaminoai.docPipeline.rag.* (see rag.nix): enable,
port (8015), qdrantImage (pinned), embeddingHost (127.0.0.1:11435),
chatModel (defaults to the pipeline text model — parity contract!),
publicHostname (ask.inaba.network), indexSchedule, reconcileSchedule, and
extraEnv (freeform env for the scoped-retrieval knobs RAG_TOP_K_SCOPED /
RAG_SCORE_FLOOR_SCOPED and the dropdown cache TTL RAG_CORRESPONDENTS_TTL,
without a module edit).