Skip to content

Architecture

Varagity is a self-hosted RAG system implementing Anthropic-style Contextual Retrieval. Everything runs locally under one Docker Compose stack: models on the host's GPUs, stores in containers, and one Python pipeline package with two peer front-ends — a terminal CLI and a FastAPI service backing the Next.js web GUI — that ingest a corpus and answer questions with grounded, cited answers.

This page describes the system as built. The forward-looking designs — spec.md (v1), spec_v2.md (v2), spec_v3.md (v3), and the per-feature specs (spec_graphrag.md among them), the documents the § references in docstrings point at — are untracked working papers under thoughts/shared/tasks/<feature-or-version>/.

Service topology

Ten default services, one network (varagity-net), plus two profile-gated metric exporters. All pipeline logic lives in the varagity Python package, which reaches the five backing services from two peer front-ends: the app CLI and the api service. The remaining service-to-service edges are few and directional: the browser talks to web (and, optionally, Grafana), web talks only to api, Prometheus scrapes api's /metrics (plus the optional exporters), and Grafana queries Prometheus.

flowchart TB
    browser(["browser"])

    subgraph net["docker compose network · varagity-net"]
        web["web · Next.js GUI<br/>:3000"]
        api["api · FastAPI<br/>SSE chat · REST · /metrics<br/>:8000"]
        app["app · Python CLI<br/>ingest / chat / eval"]

        llamacpp["llamacpp · GPU 0<br/>chat LLM — answers + blurbs<br/>:8080/v1"]
        infinity["infinity-embeddings · GPU 1<br/>e5 embeddings + bge reranker<br/>:8081/v1 (+ /v1/rerank)"]
        postgres[("postgres · pgvector/pg16<br/>vectors + metadata ·<br/>conversations · settings<br/>:5432")]
        elasticsearch[("elasticsearch<br/>contextual BM25 index<br/>:9200")]
        prefect["prefect<br/>flow / task run tracking · UI<br/>:4200"]

        prometheus["prometheus<br/>metrics store<br/>:9090"]
        grafana["grafana<br/>provisioned dashboards<br/>:3001"]
        pexp["prefect-exporter<br/>(profile-gated)"]
        dcgm["dcgm-exporter · GPUs<br/>(profile-gated)"]

        web -->|"JSON + SSE"| api

        api & app -->|"chat · contextualize"| llamacpp
        api & app -->|"embed · rerank"| infinity
        api & app -->|"BM25 index / search"| elasticsearch
        api & app -->|"flow / task runs"| prefect
        app -->|"vectors + metadata"| postgres
        api -->|"vectors + metadata ·<br/>conversations · settings"| postgres

        prometheus -->|"scrapes /metrics"| api
        prometheus -.->|"scrapes, if enabled"| pexp
        prometheus -.->|"scrapes, if enabled"| dcgm
        pexp -.->|"polls flow/task runs"| prefect
        grafana -->|"PromQL"| prometheus
    end

    browser --> web
    browser -.->|"dashboards"| grafana

    classDef emphasis stroke-width:3px;
    class api,app emphasis;
