Construire un microservice d'embeddings
Dans ce tutoriel, vous allez construire un petit service HTTP qui transforme du texte en vecteurs d'embedding — des représentations numériques que l'on peut comparer pour mesurer une proximité de sens. À la fin, vous aurez un service qui répond à deux requêtes : une pour vérifier qu'il est en vie, une pour vectoriser une liste de textes.
Ce parcours s'adresse à un développeur à l'aise avec le HTTP et le JSON, mais qui n'a pas encore construit ce type de brique. On avance étape par étape ; on garde volontairement le périmètre minimal.
Ce tutoriel apprend à construire le service. Une fois qu'il tourne, la description exhaustive du contrat (formats d'entrée/sortie, codes d'erreur, garanties) vit dans la référence du service d'embeddings.
Pourquoi un service dédié ?
On pourrait être tenté de calculer les embeddings directement dans l'application qui en a besoin. On préfère un service séparé, pour trois raisons.
- Le bon outil dans le bon langage. L'écosystème d'inférence (modèles, bibliothèques de vectorisation) est mûr et confortable en Python. On garde cette brique en Python, et l'application consommatrice reste dans son propre langage.
- Une frontière nette. Le service est stateless : il reçoit du texte, il rend des vecteurs, il n'a pas de mémoire. Toute la logique d'indexation, de stockage et de recherche appartient au consommateur. Cette séparation rend chaque côté plus simple à raisonner et à tester.
- Un contrat réseau stable. Le consommateur — par exemple un
moteur de recherche sémantique — appelle le
service en HTTP via un contrat figé (
/health,/embed). Tant que le contrat tient, chaque côté peut évoluer indépendamment.
Le résultat visé : une brique isolée, remplaçable, que l'on peut faire tourner en local sans dépendance externe payante.
Prérequis
- Python 3.10 ou plus récent.
- Un accès réseau au premier lancement : le modèle d'embedding est téléchargé puis mis en cache localement.
- Des notions de HTTP/JSON. Aucune connaissance préalable des embeddings n'est requise — on introduit ce qu'il faut au fil de l'eau.
Le modèle utilisé ici est un modèle multilingue de la famille e5
(sentence-transformers), adapté au français. Il produit des vecteurs de
dimension fixe — 768 dans notre exemple.
Étape 1 — Isoler l'environnement
On crée un dossier de travail et un environnement virtuel dédié, pour ne pas mélanger les dépendances de ce service avec le reste du système.
mkdir embeddings-service && cd embeddings-service python3 -m venv .venv source .venv/bin/activate # Windows : .venv\Scripts\activate pip install --upgrade pip pip install "fastapi" "uvicorn[standard]" "sentence-transformers"
Ces trois paquets couvrent tout le besoin : fastapi pour définir les routes et
valider les entrées, uvicorn pour servir l'application, et
sentence-transformers pour charger le modèle et encoder le texte.
Bon réflexe. Une fois l'installation terminée, figez les versions avec
pip freeze > requirements.txt. Vous pourrez recréer un environnement à l'identique plus tard — la reproductibilité évite les mauvaises surprises.
Étape 2 — Écrire le service
Créez un fichier app.py. On va le construire par morceaux, puis le montrer en
entier.
2.1 Charger le modèle une seule fois
Charger un modèle d'embedding prend du temps et de la mémoire. On le fait une fois, au démarrage du service — surtout pas à chaque requête.
from sentence_transformers import SentenceTransformer MODEL_NAME = "intfloat/multilingual-e5-base" model = SentenceTransformer(MODEL_NAME) # chargé une fois, réutilisé partout
Le modèle reste en mémoire tant que le service tourne. Toutes les requêtes se partagent la même instance.
2.2 Décrire et valider l'entrée
Avant de calculer quoi que ce soit, on veut être sûr que la requête est bien formée. On décrit la forme attendue avec un modèle Pydantic ; FastAPI se charge de rejeter automatiquement ce qui ne colle pas.
from typing import Literal from pydantic import BaseModel, Field class EmbedRequest(BaseModel): type: Literal["query", "passage"] # rejeté si autre valeur texts: list[str] = Field(min_length=1) # rejeté si la liste est vide
Deux garde-fous ici :
typene peut valoir que"query"ou"passage". Toute autre valeur est refusée d'office.textsdoit contenir au moins un élément.
Une entrée non conforme reçoit une réponse d'erreur avant tout calcul. C'est délibéré : on préfère un refus net à un résultat douteux calculé sur une entrée bancale.
2.3 Comprendre query vs passage
C'est le point conceptuel du tutoriel, alors prenons un instant.
Les modèles de la famille e5 sont entraînés à traiter différemment le texte que l'on cherche et le texte dans lequel on cherche. Concrètement, ils attendent un petit préfixe collé devant le texte selon son rôle :
| rôle | préfixe attendu | quand |
|---|---|---|
query |
query: |
vectoriser une question / requête posée au moment de la recherche |
passage |
passage: |
vectoriser un document Ă indexer, en amont |
Employer le bon préfixe améliore nettement la pertinence des comparaisons. Le
détail important : c'est le service qui ajoute le préfixe, pas l'appelant.
Le consommateur envoie du texte brut et se contente d'indiquer type. Il n'a
jamais à connaître cette convention — on lui épargne un piège.
2.4 Les deux routes
On expose une route de santé et une route de vectorisation.
from fastapi import FastAPI app = FastAPI(title="Embeddings service") @app.get("/health") def health(): return { "status": "ok", "model": MODEL_NAME, "dim": model.get_sentence_embedding_dimension(), } @app.post("/embed") def embed(req: EmbedRequest): # Convention e5 : on ajoute le préfixe selon l'usage, côté service prefix = "query: " if req.type == "query" else "passage: " inputs = [prefix + t for t in req.texts] # normalize_embeddings=True → vecteurs prêts pour la similarité cosinus vectors = model.encode(inputs, normalize_embeddings=True).tolist() return { "model": MODEL_NAME, "dim": model.get_sentence_embedding_dimension(), "vectors": vectors, }
Deux détails valent qu'on s'y arrête :
/healthrenvoie le modèle servi et la dimension des vecteurs. Cette route sert à vérifier que le service est prêt (modèle chargé) et à confirmer avec quoi il travaille.- Dans
/embed, l'optionnormalize_embeddings=Truerenvoie des vecteurs de norme 1. Résultat : côté consommateur, comparer deux vecteurs par produit scalaire revient à calculer leur similarité cosinus. On simplifie le travail d'en face.
2.5 Le fichier complet
from typing import Literal from fastapi import FastAPI from pydantic import BaseModel, Field from sentence_transformers import SentenceTransformer MODEL_NAME = "intfloat/multilingual-e5-base" model = SentenceTransformer(MODEL_NAME) # chargé une fois au démarrage app = FastAPI(title="Embeddings service") class EmbedRequest(BaseModel): type: Literal["query", "passage"] texts: list[str] = Field(min_length=1) @app.get("/health") def health(): return { "status": "ok", "model": MODEL_NAME, "dim": model.get_sentence_embedding_dimension(), } @app.post("/embed") def embed(req: EmbedRequest): prefix = "query: " if req.type == "query" else "passage: " inputs = [prefix + t for t in req.texts] vectors = model.encode(inputs, normalize_embeddings=True).tolist() return { "model": MODEL_NAME, "dim": model.get_sentence_embedding_dimension(), "vectors": vectors, }
Une petite trentaine de lignes, et tout le contrat tient dedans.
Étape 3 — Lancer le service
On démarre le serveur ASGI sur la boucle locale de votre machine. Ce service est conçu pour être joignable en local uniquement : il ne porte aucune authentification et n'a pas vocation à être exposé publiquement tel quel.
uvicorn app:app --host 127.0.0.1
Point de sécurité. L'isolation de ce service repose sur sa frontière réseau (il n'écoute qu'en local), pas sur un contrôle applicatif. Si vous deviez un jour le rendre joignable au-delà de la machine, il faudrait d'abord ajouter vous-même une couche d'accès (authentification, filtrage). Par défaut : local, et rien d'autre.
Au tout premier lancement, laissez le temps au modèle de se télécharger et de se mettre en cache. Les démarrages suivants sont rapides.
Étape 4 — Tester
Vérifions d'abord la santé, puis un vrai calcul d'embedding. Adaptez l'adresse
locale à celle affichée par uvicorn au démarrage.
# 1. Santé curl http://localhost/health # 2. Vectoriser un passage curl -X POST http://localhost/embed \ -H "Content-Type: application/json" \ -d '{"type":"passage","texts":["Comment réinitialiser mon mot de passe ?"]}'
La réponse de /embed contient :
model— le modèle servi ;dim— la dimension des vecteurs (768 dans notre exemple) ;vectors— un vecteur par texte, dans le même ordre que l'entrée.
Essayez maintenant une entrée invalide, pour voir le garde-fou à l'œuvre :
curl -X POST http://localhost/embed \ -H "Content-Type: application/json" \ -d '{"type":"autre","texts":[]}'
Vous obtenez une erreur de validation (code 422) : ni le type inconnu ni la
liste vide ne sont acceptés, et aucun calcul n'est effectué. C'est le
comportement voulu — un refus explicite plutôt qu'un résultat trompeur.
Étape 5 — Consommer le service depuis une application
Votre service est maintenant une brique réseau autonome. N'importe quel
consommateur l'appelle de la même façon : un POST /embed avec type et
texts, et il récupère des vecteurs.
Le schéma typique d'un pipeline de recherche sémantique :
- À l'indexation — le consommateur découpe ses documents en morceaux, les
envoie en
type: "passage", et stocke les vecteurs obtenus dans son propre index. - À la recherche — il envoie la question de l'utilisateur en
type: "query", puis compare ce vecteur Ă ceux de l'index pour remonter les passages les plus proches.
Le point à retenir : indexation et recherche doivent passer par le même service (donc le même modèle), sinon les vecteurs ne vivent pas dans le même espace et la comparaison n'a pas de sens. Votre service, lui, ne stocke rien et ne cherche rien — il se contente de vectoriser. Le stockage, la mise en lots et la recherche appartiennent au consommateur, comme le décrit la référence du moteur de recherche sémantique.
Ce que vous avez construit
- Un service HTTP stateless qui vectorise du texte.
- Deux routes :
/health(le service est-il prĂŞt ?) et/embed(donne-moi les vecteurs). - La convention e5
query/passagegérée côté service, transparente pour l'appelant. - Une validation stricte de l'entrée, avec refus net (
422) plutôt que fallback silencieux. - Un contrat JSON stable, prêt à être consommé par un cœur de recherche sémantique.
Pour aller plus loin
- Le contrat complet du service (tous les champs, tous les codes d'erreur, les garanties observables) : référence du service d'embeddings.
- Le côté qui consomme ces vecteurs (chunking, store vectoriel, recherche kNN) : référence du moteur RAG.