Guides & How-tos 10 min read

Chunk Meeting Transcripts for RAG: Speaker Turns, Timestamps, and Context

Meeting transcripts have turns, not paragraphs. Here is how to chunk them by speaker turn, keep timestamps and context, and evaluate the result with retrieval metrics.

Cover reading AI build guide: Chunk meeting transcripts for RAG, with three building blocks: speaker-turn chunks, timestamp metadata and hybrid BM25 plus vectors.

Quick answer: Chunk meeting transcripts along speaker turns, not fixed character counts. Group whole turns into windows of roughly 150-300 words with one turn of overlap, prepend a short header (meeting title, date, speakers), and store start and end timestamps, speaker names and the meeting ID as metadata. Index with BM25 plus embeddings, then expand hits to neighbouring turns before generation.

Most chunking advice is written for documents: split on paragraphs, cap at N tokens, add some overlap. A meeting transcript has no paragraphs. It has turns, interruptions, half-finished sentences and a clock. If you chunk it like a PDF, retrieval returns fragments that lose who said what and when, and the answer generator cannot cite anything precise.

This article covers how to cut transcripts into retrievable units that keep speaker, time and context. It is the indexing half of the problem; the companion article, Give AI Answers Verifiable Citations from Meeting Transcripts, covers how to turn those chunks into answers whose sources a user can check. For the full pipeline around both, see Build RAG Over Meeting Transcripts and Documents.

Why is chunking meeting transcripts harder than chunking documents?

Retrieval-augmented generation, as described in the original RAG paper by Lewis et al., assumes a corpus of passages that each make sense on their own. Spoken meetings break that assumption in several ways:

  • Spoken language is spread out. One decision can take ten turns: a proposal, two objections, a compromise and an "okay, let's do that". A 100-word chunk may hold the objection but not the outcome.
  • Meaning depends on who is talking. "We can ship Friday" means something different from the engineering lead than from a sales rep repeating a rumour. Drop the speaker label and the chunk is ambiguous.
  • Crosstalk and ASR errors. Overlapping speech gets merged or split, product names get misheard, and filler words inflate length. Keyword search on "Kubernetes" will miss "cooper netties".
  • Speaker attribution can be wrong. Diarization assigns anonymous labels (speaker 1, speaker 2) that you later map to names. Errors there propagate into every chunk; the speaker diarization guide in this series covers how to reduce them.
  • Transcripts are long. The MeetingBank dataset of city council meetings illustrates the scale: hours of speech per meeting, far beyond what you want to paste into a prompt. And the lost-in-the-middle study found that models use information at the start or end of a long input better than information buried in the middle, so dumping a whole transcript into context is not a reliable substitute for retrieval.
  • Decisions change across meetings. A chunk from April saying "twelve percent discount" is still correct for April, but wrong as an answer to "what is our discount now?" Chunks need dates so later stages can prefer recent evidence.
  • Sensitive content. HR, legal or deal discussions often sit next to routine items in the same call. Chunk metadata must carry the meeting ID so permission filters can be applied at retrieval time.

What are the core concepts?

Speaker turn. A contiguous stretch of speech by one speaker, with start and end time. Turns are the natural atomic unit of a transcript, the way sentences are for prose. The QMSum dataset stores meetings exactly this way, as a list of speaker and content pairs.

Window. A group of consecutive whole turns that fits a word or token budget. Windows are what you embed and retrieve. Never split inside a turn unless the turn alone exceeds the budget (a long monologue), and then split on sentence boundaries.

Overlap. Repeating the last turn of one window at the start of the next, so a question and its answer are never separated by a chunk boundary. Overlap by turns, not by characters.

Context header. A short line prepended to the chunk text before embedding: meeting title, date and the speakers in the window. It makes the chunk self-describing for both the embedding model and BM25. Research points the same way: late chunking embeds the whole long text first and splits only before pooling, so each chunk vector carries surrounding context (code on GitHub), and the Dense X Retrieval paper found that small, self-contained "proposition" units retrieved better than passages or sentences. A header is the cheap version of both ideas.

Metadata. Fields stored next to the vector but not necessarily embedded: meeting ID, start and end timestamps, speaker names, date, and an access-control reference. Timestamps are what later let you cite "minute 31 of the pricing sync" rather than "somewhere in the April meetings".

Parent expansion. Retrieve on small windows, then hand the generator a larger span (the window plus one or two neighbouring turns on each side). Small units match queries precisely; larger spans give the model enough context to answer.

