Build a RAG pipeline from video transcripts
Most RAG pipelines cannot index audio or video directly. Here is the full path from a video URL to timestamped chunks in a vector database — with the chunking code, the PostgreSQL schema and the exact shape the transcription API returns.
Before a language model can search, summarize or cite a video, you need to:
- retrieve the media;
- extract the audio;
- transcribe it;
- preserve timestamps;
- split the transcript into useful chunks;
- generate embeddings;
- store the chunks in a vector database.
Techtuel handles the first three steps through one transcription API.
Send a YouTube URL, podcast URL, direct audio URL or video URL. Techtuel resolves the source and returns structured text with timestamped segments that you can send to PostgreSQL, pgvector, Qdrant, Weaviate, Pinecone or another search system.
Video URL
↓
Techtuel transcription API
↓
Timestamped transcript
↓
Chunking and metadata
↓
Embeddings
↓
Vector database
↓
RAG answer with source timestampsWhy RAG needs structured video transcripts
A plain block of text is usually not enough for a reliable RAG application.
A useful video ingestion pipeline should preserve:
- the source URL;
- the media title;
- the start time of each segment;
- the original transcript;
- the language;
- stable identifiers;
- enough context around every chunk.
Timestamps are especially important. They let your application return an answer with a link to the exact moment where the information appears.
For example:
{
"answer": "The speaker recommends separating ingestion from indexing.",
"sources": [
{
"title": "Building a production RAG pipeline",
"url": "https://www.youtube.com/watch?v=example&t=742s",
"start": 742
}
]
}Without timestamps, a RAG system can cite a two-hour video but cannot help the user verify the answer.
Transcribe a video URL with one API call
Send the source URL to POST /v1/transcribe:
curl https://api.techtuel.com/v1/transcribe \
-H "Authorization: Bearer $TECHTUEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_url": "https://www.youtube.com/watch?v=VIDEO_ID"
}'The source is resolved, transcribed, and the text comes back in the response — one call, no job id to keep around:
{
"id": "job_9f2c",
"status": "completed",
"title": "Building a production RAG pipeline",
"language": "en",
"minutes": 42.5,
"transcript": "Today we are going to build...",
"segments": [
{
"start_seconds": 0.0,
"text": "Today we are going to build a production RAG pipeline."
},
{
"start_seconds": 4.2,
"text": "We will start with ingestion, then chunking and retrieval."
}
]
}The request body accepts four fields only: source_url, audio_url, upload_id and preferred_language. The full text lives in transcript; there is no top-level text field.
Transcription cannot depend indefinitely on the lifetime of an HTTP request, so POST /v1/transcribe waits up to thirty seconds (tunable with ?wait=, up to 120). Past that window the response becomes 202 with {"id": "job_9f2c", "status": "processing"} — not an error, just the signal to fetch the transcript later from GET /v1/transcriptions/{id}. That is the normal path for long media, covered below.
Note the shape of a segment: start_seconds and text, nothing else. The API does not return an end time and does not return word-level timestamps. A segment implicitly runs until the next segment begins — which is all you need to reconstruct a time range, as long as you do it yourself rather than expecting the field. The transcription API with timestamps page documents that contract in full.
The same integration works for YouTube, podcast episodes, RSS sources, direct audio files and remote video files.
The job model, for long media and bulk ingestion
POST /v1/transcribe covers the common case: one source, one response. As soon as you ingest a two-hour conference talk, or a whole YouTube channel back catalogue in one pass, the right tool remains the asynchronous transcription API: POST /v1/transcriptions returns 202 immediately with a job id that your worker re-reads at its own pace.
curl https://api.techtuel.com/v1/transcriptions \
-H "Authorization: Bearer $TECHTUEL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"source_url": "https://www.youtube.com/watch?v=VIDEO_ID"
}'{
"id": "job_9f2c",
"status": "processing"
}You then poll the job until it reaches a terminal status:
curl https://api.techtuel.com/v1/transcriptions/job_9f2c \
-H "Authorization: Bearer $TECHTUEL_API_KEY"There are four statuses: processing, claimed, completed, failed. Only completed and failed are terminal — claimed simply means a worker picked the job up, so treat it exactly like processing. There are no webhooks: your worker re-reads its own jobs, which is one fewer public endpoint for you to secure.
const TERMINAL = new Set(['completed', 'failed'])
export async function waitForTranscription(id: string) {
for (;;) {
const response = await fetch(
`https://api.techtuel.com/v1/transcriptions/${id}`,
{ headers: { Authorization: `Bearer ${process.env.TECHTUEL_API_KEY}` } },
)
const job = await response.json()
// `claimed` is NOT terminal: a worker has just picked the job up.
if (TERMINAL.has(job.status)) return job
await new Promise((resolve) => setTimeout(resolve, 5_000))
}
}A 202 from POST /v1/transcribe is retrieved in exactly the same way: same job object, same endpoint.
Turn transcript segments into RAG documents
Do not necessarily create one vector per transcription segment.
Speech-to-text segments can be very short. For retrieval, it is often better to combine adjacent segments into chunks while preserving the original start time — and deriving the end time from the segment that follows.
A practical chunk can look like this:
{
"id": "video_123_chunk_17",
"content": "A production ingestion pipeline should keep the original source metadata...",
"metadata": {
"source_type": "youtube",
"source_url": "https://www.youtube.com/watch?v=VIDEO_ID",
"title": "Building a production RAG pipeline",
"start": 742.4,
"end": 811.7,
"language": "en"
}
}The end field here is yours, computed during chunking from the start of the next segment. It is not part of the API response — do not go looking for it in segments.
A simple chunking strategy is to:
- target 300 to 800 tokens per chunk;
- merge consecutive transcript segments;
- keep a small overlap between chunks;
- never discard the first timestamp of a chunk;
- store the source URL on every chunk;
- keep the unmodified transcript separately.
The ideal chunk size depends on your embedding model, your search strategy and the type of content.
TypeScript example: prepare transcript chunks
The API gives you start times only, so the chunker looks one segment ahead to close the previous chunk. The final chunk has no following segment, so its end is left open — or filled in from the job's minutes field, which the API does return once the length is known.
type Segment = {
start_seconds: number
text: string
}
type TranscriptChunk = {
content: string
start: number
/** Derived, not returned by the API: the start of the next segment.
* Undefined on the last chunk unless a total duration is supplied. */
end?: number
}
export function createTranscriptChunks(
segments: Segment[],
maxCharacters = 2_000,
totalSeconds?: number,
): TranscriptChunk[] {
const usable = segments.filter((segment) => segment.text.trim() !== '')
const chunks: TranscriptChunk[] = []
let current: TranscriptChunk | null = null
usable.forEach((segment, index) => {
const text = segment.text.trim()
// A segment runs until the next one starts. The last segment has no
// successor, so it stays open and is closed below from totalSeconds.
const next = usable[index + 1]
const segmentEnd = next?.start_seconds
if (!current) {
current = { content: text, start: segment.start_seconds, end: segmentEnd }
return
}
const mergedContent = `${current.content} ${text}`
if (mergedContent.length > maxCharacters) {
chunks.push(current)
current = { content: text, start: segment.start_seconds, end: segmentEnd }
return
}
current.content = mergedContent
current.end = segmentEnd
})
if (current) chunks.push(current)
// Close the trailing chunk with the job's billable length when it is known.
const last = chunks.at(-1)
if (last && last.end === undefined && totalSeconds !== undefined) {
last.end = totalSeconds
}
return chunks
}Call it with the job's minutes converted to seconds to close the tail:
const chunks = createTranscriptChunks(job.segments, 2_000, job.minutes * 60)This intentionally simple example groups adjacent segments and reconstructs their temporal boundaries.
For a production system, you can add:
- token-based limits;
- semantic splitting;
- sentence-boundary detection;
- speaker boundaries;
- chunk overlap;
- language-specific rules.
Store video transcripts in PostgreSQL and pgvector
A minimal schema can keep the media source and its searchable chunks separately.
Because the API returns start times only, end_seconds is a value your pipeline derives — so it is nullable: the last chunk of a media item legitimately has no successor to close it.
CREATE TABLE media_sources (
id UUID PRIMARY KEY,
external_id TEXT UNIQUE NOT NULL,
source_url TEXT NOT NULL,
title TEXT,
language TEXT,
transcript TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE transcript_chunks (
id UUID PRIMARY KEY,
media_source_id UUID NOT NULL REFERENCES media_sources(id) ON DELETE CASCADE,
content TEXT NOT NULL,
-- start_seconds comes straight from the API segment.
start_seconds DOUBLE PRECISION NOT NULL,
-- end_seconds is DERIVED from the next segment's start; NULL on the last
-- chunk, which has no successor to close it.
end_seconds DOUBLE PRECISION,
embedding VECTOR(1536),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Your retrieval layer can then perform:
- vector similarity search;
- PostgreSQL full-text search;
- hybrid retrieval;
- metadata filtering;
- reranking;
- timestamped source generation.
Generate links to the exact moment in a video
For YouTube, convert the start time to an integer and append it to the source URL:
export function createYouTubeTimestampUrl(
videoUrl: string,
startSeconds: number,
): string {
const url = new URL(videoUrl)
url.searchParams.set('t', `${Math.floor(startSeconds)}s`)
return url.toString()
}For your own audio or video player, pass the timestamp to the frontend and seek directly:
player.currentTime = source.start
await player.play()This turns a generated answer into a verifiable answer.
Use cases for a video RAG pipeline
- Search across a video library — let users search thousands of videos by meaning rather than filename or title.
- Ask questions about training material — internal courses, webinars, onboarding videos and recorded workshops.
- Build a research assistant — search interviews, podcasts, hearings and conferences, and return exact citations.
- Generate knowledge bases from YouTube channels — ingest a catalogue, store timestamped chunks and make the entire channel queryable.
- Create an editorial research tool — find statements, examples, names or themes across large collections of audio and video.
- Add AI search to a media platform — index every published media item and expose semantic search in your own product.
Avoid rebuilding the media ingestion layer
A transcription model alone does not solve media ingestion.
When you build the pipeline yourself, you may also need to manage:
- YouTube and webpage extraction;
- podcast feed parsing;
- remote file downloads;
- redirects and expiring URLs;
- audio extraction;
- temporary storage;
- media formats;
- retries and timeouts;
- transcription jobs;
- timestamp normalization;
- duplicate sources.
Techtuel provides one video transcription API for these sources and returns a consistent transcript structure.
For public sources already processed by the service, the integrated cache can also prevent unnecessary reprocessing.
Data hosting for European RAG applications
Techtuel is built and hosted in France.
Media processing runs on European infrastructure, uploaded files are deleted after processing, and the resulting transcript remains available through the API until you delete it.
This can simplify the architecture of European products that do not want to send their entire media ingestion pipeline through an American hyperscaler. A separate article on transcription hosted in Europe covers that hosting in detail, along with the questions worth asking any provider.
You remain responsible for defining your own retention policy, access controls and lawful basis for processing the source content.
From transcript to production RAG
A production-ready architecture usually contains five distinct parts:
-
Ingestion Submit the media URL to
POST /v1/transcribe, and track the job when the answer is a202— long media or catalogue backfills. -
Normalization Store the transcript, segments, source metadata and language.
-
Indexing Create chunks, embeddings and search indexes.
-
Retrieval Combine semantic search, keyword search and metadata filters.
-
Generation Ask the LLM to answer only from retrieved chunks and include timestamped citations.
Keeping these stages separate makes it easier to re-index your catalogue without retranscribing the original media.
Frequently asked questions
Can I build RAG directly from a YouTube URL?
Yes. Send the YouTube URL to POST /v1/transcribe, read the structured transcript straight out of the response, create chunks and store their embeddings in your vector database.
Does the API return timestamps?
Yes, start times. Each segment carries a start_seconds and its text. There is no end time and no word-level timing: a segment runs until the next one starts, so you derive ranges yourself. You can also retrieve SRT or VTT output with ?format=srt or ?format=vtt.
Can I process videos without existing captions?
Yes. For YouTube sources, Techtuel uses available captions first and falls back to audio transcription when captions are unavailable.
Which vector database should I use?
Techtuel is independent of the vector database. You can use pgvector, Qdrant, Weaviate, Pinecone, Elasticsearch or another retrieval system.
Should I embed the full transcript?
Usually not. Long transcripts should be divided into smaller, coherent chunks with source metadata and timestamps.
Can I re-index without retranscribing?
Yes. Store the full transcript and the original segments in your own database. You can then change your chunking or embedding strategy without transcribing the media again.
How do I know when a job is done?
Most of the time the question does not come up: POST /v1/transcribe returns the finished transcript directly in its 200 response.
When you get a 202, or when you went through POST /v1/transcriptions, poll GET /v1/transcriptions/{id}. Only completed and failed are terminal, the latter carrying an error message. A job may also show up as claimed, which means a worker picked it up — keep polling it exactly like processing. There is no webhook callback.
Start building
Use one API to turn video and audio URLs into timestamped text, then connect the result to your RAG pipeline.