Read this before expanding or debugging the engine.
This engine was extracted from a single knowledge.py and split into the modules
listed below. It is config-driven: an instance is a YAML config naming a store
dir, an embedding model, chunker defaults, and a list of sources. Nothing about
any specific dataset lives in the code.
If you change anything — add a gotcha, update the architecture, or fix a bug — update this file before closing the task.
KnowledgeBase
├── EmbedderBase (ABC)
│ ├── FastEmbedEmbedder — ONNX-backed local embeddings via fastembed (returns list[list[float]])
│ └── OpenAICompatibleEmbedder — any /v1/embeddings HTTP endpoint; batching, retries, prefixes; `build_embedder()` picks by config
├── SqliteVecStore — one SQLite file: vec0 vectors + chunks + per-file manifest; auto-VACUUM
├── RerankerBase (ABC) — pluggable via RERANKER_TYPES; `reranker:` config picks one
│ ├── FastEmbedReranker — local ONNX cross-encoder (no API key)
│ └── JinaReranker — Jina AI API (/v1/rerank)
└── SearchResult (dataclass) — carries doc, metadata, cosine score, rerank_score
Flow — index:
build_source()builds each configured source;parse_file()→ ParsedDocument- Hash every file; only files whose hash differs from the store's
filestable are processed - The source's chunker splits the body into Chunks;
FastEmbedEmbedder.embed()(batched,embed_batch_size) - Chunk ids are
rel_path::sha1(text)[:16](#nfor repeated text).SqliteVecStore.sync_file()diffs them against the stored rows: unchanged chunks keep their vector (metadata refreshed), vanished ones are deleted, only new ones are embedded; one commit per file
Flow — search:
- Embed each query once via the same
FastEmbedEmbedder SqliteVecStore.knn()per source (exact cosine,k, optionalcontent_type), merge by chunk id (keep highest score)- Optional: send top-N candidates to
JinaReranker.rerank()→ reorder by cross-attention score - Print results with both cosine score and rerank score (when reranking)
| File | Purpose |
|---|---|
basic_kb/models.py |
Dataclasses: SearchResult, ParsedDocument, Chunk |
basic_kb/embedders.py |
EmbedderBase, FastEmbedEmbedder (batch size bounds peak RAM) |
basic_kb/rerankers.py |
RerankerBase, FastEmbedReranker, JinaReranker, build_reranker |
basic_kb/textsplit.py |
Recursive character splitter (split_text) |
basic_kb/chunkers.py |
ChunkerBase, RecursiveChunker, BreadcrumbHeadingChunker, build_chunker |
basic_kb/sources.py |
DataSourceBase, MarkdownSource, TranscriptSource, build_source |
basic_kb/store.py |
SqliteVecStore — vec0 + chunks + files tables, KNN, auto-VACUUM policy, model guard |
basic_kb/core.py |
KnowledgeBase — index / reindex_paths / search / status / scan / info over the store |
<store_dir>/kb.sqlite3 |
the whole index: vectors, chunk text, metadata, per-file hashes (files table powers scan) |
<store_dir>/freshness_state.json |
per-source last freshness-check timestamp |
basic_kb/config.py |
Instance config + dotenv (env_file) loading |
basic_kb/cli.py |
argparse, commands, result printing |
docs/adr/ |
architecture decision records; 0001 = why sqlite-vec + exact search |
- Two-level incrementality: file hash (
filestable) decides whether a file is looked at; chunk hash (chunks.chunk_id) decides what gets embedded. Positional index of a chunk lives in metadata (position), not in its id, so reordering never forces a re-embed. - One
vec0virtual table for all sources:sourceis a partition key (pre-filters the scan),content_typea metadata column (filterable in the KNNWHERE),embedding FLOAT[d] distance_metric=cosine.rowidequalschunks.id. Metadata columns are strictly typed and reject NULL — the store writes''for a missing content_type. - The vector table is created on the first write and its dimension is fixed.
model_id/dimlive inmeta;check_model()refuses index/search with a different model. A model switch goes throughKnowledgeBase.prepare_model_switch(sources, accept)(called byindexandwatch): on a mismatch it refuses to index anything unlessaccept(CLI--switch-model, which implies--forceand all sources), then empties the whole store first (store.clear_all()).--forcealone never switches models. Per-source clearing cannot do this — the first source's write would meet the other sources' old-dimension vectors. - Every store method opens its own connection (
PRAGMA journal_mode=WAL), loads the extension, and closes — thread-safe reads; writers are serialised byKnowledgeBase._write_lock(process-local). - Auto-vacuum:
meta.deleted_since_vacuumcounts deleted rows;_maybe_vacuum()runs after every write and VACUUMs when the policy (VacuumPolicy, from thevacuum:block) says so. Adatabase is lockedduring VACUUM is logged and retried on the next write, never raised. - Extension loading needs
sqlite3.Connection.enable_load_extension; some Python builds lack it (historically python.org macOS) and get aStoreErrornaming the fix. - TEMPORARY (2026-08-29):
legacy_chroma_leftovers()+ the CLI notice detect a pre-sqlite-vec.chromastore and tell the user toindex --force. Delete both once all instances are migrated.
Adding a new FastEmbed model: add an entry to FastEmbedEmbedder.SUPPORTED dict. The key is the CLI alias; value is the full HuggingFace model ID. Any FastEmbed-supported HF id also works directly via --model without registering. Validate speed on your hardware before committing to a slow model — indexing is CPU-bound (see the CPU gotcha below).
Adding a new reranker: subclass RerankerBase, implement rerank(), and register it in
RERANKER_TYPES (rerankers.py). It then becomes selectable via reranker: <key> in any config.
watchdog ≥ 4 on Linux emits opened/closed_no_write for plain reads. The watcher's own reindex reads the file, so an unfiltered handler re-queued every file it had just processed and looped at the debounce period; Chroma's HNSW then grew 145× from the delete+add churn and the process was OOM-killed. _Handler.dispatch ignores those event types and reindex_paths skips unchanged hashes. Do not remove either.
Attention memory ∝ batch × seq_len² and onnxruntime's arena never returns the peak. embed_batch_size (default 8) bounds it at ~0.5 GB with identical throughput. Measured on bge-small, 2 threads.
There is no "workspace root" assumption. Every relative path in a config
(store_dir, each source path, env_file) is resolved against the config
file's own directory (config.base_dir in config.py). Absolute paths pass
through unchanged. This is what lets the same engine back instances that live
anywhere. Don't reintroduce Path(__file__)-relative data paths.
The Jina API endpoint (api.jina.ai) sits behind Cloudflare. Bare urllib.request sends a minimal User-Agent that Cloudflare blocks with HTTP 403 / error code 1010.
Fix: use requests (a declared dependency). It sends a richer default User-Agent that passes Cloudflare.
# Wrong — gets blocked
urllib.request.urlopen(req, timeout=15)
# Correct
requests.post(url, headers={...}, json={...}, timeout=15)FastEmbed uses ONNX Runtime without Metal/CoreML acceleration by default. Benchmark results on M1 (1500-char chunks):
| Model | Speed | Full index (451 files) |
|---|---|---|
| bge-small-en-v1.5 | 8.3 chunks/s | ~10 min |
| bge-base-en-v1.5 | 2.5 chunks/s | ~32 min |
| nomic-embed-text-v1.5 | 1.8 chunks/s | ~45 min |
| all-MiniLM-L6-v2 | 90 chunks/s | < 1 min |
all-MiniLM is 10× faster but MTEB 56 vs 62 for bge-small. Since indexing is one-time, bge-small is the default. Do not switch to bge-base or nomic without testing — they are impractically slow on CPU.
The engine reads JINA_API_KEY straight from os.environ — it has no built-in
knowledge of any .env location (that would couple it to one workspace). cli.main()
loads, in order: --env-file (if passed), then the config's env_file (if set),
both via os.environ.setdefault — so an already-set shell variable always wins.
If reranking silently doesn't happen, the key isn't reaching the environment;
check that order. Reranking is skipped (not an error) when the key is absent.