Schema reference

DDL lives in data/sql/00*.sql at the repository root, which is the source of truth (it holds the CHECK constraints and the generated tsvector). db/models.py mirrors it for typed reads and writes, not for schema creation.

Migrations are idempotent — IF NOT EXISTS plus duplicate_object guards for enum types — so applying them repeatedly is the migration story. Sufficient for a single-user local corpus, and it avoids a migration framework.

The two axes

The design decision worth understanding: what a thing is and how trusted it is are independent.

corpus_class — the primary partition:

Value Meaning
document prose: notes, papers, reports, presentations
code source and structured config
communication messages and mail; Garage never sends it off the machine

trust_tier — provenance:

Value Meaning
authored the owner wrote it
reference external, already QA’ed: papers, vendored code, product docs
received someone else sent it

The pairing is what makes the corpus queryable:

(class, trust) Example
(code, reference) a vendored dependency
(code, authored) your own repository
(document, reference) a downloaded paper
(document, authored) your research notes
(communication, received) an inbound message

A single trust_tier with a communication value would have conflated these — “is this a private conversation” is a property of what the content is, not of how much you trust it. The embedding guard that keeps communications off any provider not on this machine is keyed on corpus_class for that reason.

Tables

sources

Registered roots. kindfilesystem | git | sqlite | maildir | feedfeed is the extension point for social media connectors.

expected_elements is the item count of the last scan, which the app’s progress bars measure ingest against. The rest of that scan lives beside it (008_source_scan.sql): scan_item_type (files, messages, …), scan_details (the scanner’s per-kind breakdown) and scanned_at. config holds only the source’s own settings, such as include_code, as written by sync from garage.json.

authors / author_identities

An author owns many identities (git_email, email, phone, imessage_handle, handle). Resolution is lookup-by-identity, create-on-miss, so the same person arriving first via a git email and later via a PDF byline collapses onto one row.

A partial unique index enforces at most one is_self author.

documents

One row per logical document, unique on (source_id, uri).

Column Purpose
source_sha256 raw bytes — cheap skip without opening the file
content_sha256 extracted text — decides whether to re-chunk
extractor + extractor_version provenance; a version bump forces a rebuild
chunker chunking signature; a config change forces a rebuild
content full extracted text, for rag_get_document
meta extractor and attribution provenance
state ok \| extract_failed \| embed_partial \| placeholder

placeholder is not a failure: it means the bytes are not on this machine yet. Recording it means garage stats can show what is pending download rather than it silently missing.

document_authors

M:N with role (author, committer, sender, recipient, cc), confidence, and evidencegit-log:self-commits:4/4, path-rule:Reference, document-metadata. Evidence makes a misattribution diagnosable instead of mysterious.

chunks

The unit of embedding, and deliberately model-agnostic — every model references these same rows, which is what makes re-indexing a backfill rather than a re-ingest.

tsv tsvector GENERATED ALWAYS AS (to_tsvector('english', text)) STORED

Postgres maintains the keyword index itself; no application bookkeeping.

char_start / char_end are the chunk’s span of documents.content: its exact text, or for markdown (whose header splitter drops blank lines) the span from its first line to its last. They are NULL when the chunk cannot be found in the content, and on chunks built before offsets were recorded; those gain them the next time the document is re-chunked.

chunks.fact_id (007_chunk_fact_link.sql) is a nullable REFERENCES facts(id) ON DELETE CASCADE column with a partial unique index (WHERE fact_id IS NOT NULL), so a fact has at most one chunk. It marks a chunk as distilled from a fact rather than cut from documents.content; such chunks carry chunker = 'facts:langextract:<model>'. Nothing else about the row is special — the backfill’s anti-join finds it like any other chunk, which is what gets facts embedded under every model without a fact-specific path. Deleting a fact cascades into its chunk and, through chunk_id, into every emb_* table.

facts

Atomic, self-contained claims distilled out of a document’s text by enrich/facts.py (006_facts.sql). Same shape as chunks: ordered rows scoped to a document_id (ON DELETE CASCADE, unique on (document_id, ord)), replaced wholesale when the document is re-extracted.

