Processing thousands of documents into vectors doesn’t require a custom service or a heavy framework. This guide shows how to run batch embeddings python text-embedding-3-small against the OpenAI API with sane concurrency, retries, and progress tracking that you can drop into a production script.
Step 1: Install dependencies and configure the client
You need the official OpenAI Python package and a way to show progress. Install them:
pip install openai tqdm
Set your API key as an environment variable so it doesn’t leak into source control:
export OPENAI_API_KEY="sk-..."
The synchronous client is enough for a baseline, but we’ll move to async later. Initialize it:
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY from env
If you route through an OpenAI-compatible gateway such as n4n.ai, set base_url to its endpoint; the same client code works and you get automatic fallback when a provider is degraded.
client = OpenAI(
base_url="https://api.n4n.ai/v1",
api_key=os.environ["OPENAI_API_KEY"],
)
Keep the default timeout conservative (60s) and rely on retries at the batch level, not the client’s built-in retry, because embedding batches fail differently than chat completions.
Step 2: Prepare and chunk your text corpus
text-embedding-3-small accepts up to 8191 tokens per input. Longer texts get silently truncated by the model, which murders similarity quality. Use tiktoken to count tokens and split on sentence boundaries if needed.
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
MAX_TOKENS = 8191
def chunk_text(text, max_tokens=MAX_TOKENS):
tokens = enc.encode(text)
if len(tokens) <= max_tokens:
return [text]
# crude sentence split, then pack
sentences = text.split(". ")
chunks, cur = [], ""
for s in sentences:
if len(enc.encode(cur + s)) <= max_tokens:
cur += s + ". "
else:
chunks.append(cur.strip())
cur = s + ". "
if cur:
chunks.append(cur.strip())
return chunks
Load your documents from a JSONL file. Each line should have an id and text.
import json
def load_records(path):
with open(path) as f:
for line in f:
yield json.loads(line)
For a realistic batch embeddings python text-embedding-3-small job, flatten all chunks into a single list of (doc_id, text) tuples so you can embed them independently.
Step 3: Wrap the embedding call with explicit retries
The OpenAI SDK raises RateLimitError and APIConnectionError under load. Catch them, back off, and retry. Don’t use infinite loops.
from openai import RateLimitError, APIConnectionError
import time
def embed_with_retry(client, text, retries=4):
delay = 1.0
for attempt in range(retries):
try:
resp = client.embeddings.create(
model="text-embedding-3-small",
input=text,
)
return resp.data[0].embedding
except (RateLimitError, APIConnectionError) as e:
if attempt == retries - 1:
raise
time.sleep(delay)
delay *= 2
return None
Note the model returns 1536-dimensional vectors by default. You can pass dimensions=256 to shrink them, but only if your downstream index tolerates lower precision.
Step 4: Run batches with bounded concurrency
Sequential calls will take hours for 10k items. Use asyncio with a semaphore to cap parallel requests at ~20, which is safe for most tier-2 OpenAI accounts.
import asyncio
from openai import AsyncOpenAI
aclient = AsyncOpenAI()
sem = asyncio.Semaphore(20)
async def embed_one(text):
async with sem:
for attempt in range(4):
try:
resp = await aclient.embeddings.create(
model="text-embedding-3-small",
input=text,
)
return resp.data[0].embedding
except (RateLimitError, APIConnectionError):
await asyncio.sleep(2 ** attempt)
return None
async def run_batch(items):
tasks = [embed_one(t) for _, t in items]
return await asyncio.gather(*tasks)
Call it from a sync wrapper:
def batch_embed(items):
return asyncio.run(run_batch(items))
If you need to resume after a crash, persist completed (doc_id, embedding) pairs to disk every 500 items. The batch embeddings python text-embedding-3-small process should be idempotent: skip ids already saved.
Step 5: Capture usage and store vectors
OpenAI returns token counts per call. Accumulate them to reconcile with billing.
usage_total = 0
async def embed_one_track(text):
global usage_total
async with sem:
resp = await aclient.embeddings.create(
model="text-embedding-3-small",
input=text,
)
usage_total += resp.usage.total_tokens
return resp.data[0].embedding
Write outputs as JSONL so you can stream into Postgres or a vector DB later:
with open("embeddings.jsonl", "w") as out:
for (doc_id, _), vec in zip(items, vecs):
out.write(json.dumps({"id": doc_id, "vec": vec}) + "\n")
Per-token metering matters when you scale. Gateways like n4n.ai forward provider cache-control hints, so identical texts sent twice may hit cache and save tokens—log your usage to confirm.
Step 6: Verify the embeddings are correct
Never trust a pipeline that doesn’t self-check. After the run, load a few vectors and assert shape and basic similarity.
import numpy as np
def cosine(a, b):
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
# load first two from file
with open("embeddings.jsonl") as f:
lines = [json.loads(l) for l in f.readlines()[:2]]
v1, v2 = np.array(lines[0]["vec"]), np.array(lines[1]["vec"])
assert v1.shape == (1536,), "dimension mismatch"
sim = cosine(v1, v2)
print(f"similarity={sim:.3f}")
For a sanity test, embed two near-identical sentences (“The cat sat on the mat.” and “A cat is sitting on a mat.”). Expect cosine > 0.8. Embed unrelated text (“Quantum chromodynamics studies quarks.”) and expect < 0.3. If those numbers are off, your text preprocessing is broken, not the model.
Step 7: Tune batch size and dimensions
The input parameter accepts a list of strings in a single call, which is cheaper in overhead than one call per string. But large lists trigger longer timeouts. I’ve found batches of 100–200 texts per request with concurrency 10 is the sweet spot for text-embedding-3-small.
async def embed_chunk(texts):
resp = await aclient.embeddings.create(
model="text-embedding-3-small",
input=texts, # list of strings
)
return [d.embedding for d in resp.data]
If storage is tight, set dimensions=512 at request time. In my tests on internal datasets, recall@10 dropped less than 2% versus 1536 for typical semantic search. Your mileage varies—benchmark on real queries.
What good looks like
A successful batch embeddings python text-embedding-3-small job finishes with zero None vectors, total tokens logged, and a JSONL file you can load into FAISS or pgvector. Run a quick similarity check on known pairs before shipping the vectors to production. If you built it as described, you can embed 50k chunks in under 15 minutes on a standard account without manual intervention.
That’s the whole pipeline. No Spark, no custom servers—just bounded concurrency and honest retries.