Skip to content

Architecture

Layered, strict downward dependencies — nothing lower knows what sits above it.

src/ingestlib/
├── services/       ingest · retrieve · lifecycle (remove · sync · backfill) — the product
├── operations/     parse · classify · split · extract — the tools (each standalone)
├── storage/        artifacts (S3 | local) · VectorStore contract · 8 connectors
├── sources/        structured retrieval — SQL databases & the corpus as queryable Sources
├── foundations/    llm (Bedrock · OpenAI · Ollama · Jina rerank) · ocr (PaddleOCR-VL)
├── cli/            the `ingestlib` command — init · doctor · ingest · sync · list · remove · backfill · search · describe-schema · eval-sql · mcp
├── mcp/            MCP server (ingestlib[mcp]) — the tools/services exposed to agents
├── utils/          logger · files · sync · aws
└── config.py       config.yaml + .env + rules.yaml + sources.yaml → typed, frozen configs

The load-bearing decisions

Provider dispatch is a per-call config read. Operations import chat/embed_text from one surface; which backend answers is decided at call time. No client is built until a call happens, and backends that aren't selected are never imported — a sqlite + ollama pipeline never touches boto3.

The VectorStore contract absorbs backend quirks. ID schemes, metadata encoding, fusion mechanics, deletion semantics — each connector handles its backend's reality internally (documented at the top of each module) so pipelines are written once. Shared guarantees: idempotent upserts, orphan pruning on re-ingest, no infrastructure creation on the read path, namespace isolation everywhere.

Artifacts are the source of truth; vectors are an index. Every stage's output persists before the next stage runs, so nothing about a corpus is ever locked inside a vector database — parses, chunks, and page renders all reload from the artifact store. backfill() rebuilds a vector store straight from these artifacts, no re-parse.

Provenance is structural, not annotated. Chunks record the parse region ids they cover, chunk boundaries can't cut through a region, and the full payload rides on the vector. The citation chain (hit → regions → bboxes → page render) needs no extra database.

Generated SQL is bounded, never trusted. Structured retrieval lets the LLM write read-only SQL over your databases, but behind a permission boundary whose floor is a read-only database role — so a wrong query is a wrong read, never a write. A statement allowlist, an injected LIMIT, and a timeout are defense in depth on top; verified queries let you pin reviewed SQL for answers that must be exact. On a wide schema, the same layer retrieves the relevant tables (embedding each table into a card, then closing over the foreign-key graph so the fed sub-schema is join-complete) rather than dumping every table — schema-RAG, reusing the very same embedding surface as the verified match, no vector store. The sources/ layer reuses foundations/llm (generate, param-fill, semantic match) — no new model, the same dispatch as everything else.

Errors carry their fix. Every backend boundary translates its classic failures — wrong AWS profile, missing model access, dead Ollama, exhausted Jina quota — into one-sentence remedies, re-raised with the original chained. ingestlib doctor is those same translations, run proactively.

Config is discovered at call time, never at import. Importing ingestlib does nothing; the first real call finds config.yaml (explicit env var, else CWD and parents). Frozen dataclasses make a loaded config immutable; changing files mid-process takes an explicit reset.

Testing philosophy

Real APIs, never mocks. Pure logic runs on every test invocation; server-hitting suites are opt-in via RUN_*_E2E gates. Failures are tested by provoking real ones — bogus keys get real 401s, dead ports get real connection refusals — so the error translations are verified against reality, not against a mock's guess. The sqlite connector's full suite runs ungated: there is no server, so in-process is the real thing.