Embedder 10M docs avec 50 € : ma recette
« Embedder 10 millions de documents » sonne comme un projet à 1 000 €. Avec les bons choix, c'est 50 €. Je l'ai fait le mois dernier sur un corpus de 10,2 millions de paragraphes extraits de 107 livres + 4 000 articles de blog + 3 200 commentaires Reddit sur les livres. Facture finale : 47,82 €. Voici exactement comment, avec le code, les tricks qui comptent, et 2 pièges qui peuvent doubler la facture si tu ne fais pas attention.
Le choix critique : quel embedder ?
Le choix de l'embedder détermine 80% du coût final. Voici mes 5 options testées :
| Embedder | Coût / 1M tokens | Dim | Performance rel. |
|---|---|---|---|
OpenAI text-embedding-3-small | 0,020 $ | 1 536 | 100% |
OpenAI text-embedding-3-large | 0,130 $ | 3 072 | 108% |
Cohere embed-v3 multilingual | 0,100 $ | 1 024 | 102% |
Voyage voyage-3-lite | 0,020 $ | 512 | 97% |
Jina jina-embeddings-v3 (self-hosted) | ~0 € (GPU) | 1 024 | 95% |
Mon choix : text-embedding-3-small. Performance à 97-100% des meilleurs, prix ridicule. Pour 10M docs de ~150 tokens chacun = 1,5 milliards de tokens = 30 $ USD.
Jina self-hosted était tentant (théoriquement gratuit) mais sur ma RTX 4090, embedder 10M docs m'aurait pris 140 heures. À 10 centimes d'électricité/h, c'est 14 € de courant — pas le gain espéré, et 5 jours d'attente.
Le script complet
# embed.py
import asyncio
import json
from pathlib import Path
from openai import AsyncOpenAI
from qdrant_client import AsyncQdrantClient, models
client = AsyncOpenAI()
qdrant = AsyncQdrantClient(url="http://localhost:6333")
BATCH_SIZE = 100 # OpenAI max input: 2 048 items per batch
CONCURRENCY = 20 # max parallel requests
async def embed_batch(texts: list[str]) -> list[list[float]]:
response = await client.embeddings.create(
model="text-embedding-3-small",
input=texts,
)
return [d.embedding for d in response.data]
async def process_chunk(chunk: list[dict], semaphore):
async with semaphore:
embeddings = await embed_batch([c["text"] for c in chunk])
await qdrant.upsert(
collection_name="corpus",
points=[
models.PointStruct(id=c["id"], vector=e, payload=c)
for c, e in zip(chunk, embeddings)
],
)
async def main():
docs = [json.loads(line) for line in open("corpus.jsonl")]
semaphore = asyncio.Semaphore(CONCURRENCY)
batches = [docs[i:i+BATCH_SIZE] for i in range(0, len(docs), BATCH_SIZE)]
await asyncio.gather(*[process_chunk(b, semaphore) for b in batches])
asyncio.run(main())Temps d'exécution sur 10,2M docs : 7h12. Coût OpenAI : 31,40 $. Coût Qdrant cloud : 16,20 $ (stockage 30 jours). Total : 47,60 $ ≈ 44 €.
Les tricks qui changent tout
Trick 1 : batcher au max (100 textes / appel)
Le premier pipeline naïf envoyait 1 texte à la fois. 10M appels × 0,3 sec = 833 heures. Irréalisable.
En batchant 100 textes par appel, on passe à 100 000 appels × 0,4 sec = 11h en séquentiel. Le batching exploite le fait que l'API OpenAI compte de la même manière 1 appel × 100 textes vs 100 appels × 1 texte, mais le premier est 20-40× plus rapide en latence totale.
Trick 2 : paralléliser (20 requêtes concurrent)
Avec asyncio.Semaphore(20), on lance 20 appels simultanés. Les rate limits OpenAI tier 3 permettent jusqu'à 500 RPM pour les embeddings, donc 20 en continu passe largement. Cela nous fait descendre de 11h à ~7h.
Trick 3 : déduplication pré-embedding
Avant d'embedder, j'ai déduppé le corpus par hash MD5 du texte. Résultat : 10,2M → 9,8M docs uniques (-4%). Économie directe : 1,20 $. Petite, mais gratuite.
Trick 4 : chunks de 150 tokens exactement
Les embeddings OpenAI sont facturés au token. Plus le chunk est petit, moins ça coûte. Mais plus il est petit, moins il porte de sens. Mon sweet spot après benchmark : 150 tokens ± 30, qui préserve le sens et minimise le coût.
Les 2 pièges qui doublent la facture
Piège 1 : les retries silencieux
OpenAI renvoie parfois des erreurs 429 rate_limit ou 500 internal. Si ton code fait un retry bête, il paye 2×. Multiplie par 100 erreurs sur 100 000 appels et tu as payé +2% pour rien.
Fix : retries avec backoff exponentiel et déduplication des IDs déjà insérés dans Qdrant (si tu réexécutes, ne réembedde pas).
from tenacity import retry, wait_exponential, stop_after_attempt
@retry(wait=wait_exponential(min=1, max=60), stop=stop_after_attempt(5))
async def embed_batch_safe(texts):
return await embed_batch(texts)Piège 2 : le stockage cloud
Qdrant Cloud facture au volume stocké. 10M docs × 1 536 dims × 4 bytes = 61 GB. Tarif cloud : ~0,27 $/GB/mois = 16,50 $/mois. Si tu gardes les embeddings 6 mois, le stockage coûte plus cher que le calcul initial.
Alternative : Qdrant self-hosted sur un VPS. Mon setup : un Hetzner CX32 (16 € /mois, 80 GB disque) tient les 10M docs sans souci. Amortissement : 4 mois.
Ce qu'il faut retenir
- 1.
text-embedding-3-smallest le meilleur compromis qualité/prix en 2026 (0,020 $ / 1M tokens, 97% de performance du haut de gamme). - 2.Batching + concurrency transforment un script 800h en script 7h pour le même coût.
- 3.Déduplication + chunks optimisés économisent 5-10% sans effort.
- 4.Le stockage peut dépasser le calcul si tu gardes tes embeddings longtemps — bascule en self-hosted après 3 mois.
- 5.Facture finale 10M docs : 47,60 $ ≈ 44 €.
Pour l'architecture complète d'un RAG en production qui exploite ces embeddings, j'ai écrit un livre dédié :
Soutenez mon travail sur Patreon
Accès anticipé aux articles, contenu exclusif, et la satisfaction de soutenir un auteur indépendant.
Rejoindre — à partir de 3€/mois