Start the core stack:
make compose-upStart the RabbitMQ ingestion worker:
make compose-workerStart the speaker identification UI:
make compose-speakerStop the stack:
make compose-downUseful endpoints:
- API liveness:
http://localhost:1234/healthz— returns 200 once the FastAPI process is up. - API readiness:
http://localhost:1234/readyz— returns 503 until the embedding model and ChromaDB connection are initialized; use this for load-balancer or Kubernetes readiness probes. - Demo UI:
http://localhost:8501— multi-page Streamlit (Home / Submit / Pipeline / Search). The Pipeline page pollsdata/processed/<video>/every 1.5 s for live step-by-step progress. - Speaker identification UI:
http://localhost:5050 - RabbitMQ management:
http://localhost:15672 - ChromaDB:
http://localhost:8000
For a full end-to-end demo, also start the worker so the Submit page has somewhere to send the job:
make compose-workerThen on the Submit page, pick a video already staged under data/videos/, optionally set title/year, and click Publish ingestion job. The Pipeline page picks up the job automatically and animates the per-step progress (shot detection → audio → transcription → audio events → visual captions → actions → speaker map → segmentation → enrichment → indexing).
When .env is copied from .env.example, RabbitMQ uses RABBITMQ_DEFAULT_USER and RABBITMQ_DEFAULT_PASS from that file. The host publisher should use a localhost RABBITMQ_URL; the Compose worker is wired to the rabbitmq service DNS name.
Direct publisher and worker commands require RABBITMQ_URL. Copy .env.example to .env for local defaults, export the variable in your shell, or pass --rabbitmq-url to the publisher.
Publisher:
.venv/bin/python -m ingestion_pipeline.publisher --video /data/videos/example.mp4Worker:
.venv/bin/python -m ingestion_pipeline.workerThe local worker imports the ingestion pipeline when it receives a job, so install requirements-ingestion.txt before running it outside Docker.
Messages are JSON objects with video_path, optional output_dir, optional title, and optional year. The worker acknowledges successful jobs and rejects failed jobs without requeueing, so failed jobs should be republished after fixing the underlying issue.
SPEAKER_MAP_TIMEOUT_SECONDS caps how long the worker waits for manual speaker identification output. The Compose and Kubernetes defaults use 3600 seconds so a missing speaker_map.json fails the job instead of occupying the worker forever.
Kubernetes queue components:
kubectl -n video-se rollout status deployment/rabbitmq
kubectl -n video-se rollout status deployment/ingestion-worker
kubectl -n video-se logs deployment/ingestion-worker -fThe bundled k8s/rabbitmq.yaml uses the same video-se-secrets object as the worker. For a managed broker, keep RABBITMQ_URL in that secret pointed at the managed endpoint and skip applying the bundled RabbitMQ manifest.
The default deploy path is kubectl apply -k k8s/. Apply k8s/ingestion-job.yaml only when you want a one-off ingestion job for a specific video path.
Required secrets are environment variables:
HF_TOKENGEMINI_API_KEYwhen using GeminiTMDB_API_KEYwhen metadata lookup is desired
Do not add these to tracked YAML. Use .env locally, Docker/Kubernetes secrets in hosted environments, and config.example.yaml for non-secret defaults.
core.logger.setup_logging honors LOG_LEVEL (CRITICAL, ERROR, WARNING, INFO, DEBUG). Default is INFO; unknown values fall back to INFO. Set LOG_LEVEL=DEBUG in the API/worker env to surface deeper diagnostics without redeploying code. Every long-running entrypoint — the FastAPI service, the ingestion worker, the Flask speaker UI, and the publisher CLI — calls setup_logging at boot, so the variable applies uniformly; configure it via LOG_LEVEL in .env for Compose or via video-se-config in Kubernetes.
LOG_FORMAT selects the output format:
pretty(default): colored text suited for local development. Each line includesreq=<id>so you can grep for a specific request.json: one JSON object per line withtimestamp,level,logger,message,request_id, plus any caller-suppliedextra=fields. Recommended for production deployments so a log shipper (Loki, ELK, Datadog) can parse and index without a regex.
Every API request is bound to a request id via the contextvar in core.observability. Inbound X-Request-ID headers are honored; missing headers get a fresh 12-char hex id. The id is echoed in the response header and surfaces on every log line emitted during the request, so a failed /search can be traced from the access log to the search service to ChromaDB without grepping by timestamp.
The API ships open by default — appropriate for a development stack on a trusted network — and exposes three opt-in knobs. Each is independent; enable any combination by setting the corresponding env var.
| Env var | Effect |
|---|---|
SEARCH_API_KEY |
When set, /search requires X-API-Key: <value>. Mismatch returns 401 with WWW-Authenticate: X-API-Key. /healthz, /readyz, /metrics stay open so probes and scrapers don't need the key. |
CORS_ALLOW_ORIGINS |
Comma-separated origin allow-list (e.g. https://app.example.com,https://staging.example.com). When unset, no CORS middleware is attached at all (cross-origin browser calls are simply not possible). |
MAX_REQUEST_BODY_BYTES |
Hard cap on Content-Length for state-changing requests. Default 32 KiB. Bodies larger than this are rejected with 413 before the handler allocates. |
RATE_LIMIT_PER_MINUTE |
Per-client /search budget over a 60-second sliding window. 0 / unset disables. Bucketing prefers X-API-Key (one budget per credential), then X-Forwarded-For first hop, then peer IP. Exceeding the budget returns 429 with Retry-After: <seconds>. /healthz, /readyz, /metrics are exempt so probes/scrapers can't be locked out. |
Compose users set these in .env; Kubernetes users put SEARCH_API_KEY
in the video-se-secrets Secret and CORS_ALLOW_ORIGINS /
MAX_REQUEST_BODY_BYTES in the video-se-config ConfigMap.
The 401 response always includes the request id from the
observability middleware — kubectl logs filtered on req=<id> shows
exactly which auth attempt failed and why.
Every auth attempt (granted and denied) emits a structured audit
record on the api.security.audit logger:
{
"audit": "search_api_auth",
"outcome": "denied",
"method": "POST",
"path": "/search",
"peer_ip": "10.42.0.7",
"forwarded_ip": "203.0.113.4",
"key_presented": true,
"key_prefix": "badk…",
"request_id": "ca9c07fa367e",
"level": "WARNING",
...
}key_prefix is the first 4 characters of the presented key followed by
an ellipsis — never the full credential. Pair it with the
request_id and peer_ip to trace credential brute-forcing or post-leak
abuse. Audit denials log at WARNING; grants at INFO so production
deployments can dial down the volume by raising the level on
api.security.audit while still capturing failures.
The API exposes Prometheus metrics at GET /metrics:
| Metric | Type | Labels |
|---|---|---|
video_se_http_requests_total |
counter | route, method, status (2xx/4xx/5xx) |
video_se_http_request_duration_seconds |
histogram | route, method |
video_se_search_requests_total |
counter | outcome (success/error) |
video_se_search_results_count |
histogram | — |
Routes that aren't recognized templates collapse to /_other to keep label cardinality bounded. The /metrics endpoint itself is excluded from the counters so a scraper doesn't dominate its own dashboards.
prometheus-client is a runtime dep of requirements-api.txt. If it's missing (e.g. in a stripped-down image) the API still boots — api.observability logs a warning and the /metrics route 404s.
The Compose stack uses persistent volumes for ChromaDB, RabbitMQ, and model caches. In Kubernetes, the manifests expect PVCs for videos, processed data, and model caches.
Service Dockerfiles, Chroma, and RabbitMQ are pinned to release images instead of floating tags. For local Compose, set CHROMA_IMAGE (a full reference such as chromadb/chroma:<tag>) or RABBITMQ_IMAGE_TAG in .env when testing an upgrade; for Kubernetes, update k8s/chroma.yaml or k8s/rabbitmq.yaml and validate before rollout. For service image base upgrades, update the FROM line in each file under docker/.
Large model downloads happen on first run. Keep MODEL_CACHE_DIR or /models persistent between restarts to avoid repeated downloads.
Port already in use:
lsof -nP -iTCP:1234 -iTCP:8501 -iTCP:8000 -iTCP:5672 -iTCP:15672Missing FFmpeg:
sudo apt update && sudo apt install ffmpegAPI starts but returns no results:
- Confirm ingestion completed.
- Confirm
CHROMA_COLLECTIONis the same for ingestion and API. - Confirm the UI sends the selected video's stem as
video_filename.
Search latency / recall:
- Successful
/searchcalls emitSearch returned <N> results in <ms>ms (top_k=..., video=...). - Failures emit
Search failed after <ms>ms (top_k=..., video=...).followed by the traceback. Both paths log duration, so a slow timeout vs. a fast crash is distinguishable in logs. - The text-side embedding is built from
title + keywords + (transcript or summary), keeping short semantic anchors ahead of long transcripts. The visual/audio-side embedding is built fromsummary + visual captions + actions + audio events, so sound cues such as music or applause stay searchable even when the transcript is silent. Changes to those recipes instep_04_indexing.pyonly take effect after re-running the ingestion pipeline on each video —collection.upsertoverwrites by id, so reingesting a video replaces its embeddings without dropping the collection.
Pipeline waits after extraction:
SPEAKER_UI_MODE=externalmeans the pipeline waits forspeaker_map.json.- Check
SPEAKER_MAP_TIMEOUT_SECONDSif the worker rejected a job while waiting. - Run the speaker UI and save the map, or provide the expected file under the processed video directory.
RabbitMQ worker does nothing:
- Check the queue name in
INGESTION_QUEUE. - Confirm the published
video_pathis valid inside the worker container. - Check RabbitMQ management UI for ready/unacked messages.
Failed jobs are routed to video.ingestion.dlq via the
video.ingestion.dlx direct exchange (routing key video.ingestion.failed).
The worker basic_nack(requeue=False)s on any unhandled exception, so the
broker moves the message to the DLQ instead of dropping it. Inspect with the
RabbitMQ management UI at http://localhost:15672 or via rabbitmqadmin:
rabbitmqadmin -u "$RABBITMQ_DEFAULT_USER" -p "$RABBITMQ_DEFAULT_PASS" \
list queues name messages_ready messages_unacknowledged
rabbitmqadmin -u "$RABBITMQ_DEFAULT_USER" -p "$RABBITMQ_DEFAULT_PASS" \
get queue=video.ingestion.dlq count=5 ackmode=ack_requeue_falseRepublish a fixed job after correcting the underlying problem; nothing auto-replays from the DLQ.
If _open_channel raises with "queue exists with different arguments", an
older queue declaration without the DLX argument is colliding with the new
topology. Either delete the queue and let it be recreated, or set
INGESTION_QUEUE to a versioned name (e.g. video.ingestion.v2) and
redeploy.
POST /search accepts these optional fields beyond query and top_k:
video_filename: restrict results to one video stem (e.g.sintel_trailer).min_duration_sec/max_duration_sec: drop segments outside the range after hybrid fusion. Useful when very short cut-segments dominate the top hits for noisy collections.
Indexing-time metadata filters are now exposed on every Chroma row:
speakers_tokens,keywords_tokens,actions_tokens,audio_events_tokens— pipe-delimited lowercase strings (|alice|bob|). Use these for precise matching with Chroma's$containsoperator, e.g.where: {"speakers_tokens": {"$contains": "|sintel|"}}. The comma-joined display fields (speakers,keywords, ...) match only the whole joined string and shouldn't be used for filtering.duration_sec— numeric, useful for$gte/$lteserver-side filters before fetching metadata.context_hash— sha256 over(embedding_model_name, indexed_text). The indexer reads these on rerun and short-circuits when every segment's hash matches what's stored, skipping the encode + upsert entirely.
Each model-bound extraction output (transcript_raw.json,
audio_events.json, visual_details.json, actions.json) is paired with
a <output>.cache_meta.json sidecar that records the model name and key
parameters. On rerun the pipeline reuses the cached output only when the
sidecar matches the current config.yaml. Switching models in
config.yaml therefore invalidates just the affected step rather than
silently reusing stale data.
Legacy artifacts produced before the sidecar was added are accepted on first read (the sidecar is backfilled with the current expected meta); afterwards, normal mismatch invalidation applies.
make audit runs pip-audit against the pinned requirement files
(requirements-api.txt, requirements-dev.txt, requirements-ui.txt)
through scripts/audit_gate.py. It exits non-zero if any package version
has a known CVE in the PyPI advisory database that
.github/pip-audit-allowlist.toml does not accept. The dependency-audit
workflow runs the same gate on every push and PR, and every Tuesday. The
weekly run also fails when PyPI has a release newer than the version an
allowlist entry covers. GitHub tells whoever last changed the cron
line about a failed scheduled run (or whoever re-enabled the workflow,
if that happened later), and only if that person has GitHub Actions
notifications turned on. It also turns the schedule off after 60 days
without repository activity; turn it back on from the Actions tab.
The heavy ingestion deps (torch, whisperx, etc.) are intentionally
excluded — their advisories are noisy and the worker is not directly
internet-facing in our deployment shape. To audit them too, override the
file list:
make audit AUDIT_FILES="requirements-api.txt requirements-dev.txt requirements-ui.txt requirements-ingestion.txt"When a CVE fires, fix it by bumping the affected package in the
requirements file, re-running make audit to confirm clean, and
re-running make bench-check to confirm no perf regression. If no
release fixes it yet, add an allowlist entry with the advisory id, its
aliases, the exact package version and a reason. An entry covers only
that version, so the gate fails again after a bump and asks for the entry
to be deleted or moved. When OSV records no fix for the new version, the
message starts with UNCONFIRMED: check the release notes before
deleting the entry.
Every Monday Dependabot (.github/dependabot.yml) opens one "chroma" PR
when there is something to bump. It updates the chromadb client in the
requirements files and the chromadb/chroma server image in
docker-compose.yml and k8s/chroma.yaml, so compose-smoke runs the
new client and server together. When the PR moves the client, its
dependency-audit check fails until the chromadb allowlist entries are
deleted or moved. Follow the CI message, push that change to the
Dependabot branch, and merge once every check is green.
Unit suite (no external services):
make testIntegration suite (requires a running compose stack — make compose-up first):
make test-integration
# or override the host explicitly:
CHROMA_HOST=127.0.0.1 CHROMA_PORT=8000 make test-integrationIntegration tests in tests/integration/ skip themselves when Chroma is
unreachable, so the same files work locally and in the compose-smoke CI
job.
Benchmark suite (deterministic in-process; no external services):
make bench-smoke # 10% iterations, fast sanity check
make bench # full suite
make bench-baseline # write benchmarks/reports/baseline.json
make bench-check # compare current code vs baseline; fails on >10% regression