tutorial/microservice-embeddings.md

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 :

  • type ne peut valoir que "query" ou "passage". Toute autre valeur est refusĂ©e d'office.
  • texts doit 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 :

  • /health renvoie 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'option normalize_embeddings=True renvoie 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 :

  1. À l'indexation — le consommateur découpe ses documents en morceaux, les envoie en type: "passage", et stocke les vecteurs obtenus dans son propre index.
  2. À 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/passage gĂ©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

Assistant documentaire

Posez une question sur la documentation. Les réponses citent leurs sources — un clic ouvre le document à gauche.