我的 RAG 分塊時間戳是 00:01:25,但首句實際在 00:00:56 説出
My RAG Chunk Was Timestamped 00:01:25. Its First Words Were Spoken at 00:00:56
一名 Apify Actor 開發者發現,啟用重疊分塊後,RAG 分塊的時間戳和字數仍按未加入重複內容的正文計算,導致時間戳晚於引文實際説出的時間。修正方法是先計算重疊引文,再設定分塊起始時間和字數,並加入 overlapChars 和 bodyStart 區分重複內容與新正文。程式亦調整重疊段落的選取方式,盡量在句子結尾切分,並限制向前回溯範圍。
I run a visa agency in Bali. The other half of my week goes into Apify Actors, and one of them does something very boring and very load-bearing: it takes a recording — a podcast, a client call, a two-hour webinar — and returns text with the second each passage was spoken.
That last part is the only reason the thing exists. Anyone can get words out of audio; the annoying problem is going backwards — a line of text in front of you, and you need the minute in the file it came from, because you are citing it to a client or feeding it to a model that will be asked "where did you get that?"
So the Actor has a chunkForRag switch. It splits the transcript into retrieval-sized chunks, each one carrying the timecode where it starts, ready to embed. The input schema promises exactly that, in its own words: "Split the transcript into retrieval chunks that keep the timecode where each one starts."
On 25 October 2026 I switched on the second chunking option — overlap — and the promise stopped being true.
The run
A four-minute public-domain recording, chunks of 1,200 characters, 30 seconds of overlap:
{
"urls": ["https://raw.githubusercontent.com/lergassy/apify-actor-assets/main/audio-samples/conversation-4min.ogg"],
"quality": "fast",
"includeSegments": false,
"chunkForRag": true,
"chunkSize": 1200,
"chunkOverlapSeconds": 30
}
Three chunks came back. Here are two of them, trimmed to what matters:
| chunk | start |
startTimecode |
charCount |
actual len(text)
|
|---|---|---|---|---|
| 0 | 0 | 00:00:00 | 1086 | 1086 |
| 1 | 85.98 | 00:01:25 | 1299 | 1631 |
| 2 | 192.02 | 00:03:12 | 512 | 804 |
Two numbers are wrong on both of the overlapping chunks, and they are wrong in the same direction: they describe the chunk the code meant to build, not the chunk it actually shipped.
Chunk 1 says it starts at 00:01:25. Its text starts like this:
"If I were young, I'd go myself in person. But as it is— You'll send your daughter? Yes, Minx, and a good dowry with her…"
That line is spoken at 56.98 seconds — 00:00:56. It is in the chunk on purpose: it is the 30-second lead-in I asked for, repeated from the tail of chunk 0 so that a question landing on the boundary still retrieves its context. Fine. But the chunk's own timecode skips it, points 29 seconds later, at the first word of the new text. Cite that chunk and you cite a moment the quote has not happened yet.
Chunk 2 does the same, 23.68 seconds late, and makes the failure mode unmistakable — its text begins with the word "out": "out of my doors. I'll promise you. My house shall no longer bear the scandalous name of a basset table…" Not a sentence. Half of one, sliced wherever the recognition engine happened to cut a segment.
Three bugs in one flush()
The chunker is forty lines. Every chunk is built in a closure called flush(), and here is the whole of it, as shipped:
start = current[0]['start']
last = current[-1]
end = round(last['start'] + (last['duration'] or 2), 2)
prefix = ''
if overlap_seconds and chunks:
previous = chunks[-1]
tail = [s for s in previous['_segments'] if s['start'] >= previous['end'] - overlap_seconds]
prefix = plain_text(tail)
chunks.append({
'start': start,
'startTimecode': _clock(start, False)[:-4],
'text': (f'{prefix} {text}'.strip() if prefix else text),
'charCount': len(text),
...
})
Read the order. start is computed on line 1, from current — the new segments. The prefix is computed on line 5, from the previous chunk. Nothing ever goes back and tells start that the text in front of it got longer at the left-hand end. Same for charCount: len(text), not len of what goes into the text field. The overlap was bolted on later, and it widened the payload without touching either of the two fields that describe the payload.
The third bug is in the tail line. The window is "every segment of the previous chunk that begins after end - overlap_seconds". But a recognition segment is cut on silence, not on grammar. A 30-second window lands wherever it lands, which in this file was the middle of the phrase "the scandalous name of a basset table". Half the point of chunking on sentence ends — which the code does, carefully, for the body — was thrown away at the seam.
None of the three raises anything. The run is SUCCEEDED, the dataset is full, the text is all there and in order. Only the metadata lies — and metadata that lies is worse than metadata that is missing: an embedding pipeline will happily store 00:01:25 next to words from 00:00:56, and nobody finds out until a human clicks the timestamp.
The fix
Compute the lead-in first, then describe what you actually built:
body_start = current[0]['start']
start = body_start
prefix = ''
if overlap_seconds and chunks:
tail = _overlap_tail(chunks[-1], overlap_seconds)
prefix = plain_text(tail)
if prefix and tail:
start = tail[0]['start']
full = f'{prefix} {text}'.strip() if prefix else text
charCount becomes len(full). Two fields join the chunk so the repeat can be told from the new material: overlapChars, how many leading characters are the repeat, and bodyStart, where the chunk's own text begins. Deduplicate on bodyStart, cite on startTimecode.
The seam gets its own helper. The window still opens at end - overlap_seconds, but it then walks backwards to the nearest segment that follows a sentence end, by at most the same number of seconds again:
_SENTENCE_END = re.compile(r'[.!?]["\')\]]?$')
index = next((i for i, s in enumerate(segments) if s['start'] >= cut), None)
limit = previous['end'] - 2 * overlap_seconds
while index > 0 and segments[index - 1]['start'] >= limit and not _SENTENCE_END.search(
(segments[index - 1].get('text') or '').strip()
):
index -= 1
The bound matters, and this file shows why. Ten of its 46 segments end with no terminal punctuation at all — one of them is 226 characters and 16.94 seconds of unbroken speech, because the fast model drops punctuation when a speaker does not pause. Without a limit, one run-on segment would drag the lead-in back half a minute past what was asked for. With it, a punctuation-free stretch can still open mid-clause; it just cannot run away.
Same input, two builds, forty minutes apart
Local replays prove arithmetic, not pipelines. So: the same JSON above, the old build and the new one, same test account.
| 0.1.32 (before) | 0.1.34 (after) | |
|---|---|---|
Chunk 1 startTimecode
|
00:01:25 | 00:00:56 |
Chunk 1 charCount / real length |
1299 / 1631 | 1631 / 1631 |
Chunk 2 startTimecode
|
00:03:12 | 00:02:25 |
| Chunk 2 first word |
out (mid-sentence) |
a sentence start |
Chunk 2 charCount / real length |
512 / 804 | 1077 / 1077 |
| Run cost | $0.038 | $0.038 |
Identical cost. Chunking is arithmetic over data the row already holds — no extra request, no billable event. Four started minutes of the fast model plus $0.002 for the file; durationSeconds came back as 240.01 and still billed as four minutes, not five, because the biller drops one grace second before rounding up. Encoders hand back 600.01 for a ten-minute file constantly, and nobody should pay an eleventh minute for a rounding artefact.
How late was the citation, per setting? Drift is start minus the moment the chunk's first word is actually spoken, on the old build:
chunkOverlapSeconds |
chunk 1 drift | chunk 2 drift |
|---|---|---|
| 0 | 0 s | 0 s |
| 10 | 10.00 s | 9.36 s |
| 20 | 14.00 s | 16.54 s |
| 30 | 29.00 s | 23.68 s |
| 60 | 57.44 s | 58.68 s |
Roughly the overlap you asked for, every time — which is the tell: the feature was wrong exactly in proportion to how much of it you used. A control run with chunkOverlapSeconds: 0 returns byte-identical chunks on both builds, so the default path never had the bug and nobody who left overlap alone was affected.
The Actor is Audio Transcriber — a file or a podcast link in, one row out with transcript, timed segments, optional speaker labels, SRT and these chunks. One call, results inline:
curl -X POST "https://api.apify.com/v2/acts/lergassy~audio-transcriber/run-sync-get-dataset-items?token=<TOKEN>" \
-H "Content-Type: application/json" \
-d '{"urls":["https://example.com/episode-12.mp3"],
"chunkForRag":true,"chunkSize":1200,"chunkOverlapSeconds":30,"includeSegments":false}'
When not to use any of this
If you need word-level citation — highlight the exact four words inside a 12-second passage — chunk timecodes are not enough and neither are segment ones. A segment is the smallest honest unit here; interpolating inside one is a guess. The subtitle writer in this same Actor does interpolate, proportionally by character count, which is fine for reading along and not fine for citation.
If your audio is one speaker reading prepared text, skip the overlap. It exists because an answer can straddle a boundary when speech is messy and topics drift mid-sentence — interviews, calls, arguments. Monologue breaks cleanly on sentence ends, and every overlapped character is one you pay to embed twice. And if you only want the words, ignore chunking: transcript is one string, it costs the same, and nothing about it can be 29 seconds late.
The lesson I would bill myself for: when you widen a payload, the fields that describe the payload are part of the payload. charCount was computed from the variable it was named after instead of from the thing that shipped, and that is the kind of bug that survives every test you would think to write — the data was right, the run was green, and only the label was lying.
來源:DEV Community · dev.to