Ce que vous saurez faire
A la fin de ce tutoriel, vous comprendrez ce qu’est un embedding (représentation vectorielle d’un texte) et vous saurez stocker des milliers de documents dans une base vectorielle, soit Pinecone (cloud) soit Qdrant (auto-hébergée). Vous construirez la fondation technique d’un moteur de recherche semantique pour votre PME sénégalaise : trouver des produits par sens et non par mots-clés, deduplicer une base clients, ou recommander des articles similaires. Coute Pinecone : 0 FCFA pour 100 000 vecteurs. Coute Qdrant : gratuit en local sur un VPS 6000 FCFA par mois.
Étape 1 : Comprendre les embeddings en 30 secondes
Un embedding transforme un texte en liste de nombres (par exemple 384 ou 1536 chiffres). Deux textes au sens proche ont des vecteurs proches dans l’espace mathématique. « Acheter un velo a Dakar » et « Achat bicyclette région Dakar » donnent des vecteurs presque identiques, alors qu’ils n’ont aucun mot commun. C’est ce qui permet la recherche semantique, bien supérieure aux mots-clés classiques.
Étape 2 : Choisir entre Pinecone et Qdrant
Pinecone : SaaS americain, 0 administration, plan gratuit jusqu’à 100 000 vecteurs. Idéal pour démarrer. Qdrant : open-source, vous l’hebergez sur votre VPS, données 100 pour cent en Afrique, scalable a des millions de vecteurs sans surcout. Pour ce tutoriel on traite les deux, vous gardez le code que vous voulez.
Étape 3 : Installer les dépendances Python
python -m venv venv
source venv/bin/activate # ou venv\Scripts\activate sur Windows
pip install pinecone-client==5.0.1
pip install qdrant-client==1.11.1
pip install sentence-transformers==3.0.1
pip install python-dotenv==1.0.1
Étape 4 : Générer un embedding test
Le modèle all-MiniLM-L6-v2 est léger (90 Mo) et rapide. Il produit des vecteurs de 384 dimensions :
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
texte = "Panneau solaire 200W garantie 24 mois"
vecteur = model.encode(texte)
print(f"Taille du vecteur : {len(vecteur)}")
print(f"Premiers chiffres : {vecteur[:5]}")
Pour un meilleur support du français, utilisez paraphrase-multilingual-mpnet-base-v2 (768 dimensions, 1,1 Go).
Étape 5 : Préparer un jeu de données réaliste
Imaginons un catalogue de 8 produits d’une PME a Dakar :
produits = [
{"id": 1, "nom": "Panneau solaire 200W monocristallin", "prix_fcfa": 145000},
{"id": 2, "nom": "Onduleur hybride 3kVA avec batterie lithium", "prix_fcfa": 850000},
{"id": 3, "nom": "Regulateur de charge MPPT 60A", "prix_fcfa": 95000},
{"id": 4, "nom": "Lampe LED solaire portable 10W", "prix_fcfa": 12500},
{"id": 5, "nom": "Cable solaire 6mm rouge 100m", "prix_fcfa": 75000},
{"id": 6, "nom": "Batterie gel 200Ah 12V deep cycle", "prix_fcfa": 320000},
{"id": 7, "nom": "Pompe solaire immergee 1HP", "prix_fcfa": 425000},
{"id": 8, "nom": "Kit eclairage maison 4 pieces", "prix_fcfa": 185000}
]
Étape 6 : Vectoriser tout le catalogue
noms = [p["nom"] for p in produits]
vecteurs = model.encode(noms, show_progress_bar=True)
print(f"{len(vecteurs)} vecteurs generes")
Pour 10 000 produits, comptez 2 minutes sur un PC standard.
Étape 7 : Option A – Pousser dans Pinecone
Créez un compte sur pinecone.io, generez une cle API. Puis :
from pinecone import Pinecone, ServerlessSpec
import os
pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"])
pc.create_index(
name="catalogue-pme",
dimension=384,
metric="cosine",
spec=ServerlessSpec(cloud="aws", region="us-east-1")
)
index = pc.Index("catalogue-pme")
vecteurs_a_inserer = [
(str(p["id"]), v.tolist(), {"nom": p["nom"], "prix": p["prix_fcfa"]})
for p, v in zip(produits, vecteurs)
]
index.upsert(vectors=vecteurs_a_inserer)
print("Index Pinecone rempli")
Étape 8 : Option B – Lancer Qdrant en local avec Docker
docker run -d -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
--name qdrant qdrant/qdrant
L’interface web tourne sur http://localhost:6333/dashboard pour visualiser vos collections.
Étape 9 : Pousser dans Qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
client = QdrantClient(host="localhost", port=6333)
client.recreate_collection(
collection_name="catalogue_pme",
vectors_config=VectorParams(size=384, distance=Distance.COSINE)
)
points = [
PointStruct(
id=p["id"],
vector=v.tolist(),
payload={"nom": p["nom"], "prix": p["prix_fcfa"]}
)
for p, v in zip(produits, vecteurs)
]
client.upsert(collection_name="catalogue_pme", points=points)
print("Collection Qdrant remplie")
Étape 10 : Lancer une recherche semantique
Un client cherche « lumière autonome pour panne ELEC ». Aucun mot ne matche directement les noms de produits, mais le sens oui :
requete = "lumiere autonome pour panne electrique"
v_requete = model.encode(requete).tolist()
# Avec Qdrant
resultats = client.search(
collection_name="catalogue_pme",
query_vector=v_requete,
limit=3
)
for r in resultats:
print(f"{r.score:.3f} - {r.payload['nom']} - {r.payload['prix']} FCFA")
Résultat attendu en tete : la lampe LED solaire portable et le kit eclairage maison.
Étape 11 : Filtrer par metadonnees
On cherche un produit similaire mais avec un budget maximum :
from qdrant_client.models import Filter, FieldCondition, Range
resultats = client.search(
collection_name="catalogue_pme",
query_vector=v_requete,
query_filter=Filter(
must=[FieldCondition(key="prix", range=Range(lte=100000))]
),
limit=5
)
Très utile pour un site e-commerce : « Articles similaires a moins de 100 000 FCFA ».
Étape 12 : Mettre a jour un produit
Quand un nom change ou un prix évolue, on re-upsert avec le même ID :
nouveau_nom = "Panneau solaire 200W monocristallin Tier 1 garantie 25 ans"
nouveau_v = model.encode(nouveau_nom).tolist()
client.upsert(
collection_name="catalogue_pme",
points=[PointStruct(
id=1,
vector=nouveau_v,
payload={"nom": nouveau_nom, "prix": 155000}
)]
)
Étape 13 : Sauvegarder et restaurer Qdrant
Pour la production en Afrique avec coupures électriques frequentes :
# Backup
client.create_snapshot(collection_name="catalogue_pme")
# La snapshot est dans ./qdrant_storage/collections/catalogue_pme/snapshots/
# A copier sur un disque externe ou Wasabi (S3 africain)
# Restauration
client.recover_snapshot(
collection_name="catalogue_pme",
location="file:///chemin/vers/snapshot.snapshot"
)
Étape 14 : Comparer les coûts mensuels
Pinecone Starter : 0 FCFA jusqu’à 100 000 vecteurs et 5 Go. Au-delà, plan Standard a 70 USD soit 42 000 FCFA. Qdrant auto-heberge : VPS Hetzner CX22 a 4 EUR soit 2700 FCFA pour 80 Go disque, supporte 1 million de vecteurs facilement. Pour une PME en croissance, Qdrant gagne vite.
Erreurs
Erreur 1 : Mismatch de dimensions. Si votre modèle genere 384 dimensions, l’index doit être cree avec dimension=384. Sinon erreur a l’insertion.
Erreur 2 : Mauvais choix de metrique. Pour les embeddings de phrases, utilisez COSINE et non EUCLIDEAN. Résultats 30 pour cent meilleurs.
Erreur 3 : Tout régénérer a chaque ajout. Vectorisez seulement les nouveaux produits, pas tout le catalogue.
Erreur 4 : Modèle anglais sur du français. Le modèle all-MiniLM-L6-v2 est purement anglais. Pour du français wolofise ou des termes locaux, prenez paraphrase-multilingual-mpnet-base-v2.
Erreur 5 : Oublier les metadonnees. Sans payload, vous obtenez des IDs sans contexte. Stockez toujours nom, prix, catégorie, URL.
Checklist
- Choix architecture : Pinecone (rapide a démarrer) ou Qdrant (souverainete des données)
- Modèle d’embedding multilingue installe en local
- Dimension du vecteur identique entre modèle et index
- Metrique cosine configurée
- Cle API Pinecone dans .env si Pinecone choisi
- Conteneur Docker Qdrant lance et persistant si Qdrant choisi
- Au moins 100 documents tests inseres avec metadonnees complètes
- Recherche semantique testée avec 10 requetes formulees différemment
- Filtres par prix, catégorie, stock fonctionnels
- Procédure de mise a jour d’un point documentée
- Snapshots automatiques planifies (cron quotidien)
- Sauvegarde externe sur S3 ou disque hors site
- Coût mensuel projete a 1 an valide par la direction
- Plan de migration entre Pinecone et Qdrant prépare au cas ou
Implémenter le reranking pour améliorer la pertinence
La recherche vectorielle pure renvoie les chunks les plus proches sémantiquement, mais cette proximité cosinus n’est pas toujours alignée avec la pertinence métier. Le reranking ajoute une 2e couche : on récupère 30-50 candidats par cosine search, puis un cross-encoder les re-trie selon une pertinence fine. Sur un assistant juridique OHADA déployé pour un cabinet à Abidjan, le reranking améliore la précision @5 de 30 à 50 % typiquement.
from cohere import Client
co = Client(os.environ['COHERE_API_KEY'])
docs = pinecone_query(query, top_k=30)
reranked = co.rerank(
query=query,
documents=[d['text'] for d in docs],
model='rerank-v3.5',
top_n=5
)
final = [docs[r.index] for r in reranked.results]
Cohere Rerank coûte 2 USD pour 1 000 requêtes (≈ 1 200 FCFA), soit 0,002 USD par requête. C’est négligeable face au coût LLM aval. Voyage AI propose voyage-rerank-2 dans la même gamme. Pour les budgets très serrés, BAAI/bge-reranker-v2-m3 tourne en local sur GPU 8 Go avec une qualité décente.
Choisir une stratégie de chunking adaptée au type de contenu
Le chunking naïf en blocs de 500 tokens fonctionne moyennement et casse souvent la cohérence sémantique. Trois stratégies plus fines selon le contenu. Pour des FAQ ou documents structurés, chunking par section (un chunk par H2 ou par paragraphe). Pour du texte juridique long, chunking récursif avec overlap de 50-100 tokens. Pour du code source, chunking par fonction ou méthode.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=120,
separators=['\n\n', '\n', '. ', ' ', '']
)
chunks = splitter.split_text(long_doc)
L’overlap de 100-150 tokens préserve le contexte au boundary entre chunks. Pour un site WooCommerce de 500 produits à indexer, prévoyez 60-90 minutes de temps d’indexation initial sur Voyage AI. Les mises à jour incrémentales (un produit modifié) prennent quelques secondes par produit.
Évaluer la qualité du RAG avec un harness de tests
Un RAG sans évaluation est un système qui dérive silencieusement. Constituez un golden set de 30-100 questions avec la réponse attendue et les chunks attendus en source. Mesurez sur ce golden set deux indicateurs clés. Hit@5 : votre pipeline retourne-t-il le bon chunk dans le top 5 ? MRR (Mean Reciprocal Rank) : à quel rang en moyenne le bon chunk apparaît-il ?
def evaluer(golden, retrieve_fn, k=5):
hit, mrr = 0, 0
for q in golden:
chunks = retrieve_fn(q['question'], k)
ids = [c['id'] for c in chunks]
if q['expected_chunk_id'] in ids:
hit += 1
rank = ids.index(q['expected_chunk_id']) + 1
mrr += 1 / rank
return {'hit@k': hit / len(golden), 'mrr': mrr / len(golden)}
Cibles raisonnables sur un golden set bien construit : Hit@5 ≥ 80 %, MRR ≥ 0,55. Sous ces seuils, le pipeline a des défauts (mauvais chunking, embedding inadapté à la langue, données polluées). Ce harness se relance à chaque modification du pipeline pour détecter les régressions.
Combiner recherche dense et BM25 pour le hybrid search
Les embeddings excellent sur la similarité sémantique mais ratent les correspondances exactes. Si l’utilisateur cherche un numéro de facture ou un nom propre rare, BM25 (recherche par mots-clés) trouve souvent mieux que la recherche dense. La technique 2026 standard : exécuter les deux requêtes en parallèle, puis fusionner les résultats avec un score pondéré 60/40 ou 70/30 dense/BM25.
# Pinecone hybrid search
result = index.query(
vector=dense_embedding,
sparse_vector={'indices': bm25_indices, 'values': bm25_values},
top_k=10,
alpha=0.7 # 0.7 dense + 0.3 sparse
)
Pour activer le sparse vector dans Pinecone, l’index doit être créé en mode hybrid (s1.x1.x ou serverless dotproduct). La latence ajoutée est négligeable (5-15 ms). Le gain en précision sur les requêtes mixtes (concept + nom propre) atteint régulièrement +15-20 %.
Sécuriser le RAG face aux données sensibles
Un RAG qui indexe des contrats clients ou des données médicales devient un point d’exposition. Trois protections minimales s’imposent. Premièrement, le chiffrement at-rest des index vectoriels (Pinecone supporte AES-256, Qdrant via volume LUKS). Deuxièmement, le filtrage par tenant pour que la requête d’un utilisateur ne remonte jamais les chunks d’un autre client.
result = index.query(
vector=embedding,
filter={'tenant_id': {'$eq': user_tenant_id}},
top_k=5
)
Troisièmement, l’audit complet des accès stocké dans une base append-only. Pour une fintech à Almadies qui indexe les conversations de support, ce trio est non négociable et conditionne la conformité RGPD ou équivalent local. Sur le même thème, voir notre guide RAG général et RAG avec pgvector.
Optimiser le coût en cachant les embeddings stables
Recalculer les embeddings d’un document inchangé est du gaspillage pur. Mettez en place un cache disque ou Redis qui stocke (hash_du_chunk, embedding_vector). À chaque indexation, calculer le hash, vérifier le cache. Sur un site qui réindexe son catalogue chaque semaine, le cache économise typiquement 80-95 % des appels Voyage AI ou OpenAI Embeddings, soit 100-300 USD/an pour un catalogue moyen.
import hashlib, redis
r = redis.Redis()
def get_or_compute_embedding(text):
h = hashlib.sha256(text.encode()).hexdigest()
cached = r.get(f'emb:{h}')
if cached:
return json.loads(cached)
emb = vo.embed([text], model='voyage-3-large').embeddings[0]
r.set(f'emb:{h}', json.dumps(emb), ex=86400*30)
return emb
Le TTL de 30 jours suffit pour la plupart des cas. Pour un contenu très stable (corpus juridique OHADA), allongez à 365 jours. Pour un contenu dynamique (catalogue produits avec descriptions évolutives), gardez 7-14 jours.
Documentez la politique de cache dans votre runbook interne pour qu’un futur ingénieur reprenne la stack sans avoir à reconstruire mentalement le pipeline. Cette discipline rend le projet pérenne au-delà du créateur initial.
La pérennité d’un projet RAG repose autant sur la documentation que sur la qualité de code, et c’est ce qui distingue un prototype d’une solution industrielle.
Ajoutez aussi un schéma d’architecture en image dans la documentation, pour qu’un nouveau membre comprenne le pipeline en 5 minutes au lieu de 5 heures.