For years the fastest way to caption a video was to click a button inside the same application you edited in. That convenience carried a structural cost. Your transcript lived inside a project file, bound to a seat licence, shaped by whatever export formats the vendor happened to support. The moment you wanted to search across five years of interviews, push captions into a localization pipeline, or let a downstream model read your footage descriptions, you hit a wall.
That wall is the reason more teams now treat transcription as a data problem rather than an editing feature. The work splits into three parts that can evolve independently: storage for the media, a relational store for the text and its metadata, and a speech model that converts audio into words. Postgres and Supabase fit the middle layer unusually well, because the middle layer is exactly where search, permissions, and workflow state collide.
A useful way to frame the decision: an editor is optimized for one human working on one timeline, while a database is optimized for many processes reading and writing the same structured facts. Transcription output is structured facts — segments, speakers, timings, confidence scores, language tags, revision history. Once you accept that framing, the architecture questions get much sharper than "which editor has the better caption button."
The stack that most teams converge on has four layers, and only one of them is a database.
The important design property is that each layer can be replaced without rewriting the others. If a new speech model beats your current one on Japanese diarization, you change one adapter. If object storage costs spike, you move buckets. The database schema, which is the expensive thing to change, stays stable because it describes the shape of transcription data rather than any particular vendor's output format.
This is also where the cost story becomes legible. Subscription suites bundle storage, compute, and seats into one line item, which makes it impossible to tell whether you are paying for transcription, version history, or simply the privilege of logging in. Separating the layers turns an opaque bill into three measurable ones.
A schema that survives contact with real footage starts with a small number of well-normalized tables and resists the temptation to dump an entire JSON response into a single column.
create table assets (
id uuid primary key default gen_random_uuid(),
owner_id uuid not null references auth.users(id),
storage_path text not null,
duration_seconds numeric,
language text,
created_at timestamptz default now()
);
create table transcripts (
id uuid primary key default gen_random_uuid(),
asset_id uuid not null references assets(id) on delete cascade,
revision int not null default 1,
model_name text not null,
is_current boolean not null default true,
full_text text not null,
search tsvector generated always as (to_tsvector('simple', coalesce(full_text,''))) stored,
created_at timestamptz default now(),
unique (asset_id, revision)
);
create table segments (
id bigserial primary key,
transcript_id uuid not null references transcripts(id) on delete cascade,
start_ms int not null,
end_ms int not null,
speaker text,
text text not null,
confidence numeric
);
The assets table deliberately does not know anything about transcription. It describes media. A single asset can accumulate many transcripts across revisions, languages, or model vendors — which is precisely the flexibility a project-file approach cannot offer.
Storing the whole transcript as a single text field is fine for display and terrible for everything else. Segment rows give you three capabilities that pay for themselves immediately: precise player synchronization, per-speaker analytics, and the ability to re-run a model on a single noisy region without reprocessing an hour of audio.
Keep both. The full_text column exists for search ranking and fast reads, while segments carries the structure. A common mistake is to store only segments and rebuild the text on every query, which turns a simple title lookup into an aggregation over thousands of rows.
Always store which model produced a transcript, when, and with what settings. Model quality changes between releases, and a caption that looked correct six months ago may have been generated by a version with known diarization bugs. A model_name column plus an immutable revision number lets you compare outputs side by side and roll back a bad batch without deleting history.
The is_current flag is a cheap alternative to a full temporal table. When a new revision lands, a single transaction flips the old flag and inserts the new row. Readers query where is_current and never see inconsistent state.
The integration pattern matters more than the provider. Three concerns dominate: getting audio to the model safely, getting results back reliably, and surviving the inevitable duplicate delivery.
Never route large media files through your application server just to hand them to a speech API. Generate a signed upload URL, let the client or worker write directly to object storage, then insert a row that records the path, checksum, and expected duration. The database learns about the file only after it exists, which keeps transactions short and keeps your function execution time low.
Once the row exists, a trigger or an edge function enqueues a transcription job. The trigger approach is elegant because it means no code path can create an asset and forget to schedule its processing.
If your speech provider supports webhooks, prefer them over polling. A webhook arrives with a job identifier, and your handler does three things: validate the signature, look up the job row, and either store the result or record the failure. Polling works but wastes function invocations and delays completion for short clips.
When the provider only supports polling, wrap the poll in a scheduled function with a short lease. Store last_polled_at on the job row so overlapping schedules do not hammer the API for the same job.
The single most common production bug in this stack is a duplicated transcript caused by a retried webhook. Solve it structurally: give every job a unique provider-side identifier and add a unique constraint on (provider, provider_job_id) in the transcripts table. The second insert fails cleanly, and your handler can treat the conflict as success.
Retries should be bounded and observable. Track attempt_count and last_error on the job row, and move anything past a threshold into a dead-letter state rather than retrying forever. A job stuck in retry loops is invisible until someone asks why a specific video has no captions.
Once transcripts live in a database, search becomes the feature that justifies the whole migration. You rarely need one search mode; you need three layered together.
A generated tsvector column with a GIN index gives you fast keyword matching with stemming and phrase support. For mixed-language libraries, choose your text search configuration deliberately. The simple configuration avoids incorrect stemming in languages it does not understand, while dedicated configurations give better recall for English, Spanish, French, German, Italian, Portuguese, or Polish content.
create index transcripts_search_idx on transcripts using gin (search);
select asset_id, ts_rank(search, websearch_to_tsquery('simple', 'budget forecast')) as rank
from transcripts
where is_current and search @@ websearch_to_tsquery('simple', 'budget forecast')
order by rank desc
limit 20;
websearch_to_tsquery is the right default for user-facing search because it accepts quoted phrases and minus operators without throwing syntax errors on stray punctuation.
Keyword search fails on paraphrases. A viewer searching for "the part where they argue about shipping delays" will not match a transcript that says "logistics slipped again." Storing a vector embedding per segment — or per transcript chunk — and querying with cosine distance closes that gap.
Keep embeddings in a separate table keyed to the segment, not in the transcript row. Embeddings are large, they change when you upgrade an embedding model, and mixing them into the hot text table hurts every query that does not need them.
In practice, hybrid retrieval outperforms either method alone. Run both queries, normalize the scores, and combine them with a weighted sum, then apply business filters such as recency, speaker, or project. A practical starting ratio is sixty percent semantic, forty percent keyword, adjusted after you look at real failed searches.
You do not need a separate message broker for a transcription pipeline. A well-designed job table handles thousands of jobs per hour and gives you something a broker usually does not: queryable history.
with next_job as (
select id from jobs
where status = 'queued' and run_after <= now()
order by priority desc, created_at
limit 1
for update skip locked
)
update jobs set status = 'running', started_at = now(), attempt_count = attempt_count + 1
from next_job where jobs.id = next_job.id
returning jobs.*;
FOR UPDATE SKIP LOCKED lets multiple workers pull jobs concurrently without blocking each other or double-claiming work. It is the single most important line in this section.
Give jobs a priority integer, a run_after timestamp for backoff, and a lease_expires_at column. If a worker dies mid-job, a reaper query can return expired leases to the queue. Without a lease, crashed jobs sit in running forever and nobody notices.
Separate short clips from long recordings. A four-hour webinar should not block a thirty-second social cut. Either use two queues or weight priority by expected duration.
Store model names, language hints, diarization flags, and prompt templates in a table rather than in environment variables. Configuration changes then become auditable rows, and you can roll out a new model to a percentage of traffic by filtering on asset tags. This is also the cleanest place to record which settings produced which output, which makes debugging quality regressions far easier.
Transcription data is often more sensitive than the video itself. An on-camera conversation that nobody scrubs through becomes fully searchable text the moment it is transcribed, which changes the risk profile completely.
Define policies on every table that holds text. A typical rule grants access when the caller owns the asset, or belongs to a team that has been granted access to the project. Keep policies simple and indexed; complex policy joins on hot tables are a common cause of slow queries.
Test policies with a dedicated set of integration tests that assume the role of a non-owner. Permissions bugs are silent, and a leaked transcript is not something you can quietly fix later.
If your content includes personal data, the region where audio and text are stored may matter more than any feature comparison. Choose a database region deliberately, keep derived artifacts in the same jurisdiction as the source media, and document which speech providers receive data and under what terms. This is a design decision, not a procurement afterthought.
Deletion must cascade. When an asset is removed, the transcript, segments, embeddings, and derived exports all go with it, and object storage objects are removed by a background task that reconciles the bucket against the database. Keep a deletion log with timestamps so you can answer the question "was this really removed?" without guessing.
Three levers control most of the bill.
Partition the segments table by month once it passes tens of millions of rows. Before that, partitioning adds complexity without much benefit. Watch query plans on the transcript list endpoint, since that is where N+1 patterns and missing composite indexes usually show up first.
One more decision deserves attention: when to keep transcription in-house versus when to outsource it. Keep it in-house when the schema, search relevance, and permission model are your differentiators. Outsource the acoustic model itself when a specialist clearly wins on accuracy for your languages, and treat the provider as a swappable adapter behind your own interface.
Each of these is cheap to prevent at schema design time and expensive to retrofit once you have a library of thousands of assets.
Do I need a separate message broker for transcription jobs?
For most workloads, no. A job table with FOR UPDATE SKIP LOCKED handles concurrency well and keeps job history queryable. Move to a dedicated broker only when you need fan-out to many consumers or throughput beyond what your database comfortably sustains.
How large should transcript chunks be for embeddings?
Roughly one to three sentences, aligned to segment boundaries. Chunks that split mid-sentence produce noisy matches, and chunks that span several minutes dilute the signal until similarity search stops being useful.
Should I store vectors in Postgres or a dedicated vector database?
Start in Postgres. Keeping vectors next to your permission rules and metadata avoids a second source of truth and lets filters run in the same query. Revisit that choice when your embedding table outgrows your working set by a wide margin.
What is the minimum viable schema?
Four tables: assets, jobs, transcripts, and segments. Everything else — speakers, projects, embeddings, exports — can be added incrementally without rewriting what already works.
How do I handle corrections made by humans?
Never overwrite machine output. Insert a new revision marked as human-edited, flip is_current, and keep the original for comparison and for measuring model accuracy over time.
How do I know the migration was worth it?
Measure three things before and after: time to find a specific spoken phrase across the library, cost per hour of processed media, and the number of integration points that break when you change tools. If all three improve, the architecture is doing its job.

Alexander
Vidu Q4 alternatives compared: Kling 4.0, Seedance 2.5, Wan 3.0, and Runway Gen-4 — 4K support, input modes, durations, audio, and what each costs in credits on Domer.
The top 4K AI image generators on Domer compared: Flux 3 Klein, Nano Banana 2.1, GPT Image 2.5, GPT Image 2, and Nano Banana Pro — 4K credit costs, reference limits, and speed.
Nano Banana 2.1 vs GPT Image 2.5 Flare compared: credit costs per resolution, aspect ratios, reference images, text rendering, and which tasks fit each model.