Pipelines¶
Every pipeline is a Prefect flow (varagity/pipeline/) whose stages run as
tracked task runs — state, duration, retries, and logs per stage at the
Prefect UI (http://localhost:4200). Business logic lives in the plain
modules; the flow layer is thin @task adapters, so the same code runs with
or without Prefect. Two peer front-ends invoke the same flows in-process —
the CLI (main.py) and the HTTP API (varagity/api/, spec_v2 §4.9) — the
API adding streaming around the tasks, never forking them.
Prefect posture¶
- Flows run in-process from the CLI and the API — no worker, deployment,
or schedule (spec §21 #8). Every ingest/question/eval, terminal or
browser, creates flow runs against the compose
prefectserver (SQLite-backed, ADR-003). PREFECT_API_URLis exported beforeprefectis imported (varagity/pipeline/__init__.py): Prefect captures its environment at import time, so a later export would be silently ignored. With no server reachable, Prefect 3 falls back to an ephemeral in-process API — host runs without the stack still work, just untracked.- Result caching is disabled on every task (
NO_CACHE): the stages are side-effecting calls against live services with unhashable inputs (store clients, progress displays); Prefect's default input-hash policy would log an error per run and a cache hit could never be correct. - Flow parameter validation is off (
validate_parameters=False): tests and the eval harness inject duck-typed store/client fakes that pydantic's is-instance validation would reject. - Flow bodies double as the Prometheus probe points (spec_v2 §6.2): the
query flows time each stage around its task call, record retrieval scores,
and count outcomes; two ingest tasks carry the blurb-latency histogram and
the corpus-growth counters (below). Recording is unconditional —
METRICS_ENABLEDgates only the API's/metricsroute — but collectors are per-process, so only flows run inside the API process ever reach the scrape (see the runbook).
Ingestion flow (ingest)¶
flowchart TB
start(["ingest_flow(docs_path)"]) --> discover["discover_documents<br/>bucket files by parser family — §9.1, spec_v2 §8.1"]
discover --> loop{{"for each file"}}
loop --> skip{"unchanged?<br/>(doc_id, content_hash)<br/>already in documents"}
skip -->|"yes · skip before parse"| loop
skip -->|no| parse["parse_document<br/>text | pdf (two-pass OCR) | office | web | image (OCR) — §9.2, spec_v2 §8"]
parse --> chunk["chunk_document<br/>CHUNKING_STRATEGY registry, 5 strategies — §9.3, spec_v2 §7"]
chunk --> ctx["contextualize_chunks<br/>LLM situating blurb per chunk — §9.4"]
ctx --> embed["embed_chunks<br/>e5 passage mode, batched — §9.5"]
embed --> store["store_chunks<br/>ES bulk first, then pg txn — §9.6"]
store --> loop
classDef retry stroke-width:3px,stroke-dasharray:4 3;
class ctx,embed,store retry;
The three model/store stages (dashed border) carry Prefect retries=2;
discovery/parse/chunk are local and deterministic, so they carry none (below).
- One orchestration loop. The loader (
varagity/ingest/loader.py) owns the loop — idempotency skip, empty-extraction guard, per-file failure containment,original_indexallocation, file-clock provenance capture (file_timestamps→file_created_at/file_modified_aton every record) — and invokes each stage through anIngestStagesseam. The flow passes@task-wrapped equivalents of the same stage functions through the same loop, so plain and tracked execution cannot drift. The task bundle is public (TASK_STAGES) — the API's ingest runner wraps these very tasks (below). - Five parser families, one loop (spec_v2 §8, ADR-009). Discovery
buckets each file:
.txt/.md/.rst→text;.pdf→pdf(fast text-layer pass with the automatic OCR fallback pass — runbook); the OOXML families,.csv, and OpenDocument →office;.html/.htm/.xhtml→web; bitmap images →image(no text layer exists, so OCR always runs and chunks carryextraction: "ocr"). Office and web share the PDF parser's Docling core but never OCR — their text is digital by construction, so every chunk carriesextraction: "text". - Five chunking strategies (spec_v2 §7).
chunk_documentdispatches onCHUNKING_STRATEGY:recursive_character(the benchmark-kept default — ADR-008),token_based,markdown_aware,semantic,docling_hybrid.CHUNK_SIZE's unit is per-strategy: characters forrecursive_character/markdown_aware, tokens fortoken_based/docling_hybrid(and forsemantic's re-split ceiling — it splits on embedding-similarity boundaries, not a size budget).markdown_awareadditionally stamps each chunk'sheading_pathbreadcrumb (e.g.Harbor Operations > Dredging) into its metadata, carried byChunkRecordinto both stores. - One preamble per document.
contextualize_chunksfits the document into the situating prompt's budget once, before its first chunk (fit_document_preambleinvaragity/context/contextual.py), and hands that one preamble to every chunk: byte-identical prompt prefixes for llama.cpp's cache, and the served token count is paid per document, not per chunk. The budget is held in the served model's tokens. A document of at most budget-many UTF-8 bytes fits as-is, uncounted. Otherwise cl100k cuts first, then llama.cpp's/tokenize(server root, not/v1) counts the cut and re-cuts it in proportion while it is over — typically one re-cut for digit-dense text, which the served model splits one digit per token. With no served count (server unreachable, not llama.cpp) the preamble is the document's first budget-many UTF-8 bytes, which fits any tokenizer: a shorter context, never a failed file. - Retries in two layers, different scopes. The model/store clients retry
transient HTTP failures within one call (
tenacity); the contextualize/embed/store tasks carryretries=2with exponential backoff to re-run the whole stage after the client gives up — e.g. an Elasticsearch restart mid-ingest. Both store writes are idempotent, so a stage re-run is safe. Discovery/parse/chunk are local and deterministic; retrying them cannot help, so they carry none. - Failure containment. One file raising is logged, counted (
failedin the summary table), and visible as a failed task run — it never aborts the corpus. A file with no extractable text gets a 0-chunkdocumentsrow and a dedicated summary count (never silently dropped). - Ordering inside
store_chunks: Elasticsearch bulk first, pgvector transaction last — the pgdocumentsrow is the idempotency marker, so a failure in between leaves the file re-attemptable (see Data model). - The corpus lock, shared. Every run holds the RAG corpus's Postgres
advisory lock shared, from before its first write (the BM25 index
create) until it returns. Ingests overlap each other exactly as before; a
backup restore holds the lock exclusive (below), so an ingest in any
process — the CLI's
ingest, orchat's startup ingest — and a restore exclude each other. A refused run raisesCorpusLockedbefore it writes anything; the CLI prints one line and exits 1. The lock belongs to the run's database session, so a killed process releases it with its connection. - Prometheus probe points:
contextualize_chunksfeedsvaragity_contextualize_latency_secondsper document (real blurb generation only — theCONTEXTUALIZE=falseidentity path doesn't count), andstore_chunksbumps the document/chunk counters after both writes land, so corpus-growth metrics count documents that stored, not attempts.
API-triggered ingest¶
POST /api/ingest runs the same flow: the API starts ingest_flow on a
daemon thread inside its own process and returns a run handle immediately
(202); GET /api/ingest/status streams the run as SSE (spec_v2 §4.2). The
runner (varagity/api/ingest_runner.py) wraps the flow's public
TASK_STAGES seam with event emitters — the Prefect tasks (tracking,
retries, metrics) are untouched; each stage transition just also emits a
frame. Tracking and streaming compose; there is no second pipeline.
- Event protocol:
status(run snapshot — the first frame, and the terminal frame carrying the summary counters) ·progress(one perdiscover/parse/chunk/contextualize/embed/store/file_donetransition, plus per-chunk contextualize ticks via a proxy over the loader'srichprogress handle — browser granularity equal to the terminal's on the ingest's long pole) ·log(relayedvaragity.ingestrecords: skips, no-text warnings, failures). - Replay from frame one: events accumulate per run, and a subscriber
always receives the full backlog before the live tail — connecting
mid-run, or after completion, renders the same picture. An API process
where nothing ever ran answers a single idle
statusframe and closes. - One run at a time: a second
POSTwhile one is in flight is a structured409 ingest_already_running. A preflight rejects with a structured503when a required backing service is down (llamacpp is required only whileCONTEXTUALIZEis on). - The stale-flag hook: a completed
reingest=trueAPI run clears the persisted corpus-stale flag (runbook); a CLIingest --reingestruns in another process and does not. - Because the flow runs in the API process, the ingest Prometheus counters actually reach the scrape — a CLI ingest records into a registry that dies with the process.
Query flows (query / query-stream)¶
flowchart TB
start(["query_flow(query, history) · query_stream_flow(query, history)"]) --> condense["condense_query<br/>chat engine: simple | condense_context — spec_v3 §4.2<br/>PreparedQuery: search_query / original_query"]
condense --> embed["embed_query<br/>retriever.encode_query(search_query) — None for bm25<br/>(hyde: LLM passage → passage-mode embed — ADR-016)"]
embed --> retrieve["retrieve<br/>top-k via semantic | bm25 | hybrid | reranked | hyde<br/>(reranked: over-fetch → cross-encode → cut — spec_v2 §5)"]
retrieve -.->|on_retrieved| hook["CLI matches table ·<br/>SSE retrieval event"]
retrieve --> generate["generate_answer<br/>grounded, cited answer — §10.2 prompt"]
retrieve --> generate_s["generate_answer_stream<br/>on_delta per fragment · should_abort<br/>polled between deltas — spec_v2 §4.3"]
generate --> state(["QueryState — §10.1<br/>{query, query_vector, retrieved,<br/>formatted_context, answer}"])
generate_s --> state_s(["StreamedQueryState<br/>… + {reasoning, aborted, usage}"])
classDef sidecar stroke-dasharray:4 3;
class hook sidecar;
Both flows share the staging — condense → embed → retrieve → generate, with
on_retrieved firing between retrieve and generate — and return the spec
§10.1 state dict (which since v3 carries prepared: PreparedQuery);
query_stream_flow extends it as StreamedQueryState with reasoning,
aborted, and usage.
- The condense stage is always in the graph (v3, spec_v3 §4.2;
ADR-011):
condense_queryruns the configured chat engine over the question and its conversation history and returns thePreparedQuerytwo-string split — everything downstream retrieves withsearch_querywhile the answer prompt always getsoriginal_query, the user's verbatim words. Undersimple(the default) it is the identity split in ~3 ms with no LLM call;condense_contextmakes one non-streaming LLM call to resolve a follow-up against history — and falls back to the raw query on any failure (empty, over-length, or a raised call — logged atWARNING, the turn never dies there). The first turn never condenses: empty history is the identity path. - Query embedding is its own tracked stage via the retrievers'
encode_query()seam; the vector is handed toretrieveso nothing is encoded twice. Forbm25it isNone(nothing to encode). rerankedcomposes insideretrieve(spec_v2 §5, ADR-006) rather than adding a stage: it over-fetches a pool ofmax(RERANK_CANDIDATES, k)fromRERANK_BASE_METHOD(defaulthybrid), cross-encodes every candidate's originalcontentagainst the query at infinity's/v1/rerank, and keepstop_n = min(k, RERANK_TOP_N)— re-ranking narrows; it never invents candidates. Each survivor'sRetrievalTracegainsrerank_score,rerank_delta(+ moved up / − moved down), andfinal_rank— the evidence panel'srerank +2badges — and itsscorebecomes the cross-encoder relevance.RERANK_ENABLED=falseis a kill switch, not a method:rerankedthen passes the base ranking through the same cut and logs the degradation. The rerank sub-stage is timed into Prometheus on its own (stage="rerank"), and the flow'sretrieveobservation deliberately includes it — total retrieval latency is what the user feels; rerank's share is subtractable.hydecomposes insideencode_query(ADR-016): the embed stage'sretriever.encode_query(search_query)is where the hypothetical answer passage is generated (one non-streaming LLM call,<think>-stripped viaclean_response— the condense lesson) and embedded in e5 passage mode;retrievethen handsHYDE_BASE_METHODthe original query text plus that vector — dense-arm-only substitution, so ahybridbase's BM25 arm keeps exact keyword recall and traces pass through untouched. The generation sub-stage is timed into Prometheus on its own (stage="hyde") and the flow'sembedobservation deliberately includes it, mirroring the rerank sub-stage. Every unusable generation (raised call, empty, overlong) andHYDE_ENABLED=falsedegrade to the base method's raw-query retrieval atWARNING— a degraded search still answers; a failed turn answers nothing. Pairing with the cross-encoder stacks rerank outside (RETRIEVAL_METHOD=reranked+RERANK_BASE_METHOD=hyde), so the reranker judges the user's real query.query_stream_flowis the streaming twin backingPOST /api/chat(spec_v2 §4.3): identical embed/retrieve staging, thengenerate_answer_stream_taskhands each delta toon_deltawhile it runs.on_retrievedfiring before generation is what puts the SSEretrievalevent (chunks with traces, method,top_k,reranked_to) on the wire before any answer token — evidence before prose. The generate stage stays a tracked Prefect task while tokens flow: the task boundary, run log, and metrics are preserved; streaming is a callback out of the task, not a bypass of it.- Deltas are classified, not raw: the LLM client's
ThinkStreamSplitterlabels each fragmentreasoning(inside<think>…</think>) oranswer, and the API maps them 1:1 onto SSEreasoning/tokenevents. The wire order isretrieval → reasoning/token (interleaved, stream order) → done | error. - Client disconnect frees the GPU:
should_abortis polled between deltas, so tearing down the SSE stream stops generation at the next token. An abort is a deliberate stop, not a failure — the task completes normally withaborted=True(counted asoutcome="aborted"), and the API persists nothing for the turn. - No Prefect-level retries on the query path: it is interactive, the clients already retry transient HTTP failures internally, and stacked backoff would multiply the wait before a hard failure surfaces at the prompt.
- No Prefect-level retries on
condense_queryeither — the same convention, sharpened by the fallback: a condense failure must fail fast into the raw-query fallback, not retry into the user's latency budget. - Prometheus probe points: both flow bodies time
condense/embed/retrieve/generatearound the task calls (varagity_query_latency_seconds{stage, method}), record the returned scores (varagity_retrieval_score), and count the outcome (varagity_query_total{outcome=ok|aborted|error}); the streaming flow additionally counts the server-reported token usage (varagity_llm_tokens_total). - Measured overhead of flow tracking: ≈0.06 s against a ~7.5 s question — LLM generation dominates.
The graph flows (graph-build / graph-query-stream)¶
The message graph is a peer
corpus, so it gets peer flows (varagity/pipeline/graph_flow.py) — one that
extracts it and one that answers from it. Both are API-only: the engine's
storages are single-writer per working directory and the API process owns
them, so there is deliberately no CLI graph build.
flowchart TB
start(["graph_build_flow(service, batches)"]) --> scan["scan + parse<br/>message sources sniff the corpus dir"]
scan --> render["render thread-day transcripts<br/>pure — identical days render identical text"]
render --> diff["manifest diff<br/>new · changed · unchanged · removed"]
diff --> del["delete changed + removed<br/>(the engine's enqueue dedup can't update)"]
del --> enqueue["enqueue pending<br/>durable PENDING doc-status rows"]
enqueue --> process["process<br/>LLM extraction, batched, status-flushed"]
process --> sidecars["rewrite manifest + summary"]
classDef durable stroke-width:3px;
class enqueue,process durable;
- One flow run per build attempt, not per document. The engine enqueues into its own durable status store and processes in batches inside a single call; per-document task runs would mean reaching inside its pipeline. Per-document progress is reported instead by the runner's status sampler, which reads the engine's own document counts — the same tracking-and-streaming composition the ingest runner uses.
- Resume is the same call. Every in-flight or failed document
(
pending,parsing,analyzing,processing,failed) is re-selected at the top of every batch, and statuses flush to disk at each stage boundary. A backfill killed 14 hours in resumes by pressing Build again — nothing is re-extracted, and the run reports what it inherited asresumed. That is whyreingestis a separate, explicit flag: it is the one path that throws the working directory away. - The manifest diff is the upsert. Rendering is pure, so an unchanged
thread-day hashes identically no matter how often its
chat.dbis re-uploaded and costs nothing. A changed day is deleted from the graph and re-inserted (the engine drops a re-submitted id in any status, so without this the stale transcript would be permanent); a removed day is retracted on a full-corpus render, while a bounded build — partial by construction — retracts only its re-span casualties (below) and keeps the rest: pruning out-of-window keys on a partial render's say-so would erase the archive. A delete the engine refuses keeps its old manifest record, so the next build still sees it as stale. - Bounded builds are the escape hatch (
message_limit,since): a full backfill runs for hours (7.17 s/message measured — see the runbook), so a spot check on the newest N messages, or a dated slice, is how the corpus is smoke-tested before committing days of GPU time. Both implyprune_removed=False. - Growing a bounded window re-keys documents, and the build cleans up
after it. Day-span packing is greedy, so widening the window backward
shifts a thread's pack boundaries and downstream documents re-key: their
old keys turn up
removedalthough nothing left the archive (observed on a 5k→10k grow: 109 new, 48 unchanged, 38 removed). A bounded build deletes such a key only on proof of full guid coverage — every message its manifest record holds is re-indexed under new keys by this very render — which is what separates a re-span casualty (a duplicate transcript, safe to drop) from genuinely out-of-window content (kept, per the rule above). Ghost keys left by grows that ran before this sweep existed are caught the same way by the next bounded build that covers their messages. - No Prefect-level retries. An engine that fails mid-backfill must surface rather than silently re-run hours of extraction — and a re-called build resumes from the durable statuses anyway.
The query path is query_stream_flow with exactly one stage swapped:
flowchart LR
q(["graph_query_stream_flow(query, service)"]) --> condense["condense_query<br/>the SAME task — spec_v3 §4.2"]
condense --> gret["graph_retrieve<br/>entities · relations · transcript days"]
gret -.->|on_retrieved| sse["SSE retrieval event<br/>(evidence before prose)"]
gret --> gen["generate_answer_stream<br/>the SAME task, graph-shaped context"]
gen --> state(["StreamedGraphState"])
classDef sidecar stroke-dasharray:4 3;
class sse sidecar;
- Condense and generate are the same tasks the chunk path runs, so a
graph turn inherits the search/answer query split (the user's verbatim
words always reach the answer prompt), the
[SOURCE]citation contract,<think>splitting, client-abort handling, and token accounting — with no second implementation of any of them. Only the middle stage differs, because only the middle stage is different. - Embed + retrieve collapse into one task. The engine owns its own
keyword extraction, embedding, and fusion inside a single call
(
GRAPH_QUERY_MODE, defaultmix); splitting the wrapper would invent task boundaries the engine does not have. - Reads never wait for a build.
graph_retrievetakes no write lock, so a question is answered during a backfill — it queues only behind the in-flight extraction call for the single llama.cpp slot. - The answer is ours, not the engine's (ADR-017):
the retrieval-only decision means the engine returns evidence and
varagity/graph/answer.pyrenders the grounding context (budgeted againstLLM_CONTEXT_TOKENS) that the ordinary streaming generate task writes from.
API-triggered graph builds¶
POST /api/graph/build is the ingest runner's shape, one corpus over:
a daemon thread inside the API process, an immediate 202 handle, and
GET /api/graph/build/status streaming the run as SSE with full backlog
replay — a browser opened six hours into a backfill renders the same
picture one opened at the start did. progress frames walk
scan → parse (per file) → bound/reset → index → sampled process ticks;
log frames relay the varagity.graph logger.
- One build at a time: a second
POSTis a structured409 graph_build_running; a preflight rejects with503when a model service is down.GRAPH_ENABLED=falserefuses with403 graph_disabled. - One writer per corpus (ADR-019): a build claims the graph corpus's
gate for its whole run, so a build during a graph backup export is
409 corpus_busy, and an export during a build is refused the same way. Thereingest=truewipe runs under the service's write lock too, so no backup can ever copy a workdir being removed. The build's scan never counts the reservedgraph-docs/varagity-exports/folder. - Killing the process is safe: the durable statuses are the run's memory, and both sidecars are written atomically.
- The graph-stale flag is cleared only by a completed API-driven
reingest=truerun — deleting a source file sets it, and the tab says so until a rebuild retracts those messages.
The backup flows (rag-export / rag-restore / graph-export / graph-restore)¶
rag-export (varagity/pipeline/backup_flow.py) writes the chunk corpus
into a backup bundle (Data model), and
rag-restore turns a bundle back into the corpus; graph-export writes the
message graph's workdir into one (below),
and graph-restore swaps a bundle's workdir back in
(below). All four are thin @flow
shells over the backup package (varagity.backup.rag's export_rag and
restore_rag, varagity.backup.graph's export_graph and
restore_graph), for the same reason graph-build is one: the work belongs
to the backup package, while Prefect owns the record that a backup ran,
how long it took, and whether it failed (ADR-019).
flowchart LR
start(["rag_export_flow(dest, ingest_settings)"]) --> stale["read _corpus_stale<br/>(app_settings)"]
stale --> snap["iter_corpus()<br/>one REPEATABLE READ snapshot"]
snap --> docs["documents.jsonl<br/>+ relative_path"]
docs --> chunks["chunks.jsonl<br/>vectors spooled beside it"]
chunks --> fin["embeddings.f32 · bundle.json<br/>fsync · os.replace"]
- One flow run per export, no tasks. An export is a single consistent
read. Splitting it into task runs would invent boundaries the snapshot
doesn't have. Stage progress travels as the API runner's
progressevents instead:snapshot→documents→chunks→finalize. - No model service, no retries. The flow reads postgres and writes one file. A failure removes the partial bundle, so the honest response is to surface it and let the user press Export again, not to re-read the corpus silently.
- One pass, aligned by construction. A zip takes one member at a time,
so the vector matrix is spooled to an unnamed temp file beside the bundle
while
chunks.jsonlstreams, then copied in asembeddings.f32(stored, not deflated, because float32 noise doesn't compress). Row i is line i's vector because both came from the same row of the same snapshot. - API and GUI only. There is deliberately no CLI export command. It matches the graph build's posture, and keeps one shape for both corpora.
The restore (rag-restore)¶
flowchart LR
start(["rag_restore_flow(bundle, ingest_settings)"]) --> validate["validate<br/>the bundle's verdict again"]
validate --> lock["corpus_lock(exclusive)<br/>advisory, until BM25 is done"]
lock --> txn["load · index — one transaction<br/>TRUNCATE · DROP INDEX · binary COPY ×2<br/>CREATE INDEX · ANALYZE · COMMIT"]
txn --> bm25["bm25<br/>recreate the index · streaming_bulk · refresh"]
bm25 --> flag["write _corpus_stale<br/>(bundle flag, or settings drift)"]
- No model call. Nothing is parsed, chunked, contextualized or embedded:
the bundle's lines stream straight into binary
COPY(vectors in pgvector's own binary form, bit for bit), each document'ssourcere-rooted onto this stack'sDOCS_PATH. - The commit is where the restore takes effect. One transaction empties
documentsandchunks, drops the HNSW index (its definition saved frompg_indexes), loads both tables, rebuilds the index and analyzes. Any failure before the commit — a malformed line, a constraint the bundle breaks, a member whose sha256 turns out wrong as it is read — rolls back and leaves the previous corpus untouched.TRUNCATEholds both tables until then, so readers wait for the restored rows rather than seeing half. - Then BM25, from the restored rows. The index is deleted and created
anew with its mapping, and the rows stream in through
helpers.streaming_bulk. If Elasticsearch fails here the run fails, but the rows are restored — restoring the same bundle again repairs the index. - The exclusive corpus lock is held from before the
TRUNCATEuntil the BM25 rebuild ends. Without it, a CLI ingest could write rows the rebuild then misses, or index into the gap between the index's delete and its re-create — where Elasticsearch would auto-create it with default mappings thatcreate_index()then keeps for good. - The stale flag comes from the bundle, written only after everything succeeded: stale if the bundle was stale at export, or if its rows were made under other reingest-affecting settings than this stack's. The runner passes a provider of those settings, read when the flag is written, so a settings change made mid-restore is judged against the restored rows too.
- No retries, for the export's reason: a failure is either a rollback or says what it left, so the user presses Restore again.
The graph export (graph-export)¶
flowchart LR
start(["graph_export_flow(dest)"]) --> lock["snapshot<br/>GraphService.writing() · list the workdir"]
lock --> pack["pack<br/>each file → <engine>/name, deflated · counted in bytes"]
pack --> fin["finalize<br/>read _graph_stale (app_settings) · bundle.json · fsync · os.replace"]
- The write lock does the hard part. The flow copies the graph's
working directory (Data model) under
GraphService.writing(), the lock every build, resume and delete takes, so no file changes mid-copy; reads take no lock, so graph chat and/graphkeep answering. The engine's*.tmp*files are skipped, and so is anything a bundle can't carry (each named in a relayed warning). - The engine names its own format. The manifest records
GraphEngine.storage_format()— for LightRAG,lightrag-hkuand its installed version, read from package metadata, so the backup code never hardcodes the library and the registry stays import-light. The counts come from the workdir's size summary, never from parsing the graphml. - No model service, no session, no retries. The flow copies files the
API itself owns and never opens the engine. It reads the stale flag from
Postgres last, just before sealing, because deleting a graph source sets
it without taking any lock: a source deleted mid-export is still
recorded. A failure removes the partial bundle, and the user presses
Export again. Progress travels as
snapshot→pack(bytes copied of the files' total, about 50 frames) →finalize.
The graph restore (graph-restore)¶
flowchart LR
start(["graph_restore_flow(bundle, run_id)"]) --> validate["validate<br/>the bundle's verdict again"]
validate --> extract["extract<br/><engine>/* → lightrag.restore-RUN · sha256 re-checked · synced"]
extract --> swap["swap<br/>write + open locks: close · lightrag → .previous-RUN · staged → lightrag"]
swap --> open["open<br/>session().stats() · commit: .previous → .replaced · delete it"]
open --> flag["write _graph_stale<br/>(the bundle's own)"]
- No model call, no re-extraction. The bundle is a workdir: its files
are extracted into a staging folder beside the live one, on the
graphdatavolume, so the renames that follow are atomic. Each member's sha256 is checked again as it is read and each file is synced, so a bundle that changed since the route verified it can't go live. Progress counts the bytes extracted. - The swap holds both of the service's locks.
GraphService.replace_workdirkeeps the write lock for the whole swap (no build, resume, delete or export can start) and the open lock around the renames, so no lazy open — a status poll, a/graphread — can land on a half-swapped folder; one that arrives mid-swap waits, then opens the restored graph. Closing the session first finalizes the engine's storages into the old files and resets LightRAG's process-global state, so the open reads the new files from disk. - The open is the check, and the commit follows it. Opening asserts the
vectors'
embedding_dim; if it (or reading the graph's size) fails, the swap is undone — the previous graph returns and reopens, and the run fails. Once it opened, renaming.previous-RUNto.replaced-RUNcommits the restore, and only then is the replaced graph deleted. A crash before the commit is rolled back by the next process'sGraphServiceconstruction; one after it just has its debris removed. - The stale flag is the bundle's own. The graph is exactly what was
exported, so
_graph_staleis set to the bundle'sstale— written only once the swap committed. - No retries: a failed restore left the previous graph in place, so the user presses Restore again.
API-triggered exports and restores¶
POST /api/backups/{corpus}/export and POST /api/backups/{corpus}/restore
are the ingest runner's shape again: a daemon thread inside the API
process, an immediate 202 handle, and GET /api/backups/{corpus}/status
streaming the run as SSE with full backlog replay
(HTTP API).
Each corpus (rag, graph) has its own runner and gate, so a graph export
and a RAG restore run side by side.
- The corpus gate.
BackupRunner(varagity/api/backup_runner.py) claims the corpus'sCorpusGate(varagity/api/corpus_gate.py) for the whole run, and so do the ingest runner (RAG) and the graph build runner (graph). A second backup run, an ingest or build during a backup, or a backup during an ingest or build is refused with409 corpus_busy, which names the holder (a second ingest or build keeps its own409 ingest_already_running/graph_build_running). The gate is the one-at-a-time rule inside the API process; for RAG, the advisory corpus lock (above) is its cross-process half — the graph needs none, since the API is its only writer. - A restore refuses what it can see before it starts. The route checks
the gate, the bundle's cheap verdict (for a graph bundle the engine's
format is judged too) and any damage an earlier restore of the file found,
and for RAG the corpus lock, before the
202— a busy corpus or an unsound bundle is a clean409/422. The member hashes are the run's: it verifies every member as it loads it (one read of the bundle), so a bundle damaged inside an intact zip fails its first run with the corpus untouched — and the runner remembers it, so the list shows it damaged and the route refuses it from then on. - Each run relays only its own lines. Both corpora's runs log to the one
varagity.backupnamespace and may run side by side; a context variable marks the worker's records so each feed carries its own run's, and the namespace's INFO pin is shared by count. - What the runner hands the flow. For RAG it captures the
reingest-affecting settings at start (in
app_settings' env-string form) and passes them in — a provider of them to a restore — sovaragity.backupnever imports the API layer; the export flow reads the stale flag itself, after those settings. A graph restore gets the run's id instead, which names the folders the swap parks beside the workdir. - The saved path. A completed run reports
bundleandsaved_to(docs/varagity-exports/<name>orgraph-docs/varagity-exports/<name>), relative to the checkout. The API sees/app/docsin compose, but the user's file manager sees./docs, so the corpus folder's own name is the path that reads right in both modes. - While a restore runs, chat against that corpus answers
503 corpus_restoring(a graph turn before its session is opened), and its deletes answer409 corpus_busy. - The graph's kill switch. While
GRAPH_ENABLED=false, the graph's list, export, restore and download answer403 graph_disabled(before any store is touched); its status stream keeps reporting.
Evaluation flows¶
Thin flows (eval-matrix, eval-ocr, eval-chat, eval-graph) over the
spec §16 harness (varagity/eval/); each eval ingest is a tracked
subflow with per-stage task runs. All need the live GPU services; all
but eval-graph also need Docker (ephemeral testcontainers stores — the
graph harness stores nothing in pg or ES).
eval-matrix(main.py eval): recall@k / pass@k (k ∈ {5, 10, 20}) across the seven-configuration ladder — (1) semantic non-contextual, (2) semantic contextual, (3) contextual BM25, (4) hybrid contextual, (5) hybrid + rerank contextual (spec_v2 §5.5 — the ≈67% tier), (6) HyDE → hybrid contextual, (7) HyDE → hybrid + rerank contextual (the ADR-016 pairing). Two ingests into ephemeral testcontainers stores cover all seven configs (ingest A non-contextual → config 1; ingest B contextual, reingest → configs 2–7); embeddings, the contextualizing LLM (which also writes the HyDE passages — one generation per query per HyDE config), and the reranker are the live GPU services (stateless). The live corpus is never touched (ADR-003).eval-ocr(main.py eval ocr): the OCR engine benchmark behind ADR-004 — per engine: CER/WER against the fixtures' known ground truth, pages/sec, and (supplementary) retrieval recall on the scanned-doc golden queries with the engine as the only variable.eval-chat(main.py eval chat, v3 — spec_v3 §4.9, ADR-011): the multi-turn chat-engine eval that decided the shippedCHAT_ENGINEdefault. One contextual ingest into ephemeral stores backs the run; every registered engine (enumerated from the registry, like the chunker sweep) plays the hand-built conversation fixtures (tests/fixtures/conversations/— pronoun follow-ups, elliptical refinements, and topic shifts, each turn tagged and fact-anchored), with scripted assistant replies so both engines see byte-identical history and zero answer-generation calls are paid. Each turn's engine-producedsearch_queryis scored fact-anchored under bothhybridandrerankedat k ∈ {1, 3, 5} (shallow on purpose — the 16-chunk corpus makes recall@20 meaningless), with condense latency recorded per turn. The condense settings are pinned so a host env with the kill switch off can't silently measurecondense_contextassimple. The 2026-07-19 verdict: condensing wins the pronoun slice decisively (recall@1 1.000 vs 0.600 underreranked) and never drags a topic shift, but costs a mean 8.6 s pre-retrieval LLM call on follow-ups — the default stayssimple, a measured "no".eval-graph(main.py eval graph— ADR-017): the harness that decided the graph engine and now guards it. It builds a synthetic (or real) message corpus into each registered engine's own working directory underdata/eval/graph/and scores the answers against a golden QA set — production'sGRAPH_STORAGE_PATHis never touched, and neither store container is needed.--skip-buildre-scores an already-built graph in seconds, which is what makes a retrieval-side change (query mode, the e5 query prefix, a rerank experiment) one run from a number. Details and profiles in the runbook.
How eval-matrix covers all seven configs with only two ingests into the
throwaway stores — and where the chunker sweep rides:
flowchart LR
gpu["live GPU services<br/>embeddings + contextualizing LLM + reranker<br/>(stateless — never touch the live corpus)"]
subgraph tc["ephemeral testcontainers · pg + ES (ADR-003)"]
A["ingest A<br/>CONTEXTUALIZE=false"] --> c1["config 1<br/>semantic non-contextual"]
B["ingest B<br/>CONTEXTUALIZE=true, reingest"] --> c2["config 2 · semantic contextual"]
B --> c3["config 3 · contextual BM25"]
B --> c4["config 4 · hybrid contextual"]
B --> c5["config 5 · hybrid + rerank contextual"]
B --> c6["config 6 · HyDE → hybrid contextual — ADR-016"]
B --> c7["config 7 · HyDE → hybrid + rerank contextual"]
B --> sweep["chunker sweep — spec_v2 §7<br/>reingest per strategy ×<br/>{semantic, bm25, hybrid, reranked}<br/>(no HyDE re-measure: the passage depends on the query, not the boundaries)"]
end
gpu -.-> A
gpu -.-> B
gpu -.-> sweep
The chunker sweep (spec_v2 §7, ADR-008) follows the matrix in the same
run: every registered strategy is ingested contextually
(recursive_character reuses ingest B's index; the other four reingest over
the same throwaway stores) and measured across all four retrieval methods.
Golden refs are re-resolved by fact per strategy
(resolve_golden_by_fact): the golden set's chunk_index anchors are
authored against the default boundaries and are meaningless under a foreign
chunker, so each ref's literal fact snippet is located in the
strategy-true chunks, and a ref counts as retrieved when any fact-carrying
chunk is in the top-k. A fact that matches no chunk is reported as
unresolved and scored as a guaranteed miss — never silently dropped.
Results are rendered as rich tables and persisted as timestamped JSON
under data/eval/results/ (gitignored) for regression comparison. Metric
semantics: recall@k is the cookbook's headline number (per-query fraction
of golden chunks in the top-k, averaged); pass@k is the strict complement
(share of queries with all golden chunks in the top-k). The sweep's
variants are the same two numbers with each golden ref generalized from one
chunk id to its acceptable fact-carrying set.
The fixtures corpus saturates the matrix
At 16 chunks, k ≤ 20 ≥ corpus size, so every config scores 1.000 at
k ∈ {10, 20} — the harness is proven, but the numbers only become a
quality signal on a larger corpus (the ladder's honest deliverable is
reranked ≥ hybrid, which holds). The planned follow-up is the Anthropic
cookbook dataset (737 chunks / 248 queries), which needs a pre-chunked
ingest path and a second golden loader (deferred — spec_v2 §5.5, §15).
CLI entry points¶
| Command | Flow(s) | Behavior |
|---|---|---|
uv run main.py ingest [--reingest] |
ingest |
One tracked ingest run; exit 1 if any file failed — or, with one line, if a backup restore holds the corpus |
uv run main.py chat (default) |
ingest, then query per question |
Spec §13 startup sequence: ingest first, then the Q&A loop (:quit exits). Since v3 the loop threads an in-memory history into each turn (session-scoped, cleared by :quit, never persisted) so the configured chat engine can condense |
uv run --group eval main.py eval |
eval-matrix (+ ingest subflows) |
7-config retrieval matrix + the chunker sweep, on ephemeral stores |
uv run --group eval main.py eval ocr |
eval-ocr (+ ingest subflows) |
OCR engine benchmark (CER/WER, pages/s, recall) |
uv run --group eval main.py eval chat |
eval-chat (+ ingest subflow) |
Multi-turn chat-engine eval over the conversation fixtures (above) |
uv run main.py eval graph |
eval-graph |
GraphRAG regression harness over its own throwaway workdirs (no --group eval, no stores — runbook) |
The HTTP API is a peer front-end over the same flows (spec_v2 §4.9):
POST /api/chat runs query-stream — or graph-query-stream when the turn
targets the graph — in a threadpool under the async edge (ADR-005), and
POST /api/ingest / POST /api/graph/build run ingest / graph-build on
a daemon thread (above), as the backup routes run
rag-export / rag-restore / graph-export / graph-restore. Runs from
either front-end land at the same
Prefect UI. The graph build is one flow the CLI cannot start —
single-writer storage means the API process owns it — and the backup flows
are API-only by choice, so both corpora keep one shape.