Service Image / build Role Host port GPU
llamacpp ghcr.io/ggml-org/llama.cpp:server-cuda Chat LLM (answers + contextualization blurbs), OpenAI-compatible /v1, model .gguf bind-mounted from the host 8080 GPU 0 (count: 1)
infinity-embeddings michaelf34/infinity:0.0.76-trt-onnx multilingual-e5-large-instruct embeddings (1024-dim) and bge-reranker-v2-m3 at /v1/rerank, wired into the query path (see Reranking) 8081 (single-interface binding) GPU 1 (device_ids: ["1"])
postgres pgvector/pgvector:pg16 Dense vectors + the canonical chunk metadata, plus the v2 conversation and settings tables (see Data model) 5432 —
elasticsearch docker.elastic.co/elasticsearch/elasticsearch:9.2.0 Contextual BM25 index (single-node, yellow by design) 9200 (+ 9300) —
prefect prefecthq/prefect:3-latest Flow/task run tracking; UI at :4200; SQLite backing store (ADR-003) 4200 —
api local Dockerfile.api (uv, single uvicorn worker) FastAPI (varagity/api/): SSE chat streaming, conversation CRUD, corpus + settings routes, the graph corpus/build/export routes, /metrics; invokes the Prefect flows in-process. Mounts ./graph-docs (bind) and the graphdata volume — it is the graph's single writer 8000 —
web local web/Dockerfile (NEXT_PUBLIC_API_URL baked at build time) The Next.js GUI — the only browser-facing app surface; talks only to api 3000 —
prometheus prom/prometheus:v3.13.1 Scrapes api:/metrics every 15 s, plus the optional exporters ${PROMETHEUS_PORT:-9090} —
grafana grafana/grafana:12.3.8 Dashboards-as-code: provisioned Prometheus datasource + Query/Ingestion/Infra dashboards; anonymous read-only viewing (dev posture) ${GRAFANA_PORT:-3001} → 3000 —
app local Dockerfile (uv, non-root) The Varagity CLI: ingest, chat (default), eval — —

Two exporters ship profile-gated (off by default; their Prometheus scrape jobs are always configured, so a disabled profile just shows up == 0):

Service Image Enable with Role
prefect-exporter prefecthq/prometheus-prefect-exporter:3.6.1 docker compose --profile prefect-exporter up -d Flow/task-run metrics polled from the Prefect API; scraped in-network at :8000 (no host port — api owns host 8000)
dcgm-exporter nvidia/dcgm-exporter:4.5.2-4.8.1-ubuntu22.04 docker compose --profile gpu-metrics up -d GPU VRAM/utilization via NVIDIA DCGM; scraped in-network at :9400

GPU pinning, VRAM budgets, and healthcheck semantics are operational concerns — see the runbook.

Two peer front-ends, one pipeline package

The CLI (app) and the API (api) are peers over the same in-process Prefect flows — neither wraps the other, and there are no Prefect workers or deployments. The web app holds no pipeline logic at all: it is a pure client of api (JSON + SSE), which is what keeps the frontend swappable (spec_v2 §3).