How should you chunk a transcript, step by step?

  1. Start from word-level timestamps and speaker labels. Run ASR with alignment and diarization. WhisperX adds word-level timestamps through forced alignment and speaker labels through pyannote.audio; its README notes that plain Whisper timestamps are per utterance and can be off by several seconds, which matters when you want a citation to land on the right moment.
  2. Merge segments into speaker turns. ASR segments break on pauses, not speakers. Concatenate consecutive segments with the same speaker label into one turn and keep the first start and last end time. Very short interjections ("yeah", "mm-hm") can be folded into the surrounding turn or dropped from the embedded text.
  3. Map speaker labels to names. Replace SPEAKER_00 with a real name using participant data from the meeting platform and calendar invite. Keep the original label in metadata so you can fix a wrong mapping later without re-chunking.
  4. Group whole turns into windows. Add turns until the next one would exceed your budget (start around 150-300 words and tune it, see the evaluation section), then start a new window that repeats the last turn. Keep each window under your embedding model's maximum input length; sentence-transformers exposes it as model.max_seq_length.
  5. Prepend a context header. Title, date and speakers. If you have an agenda or topic segmentation, add the topic name too.
  6. Index twice. Store embeddings in a vector index (pgvector if you already run Postgres, FAISS for an in-process index) and keep a lexical index (BM25) for names, numbers and jargon that embeddings blur.
  7. Retrieve, then expand. Run hybrid search, filter by the user's permitted meetings, and expand each hit to its neighbouring turns before building the prompt. Deduplicate overlapping windows from the same meeting.

Pipeline for chunking meeting transcripts: normalize the transcript into speaker turns, label speakers, group whole turns into windows, add a context header, index with vectors and BM25, then retrieve and expand to neighbouring turns.

The function below implements steps 4 and 5 in plain Python. It takes merged turns and returns chunks with a header and metadata:

def chunk_turns(meeting, turns, max_words=220, overlap_turns=1):
    """turns: dicts with speaker, start, end (seconds) and text, in order."""
    chunks, window = [], []

    def flush():
        if not window:
            return
        speakers = sorted({t["speaker"] for t in window})
        header = f'{meeting["title"]} ({meeting["date"]}). Speakers: {", ".join(speakers)}'
        body = "\n".join(f'{t["speaker"]}: {t["text"]}' for t in window)
        chunks.append({
            "id": f'{meeting["id"]}:{window[0]["start"]:.0f}',
            "text": header + "\n" + body,
            "meeting_id": meeting["id"],
            "start": window[0]["start"],
            "end": window[-1]["end"],
            "speakers": speakers,
        })

    for turn in turns:
        used = sum(len(t["text"].split()) for t in window)
        if window and used + len(turn["text"].split()) > max_words:
            flush()
            window = window[-overlap_turns:] if overlap_turns else []
        window.append(turn)
    flush()
    return chunks

Here is what one resulting chunk looks like for an invented example meeting. Note that the decision ("ten percent from June") and the objection it replaces sit in the same chunk, with names and times attached:

{
  "id": "mtg_0412:1835",
  "meeting_id": "mtg_0412",
  "start": 1835.2,
  "end": 1961.8,
  "speakers": ["Dana Okafor", "Luis Brandt"],
  "text": "Pricing sync (2026-04-12). Speakers: Dana Okafor, Luis Brandt\nLuis Brandt: So for the annual tier we keep twelve percent?\nDana Okafor: No, let's go to ten. Finance flagged margin.\nLuis Brandt: Okay, ten percent from June, I'll update the deck."
}

For retrieval, combine BM25 from rank_bm25 with dense vectors from sentence-transformers and merge the two rankings with reciprocal rank fusion. The model ID is a small general-purpose example; pick yours from the retrieval columns of the MTEB benchmark (MTEB on GitHub). The calls follow the libraries' READMEs:

from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer

texts = [c["text"] for c in chunks]
bm25 = BM25Okapi([t.lower().split() for t in texts])
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
doc_emb = model.encode(texts, normalize_embeddings=True)