Column Purpose
fact the claim, in the document’s own wording
fact_class the extractor’s label (its prompt’s extraction_class, default 'fact'); unconstrained so the prompt can be specialized per corpus
attributes jsonb extractor attributes, default '{}'
char_start / char_end span of documents.content the fact was grounded to; an ungrounded fact is dropped by the extractor rather than stored
extractor / extractor_model provenance, default 'langextract' and the model id
tsv generated to_tsvector('english', fact), GIN-indexed — the keyword half of hybrid search over facts

conversations / messages

Structured storage for communication sources (sms/imessage chat.db, mail) prior to synthesis into a document.

A conversations row is one thread between the corpus owner and a single other participant (other_author_id), scoped to the source_id it came from and keyed on the source’s native thread identifier (external_id) so re-ingest finds the same conversation rather than duplicating it.

messages rows accumulate under a conversation as they are ingested, each attributed to its sender via author_id (the owner or the conversation’s other_author_id) and deduplicated on (conversation_id, external_id).

A synthesis step concatenates a conversation’s messages in sent_at order into one synthetic documents row — conversations.document_id — which is then chunked and embedded exactly like any other document, using the same comms_window_minutes / comms_window_messages grouping as the chunker. Deleting that document (e.g. to force a rebuild) clears document_id back to null rather than deleting the raw messages, so re-synthesis has the full history to work from.

embedding_models and the emb_* tables

One table per model, because vector(1024) and vector(2560) cannot share a column, and per-model nullable columns would make HNSW indexes and backfills painful.

CREATE TABLE emb_<slug> (
  chunk_id  bigint PRIMARY KEY REFERENCES chunks(id) ON DELETE CASCADE,
  embedding <type>(<dims>) NOT NULL,
  embedded_at timestamptz NOT NULL DEFAULT now()
);

chunk_id as both primary key and cascading foreign key is the load-bearing detail: deleting a chunk removes its vectors from every model table at once, so stale vectors cannot outlive the text they came from.

Storage selection

pgvector 0.8 HNSW ceilings are hard limits — vector ≤ 2000 dims, halfvec ≤ 4000:

Model width Storage Index
≤ 2000 vector(d) HNSW on the model’s distance
2001–4000 halfvec(d) HNSW on the model’s distance
> 4000, Matryoshka halfvec(4000) truncated + renormalized HNSW on the model’s distance
> 4000, not Matryoshka vector(d) HNSW on binary_quantize(...)::bit(d), re-ranked on the exact distance

Distance

embedding_models.distance (009_model_distance.sql) is the similarity the model was trained for: cosine, l2 or inner_product. It is declared per model in data/models/models.json (or register-model --distance for a model the catalog does not list) and fixes two things that must agree: the HNSW operator class (vector_cosine_ops, halfvec_l2_ops, vector_ip_ops, …) and the operator search orders by (<=>, <->, <#>). An index built for one metric is not used by a query on another, so the metric is chosen once, at registration. Models registered before the column existed were indexed for cosine, which is its default.

models.json is the one model catalog: the app reads it for presets and downloads, and garage_rag.db.catalog reads the same file for widths, supports_mrl, distance and per-provider names (provider_refs, e.g. an Ollama tag), found through GARAGE_MODEL_MANIFEST (the app points it at its bundled copy) or in the repository.

Truncation is only sound for MRL-trained models, so supports_mrl is declared per model rather than assumed. A CHECK constraint refuses to register an hnsw-indexed model above its type’s ceiling, so the mistake cannot reach a failing CREATE INDEX thousands of documents into an ingest.

Truncated vectors are renormalized: a prefix of a unit vector is not itself unit length, and pgvector’s cosine operator does not normalize for you.

ingest_runs / ingest_seen

Coverage bookkeeping that makes deletion safe. completed is true only for a walk that ran to exhaustion (not one cut short by --limit or cancellation). ingest_seen holds one row per observed URI per run, including files that were stat-skipped without being opened; nothing prunes old runs yet, so it grows with every ingest.