AI & Machine Learning Bug Report

Embedding backfill crashes: input exceeds the model's maximum context length

Our nightly embedding backfill died on the same few documents every night. A handful of chunks were far over the embedding model's token limit because the chunker split by characters and some files were minified JSON. Token-aware chunking and batching fixed it.

Ryan Pham Ryan Pham @ryan_bugs · January 29, 2025 · 6 min read · 12 Views
Banner: embedding backfill failing because some chunks exceed the token limit

We re-embed changed documents for our internal search every night. For three nights in a row, the job failed at around 62 percent and left the index partially updated. The worker log showed:

openai.BadRequestError: Error code: 400 - {'error': {'message': "This model's maximum context length
is 8192 tokens, however you requested 23514 tokens (23514 in your prompt; 0 for the completion).
Please reduce your prompt; or completion length.", 'type': 'invalid_request_error', 'param': None,
'code': None}}

The job sent batches of 100 chunks. One bad chunk failed the whole batch, the retry failed the same way, and after three retries the worker gave up on the entire run.

Reproducing it

We logged the token count of each chunk in the failing batch:

chunk_id=wiki/infra/terraform-outputs.json#0   chars=7980  tokens=23514
chunk_id=wiki/infra/terraform-outputs.json#1   chars=7990  tokens=22871
chunk_id=wiki/runbooks/db-failover.md#3        chars=7995  tokens=1840

Our chunker capped chunks at 8,000 characters. For English prose that is about 1,800 tokens. For a minified JSON file full of ARNs, hashes and UUIDs, it was nearly 24,000.

What we ruled out

  • A wrong model name or dimension. The rest of the batches embedded fine.
  • Batch size limits. The total batch was under the per-request limits once the oversized chunks were removed.
  • Encoding issues. The text was valid UTF-8.

Root cause

The chunker assumed roughly 4 characters per token. That ratio holds for English prose and fails badly for code, JSON, base64, logs with identifiers, and non-Latin scripts. A new team started publishing Terraform output dumps to the wiki, and those files produced chunks three times over the limit. Because one invalid input fails the whole request, and the job had no per-item error handling, one file stopped the whole backfill.

What is happening under the hood

Embedding models have a maximum input length in tokens per input (8,192 for the OpenAI embedding models we use). An embeddings request can include many inputs, with additional limits on the number of inputs and total tokens per request. If any single input is too long, the request fails; the API does not skip it.

Token density varies widely:

Content Rough chars per token
English prose 4
Python or TypeScript code 3
Minified JSON with ids 1 to 1.5
Base64 or hex Under 1
Vietnamese, Thai, Chinese Varies, often 1 to 2

Environment

Component Version / value
Runtime Python 3.12.8
SDK openai 1.60
Tokenizer tiktoken 0.8, cl100k_base
Model text-embedding-3-small, 8,192-token input limit
Scheduler Airflow 2.10 task, nightly
Corpus 410k chunks from wiki, runbooks, tickets

Minimal reproduction

def chunk(text: str, max_chars: int = 8000) -> list[str]:
    return [text[i:i + max_chars] for i in range(0, len(text), max_chars)]

chunks = chunk(open("terraform-outputs.json").read())
client.embeddings.create(model="text-embedding-3-small", input=chunks)   # 400

The fix

1. Chunk by tokens with the model's tokenizer. We keep structure-aware splitting (headings, paragraphs, code blocks) and enforce a token cap at the end.

import tiktoken

ENC = tiktoken.get_encoding("cl100k_base")
MAX_TOKENS = 800          # retrieval quality, well under the 8192 limit
HARD_LIMIT = 8000

def split_tokens(text: str, max_tokens: int = MAX_TOKENS, overlap: int = 80) -> list[str]:
    ids = ENC.encode(text)
    out, start = [], 0
    while start < len(ids):
        end = min(start + max_tokens, len(ids))
        out.append(ENC.decode(ids[start:end]))
        if end == len(ids):
            break
        start = end - overlap
    return out

2. Batch by tokens, not by count.

def batches(items: list[tuple[str, str]], max_batch_tokens: int = 250_000, max_items: int = 512):
    batch, tokens = [], 0
    for chunk_id, text in items:
        n = len(ENC.encode(text))
        if n > HARD_LIMIT:
            yield_dead_letter(chunk_id, n)          # should never happen after split
            continue
        if batch and (tokens + n > max_batch_tokens or len(batch) >= max_items):
            yield batch
            batch, tokens = [], 0
        batch.append((chunk_id, text))
        tokens += n
    if batch:
        yield batch

3. Per-item failure handling. If a batch still fails with a 400, we split it in half and retry each half, down to single items. A single item that fails goes to a dead-letter table with its id and token count, and the job continues.

4. Skip content that should not be embedded. Minified JSON and machine output have little search value. Files over 70 percent non-alphanumeric characters, or with a known generated-file header, are now indexed by title and path only.

Alternatives and trade-offs

  • Truncate long inputs. Quick, and it silently drops content. Acceptable for titles, not for documents.
  • Average embeddings of sub-chunks. Gives one vector per document but blurs meaning; re-normalise if you do it.
  • Character limits tuned per file type. Better than one global ratio, still a guess. Counting tokens is cheap enough.

How to test the fix

def test_dense_json_is_split_under_limit():
    text = json.dumps({f"arn:aws:iam::123456789012:role/r{i}": uuid4().hex for i in range(20000)})
    parts = split_tokens(text)
    assert all(len(ENC.encode(p)) <= MAX_TOKENS for p in parts)

def test_bad_item_does_not_fail_batch(fake_client):
    fake_client.reject_ids({"x"})
    results = embed_all([("a", "ok"), ("x", "bad"), ("b", "ok")])
    assert set(results.ok) == {"a", "b"} and results.dead_letter == ["x"]

Confirming in production

  • The next nightly run completed in 41 minutes with zero failed batches and 7 dead-lettered items, all generated files.
  • Index freshness, measured as the share of documents embedded within 24 hours of change, went from 62 percent to 99.8 percent.
  • Embedding spend dropped 9 percent, because dense generated files are no longer embedded in full.

Regression guard

  • Metric and alert for dead-letter count above 50 per run.
  • A guard in the chunker that raises if any chunk exceeds the hard limit, so a future change to splitting rules fails tests rather than production.
  • Job success is a dashboard tile next to search quality metrics.

Security implications

The failing files were Terraform outputs containing internal ARNs and, in one case, a database endpoint with a password in the connection string. They should not have been in the wiki at all. We reported them to the owning team, rotated the credential, and added a secret scanner to the ingestion step so secrets are dropped before embedding. Embeddings can leak information about their source text, so the vector store is protected like the documents themselves.

Prevention checklist

  • Chunk and batch by tokens using the model's tokenizer.
  • Expect dense content: code, JSON, ids, non-Latin scripts.
  • Isolate failures per item; never let one input stop a backfill.
  • Decide which content is worth embedding.
  • Scan ingested content for secrets.

References

Add a label

#python #openai #rag #embeddings #tiktoken
Written by
Ryan Pham @ryan_bugs

I build and run the platforms other engineers ship on: CI/CD pipelines, Kubernetes clusters, GPU inference services, LLM features in production, and the cloud bills that come with all of it. I have also spent years writing and operating automated trading systems, where a rounding bug or a missed websocket message is not a log line but a position you did not intend to hold. Each post here starts from a real failure: the alert, the exact error, what we ruled out, the root cause, and how we proved the fix. Same path every time: Investigate, Explain, Fix, Verify. No hype, no price predictions, just engineering.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.