Skip to content

Storage model

ingestlib splits a corpus across three stores that share one join key — the doc_id (content checksum): the registry holds everything queryable, the artifact store holds the bytes, and the vector store holds the search index. Each is chosen independently.

The registry — the source of truth

An internal Postgres database (INGESTLIB_REGISTRY_URL) that records every document's structure, classification, sections, chunks, extractions, and lifecycle state. It is authoritative: get_document(doc_id) reads a whole document back from here, the registry-backed retrieve filters (collection, confidence, kind) run against it, and verify audits the corpus against it. It is ingestlib's own bookkeeping — required for the corpus path, not a user data store. Bring it up with the bundled compose file plus ingestlib registry init (see Manage a corpus).

The artifact store — the bytes

A document's bytes only, keyed by checksum, on s3 (AWS, or a self-hosted S3-compatible MinIO via s3.endpoint_url) or a plain local folder: the source file, page renders, figure crops, and the whole-document markdown — one layout on every backend, documented in What just happened. Choosing between them: artifact stores.

Because the registry keeps the chunks and the artifact store keeps the bytes, the vector store is rebuildable: reindex() re-embeds every document's chunks straight from the registry — no re-parse — so wiping and rebuilding an index costs embedding time, not pipeline time.

The vector store — the search index

One vector per chunk, with the chunk's complete payload riding along (markdown, text, pages, region ids, section, category). Eight connectors implement one contract:

class VectorStore:
    def upsert_chunks(document_id, chunks, embeddings, category, namespace) -> int
    def query(vector, top_k, filters, namespace, text) -> list[RetrievedChunk]
    def delete_document(document_id, namespace) -> int

Guarantees every connector honors:

  • Idempotent upserts — re-ingesting a document overwrites its vectors in place; a re-parse with fewer chunks prunes the orphans
  • No infrastructure on the read path — querying a store that was never written to returns []; indexes and collections are created on first upsert only
  • Namespaces isolate corpora — same interface on every backend, even the ones with no native namespace concept
  • Filters are equality constraints on document_id, category, section, kind — unknown fields raise, with the valid list

All eight connectors run two signals over the same chunks:

  • Dense — cosine similarity over the embeddings
  • Lexical — BM25/full-text over the chunk text, with the breadcrumb boosted over the body

When retrieve() passes the question text, both run and the results fuse (Reciprocal Rank Fusion, server-side where the backend supports it). The lexical side is what catches exact tokens — invoice numbers, names, error codes — that embedding similarity smooths over. A lexical failure always degrades to dense-only with a warning, never an error.

The reranker then produces the final order across both signals' candidates.

Choosing a backend

vector_store Runs on Best when
sqlite (default) one local file — sqlite-vec + FTS5 zero setup, single machine, honest hybrid search
pgvector the Postgres you already run you want vectors next to your app's data
qdrant local docker or Qdrant Cloud a dedicated vector DB, server-side fusion
pinecone serverless cloud managed, nothing to operate
mongodb Atlas / mongodb 8.2+ your data already lives in Mongo
milvus local docker or Zilliz Cloud large-scale dedicated vector infra
opensearch Amazon domain or docker AWS-native search stack
weaviate local docker or Weaviate Cloud server-side hybrid in one call

Connection details per backend are in Connect a vector store. Only the selected connector ever builds a client — the others' SDKs are never imported, and (except sqlite, which ships with the core install) not even installed unless you add their pip extra.


That's the last concept. From here, the how-to guides cover the tasks.