The Nightly Job That Took 41 Minutes
A nightly job re-embeds the document library: 50,000 files, one API call each. It finished in 41 minutes on the first run and started returning 429s on the third. The loop is correct and the design is wrong — every request pays round-trip latency, connection setup, and a slot in the provider's per-minute quota, and none of that buys anything.
Batching sends many strings per request:
from openai import OpenAI
client = OpenAI()
MODEL = 'text-embedding-3-small'
def chunks(items, n):
for i in range(0, len(items), n):
yield items[i:i + n]
def embed_batched(docs, n=100):
out = []
for batch in chunks(docs, n):
resp = client.embeddings.create(model=MODEL, input=batch)
out.extend(d.embedding for d in resp.data) # order mirrors the batch
return out
Input: 50,000 documents with n=100.
Output: 500 requests instead of 50,000, returning the same vectors in the same order.
| 1 string per call | 100 strings per call | |
|---|---|---|
| requests for 50,000 docs | 50,000 | 500 |
| quota slots consumed | 50,000 | 500 |
| failure blast radius | one doc breaks one call | one doc can break a batch |
out.extend(d.embedding for d in resp.data) is the line to defend: response entries mirror the batch, so position is the only link between a vector and its document — the same contract described in Embedding Models Explained.
Two caps decide batch size, and only one of them counts items. Providers limit entries per request and total tokens per request. A hundred short FAQ lines fit; a hundred whole documents do not, and the rejection comes back as a 400 against the entire batch. When document lengths vary, size batches by cumulative tokens rather than by count.
A workable sizing rule: take the provider's tokens-per-request limit, divide by the 95th percentile of your document token lengths, and use that as the item cap. It is approximate — a batch of short documents under-uses the request — but it keeps every request under the cap without measuring each document individually. Re-derive the cap after any chunking change, since chunking sets the length distribution the cap depends on.
Retry Only What Failed
Re-running the corpus after a transient failure re-embeds 49,900 documents that already succeeded — the expensive half of the job paid twice, to recover from a two-second throttle. Failures need an owner at each level instead.
import random, time
from openai import APIConnectionError, APIStatusError, RateLimitError
def embed_with_retry(batch, attempts=4):
for attempt in range(attempts):
try:
return list(client.embeddings.create(model=MODEL, input=batch).data)
except RateLimitError:
time.sleep(2 ** attempt + random.random()) # full jitter; quota refills
except APIConnectionError:
time.sleep(2 ** attempt + random.random()) # socket died mid-flight
except APIStatusError as err:
if err.status_code and err.status_code >= 500:
time.sleep(2 ** attempt + random.random())
else:
raise # 400/401: the payload is wrong, retrying repeats it
return None # caller marks only this batch as pending
def embed_corpus(docs, n=100):
done, failed = [], []
for batch in chunks(docs, n):
data = embed_with_retry(batch)
if data is None:
failed.extend(batch) # 100 documents, not 50,000
else:
done.extend(zip(batch, (d.embedding for d in data)))
return done, failed
Input: 50,000 documents where one batch of 100 meets a sustained throttle.
Output: 49900 embedded, 100 pending — the retry queue holds the failed slice, and the next run sends only those 100.
Three lines carry the policy:
return Noneinstead of raising. A batch that exhausts its attempts is data for the caller, not an exception that kills a backfill halfway through with 49,900 vectors already computed.- The bare
raiseon 4xx. A 400 means one document in the batch is malformed or over the token cap; three identical requests will fail identically, so the batch gets bisected to find the bad input instead of burning attempts on it. - The jitter term. Workers that slept the same duration wake together and recreate the 429 that throttled them;
random.random()spreads the wake-ups across the window.
Re-embed Only What Changed
Batching and retries still re-embed text that never changed. On a library where three documents were edited yesterday, the third run should cost three documents, not fifty thousand. Store a fingerprint beside each vector and the run becomes arithmetic:
import hashlib
def fingerprint(text):
return hashlib.sha256(text.encode('utf-8')).hexdigest()
def plan(docs, store):
to_embed, reused = [], []
for doc in docs:
if store.get(doc.id, {}).get('hash') == fingerprint(doc.text):
reused.append(doc.id) # unchanged: keep the stored vector
else:
to_embed.append(doc) # new, edited, or never seen
return to_embed, reused
Input: 10,000 documents against a store holding 9,963 matching fingerprints.
Output: embed 37, reuse 9963 — 99.6% of the corpus costs no API call.
The job as a whole:
documents ──► fingerprint ──┬── hash matches stored ──► keep vector, no call
│
└── new or changed ──► token-sized batches
│
retry + jitter
│
┌───────────┴───────────┐
▼ ▼
upsert(id, hash, vector) failed batch ─►
pending queue
The upsert writes id, hash, and vector in one operation. Split them across two writes and a crash in between leaves a fresh hash pointing at an old vector — after which the skip logic reports a document as current when its index entry no longer matches its text.
Rate limits then shape the schedule rather than the code. Issuing two million chunks at full concurrency produces a 429 storm that no retry policy can outwait. Pace the job under the provider's published requests-per-minute and tokens-per-minute limits, run backfills off-peak, and chart the 429 rate against throughput — a rising rate at flat throughput is throttling, not an outage.
Quiz
1. Embedding 50,000 documents in batches of 100 instead of one call per document primarily reduces what?
2. One batch of 100 documents returns 429 on every attempt for 30 seconds. What should the pipeline do with those 100 documents?
3. A batch still returns 400 after the retry budget is exhausted. What does that usually mean?
4. Why store a content hash alongside each vector?
Flashcards
Question
What two limits decide embedding batch size?
Click to reveal answer
Answer
Entries per request and total tokens per request. The item count is easy to see; the token cap is what rejects a batch of 100 long documents with a 400.
Question
How should a failed batch be recovered?
Click to reveal answer
Answer
Keep the succeeded batches, mark only the failed slice pending, and re-send that slice after backoff. Bisect batches that fail with 4xx to find the single bad input.
Question
Why add jitter to retry sleeps?
Click to reveal answer
Answer
Workers that slept the same duration wake together and recreate the 429 that throttled them. Randomized backoff spreads the wake-ups across the window.
Question
What does storing a content hash with a vector buy you?
Click to reveal answer
Answer
Incremental re-embedding: the next run compares fingerprints and calls the API only for new or edited documents, turning a 50,000-document job into a 37-document job.
Question
Why must hash and vector be written in one upsert?
Click to reveal answer
Answer
A crash between two writes leaves a current hash beside a stale vector, so the skip logic treats a document as indexed when its stored embedding no longer matches its text.
Revision Notes
Key Takeaways
- 1. Batching cuts round trips and quota slots, not token cost — size batches by tokens when document lengths vary
- 2. Retry at batch granularity so one throttle does not re-bill 49,900 correct vectors
- 3. 4xx means the payload is wrong: bisect instead of retrying, and never requeue it unchanged
- 4. Stored content hashes make re-embedding a diff, and hash plus vector must land in one write
Interview Tips
- • Frame batching as a quota and latency problem: 50,000 round trips versus 500, and name both provider caps (items and tokens)
- • Walk through your failure ladder — 429 jittered, 5xx capped, 4xx bisected — and say where the retry budget lives
- • Describe incremental indexing with content hashes, including why the hash write must be atomic with the vector write
- • Be ready to size a backfill: documents times tokens, divided by tokens-per-minute limits, to estimate wall-clock time
Cheat Sheet
Embedding Batching — Cheat Sheet
Batch loop
for batch in chunks(docs, n): # size by tokens when lengths vary
data = embed_with_retry(batch)
if data is None: failed.extend(batch) # isolate failures to the slice
else: done.extend(zip(batch, (d.embedding for d in data)))
Retry policy
| Status | Action |
|---|---|
| 429 | sleep with full jitter, honor retry-after, retry the same batch |
| 5xx / connection drop | exponential backoff with jitter, cap attempts |
| 400 / 401 | never retry; bisect the batch or fix credentials |
| attempts exhausted | return the batch as pending, keep everything else |
Incremental pass
fingerprint(text) == stored hash → keep vector, skip call
otherwise → batch embed → upsert(id, hash, vector) atomically
Pacing: stay under the published requests/min and tokens/min, backfill off-peak, chart 429 rate against throughput — a rising rate at flat throughput is throttling, not an outage.
Failure modes: whole-corpus rerun after one transient error · blind retry of 4xx · unslept synchronized retries · hash and vector written non-atomically · batch sized by item count when documents are long.
Continue Learning
Before You Start
- No prerequisites listed.