def search(query, k=5, rrf_k=60):
    lexical = bm25.get_scores(query.lower().split()).tolist()
    q_emb = model.encode([query], normalize_embeddings=True)
    dense = model.similarity(q_emb, doc_emb)[0].tolist()
    fused = {}
    for scores in (lexical, dense):
        ranked = sorted(range(len(texts)), key=lambda i: scores[i], reverse=True)
        for rank, i in enumerate(ranked):
            fused[i] = fused.get(i, 0.0) + 1.0 / (rrf_k + rank + 1)
    best = sorted(fused, key=fused.get, reverse=True)[:k]
    return [chunks[i] for i in best]

In production you would replace the in-memory lists with pgvector or FAISS and apply the permission filter before ranking, but the chunk shape stays the same.

What about generic text splitters?

Framework splitters are a fine baseline. LangChain's RecursiveCharacterTextSplitter tries separators in order (paragraph breaks, line breaks, spaces, characters), and LlamaIndex's SentenceSplitter and other node parsers prefer sentence boundaries. If your transcript is formatted as one line per turn, a recursive splitter that splits on newlines already respects turns most of the time. What they do not do is carry turn-level timestamps and speaker lists into metadata, add a header, or overlap by whole turns, so you end up writing a thin layer like the function above anyway.

What are the trade-offs and pitfalls?

  • Chunk size. Small windows match specific questions ("who owns the migration?") but lose the outcome of a discussion. Large windows keep the outcome but dilute the embedding with unrelated chatter. Parent expansion lets you retrieve small and generate large.
  • Overlap inflates the index. One turn of overlap is usually enough. More than that creates near-duplicate hits that crowd out other meetings in the top-k.
  • Headers can dominate. If every chunk from a meeting starts with the same long title, the embeddings cluster by meeting rather than by content. Keep headers short.
  • Filler and disfluencies. Removing "um" and false starts from the embedded text helps matching, but keep the raw text for display and citation, so quotes match what was actually said.
  • Topic-based chunking. Splitting on agenda items or detected topic shifts gives cleaner units, but topic segmentation on spoken text is error-prone. Use topics as metadata and a soft boundary, not a hard rule.
  • Re-chunking cost. If you change the speaker-name mapping or window size, you have to re-embed. Store the merged turns separately so re-chunking does not require re-transcribing.
  • Stale facts. Chunking cannot solve decisions that change over time, but dates in metadata let the retriever or reranker prefer the latest evidence. The multi-meeting RAG article goes deeper.

How do you evaluate a chunking strategy?

Judge chunking by retrieval, not by how chunks look. Build a small test set of real questions about your meetings, and for each one mark the turns that contain the answer. Datasets like QMSum, which pairs queries with relevant transcript spans across 232 meetings, show the format. Then measure:

  • Recall@k: does any of the top-k chunks contain the gold turns?
  • MRR or nDCG@k: how high does the first relevant chunk rank?
  • Span coverage: does the retrieved text include the whole exchange (proposal and final decision), not just part of it?
  • Timestamp accuracy: does the chunk's start time land within a few seconds of where the answer is spoken?
  • Answer-level metrics: faithfulness and answer correctness once the generator runs on top.

Change one variable at a time (window size, overlap, header, embedding model) and rerun. The evaluation article in this series covers building the test set and the answer-level metrics. For questions about embedding benchmarks, the MTEB GitHub Discussions are active, and the sentence-transformers application examples cover semantic search and retrieve-and-rerank.

How does Acta approach this?

We do not publish Acta's internal chunk sizes, so this article describes general practice. What Acta's product does provide is the input this method relies on: transcripts with speaker names (taken from the meeting and the calendar invite) and timestamps. Transcription happens after the meeting, not live, so chunking runs on a complete transcript. On top of that, Ask Acta answers questions across the meetings you choose using retrieval-augmented generation over their transcripts, and its answers cite the meeting they came from.

Frequently asked questions

What chunk size should I use for meeting transcripts?

There is no universal number. Start with windows of whole speaker turns around 150-300 words with one turn of overlap, keep them under your embedding model's input limit, and tune against recall@k on your own question set.

Should I embed the speaker names?

Yes, in the text and in metadata. In the text they help queries like "what did Dana say about pricing?" match; in metadata they let you filter by speaker and show attribution in citations.

Is a long-context model a replacement for chunking?

Not for search across many meetings. Even a single long meeting can be large, and research on long contexts shows models use information in the middle of the input less reliably. Retrieval keeps prompts small and lets you cite exact moments.

Do I need timestamps if I only cite the meeting?

Store them anyway. They cost little, they let users jump to the moment, and they make the citation verification step described in the companion article far more precise.

Found this useful? Share it with your team.