Architecture¶
Overview¶
knomi has two runtime modes that share core infrastructure:
┌─────────────────────────────────────────────────────────────────┐
│ knomi CLI │
│ │
│ knomi ingest <dir> knomi serve │
│ │ │ │
│ ▼ ▼ │
│ Ingest Pipeline RAG Query Interface │
│ ───────────────── ──────────────────── │
│ Scanner VectorStore.search() │
│ → Parser → return ranked chunks │
│ → Chunker │
│ → Embedder │
│ → VectorStore.upsert() │
└───────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────┐
│ Vector DB │
│ qdrant / chroma │
│ / pgvector │
└─────────────────┘
Each swappable part — store, embedder, chunker — is chosen at runtime by a factory
that reads the resolved Config. Callers depend only on the abstract interface, never
on a concrete backend.
Module Responsibilities¶
knomi/cli.py¶
Typer-based CLI. Defines subcommands (ingest, serve, status, delete, profiles). A @app.callback exposes the global --profile / -p option (and --log-level) and stashes the profile name on the Typer context. Each subcommand collects its flags into a nested overrides dict, drops unset (None) values, and calls resolve_config(profile, overrides). No business logic lives here.
knomi/config.py¶
Pydantic-settings BaseSettings. The Config model has a few top-level fields (source_dir, serve_host, serve_port, top_k) plus three nested groups — StoreSettings, EmbeddingSettings, ChunkingSettings. It is the single source of truth for all tunable parameters.
Resolution layers (highest priority first), wired in settings_customise_sources:
init_settings— CLI overrides passed toresolve_config.env_settings—KNOMI_-prefixed environment variables, nested via__(e.g.KNOMI_STORE__COLLECTION).dotenv_settings— the same, read from.env._ProfileSettingsSource— the selected profile block fromknomi.json.
Field defaults sit below all sources.
Profile resolver. resolve_config(profile, overrides):
- strips None-valued keys from overrides so unset flags never clobber lower layers;
- sets the active profile name in a ContextVar (_ACTIVE_PROFILE) so the profile source knows which block to load — this keeps resolution isolated per thread/async context;
- constructs the Config (triggering the four sources above);
- calls _resolve_secrets() to fill unset secrets from the environment based on the chosen backend.
_ProfileSettingsSource is a custom PydanticBaseSettingsSource. On call it resolves the effective profile name (resolve_profile_name: explicit arg > KNOMI_PROFILE > file default_profile), loads knomi.json (load_profiles, searched in the CWD then ~/.config/knomi/), and returns the matching profile dict — raising a helpful KeyError listing available profiles if the name is unknown.
_resolve_secrets reads OPENAI_API_KEY / COHERE_API_KEY (per embedding backend), QDRANT_API_KEY (qdrant store), and KNOMI_PG_DSN (pgvector store). Secrets are never persisted in knomi.json.
knomi/ingest/scanner.py¶
Recursive pathlib walk. Filters by extension whitelist (.pdf, .md, .txt, .docx, .html). Returns an iterator of Path objects. Computes a SHA-256 hash per file for deduplication.
knomi/ingest/parser.py¶
Extension-dispatched text extraction. Each format (pdf, md, txt, …) has its own extractor function. Output is normalised plain text: de-hyphenated, whitespace-collapsed, header/footer stripped.
knomi/ingest/chunker.py¶
Three interchangeable BaseChunker strategies, all expressing chunk_size / chunk_overlap in tokens (via a tiktoken encoding), returning list[Chunk(text, metadata)]:
- TokenChunker (token) — fixed-size sliding window over the raw token stream.
- StructureChunker (structure) — split on blank lines / Markdown headings, then greedily pack whole blocks up to the token limit.
- SentenceChunker (sentence) — split into sentences, then greedily pack them up to the token limit.
build_chunker(config) selects the strategy from config.chunking.strategy via the _STRATEGIES registry.
knomi/ingest/embedder.py¶
BaseEmbedder subclasses wrap one embedding backend each and return list[list[float]] for a batch of texts. embed_query and embed_chunks (batching + optional concurrent workers) are inherited. Backends: LocalEmbedder (sentence-transformers), OpenAIEmbedder, CohereEmbedder, OllamaEmbedder. build_embedder(config) routes on config.embedding.backend, with a legacy fallback that treats a text-embedding-* model as OpenAI and everything else as local.
knomi/ingest/pipeline.py¶
Orchestrator: scanner → dedup check → parser → chunker → embedder → store. Emits progress via a callback so the CLI can render a progress bar. All steps are pure functions; the pipeline wires them together.
knomi/store/base.py¶
Abstract VectorStore protocol. Methods: upsert, search, delete, has_document, describe, get_source, update_source. Any store backend must satisfy this interface.
knomi/store/factory.py¶
build_store(config) returns the concrete VectorStore for config.store.backend (qdrant → QdrantStore, chroma → ChromaStore, pgvector → PgVectorStore). Backends are imported lazily so optional dependencies (chromadb, psycopg/pgvector) are only required when actually used. The pipeline and serve layers obtain their store exclusively through this factory.
knomi/store/qdrant.py¶
Qdrant implementation. Uses the official qdrant-client SDK. Handles both local-file mode (no Docker) and server mode (HTTP/gRPC URL), plus Qdrant Cloud via store.url (https) + store.api_key.
knomi/serve/server.py¶
FastAPI application for the RAG query API. Exposes GET /health (liveness check) and POST /query (embed a query string, search the vector store, return ranked chunks). The create_app(config) factory builds the app; start_server(config) runs it with uvicorn.
Factory pattern¶
Three factories decouple the orchestration layer from concrete implementations. Each reads one nested config group and returns an object behind an abstract base class:
| Factory | Reads | Base class | Returns |
|---|---|---|---|
build_store (knomi/store/factory.py) |
config.store.backend |
VectorStore |
QdrantStore / ChromaStore / PgVectorStore |
build_embedder (knomi/ingest/embedder.py) |
config.embedding.backend |
BaseEmbedder |
OpenAI / Local / Cohere / Ollama embedder |
build_chunker (knomi/ingest/chunker.py) |
config.chunking.strategy |
BaseChunker |
Token / Structure / Sentence chunker |
The pipeline (knomi/ingest/pipeline.py) and serve layer call these factories and then
program against the base classes only — adding a backend never touches the orchestration code.
Data Flow: Ingest¶
File path
│ scanner.py – hash check
▼
Raw bytes
│ parser.py – format-specific extraction
▼
Plain text
│ chunker.py – token-aware splitting
▼
[Chunk(text, metadata), ...]
│ embedder.py – batch embed
▼
[vector, ...]
│ store.upsert()
▼
Qdrant collection
Data Flow: Query (serve)¶
User query string
│ embedder.embed_query()
▼
Query vector
│ store.search(vector, top_k)
▼
Ranked [Chunk, score]
│ returned to agent / HTTP response
▼
LLM context
Configuration Contract¶
Nested groups use the __ delimiter in env vars (e.g. KNOMI_STORE__COLLECTION).
| Key | Env var | Default |
|---|---|---|
source_dir |
KNOMI_SOURCE_DIR |
. |
store.backend |
KNOMI_STORE__BACKEND |
qdrant |
store.url |
KNOMI_STORE__URL |
http://localhost:6333 |
store.path |
KNOMI_STORE__PATH |
None |
store.collection |
KNOMI_STORE__COLLECTION |
knomi |
embedding.backend |
KNOMI_EMBEDDING__BACKEND |
openai |
embedding.model |
KNOMI_EMBEDDING__MODEL |
text-embedding-3-small |
embedding.dim |
KNOMI_EMBEDDING__DIM |
1536 |
chunking.strategy |
KNOMI_CHUNKING__STRATEGY |
token |
chunking.chunk_size |
KNOMI_CHUNKING__CHUNK_SIZE |
512 |
chunking.chunk_overlap |
KNOMI_CHUNKING__CHUNK_OVERLAP |
64 |
serve_port |
KNOMI_SERVE_PORT |
8080 |
top_k |
KNOMI_TOP_K |
5 |
Secrets are resolved separately from the environment (never from knomi.json):
OPENAI_API_KEY, COHERE_API_KEY, QDRANT_API_KEY, KNOMI_PG_DSN.
Extension Points¶
Add a vector store backend¶
- Create
knomi/store/<backend>.py, subclassVectorStore, implement all abstract methods (upsert,search,delete,has_document,describe,get_source,update_source). - Add a lazy-import branch for it in
build_store(knomi/store/factory.py) and extend thestore.backendLiteralinknomi/config.py. - If it needs a heavy dependency, add an extra in
pyproject.toml; import it lazily. - Add integration tests under
tests/integration/.
Add an embedding backend¶
- Subclass
BaseEmbedderinknomi/ingest/embedder.pyand implementembed(texts)(embed_query/embed_chunksare inherited). - Add a routing branch in
build_embedderand extend theembedding.backendLiteral. - Add unit tests that mock the client and assert the routing.
Add a chunking strategy¶
- Subclass
BaseChunkerinknomi/ingest/chunker.py, implementsplit(text, source, doc_id). - Register it in the
_STRATEGIESdict and extend thechunking.strategyLiteral. - Add unit tests in
tests/unit/test_chunker.py.
Infrastructure¶
compose.yml runs Qdrant with a named volume for persistence. No other services are required for local development. For production deployments, point store.url at a managed Qdrant Cloud instance and provide QDRANT_API_KEY.
Distribution¶
The canonical artifact is the PyPI package (pip install knomi, requires-python >= 3.12).
Two thin wrappers install and delegate to it, so there is a single source of truth for the CLI:
- npm (
packaging/npm/) — apostinstallhook installs the PyPI package viauv/pipx/pip --user(never failingnpm install);bin/knomi.jsshims to the realknomiconsole script. - Homebrew (
Formula/knomi.rb) — builds knomi and its runtime deps into an isolated virtualenv viaLanguage::Python::Virtualenv;resourceblocks are generated withbrew update-python-resourcesand bumped automatically on release.