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 the search_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 --full rebuilds 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 modified field ($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 — created so 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 optional filters object (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-User header (set by the cf-origin vhost) — the app itself does no auth, which is why it must stay on loopback.
  • GET /healthz checks 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).