Data model¶
Storing complete, queryable metadata for every chunk is a hard requirement
(spec §8). This page documents the schema as built, including the two
deliberate v1 amendments over the spec: the relative-path doc_id and the
unique identity index (both ADR-003). v2's changes are additive
(spec_v2 §9): the chunk/vector tables keep their shape; conversation
persistence and runtime-settings tables arrive through the
migration runner, and query-time rank provenance rides
the RetrievalTrace — a record that is never stored on
chunk rows.
Identity derivation¶
Implemented in varagity/stores/records.py:
content_hash = sha256(file_bytes) # bytes, not parsed text
doc_id = sha256(f"{relative_path}:{content_hash}")[:16] # path relative to DOCS_PATH
chunk_id = f"{doc_id}::{chunk_index}"
content_hashhashes raw bytes so unchanged files are skipped before paying the parse cost, and sodoc_idis stable across OCR engines (bytes, not extracted text).doc_idhashes the relative path (spec §8.1 said absolute). Absolute paths differ between host (/home/…/docs/a.md) and container (/app/docs/a.md) and across machines — hashing them would break idempotency and make golden eval sets non-portable. The absolute path is still recorded insource.(doc_id, original_index)is the cross-store join/fusion identity;original_indexis a global monotonic counter allocated per ingest run (SELECT COALESCE(MAX(original_index), -1) + 1, then incremented in-process).
Chunk metadata (ChunkRecord)¶
Every chunk persists this record (pydantic-validated; stored whole in the
chunks.metadata JSONB column and mirrored into typed columns where queried):
| Field | Type | Description |
|---|---|---|
doc_id |
str | Stable per-document id (see above) |
chunk_id |
str | f"{doc_id}::{chunk_index}" — pg primary key and ES _id |
original_index |
int | Global monotonic chunk index (fusion key) |
chunk_index |
int | Chunk position within its document |
source |
str | Absolute file path (provenance only — never identity) |
file_name |
str | Basename |
file_type |
str | File extension without the dot: pdf / txt / md / docx / pptx / xlsx / html / htm |
page |
int? | First page that contributed text (Docling-parsed formats; None otherwise)¹ |
content |
str | Original chunk text |
context |
str? | LLM situating blurb (None when ingested with CONTEXTUALIZE=false) |
contextualized_content |
str | context + "\n\n" + content — the text actually embedded & BM25-indexed; equals content when context is None |
chunk_size / chunk_overlap |
int | Parameters used, in the strategy's unit — characters for recursive_character/markdown_aware, tokens for token_based/docling_hybrid/semantic (provenance) |
chunking_strategy |
str | Registry name of the chunker used, e.g. recursive_character |
embedding_model |
str | Served model name, e.g. infloat/multilingual-e5-large-instruct |
n_tokens |
int | Approximate token count of content (cl100k — a documented approximation; the e5 tokenizer differs) |
content_hash |
str | The parent document's byte hash |
created_at |
datetime | Ingestion timestamp (UTC) |
file_created_at |
datetime? | Filesystem birth time of the source file (UTC) — best-effort: os.stat exposes it on macOS/Windows; on Linux a GNU-coreutils stat -c %W fallback reads it, and None means the filesystem records none (a copy/download also resets it) |
file_modified_at |
datetime? | Filesystem mtime of the source file (UTC) — the document's clock, distinct from created_at (the ingest's). Uploads keep the original file's clock: the GUI sends File.lastModified and the upload route restamps the stored file's mtime. None only on pre-field rows (backfilled by the next reingest) |
extraction |
str | "text", "ocr" (image parser — OCR is that format's only path), or "ocr_fallback" (the PDF pass-2 recovery) — beyond spec §8.1: extraction provenance for retrieval-quality debugging (OCR noise hits BM25 keyword matching hardest) |
heading_path |
str? | v2: markdown heading breadcrumb (e.g. "Operations > Dredging") set by the heading-aware chunkers (markdown_aware, docling_hybrid — spec_v2 §7); None for strategies without structure. New ChunkRecord fields land in the metadata JSONB — no migration needed |
¹ page is document-level (the first page that contributed text), not
per-chunk: the shared chunker copies one source_meta per document, so
per-chunk page attribution has no data path yet. A future chunker with
start_index support plus a loader page-map lookup would make it per-chunk.
For the v2 office formats, Docling models .pptx slides and .xlsx/.ods
sheets as pages, so page carries a slide/sheet number there (.odp slides
expose no item provenance, so page stays None); an OCR'd image is a
one-page document, so page is 1 whenever text was recognized.
The RetrievalTrace¶
Where ChunkRecord is ingest-time and immutable, the RetrievalTrace
(also varagity/stores/records.py; spec_v2 §9.2) is query-time only —
computed per answer by the retrievers and never persisted on the chunk rows.
It is the "why did this chunk rank here" record behind the web evidence panel
and the CLI's rank badges:
| Field | Type | Meaning |
|---|---|---|
semantic_rank / semantic_score |
int? / float? | Rank and cosine similarity in the semantic (pgvector) arm; None when that arm's ranked list never surfaced the chunk |
bm25_rank / bm25_score |
int? / float? | Rank and BM25 relevance in the Elasticsearch arm; None likewise |
fused_score |
float | Weighted reciprocal-rank fusion score (required) |
fused_rank |
int | Rank after fusion (required) |
rerank_score |
float? | Cross-encoder relevance; None when the rerank stage is off the path |
rerank_delta |
int? | Positions moved by reranking, pre − post (+ moved up / − moved down); None when off the path |
final_rank |
int | The rank actually returned to the caller |
Semantics and population:
- All ranks are 1-based (display-ready). Single-arm retrievers
(
semantic,bm25) report their arm's score/rank as the fused values — there is nothing to fuse. hybrid.fuse_with_tracesbuilds the trace during fusion (preserving the per-arm rank/score maps that plainfusediscards; raw arm scores never enter the fusion math — they ride along for display only), andhydrateattaches it to each returned chunk withfinal_rank == fused_rank.RerankedRetriever.apply_rerankthen overwritesrerank_score,rerank_delta(pre_rank − final_rank), andfinal_rankon the surviving candidates — the base retriever's arm and fused fields stay intact — and synthesizes a trace for any trace-less candidate.- Backward compatible:
RetrievedChunk.trace: RetrievalTrace | Nonedefaults toNone, so pre-trace callers (the eval harness, raw store search results) are unaffected.
Consumers: the CLI matches table (-v 2 renders the badges
sem #1 · bm25 #3 · fused 0.94 · rerank +2), the web evidence panel, and —
for history — the message_sources.trace snapshot
below.
PostgreSQL schema¶
varagity/stores/schema.sql, mounted into the postgres container's
/docker-entrypoint-initdb.d/ — it runs only on first boot (empty data
directory). Reset with docker compose down -v (see the
runbook). It holds the chunk-side schema
below and stays the fresh-install fast path; the v2 tables (conversations,
messages, message_sources, app_settings) live in migrations/ and
reach fresh and existing volumes alike through the
migration runner.
erDiagram
documents ||--o{ chunks : "doc_id · ON DELETE CASCADE"
documents {
text doc_id PK "sha256(rel_path:content_hash)[:16]"
text source
text file_type
text content_hash
int n_chunks "0 = seen but textless"
timestamptz ingested_at
}
chunks {
text chunk_id PK "doc_id::chunk_index"
text doc_id FK
int original_index UK "UNIQUE(doc_id, original_index) — fusion key"
int chunk_index
text content "original"
text context "situating blurb · nullable"
text contextualized_content "embedded + BM25-indexed text"
vector1024 embedding "cosine HNSW"
jsonb metadata "full ChunkRecord"
timestamptz created_at
}
CREATE EXTENSION IF NOT EXISTS vector;
-- one row per ingested source document (idempotency + provenance)
CREATE TABLE IF NOT EXISTS documents (
doc_id TEXT PRIMARY KEY,
source TEXT NOT NULL,
file_type TEXT NOT NULL,
content_hash TEXT NOT NULL,
n_chunks INT NOT NULL,
ingested_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- one row per chunk
CREATE TABLE IF NOT EXISTS chunks (
chunk_id TEXT PRIMARY KEY,
doc_id TEXT NOT NULL REFERENCES documents(doc_id) ON DELETE CASCADE,
original_index INT NOT NULL,
chunk_index INT NOT NULL,
content TEXT NOT NULL, -- original
context TEXT, -- LLM situating blurb
contextualized_content TEXT NOT NULL, -- embedded/indexed text
embedding vector(1024) NOT NULL, -- EMBEDDING_DIM
metadata JSONB NOT NULL, -- full ChunkRecord (source, page, tokens, …)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- cosine HNSW index (e5 embeddings are normalized)
CREATE INDEX IF NOT EXISTS chunks_embedding_hnsw
ON chunks USING hnsw (embedding vector_cosine_ops);
CREATE INDEX IF NOT EXISTS chunks_doc_id_idx ON chunks(doc_id);
-- (doc_id, original_index) is the fusion/join identity across pgvector and
-- Elasticsearch; enforcing uniqueness catches ingest bugs early.
CREATE UNIQUE INDEX IF NOT EXISTS chunks_doc_orig_uidx ON chunks(doc_id, original_index);
Notes:
- Cosine, not L2: e5 embeddings are L2-normalized, so search orders by
embedding <=> :qvec(cosine distance) and reportsscore = 1 - distance(spec §11.2). - The unique identity index is the one schema addition over spec §8.2
(ADR-003): a duplicated
(doc_id, original_index)would silently corrupt hybrid fusion, so it fails loudly at write time instead. n_chunks = 0documents are deliberate: a file with no extractable text still gets adocumentsrow, so it is visibly "seen", cheaply re-warned on later runs without re-parsing, and re-attempted under--reingest. Files are never silently dropped.- Writes are upserts:
ON CONFLICT (chunk_id) DO UPDATEfor chunks; the per-document write (documentsrow + all its chunks) is one transaction, so a partial failure leaves no idempotency marker and the next run re-attempts the file.
Schema migrations¶
schema.sql runs only on the container's first boot, so schema added
after a pgdata volume exists would never reach it — the gotcha spec_v2 §9.3
exists to close. v2 adds a lightweight, idempotent migration runner
(varagity/stores/migrate.py) instead of a heavier dependency (Alembic is the
recorded fallback if migrations ever get non-trivial —
ADR-005):
- Ordered files:
varagity/stores/migrations/NNN_*.sql, applied in filename order. A.sqlfile in that directory not matching^\d{3}_[a-z0-9_]+\.sql$fails the run loudly (ValueError) — a silently skipped migration is exactly the drift the runner exists to end. - Tracked: applied filenames land in
schema_migrations (name TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now()), created on demand. - One transaction per file: each unapplied migration runs together with
its bookkeeping
INSERTin a single transaction — a failing migration rolls back atomically and the next run re-attempts exactly that file. - Convergent: every migration is written
IF NOT EXISTS-safe, so the runner takes a fresh volume and a v1 volume to the same schema; re-running is a no-op. - Applied on API startup (the
lifespaninvaragity/api/main.py, in a threadpool) — not by the CLI. An existing v1pgdatavolume therefore gains the v2 tables on the nextapiboot, with nodocker compose down -v(see Volumes and resets). An unreachable postgres at startup is tolerated (logged; the persistence routes 503 until it returns — the host-mode-without-stack case), but a SQL failure fails API startup: serving on a half-applied schema would be worse than not starting.
Current migrations: 001_conversations.sql and 002_app_settings.sql (the
next two sections), v3's 003_condensed_query.sql and
004_message_engine.sql — two nullable ALTER TABLE messages ADD COLUMNs
for the chat engine's provenance snapshot
(below) — 005_conversation_groups.sql, the
sidebar's conversation folders (also
below), and 006_graph_turns.sql, the
graph-targeted turn's two nullable columns
(below). schema.sql deliberately needed no
edit for any of them: it holds only the v1 chunk-side tables — messages
exists solely in 001, so both install paths (fresh volume and existing
pgdata) converge through the runner alone.
Conversation persistence¶
Single-user chat history (spec_v2 §9.1) lives in the same Postgres as the
chunks — one datastore to operate — but in deliberately independent
tables, created by migration 001_conversations.sql:
CREATE TABLE IF NOT EXISTS conversations (
conversation_id TEXT PRIMARY KEY, -- app-generated id
title TEXT NOT NULL, -- auto-titled from first question
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS messages (
message_id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL REFERENCES conversations(conversation_id) ON DELETE CASCADE,
role TEXT NOT NULL, -- 'user' | 'assistant'
content TEXT NOT NULL, -- question or generated answer
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- assistant-only provenance snapshot (null for user turns):
retrieval_method TEXT,
latency_ms JSONB, -- per-stage timings
reasoning TEXT -- captured <think> stream, if any
);
-- v3 (migrations 003/004): the chat-engine provenance snapshot — both
-- nullable, both assistant-only (ADR-011):
ALTER TABLE messages ADD COLUMN IF NOT EXISTS condensed_query TEXT;
ALTER TABLE messages ADD COLUMN IF NOT EXISTS chat_engine TEXT;
-- 006: the graph-targeted turn (ADR-017) — which corpus was ASKED for, and
-- what the graph retrieval found. Both nullable, both assistant-only:
ALTER TABLE messages ADD COLUMN IF NOT EXISTS target_corpus TEXT; -- 'rag' | 'graph' | NULL
ALTER TABLE messages ADD COLUMN IF NOT EXISTS graph_evidence JSONB; -- entities/relations/mode
CREATE TABLE IF NOT EXISTS message_sources (
message_id TEXT NOT NULL REFERENCES messages(message_id) ON DELETE CASCADE,
rank INT NOT NULL, -- final rank in the answer's evidence
chunk_id TEXT NOT NULL, -- soft ref (survives reingest as a snapshot)
trace JSONB NOT NULL, -- RetrievalTrace (§9.2) + score + content/context/source snapshot
PRIMARY KEY (message_id, rank)
);
-- Transcript fetches read a conversation's messages in order.
CREATE INDEX IF NOT EXISTS messages_conversation_created_idx
ON messages(conversation_id, created_at);
-- 005: sidebar conversation groups — user-created folders. Deleting a
-- group detaches its conversations (SET NULL), never deletes them.
CREATE TABLE IF NOT EXISTS conversation_groups (
group_id TEXT PRIMARY KEY, -- app-generated id
name TEXT NOT NULL, -- display name (not unique — ids are the identity)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE conversations ADD COLUMN IF NOT EXISTS group_id TEXT
REFERENCES conversation_groups(group_id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS conversations_group_id_idx ON conversations(group_id);
erDiagram
conversation_groups |o--o{ conversations : "ON DELETE SET NULL"
conversations ||--o{ messages : "ON DELETE CASCADE"
messages ||--o{ message_sources : "ON DELETE CASCADE"
chunks |o..o{ message_sources : "chunk_id — soft ref, no FK"
conversation_groups {
text group_id PK
text name "display name"
timestamptz created_at
}
conversations {
text conversation_id PK
text title "auto-titled from the first question"
timestamptz created_at
timestamptz updated_at
text group_id FK "sidebar group — 005, nullable"
}
messages {
text message_id PK
text conversation_id FK
text role "user / assistant"
text content
timestamptz created_at
text retrieval_method "assistant turns only"
jsonb latency_ms "per-stage timings"
text reasoning "captured think stream"
text condensed_query "v3 · the search rewrite, if any"
text chat_engine "v3 · engine that produced the turn"
text target_corpus "006 · corpus asked for — rag / graph"
jsonb graph_evidence "006 · entities + relations + mode"
}
message_sources {
text message_id PK "composite with rank"
int rank PK "final evidence rank"
text chunk_id "soft reference — or a graph doc_key"
jsonb trace "score + texts + provenance + RetrievalTrace, or a graph_transcript"
}
Notes:
message_sources.traceis a snapshot, not a join (ConversationStore._source_snapshot): it persistsscore,content,context,source,file_name,file_type,page,extraction,file_created_at/file_modified_at, and the serializedRetrievalTraceunder"trace"(nullwhen the retriever attached none) — everything the provenance panel renders. A historical conversation therefore still explains itself after a reingest replaces the chunk rows and changeschunk_ids.chunk_idis deliberately a soft reference — no FK tochunks. A hard FK would either cascade history away on reingest or block corpus maintenance; the id is kept purely as a record of which chunk backed the evidence at answer time.- Assistant-turn provenance rides on
messagesitself:retrieval_method, per-stagelatency_ms(JSONB), and the captured<think>stream inreasoning— allNULLfor user turns. - v3 adds the chat-engine snapshot (migrations 003/004;
ADR-011):
condensed_queryis the standalone search query that actually drove retrieval — the evidence panel's "Searched for: …" line, live and on reload — andchat_engineis the registry name of the engine that produced the turn.NULLcondensed_querymeans "not condensed" (a first turn, thesimpleengine, the kill switch, or the raw-query fallback); the engine name is persisted even when degraded, mirroring howretrieval_methodpersistsrerankedunderRERANK_ENABLED=false(and, post-v3,hydeunderHYDE_ENABLED=falseor a failed passage generation — ADR-016; the hypothetical passage itself is deliberately not persisted). Same snapshot-over-reference rationale asmessage_sources.trace: the columns explain a historical answer, so they must outlive the settings that produced it. - A turn is persisted in one transaction (
append_message): the message, itsmessage_sourcesrows (rank 1-based, best first), and the conversation'supdated_atbump — a partial failure leaves no dangling turn. An aborted stream persists nothing. - Groups are organization, never ownership (migration 005): the sidebar's
folders live in
conversation_groups, andconversations.group_idis a nullable FK withON DELETE SET NULL— deleting a group returns its conversations to the ungrouped list with history intact. Moving a conversation (PATCH /api/conversations/{id}) deliberately does not bumpupdated_at, so filing a chat never re-orders the recency-sorted list.
Graph-targeted turns¶
Migration 006_graph_turns.sql records what a
message-graph turn asked for
and what the graph gave back, in the same snapshot discipline as everything
else on the turn:
target_corpusis the request, not the outcome —'rag'or'graph',NULLfor user turns and all pre-stage-2 history (read as chunk RAG). A graph turn that degraded to a document answer (kill switch off, engine unreachable) still persists'graph': the ask is part of the record, and the GUI's source selector stays where the user left it instead of silently flipping back.graph_evidenceis the retrieval, minus the transcripts — the entities, the relations, and the engine query mode that found them ({"entities": [...], "relations": [...], "mode": "mix"}, the same shape the live SSEretrievalevent carries). It isNULLon a degraded turn, which is precisely the honest record: the graph produced nothing. Together the two columns distinguish all three states — chunk turn (NULL/NULL), graph turn ('graph'/payload), degraded graph turn ('graph'/NULL).- The cited days ride in
message_sources, not a second evidence table. A graph row stores the transcript day'sdoc_keyinchunk_idand atracecarrying{"kind": "graph_transcript", "thread_name", "span", "excerpt", "message_guids"}. Thekinddiscriminator is what readers branch on; the v2 chunk snapshots have nokindand were never migrated. The soft-reference rationale carries over verbatim — a graph rebuild changesdoc_keys exactly as a reingest changeschunk_ids, and the snapshot is what keeps the old answer explicable.
The graph workdir¶
The graph itself is not in Postgres. It lives in files, in the
graphdata volume (GRAPH_STORAGE_PATH/<engine>, /app/graph-data/lightrag
in compose), because the engine's storages are single-writer per directory
and the API is that writer (ADR-017):
| File(s) | Owner | What it holds |
|---|---|---|
graph_chunk_entity_relation.graphml |
engine | The entity/relation graph itself |
vdb_entities.json, vdb_relationships.json, vdb_chunks.json |
engine | The embedding indexes retrieval searches |
kv_store_full_docs.json, kv_store_text_chunks.json, kv_store_full_entities.json, kv_store_full_relations.json, kv_store_entity_chunks.json, kv_store_relation_chunks.json |
engine | The extracted text and its derived records |
kv_store_doc_status.json |
engine | Per-document processing status — the resume ledger, and what the varagity_graph_documents{status} gauge counts |
kv_store_llm_response_cache.json |
engine | Extraction responses, reused by a delete-then-reinsert |
varagity_manifest.json |
ours | doc_key → {content_sha256, message_guids, thread_name, span} |
varagity_graph_summary.json |
ours | Last known {entities, relations, message_guids, docs, refreshed_at} |
The two sidecars are ours and earn their keep three times over
(varagity/graph/manifest.py):
- They are the upsert diff the engine refuses to do. A re-submitted
document id is dropped by the engine's enqueue stage in any status, so a
re-exported
chat.dbwhose newest thread-day gained messages would keep the stale transcript forever. Comparing rendered content hashes against the manifest is what turns that into an explicit delete-then-reinsert. Never hand-edit the manifest: an entry whose hash does not match the graph's content makes the stale transcript permanent. (A graph backup carries both sidecars with the engine's files, so the diff is backed up with the graph it describes.) - They are the durable provenance index.
doc_key → message_guidssurvives a restart, so a citation still resolves to messages on a graph nothing has rebuilt in this process. - They are the size cache.
GET /api/graph/statusand the Prometheus gauges read the summary rather than walking a multi-megabyte graphml.
Both are written atomically (temp file + os.replace), and both degrade to
"absent" when unreadable or written by a different schema version: every
rendered document then looks new, which the engine's own dedup absorbs,
where trusting a mis-shaped file would corrupt the graph. Neither is
authoritative over the engine — they are caches of what the last write did.
doc_key is the graph's identity thread, the counterpart of
(doc_id, original_index) on the chunk side:
doc_key = f"{thread_id}::{span}" # span is YYYY-MM-DD or first..last
It is what the engine attributes a chunk to (file_path), what
message_sources.chunk_id stores on a graph turn, what the manifest keys on,
and what the graph view's entity drill-down resolves into source days. It is
day-grain by construction — which is why the evidence panel says so.
Runtime settings overrides¶
Migration 002_app_settings.sql backs the GUI's live settings panel
(spec_v2 §4.7):
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
- One row per overridden
Settingsfield, keyed by the field name (e.g.RETRIEVAL_METHOD) with the value in its env-string form — exactly what pydantic-settings parses from the environment, so the override layer (varagity/api/runtime_settings.py) replays rows as process environment variables verbatim (env beats.env) and clears the settings cache. Every module that readsget_settings()— the repo-wide convention — sees the override on its next call. - Written by
PATCH /api/settings, which validates the merged whole through every config validator (the fusion-weight pair, the rerank bounds, the vocabularies) before persisting — an invalid patch changes nothing. Replayed at API startup, after the migration runner, so overrides survive anapirestart; persisted rows that no longer validate are skipped with an error log (the API must boot on env defaults rather than crash). - Keys beginning
_are reserved for app metadata and never surface as overrides. The one in use is_corpus_stale— "a reingest-affecting setting changed since the last completed reingest" — set byPATCH /api/settingswhen such a setting actually changes on a non-empty corpus, cleared only by a completed API reingest (see below).
Elasticsearch index¶
varagity/stores/bm25_store.py (default index name
varagity_contextual_bm25). Mirrors the Anthropic cookbook's
ElasticsearchBM25: analyzed text fields under the built-in english
analyzer with BM25 similarity; identity fields stored but not indexed.
{
"settings": {
"analysis": { "analyzer": { "default": { "type": "english" } } },
"similarity": { "default": { "type": "BM25" } }
},
"mappings": { "properties": {
"content": { "type": "text", "analyzer": "english" },
"contextualized_content": { "type": "text", "analyzer": "english" },
"doc_id": { "type": "keyword", "index": false },
"chunk_id": { "type": "keyword", "index": false },
"original_index": { "type": "integer", "index": false }
}}
}
Notes:
- The index is contextual from its first document: chunks are
contextualized before they reach either store, and search is a
multi_matchovercontentandcontextualized_content. - ES stores identity + text only. Full records (source, page, context,
metadata) are hydrated from pgvector by
(doc_id, original_index)— pg is the single source of truth for metadata. "index": falsefields keep doc values, so the term-leveldelete_by_queryused by--reingeststill works (a slower doc-values scan — fine at dev scale).- Documents are addressed by
chunk_id(_id), so re-indexing overwrites instead of duplicating — the sparse-side counterpart of the pg upsert.
Idempotency & re-ingestion semantics¶
- A file whose
(doc_id, content_hash)already exists indocumentsis skipped before parsing. - Pipeline-setting changes (
CONTEXTUALIZE, chunk params,OCR_ENGINE) do not change content hashes → unchanged files stay skipped. Re-process withmain.py ingest --reingest(CLI) orPOST /api/ingestwithreingest=true(GUI) — both run the same flow, which deletes each discovered document from both stores (ESdelete_by_queryfirst, then the pg cascade delete) before ingesting fresh. - The GUI surfaces that footgun as a persisted flag (v2): changing a
reingest-affecting setting through
PATCH /api/settings(CHUNKING_STRATEGY,CHUNK_SIZE,CHUNK_OVERLAP,CONTEXTUALIZE,OCR_ENGINE) on a non-empty corpus sets_corpus_staleinapp_settings, rendered as a "Re-ingest to apply" affordance. Only a completed API reingest clears it — a CLIingest --reingestnever touches the flag, and patching the setting back to its old value doesn't clear it either. - The dual-write order is BM25 first, pgvector last: the pg
documentsrow is the idempotency marker, so a failure between the two writes leaves no marker and the next run re-attempts the file (thechunk_id-addressed ES bulk then overwrites rather than duplicates). - Removing a file from
docs/does not remove its chunks — the ingest path has no corpus GC beyond--reingest. v2'sDELETE /api/documents/{doc_id}closes that gap per document: ES delete first, then the pg cascade (the marker is deleted last, so a failed ES delete leaves the document visible and retryable), with opt-in source-file removal (?remove_file=true, honored only for paths insideDOCS_PATH; folders the unlink empties are pruned too, so deleting a folder upload's documents leaves no empty shells in the corpus directory).POST /api/documents/deleteis the same operation over a set (the corpus table's multi-select): onetermsdelete_by_queryand oneDELETE … WHERE doc_id = ANY(…), so the ordering — and therefore the retryability — holds set-wise, at one round trip per store rather than per document. Ids with nodocumentsrow come back innot_foundinstead of failing the batch, so a selection racing a concurrent delete still removes what remains. GET /api/documentsreports each document'srelative_pathunderDOCS_PATH(nullfor sources living elsewhere), and the GUI folds the corpus table back into the folders a folder upload created — collapsible rows whose checkbox and delete act on every descendant, so "delete a directory" is the same bulk delete over that folder'sdoc_ids, not a second delete path.
The write ordering that makes the documents row a reliable idempotency
marker — sparse store first, transactional store last:
sequenceDiagram
autonumber
participant L as loader.store_chunks
participant ES as Elasticsearch
participant PG as pgvector
L->>ES: bulk index chunks (addressed by chunk_id)
Note right of ES: overwrite, never duplicate
L->>PG: BEGIN
L->>PG: upsert documents row + chunks (ON CONFLICT DO UPDATE)
L->>PG: COMMIT
Note right of PG: documents row = idempotency marker
Note over L,PG: crash before COMMIT ⇒ no marker ⇒ file re-attempted next run<br/>(the chunk_id-addressed ES bulk already overwrote, so nothing duplicates)
Backup bundles¶
docker compose down -v deletes every named volume, and with them the
processed corpora: the chunk rows, their contextual blurbs and embeddings,
the BM25 index, and the extracted message graph in graphdata. The source
folders survive, because they are host binds. A backup bundle is
written into a reserved folder inside one of those binds, so it survives
beside the sources (varagity/backup/). Each corpus has its own folder and
its own bundle layout in the same container: the RAG bundle below, and
the graph bundle.
docs/varagity-exports/ reserved: discovery skips it, uploads refuse it
├── .varagity-rag-20261108-1312.zip.partial an export in flight; never listed, swept by the next export
└── varagity-rag-20261108-1312.zip renamed into place once complete
├── documents.jsonl documents rows + relative_path deflated
├── chunks.jsonl every chunks column but the embedding, deflated
│ ORDER BY original_index
├── embeddings.f32 N × EMBEDDING_DIM little-endian float32; stored
│ row i belongs to chunks line i
└── bundle.json the manifest, written last
- The reserved folder.
varagity-exports/at the corpus root is never corpus. Ingest discovery (and so the CLI's ingest too) skips it whole, and both upload routes refuse a path under it, or a flat file named like it, with the per-file reasonreserved_path. On the graph side the build's scan never counts it, the Graph sources listing never shows it, and the graph delete answers404for anything in it (deleting a bundle there would flag the graph stale for a file that was never a source). Only the root's own folder is reserved, matched case-insensitively. Avaragity-exports/nested deeper is ordinary corpus. - One consistent snapshot. Every row comes from a single
REPEATABLE READ, READ ONLYtransaction (ContextualVectorDB.iter_corpus), streamed through server-side cursors. An ingest or delete committing mid-export can't tear a bundle, and the export blocks no writer. - Bit-exact vectors. The snapshot reads pgvector's binary format, and
each vector becomes a little-endian float32 matrix row by a byte swap, not
a decimal round trip. Row i of
embeddings.f32is the vector of line i ofchunks.jsonlby construction: both are written in one pass over the same snapshot. - No BM25 member. Every field of an Elasticsearch document is a copy of a
chunkscolumn (below), so the index can always be rebuilt from the rows with no model call. relative_pathis each document'ssourcerelative toDOCS_PATH. It isnullfor a document ingested from outside the corpus directory.doc_idalready hashes it, and it is the portable form ofsourceacross host and container mode. (An export run by a host-mode API against container-written rows records no relative paths, because/app/docs/…doesn't resolve under./docs.)- All-or-nothing. The writer streams into a hidden
.{name}.partial, hashes every member as it goes, writesbundle.jsonas the last member (so it can record every member's sha256), syncs the file, and renames it into place withos.replace. Any failure deletes the partial, and the next export sweeps whatever a crash left behind. A listing therefore sees a whole bundle or nothing. - Names are
varagity-<corpus>-<UTC yyyymmdd-HHMM>.zip. A same-minute repeat gets-2,-3, …, and an export never replaces a file.
bundle.json (BundleManifest) carries what the rows themselves don't
record: the export-time facts a restore must judge the bundle by.
| Field | Meaning |
|---|---|
format, version |
"varagity-backup", 1 — what makes a zip a Varagity bundle, and its layout. |
corpus |
rag or graph — ChatRequest.corpus's vocabulary; a bundle restores only its own corpus. |
created_at |
The snapshot instant (UTC). |
embedding_model, embedding_dim |
EMBEDDING_MODEL / EMBEDDING_DIM at export. pgvector checks only the vector size (and LightRAG only embedding_dim), so the model name is the only guard against vectors from another embedding space. An export whose rows record a different metadata.embedding_model logs a warning. |
stale |
The corpus's stale flag at export: _corpus_stale for RAG, _graph_stale for the graph. |
counts |
RAG: {"documents": …, "chunks": …}. Graph: {"thread_days": …, "messages": …}, plus entities and relations when the graph reported them (each from the workdir's size summary). |
ingest_settings |
RAG only (null for the graph). The reingest-affecting settings at export (CHUNKING_STRATEGY, CHUNK_SIZE, CHUNK_OVERLAP, CONTEXTUALIZE, OCR_ENGINE), in the env-string form app_settings stores. They are read before the stale flag, so a settings change racing the export can only make the bundle more conservative, never less. |
engine |
Graph only (null for RAG). {name, package, version} — the engine that wrote the files (lightrag / lightrag-hku / 1.5.4), as the engine itself reports it (GraphEngine.storage_format(), read from package metadata). |
members |
{name, size_bytes, sha256} per data member, in write order. |
The zip's central directory lets GET /api/backups/{corpus} read
bundle.json alone. Listing a folder of large bundles costs a directory
read, not an unpack. Chat history, conversation folders and settings-drawer
overrides are deliberately not in a bundle: it backs up the corpus, not
the app.
The graph bundle¶
The message graph's bundle is the graph workdir verbatim — every file the engine wrote, plus the repo's two sidecars — in the same container, under one folder named for the engine:
graph-docs/varagity-exports/varagity-graph-20261108-1315.zip
├── lightrag/ the workdir verbatim, *.tmp* skipped deflated
│ ├── kv_store_*.json ×8 incl. the LLM cache later incremental builds rebuild from
│ ├── vdb_*.json ×3 embedding_dim + base64 float32 matrices
│ ├── graph_chunk_entity_relation.graphml
│ ├── varagity_manifest.json what keeps the next build a diffing upsert
│ └── varagity_graph_summary.json the size cache — the bundle's counts
└── bundle.json + engine format · summary counts · stale = _graph_stale
- Verbatim, so everything comes back. Nothing is converted or
re-derived: the doc-status resume ledger, the manifest (so the next build
stays a diffing upsert rather than re-extracting everything), and the LLM
response cache (which a later incremental build needs to rebuild the
entities a changed thread-day shares with others) all ride along. The
price is that a bundle is tied to the engine version that wrote it, which
is why the manifest records
engine. - Copied under the write lock. The export holds
GraphService's write lock — the one every build, resume and delete takes — for the whole copy, so no build changes the graph mid-copy. Reads take no lock, so graph chat and/graphkeep answering throughout. A read does write one file: LightRAG flushes its LLM response cache after every query. That is harmless, because the engine writes each file to a temp sibling (<file>.tmp.<pid>.<thread>.<ns>) and renames it into place, so every member is a whole file, from before a query or after it. The temp files are never copied, nor is anything a bundle can't carry (a folder, a symlink, an unsafe name) — each is named in a warning instead. - The member rule is a pattern, not a list. A workdir holds only the
namespaces its engine has written so far, so no member is individually
required. Every member must be a flat file directly in
<engine>/, its name made of ASCII letters, digits,_,.and-and never hidden; every file in the zip must be recorded inbundle.jsonwith its sha256 (an unrecorded file could never be verified). The verdict tolerates the<engine>/directory entry a hand re-zip (zip -r) adds. - Counts come from the size summary (
varagity_graph_summary.json), never from parsing the graphml:thread_daysis itsdocs,messagesitsmessage_guids. A graph whose first build was killed before writing a summary still exports — its files are the resume state — with empty counts. - No model service. An export needs only Postgres, for the
_graph_staleflag the bundle records; it opens no engine session. The flag is read last, once every file is copied: deleting a graph source flags the graph stale without taking any lock, so a source deleted mid-export is still recorded.
Restoring a bundle¶
A restore replaces the corpus with the bundle's contents; nothing is merged, and nothing is re-parsed, re-chunked, re-contextualized, re-embedded or re-extracted (ADR-019). How a restore reads the bundle back:
- Two depths of checking. Every listed bundle gets the cheap verdict
(
inspect_bundle): the zip directory andbundle.jsononly. The format version, the corpus, the member allowlist (exactly the three RAG members; for a graph bundle, the pattern above, with at least one file), the zip-slip rules (no.., absolute path, backslash or symlink entry), each member's recorded size against the zip's, the vector matrix's size againstcounts.chunks × embedding_dim × 4, then the embedding model and dimension against this stack's — and for a graph bundle the engine that wrote its files: it must be the engineGRAPH_ENGINEselects, from the same package, at the same major.minor version as this stack's (running_engine_format(), the engine's own report). A restore then checks the sha256 of every member while it reads them — intoCOPY, or onto disk — its one read of the bundle; nothing takes effect until every digest matched (the transaction commits, the staged graph is swapped in), so a damaged bundle can never go live.verify_bundleruns the same checks standalone, without restoring. A mismatch in the model, dimension, engine, corpus or version is refused, never converted (bundle_incompatible); anything unsound or damaged isbundle_invalid. - Re-rooting. Each document's
sourcebecomes(DOCS_PATH / relative_path).resolve(), exactly as ingest builds it, and every chunk'smetadata.sourcefollows its document.doc_id,chunk_id,original_index, the blurbs, the vectors and every timestamp come back as exported. A document with norelative_path(ingested from outside the corpus) keeps its absolutesource. So a bundle exported in compose (/app/docs/…) restores onto a host-mode./docsand back, and the document list, page previews and "also remove the file" keep working. - The stale flag is derived.
_corpus_staleis set when the bundle'sstalewas, or when itsingest_settingsdiffer from this stack's reingest-affecting settings — the same "the corpus may not match the current settings" the flag always means. The list reports those differing settings asdriftbefore you restore. - The BM25 index is rebuilt from the restored rows — every one of its
fields is a copy of a
chunkscolumn (below).
A graph bundle restores as a whole working directory:
- Extract beside, then swap. The bundle's
<engine>/files are extracted into<workdir>.restore-<run>— a sibling of the workdir on the samegraphdatavolume — each synced to disk.GraphService.replace_workdirthen closes the session, renames the live workdir to<workdir>.previous-<run>and the staged folder into its place, and opens it. The workdir is never half-written: it is the old folder or the new one, and readers that arrive mid-swap wait for it. - The commit point. Once the restored graph has opened, the previous
one is renamed to
<workdir>.replaced-<run>and deleted. A failure before that puts the previous graph back and reopens it; a crash before it is rolled back when the next process constructs itsGraphService, and a crash after it leaves only debris, which that construction removes. - Everything comes back. The manifest (so the next build stays a
diffing upsert — a re-exported
chat.dbstill costs only its changed days), the size summary (so the status route and the gauges read the restored sizes at once), the doc-status ledger (a build that was resuming when exported resumes after the restore), and the LLM cache. - The stale flag is the bundle's own.
_graph_staletakes the bundle'sstale, written once the swap committed: the graph is exactly what was exported, so it is stale exactly when it was then.
Golden eval dataset¶
data/eval/golden_qa.jsonl — one JSON object per line:
{"query": "…", "relevant": [{"rel_source": "aurora_station.md", "chunk_index": 1, "fact": "4.2 megawatts"}]}
Relevant chunks are identified portably by rel_source (path relative to
the corpus root — exactly the string doc_id hashes) plus chunk_index, so
entries resolve to concrete chunk_ids from corpus files alone, on any
machine. chunk_index values assume the pinned eval chunk boundaries
(recursive_character, 400/50 — PINNED_EVAL_SETTINGS); re-author the golden
set if those pins change.
fact (v2) is the literal answer snippet the chunker sweep keys on
(spec_v2 §7.4): foreign chunk boundaries invalidate chunk_index, so the
sweep re-resolves each ref by content (resolve_golden_by_fact) — any
strategy-true chunk containing the fact satisfies the ref; index-anchored
scoring under foreign boundaries would measure nothing but the boundary
mismatch. Ground-truth transcriptions of the OCR fixture PDFs live in
data/eval/ocr_truth/.