The API is async at the edge, sync underneath (spec_v2 §4.1): FastAPI handlers are async, but they run the unchanged synchronous flows in a threadpool and marshal streaming callbacks back onto the event loop — no pipeline code was rewritten to async. varagity/api/schemas.py is the wire contract (the web app's TypeScript types are generated from the OpenAPI schema, never hand-edited), and the chat SSE protocol is retrieval → reasoning/token deltas → done (or an in-band error) — evidence always arrives before prose. Task graphs and streaming details are in Pipelines.

The api service also owns the evidence panel's page previews (ADR-010): for chunks from digital PDFs and PPTX decks, varagity/preview/ locates the one source page containing the chunk at preview time (word-trigram page scoring + pdfium text-search highlight rects — no ingest-time provenance, no reingest) and serves it as an immutably-cacheable PNG, with PPTX decks converted once per container lifetime by headless LibreOffice inside the api image. Every failure mode degrades to the GUI's full-text view via an available:false + reason envelope — the path never 500s per document.

Why Contextual Retrieval

A chunk embedded in isolation loses its parent document's context ("the company grew 3%" — which company, which period?), causing retrieval misses. Anthropic's Contextual Retrieval fixes this by prepending an LLM-generated situating blurb to each chunk before indexing. Their measured ladder of retrieval-failure reduction:

  1. ≈35% — contextual embeddings alone,
  2. ≈49% — contextual embeddings + contextual BM25 ← v1 shipped here (the hybrid default),
  3. ≈67% — the above + reranking ← v2 ships here (RETRIEVAL_METHOD=reranked — see Reranking).

Concretely, per chunk at ingest time (varagity/context/contextual.py):

  • the LLM sees the whole document and the chunk under the verbatim Anthropic cookbook prompt and returns a short situating blurb (context);
  • contextualized_content = context + "\n\n" + content is what gets embedded (pgvector) and BM25-indexed (Elasticsearch); the original content is preserved alongside;
  • a document's chunks are contextualized sequentially so llama.cpp reuses its prompt cache across the shared document preamble (throughput, not billing);
  • that preamble is fitted once per document, in the served model's tokens: a cl100k first cut, then llama.cpp's /tokenize has the last word — cl100k undercounts digit-dense text, which the served model splits one digit per token (ADR-003's amendment).

CONTEXTUALIZE=false keeps the identity path (contextualized_content = content) — the measured non-contextual baseline of the eval matrix and a throughput knob.

The two pipelines

Both live in the varagity package and are Prefect-tracked (task graphs in Pipelines). At the system level they meet at the two stores — ingest writes both, query reads both:

flowchart LR
    corpus[("docs/ corpus<br/>pdf · txt/md · docx/pptx/xlsx · html")]

    subgraph ingest["Ingestion (idempotent per file)"]
        direction LR
        parse["parse<br/>(4 parser families;<br/>PDF: two-pass OCR)"] --> chunk["chunk<br/>(5 strategies)"] --> ctx[contextualize] --> iembed[embed]
    end

    corpus --> parse
    iembed --> pg[("pgvector<br/>vectors + metadata")]
    iembed --> es[("Elasticsearch<br/>contextual BM25")]

    subgraph query["Query (RETRIEVAL_METHOD · CHAT_ENGINE)"]
        direction LR
        condense["condense<br/>(chat engine)"] --> retrieve["retrieve<br/>semantic · bm25 · hybrid · reranked · hyde"] --> generate["generate<br/>grounded + cited"]
    end

    question(["question"]) --> condense
    pg --> retrieve
    es --> retrieve
    generate --> answer(["cited answer"])
  • Ingestion — discover → parse → chunk → contextualize → embed → store(pgvector + Elasticsearch). Idempotent per file (byte-hash keyed). Five parser families cover PDF (a two-pass Docling parse that OCRs scanned pages automatically), plain text (.txt/.md/.rst), office (the .docx/.pptx/.xlsx families, .csv, OpenDocument), web (.html/.htm/.xhtml), and bitmap images (OCR-only); the chunking strategy is config-selected from five registered implementations.
  • Query — condense → embed(search query) → retrieve → generate → display. The condense stage is v3's chat engine (CHAT_ENGINE, ADR-011): given the turn and its conversation history it decides what string the retriever searches with — simple (the default) passes the question through verbatim; condense_context rewrites a follow-up into a standalone query. The split is the invariant: the engine's search_query drives the query embedding and BM25 while the original question always reaches the answer prompt. Retrieval method is config-selected (RETRIEVAL_METHOD): semantic (pgvector cosine), bm25 (Elasticsearch), hybrid (weighted reciprocal-rank fusion of both — the default), reranked (a base method's candidates re-ordered by a cross-encoder — see below), or hyde (an LLM-written hypothetical passage steering the dense arm — see below). The API front-end runs the same path as a streaming flow (query-stream), delivering evidence and answer deltas over SSE.

Since v2 every retrieved chunk carries an optional RetrievalTrace — per-arm ranks and scores, fused score and rank, rerank delta, final rank — threaded through fusion and hydration and filled in by the rerank stage (spec_v2 §9.2). The CLI matches table (-v 2), the web evidence panel, and the persisted message_sources.trace snapshots all render that same record; field-level documentation is in the data model.

The identity thread: (doc_id, original_index)

Chunks live in two stores that must agree on identity. The whole system joins on one composite key:

doc_id         = sha256(relative_path + ":" + sha256(file_bytes))[:16]   (per document)
chunk_id       = f"{doc_id}::{chunk_index}"                              (per chunk, pg PK / ES _id)
original_index = global monotonic chunk counter across the corpus        (fusion key)
  • doc_id hashes the path relative to DOCS_PATH, not the absolute path (ADR-003): absolute paths differ between host and container and across machines, which would break idempotency and make golden eval sets non-portable. The absolute path is still stored as source provenance.
  • Hybrid fusion scores and dedupes on (doc_id, original_index); the BM25 arm returns only identity + text, and full rows are hydrated from pgvector by the same key (ContextualVectorDB.fetch_by_identity).
  • The golden eval set resolves to chunk_ids from corpus files alone — no store round-trip — because the derivation is pure.
  • A unique index chunks(doc_id, original_index) enforces the identity in PostgreSQL so ingest bugs surface as constraint violations, not silent retrieval weirdness.

Hybrid retrieval is where the composite key earns its keep — two independent ranked lists fuse and dedupe on it, then full rows are hydrated from pgvector:

flowchart TB
    q(["query"]) --> enc["encode_query · e5 query mode"]
    enc --> sem["semantic arm · pgvector<br/>cosine top-k → ranked identities"]
    q --> bm["bm25 arm · Elasticsearch<br/>BM25 top-k → identities + text only"]
    sem --> fuse["weighted RRF fusion<br/>score += weight · 1/(rank+1)<br/>dedupe on (doc_id, original_index)"]
    bm --> fuse
    fuse --> hydrate["hydrate full rows from pgvector<br/>fetch_by_identity — the single metadata source"]
    hydrate --> topk(["top-k chunks · fused score ≤ 1.0"])

Since v2, fusion keeps its receipts: fuse_with_traces — the trace-building sibling of fuse, delegating the identical fusion math — preserves the per-arm rank/score maps that fuse builds and discards into a per-survivor RetrievalTrace, which hydrate attaches to each returned chunk (spec_v2 §9.2).

Modularity: the registry pattern

Anything expected to have "dozens of" implementations is a directory where each file is one implementation, self-registered via a decorator and selected by an .env value — adding one means adding a file plus its import line, with zero caller edits:

Family Registry Selected by Implementations
Parsers varagity/ingest/parsers/base.py discovery bucket text (.txt/.md/.rst), pdf (Docling two-pass OCR), office (OOXML families / .csv / OpenDocument), web (.html/.htm/.xhtml), image (bitmaps, OCR-only)
Chunking strategies varagity/chunking/base.py CHUNKING_STRATEGY recursive_character (default — ADR-008), token_based, markdown_aware, semantic, docling_hybrid
Retrievers varagity/retrieval/base.py RETRIEVAL_METHOD semantic, bm25, hybrid (default), reranked, hyde (ADR-016)
Chat engines varagity/chat/base.py CHAT_ENGINE simple (default — ADR-011), condense_context
Message sources varagity/graph/sources/base.py structural dispatch (the parser shape — no config vocabulary) imessage (a sniffed SQLite chat.db)
Graph engines varagity/graph/base.py GRAPH_ENGINE lightrag (default — ADR-017)
OCR engines varagity/ingest/parsers/pdf.py (a factory, deliberately not a registry) OCR_ENGINE easyocr (default, ADR-004), tesseract
Model clients varagity/models/registry.py model_type argument embedding, rerank, default (+ reasoning/tool aliases)

The office, web, and image parsers share an unregistered Docling core (parsers/docling_base.py) — shared machinery stays a plain module; only selectable implementations register. (image layers the PDF parser's OCR-engine factory on top: an image has no text layer, so OCR always runs and its chunks carry extraction: "ocr".)

Cross-cutting conventions

  • Three output channels (never conflated): the verbose: int parameter (0/1/2) renders human-facing console output via rich helpers in varagity/debug/show.py; stdlib logging (one logger per module, configured only in logging_setup.py) carries persistent leveled logs; Prefect run logs carry pipeline observability.
  • Typed settings: modules read the pydantic-settings Settings object (varagity/config.py), never os.getenv. Validation fails fast (fusion weights must sum to 1.0, RETRIEVAL_METHOD vocabulary, the rerank bounds, verbosity domain…). The API additionally layers persisted runtime overrides over the env values through that same object.
  • Retries in layers: every model/store client — the rerank client included — retries transient HTTP failures internally (tenacity); ingest's model/store tasks additionally carry Prefect retries for whole-stage re-runs (safe — both store writes are idempotent). Interactive query tasks deliberately carry none.
  • Docstrings are load-bearing: every public module/class/function carries a Google-style docstring, machine-enforced by ruff's pydocstyle rules and rendered into the API reference by mkdocstrings.

Reranking (wired in v2)

Reranking — the ≈67% tier — is wired into the query path as the reranked retriever (varagity/retrieval/reranked.py; spec_v2 §5, ADR-006). It composes a base retriever rather than forking fusion:

  1. over-fetch a wide candidate pool from RERANK_BASE_METHOD (default hybrid) — max(RERANK_CANDIDATES, k) chunks, default pool 40;
  2. cross-encode every candidate's original content against the query with bge-reranker-v2-m3 at infinity's /v1/rerank (the contextual blurb already did its job at the embedding/BM25 stage);
  3. keep the min(k, RERANK_TOP_N) most relevant, recording rerank_score and rerank_delta on each chunk's trace.

RERANK_ENABLED=false is a kill switch, not a method: RETRIEVAL_METHOD=reranked then degrades to its base method's ranking and logs the degradation. Method selection and the toggle are deliberately orthogonal — the GUI toggle and the eval baseline both work without renaming the method. Operational notes on serving the reranker and the embedder from one 8 GB GPU are in the runbook.

HyDE (post-v3)

HyDE — Hypothetical Document Embeddings (Gao et al. 2022) — is the hyde retriever (varagity/retrieval/hyde.py; ADR-016), added to be evaluated against the ladder rather than to move the default (RETRIEVAL_METHOD stays hybrid). Like reranked it composes a base retriever (HYDE_BASE_METHOD: semantic | hybrid):

  1. one non-streaming LLM call writes a hypothetical answer passage for the query (the same llama.cpp server that answers; <think>-stripped via clean_response, label-echo stripped, empty/overlong guarded);
  2. the passage — not the query — is embedded in e5 passage mode (embed_passages, the paper's document-encoder choice), landing it in the corpus's own vector space where passage↔passage neighbors are the chunks that look like the answer;
  3. the base retriever runs with the original query text plus that vector via the query_vector seam — dense-arm-only substitution: a hybrid base's BM25 arm keeps exact keyword recall, and traces pass through untouched.

Pairing with the cross-encoder stacks rerank outside: RETRIEVAL_METHOD=reranked + RERANK_BASE_METHOD=hyde, so HyDE shapes the candidate pool while the reranker judges the user's real query (the reverse nesting is config-rejected, as are bm25 — it ignores query vectors — and recursion). HYDE_ENABLED=false is the same kill-switch shape as RERANK_ENABLED: hyde degrades to its base method's raw-query retrieval and logs it — and every generation failure degrades identically at WARNING. Matrix configs 6–7 (hyde_contextual, hyde_rerank_contextual) measure both forms.

The message graph (GraphRAG)

The graph is a peer corpus, not a sixth retriever (ADR-017; spec_graphrag). Chunk RAG answers "what do my documents say"; the graph answers "who, with whom, and when" over a structured message archive — an iMessage chat.db in v1 — where the unit of meaning is a conversation, not a page. Every chat turn carries which corpus it asked for (ChatRequest.corpus, the composer's source selector today and a query router's output later), and the two corpora share the transport, the evidence contract, and the answer prompt while sharing no storage at all.

flowchart LR
    db[("graph-docs/<br/>chat.db + -wal/-shm")]

    subgraph build["Graph build (resumable, API-only)"]
        direction LR
        scan["scan + parse<br/>(message sources)"] --> render["render<br/>thread-day transcripts"] --> diff["manifest diff<br/>new / changed / removed"] --> extract["enqueue → process<br/>(LLM entity extraction)"]
    end

    db --> scan
    extract --> wd[("graphdata volume<br/>graphml + vectors +<br/>doc statuses + sidecars")]

    subgraph gquery["Graph turn (corpus = graph)"]
        direction LR
        retrieve["retrieve<br/>entities · relations · transcript days"] --> answer["generate<br/>our prompt, our stream"]
    end

    question(["question"]) --> retrieve
    wd --> retrieve
    answer --> cited(["cited answer<br/>day-grain evidence"])

Four properties are the design:

  • Retrieval-only engine, our answer. The engine returns structured evidence (entities, relations, cited transcript excerpts); the grounded, capped, <think>-stripped answer is written by varagity/graph/answer.py over the repo's own prompt — so a graph turn cites, streams, and degrades like every other turn (ADR-017 decision 1). The evidence panel renders the same "how this answer was built" contract, in the graph's units.
  • One process is the single writer. The engine's storages are single-writer per working directory, so the API owns them: varagity/graph/service.py holds the process's one session behind a single-flight write lock, and reads take no lock at all — the session runs its event loop on its own thread, so chat queries and the graph view are answered while a multi-day backfill is still extracting. This is why builds are API-only and there is deliberately no CLI graph build.
  • Builds are resumable and diffing. The engine's document statuses live on disk, so a killed backfill resumes by pressing Build again. A workdir sidecar manifest (doc_key → {content hash, message guids, thread, span}) turns re-uploading a grown chat.db into an upsert — the engine's own enqueue dedup would otherwise keep a stale transcript forever — and doubles as the durable provenance index behind a citation.
  • Evidence is day-grain, and says so. The engine attributes a conversation-day, not a single message; the panel's footer states that rather than letting day cards read as message-grain proof.

The eleventh storage surface is a compose volume: graphdata (/app/graph-data, api-only) holds the engine's graph, vectors, document statuses, and the two sidecars; ./graph-docs is a host bind holding the uploaded archives, so a docker volume rm varagity_graphdata throws the graph away and keeps its sources. Operations — upload, build, kill, resume, bounded backfills, reset — are in the runbook; the flows are in Pipelines; the on-disk shapes are in the data model.

GRAPH_ENABLED=false is the subsystem's kill switch in the shape RERANK_ENABLED established: uploading, building, and reading the graph become a structured 403 graph_disabled, and a graph-targeted turn degrades to a document answer — reported on the turn, never silently.

Backups beside each corpus

docker compose down -v deletes every named volume — the chunk rows and their vectors, the BM25 index, the extracted graph — while the host binds holding the sources survive. Each corpus therefore keeps its backups in a reserved folder inside its own bind, where they survive with the sources (ADR-019): one zip per backup, written by Export on the corpus tab and turned back into the corpus by Restore, with no model call either way.

flowchart LR
    subgraph rag["document corpus"]
        direction TB
        pg[("pgvector<br/>documents · chunks")] -->|"rag-export<br/>one snapshot"| rz["docs/varagity-exports/<br/>varagity-rag-….zip"]
        rz -->|"rag-restore<br/>one transaction · COPY"| pg
        pg -->|"rows → streaming_bulk"| es[("Elasticsearch<br/>BM25 index")]
    end
    subgraph graph["message graph"]
        direction TB
        wd[("graphdata<br/>the engine's workdir")] -->|"graph-export<br/>under the write lock"| gz["graph-docs/varagity-exports/<br/>varagity-graph-….zip"]
        gz -->|"graph-restore<br/>extract · swap · open"| wd
    end
  • The bundles are never corpus. varagity-exports/ at a corpus root is skipped by ingest discovery and the graph build's scan, hidden from the Graph sources listing, and refused by both upload routes; nothing there can be ingested, built from, listed or deleted as a source.
  • A RAG bundle is the documents and chunks rows with their vectors as a raw float32 matrix; its restore replaces both tables in one Postgres transaction, re-roots each source onto the restoring stack's DOCS_PATH, and rebuilds the BM25 index from the rows. A graph bundle is the engine's working directory, verbatim — LLM cache, build manifest and size summary included — and its restore extracts it beside the live workdir and swaps it in, putting the previous graph back if the restored one won't open.
  • One writer per corpus. A per-corpus CorpusGate (varagity/api/corpus_gate.py) is claimed by the ingest runner, the graph build runner and the backup runner alike, so an ingest, build, export or restore of the same corpus never overlap (409 corpus_busy, naming the holder); the two corpora's operations run side by side. The gate is in-process. For RAG — whose CLI ingests run in their own processes — a Postgres advisory lock is its cross-process half: every ingest holds it shared, a restore exclusive. The graph needs none: the API process is its single writer.
  • Strict compatibility. A bundle from another embedding model or dimension — or, for the graph, another engine or engine major.minor version — is listed with its reason and refused, never converted: neither store would notice, and retrieval would quietly return the wrong passages.
  • While a restore runs, chat against that corpus answers 503 corpus_restoring, and the corpus's deletes answer 409 corpus_busy.

The format is in the data model, the flows in Pipelines, the HTTP contract and its status stream in the HTTP API, and the curl recipes and the wiped-stack drill in the runbook.

The codebase map

This page explains Varagity in prose and Mermaid fragments; the web GUI also ships the whole system as one interactive graph at /map (and this site at Codebase map) — entry points, the Prefect flows, the chat engines, the pluggable registries, and the datastores as cards on a pan/zoom canvas (HTML cards over one SVG edge underlay), with the three models folded into favicon chips on the cards that call them, the Ingestion / Query path / Graph corpus / Observability groups drawn as containers, and click-to-spotlight of downstream flows (ADR-015, amended). It complements this document by putting the relational facts on the edges where they belong ("reranked composes a base retriever", "condense calls the same llama.cpp server the answer generator does"). It is reachable from the sidebar and the ⌘K palette when developer mode (default on) is enabled, and by URL regardless — the gate is cosmetic (single-user local app).

The map is a curated artifact, not generated: its graph lives in web/lib/codebase-map.data.ts. Update rule — edit that file when the architecture changes, the same discipline as regenerating golden-docs/openapi.json after an API-surface change. A Vitest drift guard (web/lib/__tests__/codebase-map.test.ts) resolves every node's sourceRef against the repo root and fails CI when a referenced file is renamed or truncated past its pinned line — the mirror of the drift-guarded openapi.json snapshot. What it cannot guard is edge semantics: whether an arrow still tells the truth is the human's job, so an architectural change that leaves the referenced files in place still needs the map edited by hand.

The same view is built into this site at Codebase map: a committed Vite bundle (golden-docs/assets/map/, built from web/docs-map/ — ADR-018) that map.md frames, so the picture reads without the stack running. The update rule therefore has a second step: after editing the data — or anything under web/components/map/, the layout snapshot, web/app/globals.css, or web/docs-map/ — run cd web && bun run build:docs-map and commit the output. A second Vitest guard (web/lib/__tests__/docs-map.test.ts) hashes exactly those inputs and fails CI when the committed bundle was built from anything else.

Module map

varagity/
├── config.py             # typed Settings (.env + persisted runtime overrides), validation
├── logging_setup.py      # RichHandler root config (the only place logging is configured)
├── tokens.py             # cl100k token counting (documented approximation)
├── models/               # OpenAI-SDK clients for llama.cpp + infinity; get_model() factory
│   ├── rerank.py         # RerankClient — infinity /v1/rerank (cross-encoders only)
│   └── stream.py         # ThinkStreamSplitter — reasoning vs answer delta classification
├── ingest/
│   ├── discovery.py      # scan DOCS_PATH, bucket by parser family
│   ├── loader.py         # the single orchestration loop (idempotency, guards, dual-write)
│   └── parsers/          # text.py, pdf.py (two-pass OCR + engine factory),
│                         #   docling_base.py (shared core), office.py, web.py,
│                         #   image.py (OCR-only)
├── chunking/             # recursive_character (default), token_based, markdown_aware,
│                         #   semantic, docling_hybrid
├── context/              # situate_context() — the contextual blurb
├── stores/
│   ├── records.py        # ChunkRecord, RetrievalTrace, RetrievedChunk
│   ├── vector_store.py, bm25_store.py, schema.sql
│   ├── conversation_store.py   # conversations / groups / messages / message_sources snapshots
│   ├── app_settings_store.py   # persisted runtime overrides + the _corpus_stale / _graph_stale flags
│   └── migrate.py, migrations/ # idempotent NNN_*.sql runner (runs on API startup)
├── backup/               # corpus backups (ADR-019): bundle.py (zip + bundle.json, the cheap
│                         #   verdict, sha256 verification), rag.py (snapshot export, COPY
│                         #   restore + BM25 rebuild), graph.py (workdir export, extract + swap)
├── retrieval/            # semantic.py, bm25.py (+ hydrate), hybrid.py, reranked.py, hyde.py
├── chat/                 # chat-engine registry (ADR-011): base.py (PreparedQuery, Turn),
│                         #   simple.py, condense.py + prompts.py
├── graph/                # the peer message-graph corpus (ADR-017)
│   ├── sources/          #   message-source registry — imessage.py (sniffed chat.db)
│   ├── render.py         #   messages → thread-day transcripts (pure; the diff rests on it)
│   ├── base.py, records.py  # engine seam + engine-independent records
│   ├── engines/lightrag.py  # the adapter (imports the library inside session())
│   ├── manifest.py       #   workdir sidecars: build diff + durable provenance + size cache
│   ├── service.py        #   the process's one session; single-flight writes, unlocked reads,
│   │                     #     the restore's workdir swap (+ its crash heal)
│   └── answer.py         #   our grounded answer over the engine's evidence
├── preview/              # evidence-panel page previews (ADR-010): normalize, locate
│                         #   (pdfium, one global lock), render, source gate, pptx→pdf
├── generation/           # answer.py — context prompt + grounded generation + QueryState
├── pipeline/             # Prefect flows: ingest, query, query-stream, graph, backup, eval
│                         #   (thin @task adapters)
├── observability/        # metrics.py (the Prometheus collector catalog, spec_v2 §6.2)
│                         #   + corpus.py (store-derived corpus gauges — ADR-013)
│                         #   + graph.py (workdir-derived graph gauges, same shape)
├── api/                  # the FastAPI front-end (spec_v2 §4)
│   ├── main.py           # app factory + lifespan (migrations, override replay)
│   ├── routes/           # system, conversations, groups, chat, settings, documents,
│   │                     #   ingest, graph, backups, metrics
│   ├── schemas.py        # the wire contract (REST + SSE payloads → generated TS types)
│   ├── streaming.py      # EventBridge — sync flow callbacks → SSE frames
│   ├── ingest_runner.py  # one-at-a-time background ingest + status stream replay
│   ├── graph_runner.py   # the same shape for graph builds (resumable, killable)
│   ├── backup_runner.py  # the same shape for backup exports and restores, one per corpus
│   ├── corpus_gate.py    # one writer per corpus across every runner → 409 corpus_busy
│   └── runtime_settings.py, deps.py
├── eval/                 # golden set, 7-config matrix, chunker sweep, OCR benchmark, testcontainers
├── cli/                  # argparse subcommands: ingest / chat / eval [ocr]
└── debug/show.py         # v_<name>() rich renderers behind the verbose= convention

web/                      # the Next.js GUI — own toolchain (bun, Vitest, Playwright); talks only to api
observability/            # prometheus.yml + provisioned Grafana datasource/dashboards (mounted read-only)