Infrastructure
All of it is optional. karani runs end to end on an in-memory store with no model key, and each piece here is an upgrade rather than a prerequisite.
karani infra up # Postgres + pgvector on :5433
karani infra up --profile local-llm # and Ollama on :11434
karani infra down # stop; volumes persistPostgres
DATABASE_URL=postgresql://karani:karani@localhost:5433/karaniAny Postgres works and the DSN is the only switch — a managed one is fine. The compose stack binds 5433 rather than 5432 so it cannot collide with a system Postgres.
docker exec -it karani-db psql -U karaniSchema changes are additive, always
Every schema addition is ALTER TABLE ... ADD COLUMN IF NOT EXISTS, and
the whole schema block runs idempotently on every connect. Callers never
touch DDL.
There is never a destructive migration. The database has a real application history in it, and that history is the only thing the conversion funnel has to learn from. If you see a migration failure, somebody broke the rule (CLAUDE.md 4.7 ).
The in-memory fallback is a real code path
With no DATABASE_URL, karani uses an in-memory store. Every write method
has that branch, the tests run on it, and it is how you try the pipeline
before deciding you want a database (ADR
0006).
State does not survive the process. That is the only catch.
Object storage
MinIO or S3, for the rendered .docx and PDF documents, one prefix per
job, with presigned links that open on a phone (ADR
0014).
[artifacts]
endpoint = "localhost:9010"
bucket = "karani"
secure = false
presign_days = 7Without it, packs are still written to drafts/ as markdown. You lose the
phone-friendly links on the Slack card, not the pack.
A local model server
This is where the real operational knowledge is, and all three items below were learned by something going wrong.
On macOS, run the app natively — never the container
Docker on a Mac has no GPU passthrough. The same small model measured
30 to 60 times slower CPU-only inside the VM. The compose local-llm
profile is for Linux hosts with --gpus.
Both bind 11434, so stop one before starting the other.
Raise the context window
Ollama defaults to a 4096-token context and silently truncates anything longer. A qualification prompt — instructions, your whole resume, the posting, past verdicts — is comfortably bigger than that, so the default means the model is judging a posting against a resume it was never shown the end of, and nothing anywhere says so.
launchctl setenv OLLAMA_CONTEXT_LENGTH 16384
killall Ollama ollama # the menu-bar quit can leave the server runningVerify after one request:
curl -s localhost:11434/api/ps # check context_lengthPin one parallel slot
With more than one slot, Ollama splits the context window per slot, quietly undoing the setting above.
launchctl setenv OLLAMA_NUM_PARALLEL 1karani matches its own fan-out to this. When it did not, queued rows burned the client timeout and one pass took 13.5 hours.
Budget the batch
A small local model runs roughly 75 to 170 seconds per row on a laptop, and its thinking trace cannot usefully be disabled — asking for no thinking just moves the same volume into the visible answer.
So keep [hunt] qualify_limit low enough that a pass fits the hour: 15
rows is about 30 minutes. A pass that cannot finish silences every pass
after it (ADR 0025).
No JSON grammar mode
karani deliberately does not ask Ollama for JSON-object output format. Ollama implements it as grammar-constrained decoding, which fights thinking models — measured at roughly 60 times slower. Do not add it back.
The vector store
mem0’s index lives in pgvector in the compose Postgres, reached through its
own DSN rather than DATABASE_URL. That separation is deliberate: the
ledger can live in a managed database while the disposable index stays
local.
uv sync --extra memory
uv run python -m spacy download en_core_web_sm
KARANI_MEMORY=mem0Drop the collection and nothing is lost — karani reindex replays the
ledger. Memory has the invariant.
Backup
The database is the only thing that cannot be regenerated. Your resume and
karani.toml are files you wrote; the packs can be re-drafted; the vector
index is derived.
pg_dump "$DATABASE_URL" | gzip > backups/karani-$(date +%F).sql.gzWhat is worth backing up is the application history, because the conversion funnel has nothing else to learn from.
Pruning
The raw payload stored on every row is the biggest consumer of space, and
it is kept on purpose: feed schemas drift, and raw is what lets you write
a reparser rather than losing the data.
When size becomes a problem, closed postings you never applied to are the safe thing to drop:
DELETE FROM jobs
WHERE active = FALSE
AND closed_at < NOW() - INTERVAL '90 days'
AND application_status IS NULL;The application_status IS NULL clause is the important one. Never prune a
row with an application on it.