04. Data model¶
Time model¶
Every timestamp in the index is a Timestamp { num: i64, den: u32 } rational in the source Track's timebase, plus a derived f64 seconds column for indexing and display. Rationals avoid drift over hour-long videos with odd frame rates (29.97, 23.976). Wall-clock time, when known from container metadata, is stored once per Video as start_wallclock so events can be placed on a calendar without touching every row.
Ranges are half-open: [t0, t1).
Entities¶
erDiagram
VIDEO ||--o{ TRACK : has
VIDEO ||--o{ SEGMENT : has
SEGMENT ||--o{ SEGMENT : contains
TRACK ||--o{ FRAME_SAMPLE : yields
TRACK ||--o{ TRANSCRIPT_SPAN : yields
FRAME_SAMPLE ||--o{ OCR_SPAN : yields
SEGMENT ||--o{ DESCRIPTION : described_by
FRAME_SAMPLE ||--o{ DESCRIPTION : described_by
VIDEO ||--o{ ENTITY : mentions
ENTITY ||--o{ ENTITY_MENTION : at
VIDEO ||--o{ EVENT : has
DESCRIPTION ||--o{ EMBEDDING : embedded_as
TRANSCRIPT_SPAN ||--o{ EMBEDDING : embedded_as
OCR_SPAN ||--o{ EMBEDDING : embedded_as
FRAME_SAMPLE ||--o{ EMBEDDING : embedded_as
PROVENANCE ||--o{ DESCRIPTION : produced
PROVENANCE ||--o{ TRANSCRIPT_SPAN : produced
PROVENANCE ||--o{ OCR_SPAN : produced
PROVENANCE ||--o{ SEGMENT : produced
PROVENANCE ||--o{ EMBEDDING : produced
Video¶
| Field | Type | Notes |
|---|---|---|
| id | ULID | Stable across re-indexing of the same content |
| source_uri | text | Original location |
| content_hash | blake3 | Of the media file; drives caching and dedup |
| title, description, channel, published_at | text/ts | From container or yt-dlp metadata |
| duration | Timestamp | |
| start_wallclock | ts, nullable | |
| probe | JSON | ffprobe-equivalent output |
| index_state | enum | acquired, coarse, fine, failed |
Track¶
id, video_id, kind (video|audio|subtitle), stream_index, codec, timebase, width, height, fps, sample_rate, channels, language.
Segment¶
The retrieval unit. A hierarchy: shot (visual cut boundaries), scene (grouped shots, typically 20 s to 3 min), chapter (from chapters metadata, slide titles, or topic shifts, typically minutes). Every level spans the whole video with no gaps.
id, video_id, level, parent_id, t0, t1, keyframe_sample_id, title (nullable), summary (nullable), provenance_id.
FrameSample¶
id, track_id, t, pts, is_keyframe, phash (u64), thumbnail_blob (nullable), width, height. Only sampled frames exist here. Full frames are never stored; they are re-decoded on demand.
TranscriptSpan¶
id, track_id, t0, t1, text, speaker (nullable), language, confidence, words (JSON, word-level timings when available), provenance_id.
OcrSpan¶
id, frame_sample_id, t, text, bbox (x, y, w, h normalized), confidence, provenance_id. Consecutive identical OCR text across frames is collapsed into a range on a derived view, not duplicated in storage.
Description¶
Text a VLM produced about a Segment or a FrameSample. id, target_kind (segment|frame), target_id, kind (caption|summary|qa|structured), text, structured (JSON, nullable), provenance_id. Several Descriptions can exist for the same target from different providers, which is what enables A/B evaluation on one index.
Entity and EntityMention¶
Entity: id, video_id, kind (person|object|text|place|concept), name, canonical_name, attributes (JSON).
EntityMention: entity_id, t0, t1, source_kind (transcript|ocr|description), source_id, confidence.
Event¶
id, video_id, t0, t1, text, participants (entity ids), provenance_id. Extracted from Descriptions and transcript by an LLM operator.
Embedding¶
id, target_kind, target_id, model, dim, vector. Stored in the vector store, referenced from SQLite by (target_kind, target_id, model). Multiple models can coexist.
Provenance¶
id, operator, operator_version, provider, model, model_version, prompt_hash, params (JSON), created_at, cost_usd, tokens_in, tokens_out, latency_ms. Every derived row points at exactly one Provenance. Cost roll-ups per video and per configuration are a single aggregate over this table.
Storage trait¶
#[async_trait]
pub trait Storage: Send + Sync {
// metadata
async fn put_video(&self, v: &Video) -> Result<()>; // upsert; VideoId is stable per content hash
async fn get_video(&self, id: VideoId) -> Result<Option<Video>>;
async fn find_video_by_hash(&self, content_hash: &str) -> Result<Option<Video>>;
async fn list_videos(&self) -> Result<Vec<Video>>;
async fn set_index_state(&self, id: VideoId, state: IndexState) -> Result<()>;
async fn put_tracks(&self, t: &[Track]) -> Result<()>;
async fn tracks(&self, video: VideoId) -> Result<Vec<Track>>;
async fn put_segments(&self, s: &[Segment]) -> Result<()>;
async fn put_frame_samples(&self, s: &[FrameSample]) -> Result<()>;
async fn update_frame_phash(&self, updates: &[(FrameSampleId, u64)]) -> Result<()>;
async fn update_frame_thumbnail(&self, updates: &[(FrameSampleId, BlobKey)]) -> Result<()>;
async fn delete_frame_samples(&self, track: TrackId) -> Result<u64>;
async fn frame_samples(&self, track: TrackId, range: Option<TimeRange>) -> Result<Vec<FrameSample>>;
async fn put_spans(&self, s: &[Span]) -> Result<()>; // transcript + ocr
async fn delete_spans_by_operator(&self, track: TrackId, operator: &str) -> Result<u64>; // re-runs replace their own output
async fn spans_by_operator(&self, video: VideoId, operator: &str) -> Result<Vec<Span>>; // replay of cached stages
async fn put_descriptions(&self, d: &[Description]) -> Result<()>;
async fn put_embeddings(&self, e: &[Embedding]) -> Result<()>;
async fn put_provenance(&self, p: &Provenance) -> Result<ProvenanceId>;
async fn get_embeddings(&self, model: &str, targets: &[(TargetKind, String)]) -> Result<Vec<Option<Vec<f32>>>>;
async fn descriptions(&self, video: VideoId) -> Result<Vec<Description>>;
async fn put_entities(&self, e: &[Entity], m: &[EntityMention]) -> Result<()>;
async fn entities(&self, video: VideoId) -> Result<Vec<Entity>>;
async fn put_events(&self, e: &[Event]) -> Result<()>;
async fn events(&self, video: VideoId) -> Result<Vec<Event>>;
async fn delete_extractions(&self, video: VideoId) -> Result<u64>;
// search
async fn text_search(&self, q: &TextQuery) -> Result<Vec<Hit>>; // BM25 / FTS
async fn vector_search(&self, q: &VectorQuery) -> Result<Vec<Hit>>;
async fn time_window(&self, video: VideoId, t0: Ts, t1: Ts, kinds: &[Kind]) -> Result<Window>;
// blobs
async fn put_blob(&self, key: &BlobKey, bytes: Bytes) -> Result<()>;
async fn get_blob(&self, key: &BlobKey) -> Result<Option<Bytes>>;
// sessions (agent conversations, TTL)
async fn put_session(&self, id: &str, state: &serde_json::Value, ttl_secs: u64) -> Result<()>;
async fn get_session(&self, id: &str) -> Result<Option<serde_json::Value>>;
// jobs
async fn checkpoint(&self, job: JobId, state: &JobState) -> Result<()>;
async fn load_checkpoint(&self, job: JobId) -> Result<Option<JobState>>;
async fn list_jobs(&self) -> Result<Vec<JobState>>;
// maintenance
async fn manifest(&self) -> Result<Manifest>;
async fn stats(&self) -> Result<IndexStats>; // sizes and per-video counts for `vidx status`
async fn compact(&self) -> Result<()>; // VACUUM + refresh manifest hashes
}
Implementations:
| Backend | Metadata + FTS | Vectors | Blobs | Use |
|---|---|---|---|---|
| Embedded (default) | SQLite + FTS5 | Flat memory-mapped files per model (exact search); Lance or usearch later for approximate search | Files under blobs/ |
Local SDK, CLI, single-node server |
| Postgres | Postgres + tsvector | pgvector | S3/GCS/R2 | Hosted, multi-tenant |
| Qdrant | Postgres or SQLite | Qdrant | S3/GCS/R2 | Hosted at larger scale |
Only the embedded backend ships in v1. The trait exists from day one so the pipeline and query layers never touch SQLite directly. The trait is implemented in vi-index; the record types live in vi-core::model so vi-media and vi-pipeline share them without depending on the storage crate.
Writes to parent tables (videos, tracks, frame samples, segments) are upserts. INSERT OR REPLACE would delete and re-insert the row and the ON DELETE CASCADE constraints would silently drop every child row (see the decisions log in vi_internal).
Embedded index directory layout¶
myindex.vidx/
manifest.json schema_version, created_by, index ids, content hashes, optional signature
meta.sqlite all tables above, FTS5 virtual tables for spans and descriptions
vectors/
<model-name>.vec normalised f32 rows, append-only, memory-mapped for exact search
<model-name>.meta per row: embedding id, video id, target kind, alive flag
blobs/
ab/cd/abcdef... content-addressed: thumbnails (WebP), audio chunks (Opus), frame grids
cache/
operators/ operator output cache keyed by (content_hash, operator, version, provider, model, prompt_hash)
jobs/
<job-id>.json checkpoints
Properties:
- Copy the directory anywhere and open it. No absolute paths inside.
- manifest.json is the only file a reader must parse before deciding whether it can open the index. Unknown newer schema versions are refused with a clear error; older ones are migrated in place with a backup.
- cache/ and blobs/thumbnails are safe to delete; they regenerate on demand from the source media if it is still reachable.
- Sizes: roughly 30 to 80 MB per hour of video, dominated by thumbnails and embeddings. Tunable by thumbnail resolution and sampling rate.
Full-text and vector search details¶
- FTS5 with the
unicode61tokenizer and prefix indexes for spans and descriptions. BM25 ranking. Tantivy is an alternative if FTS5 ranking quality proves limiting; the trait hides the choice. - Vectors live in flat per-model files searched exactly (brute force, parallel); the
embeddingstable maps each row back to its target and the.metafile carries the video id and target kind so filters apply before scoring. An approximate index (Lance IVF-PQ or usearch HNSW) can replace the file behind the trait when tables grow past a few million rows. - Temporal fusion, described in 06-query-and-agents, happens above the storage layer.
Schema versioning¶
The schema version is a single integer in manifest.json and in a schema_meta table. Migrations are Rust functions registered in order. Every migration is tested against fixture indexes produced by the previous version.