Exécuter des modèles d'embedding avec sentence-transformers
1. Aperçu
L’embedding est une manière de transformer une phrase en un vecteur numérique. Deux phrases ayant une signification similaire produiront deux vecteurs proches l’un de l’autre. C’est le fondement de la recherche sémantique, de la RAG, du clustering de texte et de la recommandation de contenu.
Dans cet article, vous utiliserez la bibliothèque sentence-transformers pour exécuter un modèle d’embedding de Hugging Face directement sur votre machine. Après la lecture, vous saurez comment créer des embeddings, calculer la similarité, effectuer une recherche sémantique, écrire des tests, et exposer le modèle sous la forme d’un service HTTP que votre application Spring Boot pourra appeler.
2. Installation
sentence-transformers est une bibliothèque Python qui s’exécute sur PyTorch. Vous devriez créer un environnement virtuel dédié :
python -m venv .venv
source .venv/bin/activate
pip install sentence-transformers
Cette commande télécharge aussi torch, transformers et huggingface_hub. Si votre machine dispose d’un GPU NVIDIA, installez d’abord la version torch avec support CUDA, puis installez sentence-transformers.
La première fois que vous chargez un modèle, la bibliothèque le télécharge depuis Hugging Face Hub dans un dossier de cache (par défaut ~/.cache/huggingface). Les fois suivantes, le modèle est lu directement à partir du cache.
3. Créer votre premier embedding
Commençons avec all-MiniLM-L6-v2. C’est un petit modèle (environ 90 MB), rapide et approprié pour expérimenter avec du texte anglais :
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
sentences = [
"The order was shipped yesterday.",
"Your package left our warehouse last night.",
"I would like to change my password.",
]
embeddings = model.encode(sentences)
print(embeddings.shape)
print(model.get_sentence_embedding_dimension())
Explication :
SentenceTransformer(...)charge le modèle et le tokenizer depuis Hub, ou depuis le cache s’ils existent déjà.model.encode(sentences)accepte une liste de chaînes de caractères et retourne unnumpy.ndarray. Chaque phrase correspond à une ligne.get_sentence_embedding_dimension()indique le nombre de dimensions du vecteur.
Résultat :
(3, 384)
384
Vous avez trois phrases, chacune devenant un vecteur de 384 dimensions. D’autres modèles auront un nombre de dimensions différent, par exemple 768 ou 1024.
3.1. Texte en vietnamien
all-MiniLM-L6-v2 a été entraîné principalement sur l’anglais. Pour le vietnamien, vous devriez utiliser un modèle multilingue :
model = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)
vectors = model.encode([
"Đơn hàng đã được giao cho đơn vị vận chuyển.",
"Gói hàng của bạn đã rời kho tối qua.",
])
print(vectors.shape)
(2, 384)
Ce modèle produit également des vecteurs de 384 dimensions. Il comprend environ 50 langues, y compris le vietnamien.
4. Calculer la similarité
Les vecteurs n’ont de l’intérêt que lorsque vous les comparez entre eux. La mesure la plus courante est la similarité cosinus. À partir de la version 3, la bibliothèque fournit model.similarity :
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
sentences = [
"The order was shipped yesterday.",
"Your package left our warehouse last night.",
"I would like to change my password.",
]
embeddings = model.encode(sentences)
scores = model.similarity(embeddings, embeddings)
print(scores)
model.similarity(a, b) retourne une matrice torch.Tensor, où la cellule [i][j] est la similarité entre la phrase i et la phrase j. La sortie ressemble à ceci (les nombres peuvent varier légèrement selon la version) :
tensor([[1.0000, 0.6123, 0.0412],
[0.6123, 1.0000, 0.0587],
[0.0412, 0.0587, 1.0000]])
La diagonale est toujours 1 car chaque phrase est identique à elle-même. Les deux phrases sur la livraison ont un score élevé, bien qu’elles n’aient pratiquement aucun mot en commun. La phrase sur le changement de mot de passe est pratiquement sans rapport.
4.1. Normaliser les vecteurs
Si vous prévoyez de stocker des vecteurs dans une base de données vectorielle, normalisez-les à une longueur de 1 :
embeddings = model.encode(sentences, normalize_embeddings=True)
score = embeddings[0] @ embeddings[1]
print(round(float(score), 4))
0.6123
Quand les vecteurs sont normalisés, le produit scalaire (@) donne la valeur exacte de la similarité cosinus. Cette opération est moins coûteuse, et de nombreuses bases de données comme pgvector, Qdrant ou Elasticsearch l’optimisent spécifiquement.
5. Recherche sémantique
Un problème réel consiste à trouver la réponse la plus appropriée dans une base de FAQ pour une question de client. La fonction util.semantic_search effectue ce travail :
from sentence_transformers import SentenceTransformer, util
model = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)
faq = [
"Làm sao để theo dõi đơn hàng?",
"Chính sách hoàn tiền như thế nào?",
"Tôi quên mật khẩu đăng nhập.",
"Phí vận chuyển ra nước ngoài là bao nhiêu?",
]
faq_embeddings = model.encode(faq, convert_to_tensor=True, normalize_embeddings=True)
query = "Hàng của tôi đang ở đâu rồi?"
query_embedding = model.encode(query, convert_to_tensor=True, normalize_embeddings=True)
hits = util.semantic_search(query_embedding, faq_embeddings, top_k=2)[0]
for hit in hits:
print(f"{hit['score']:.3f} {faq[hit['corpus_id']]}")
Explication :
- Vous codifiez toute la FAQ une seule fois. Dans une application réelle, cette étape s’exécute au démarrage ou quand les données changent.
convert_to_tensor=Trueconserve les résultats sous forme de tenseurs, donc vous n’avez pas à convertir entre numpy.semantic_searchretourne une liste pour chaque query. Chaque élément est un dictionnaire contenantcorpus_idetscore, triés par ordre décroissant.
Exemple de sortie :
0.721 Làm sao để theo dõi đơn hàng?
0.388 Phí vận chuyển ra nước ngoài là bao nhiêu?
Cette approche convient pour plusieurs dizaines de milliers de documents en mémoire. Avec des millions de documents, vous devriez passer à une base de données vectorielle avec index ANN.
Note : Certains modèles comme la famille E5 ou BGE exigent des préfixes supplémentaires, par exemple
"query: "pour les questions et"passage: "pour les documents. Omettre les préfixes peut réduire considérablement la qualité de la recherche. Consultez la fiche du modèle avant de l’utiliser. Vous pouvez passerprompt="query: "àencode.
6. Optimiser les performances
encode a plusieurs paramètres qui affectent directement la vitesse :
import torch
from sentence_transformers import SentenceTransformer
device = "cuda" if torch.cuda.is_available() else "cpu"
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2", device=device)
documents = [f"Customer review number {i}" for i in range(10_000)]
embeddings = model.encode(
documents,
batch_size=64,
show_progress_bar=True,
normalize_embeddings=True,
)
print(embeddings.shape, device)
Batches: 100%|██████████| 157/157 [00:09<00:00, 16.8it/s]
(10000, 384) cpu
devicechoisit entre CPU ou GPU. Sur GPU, la vitesse peut être plusieurs dizaines de fois plus rapide.batch_sizeest 32 par défaut. Sur GPU, l’augmenter à 64–256 aide généralement, tant que vous n’épuisez pas votre VRAM.- Passez toujours la liste entière à
encode. Appelerencodephrase par phrase dans une boucleforest plusieurs fois plus lent.
Chaque modèle a aussi une limite de longueur d’entrée, visible via model.max_seq_length. Pour all-MiniLM-L6-v2, cette valeur est 256 tokens. Le texte dépassant cette limite est silencieusement tronqué, sans avertissement. Par conséquent, les documents longs doivent être divisés en chunks avant d’être codifiés.
7. Tester la partie embedding
Les embeddings étant des nombres réels, vous ne pouvez pas comparer les valeurs exactes. Testez plutôt les propriétés au lieu des valeurs spécifiques. Installez pytest :
pip install pytest
# test_embeddings.py
import numpy as np
import pytest
from sentence_transformers import SentenceTransformer
@pytest.fixture(scope="module")
def model():
return SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
def test_embedding_has_expected_dimension(model):
vectors = model.encode(["Order #42 is delayed"])
assert vectors.shape == (1, 384)
def test_normalized_embedding_has_unit_length(model):
vector = model.encode("Refund request", normalize_embeddings=True)
assert np.linalg.norm(vector) == pytest.approx(1.0, abs=1e-5)
def test_similar_sentences_score_higher(model):
shipping = model.encode("The order was shipped yesterday.")
package = model.encode("Your package left our warehouse last night.")
password = model.encode("I would like to change my password.")
related = model.similarity(shipping, package).item()
unrelated = model.similarity(shipping, password).item()
assert related > unrelated
La fixture avec scope="module" garantit que le modèle n’est chargé qu’une fois pour le fichier entier. Le troisième test capturerait une erreur si quelqu’un changeait accidentellement vers un modèle inapproprié.
8. Appeler le modèle depuis une application Spring Boot
Vous avez probablement écrit la plupart de votre backend en Java. Un moyen simple est d’encapsuler le modèle dans un petit service HTTP avec FastAPI, puis de l’appeler depuis Spring Boot.
8.1. Service d’embedding avec FastAPI
pip install fastapi uvicorn
# app.py
from fastapi import FastAPI
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
app = FastAPI()
model = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)
class EmbedRequest(BaseModel):
texts: list[str]
@app.post("/embed")
def embed(request: EmbedRequest):
vectors = model.encode(request.texts, normalize_embeddings=True)
return {
"embeddings": vectors.tolist(),
"dimension": model.get_sentence_embedding_dimension(),
}
uvicorn app:app --port 8000
Le modèle est chargé une seule fois au démarrage du module, et n’est pas rechargé pour chaque requête. L’endpoint utilise def plutôt que async def. C’est parce que encode est une opération bloquante, et FastAPI exécutera la fonction def dans un thread pool dédié au lieu de bloquer la boucle d’événements.
8.2. Client dans Spring Boot
Dépendance Maven :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
embedding.base-url=http://localhost:8000
public record EmbedRequest(List<String> texts) {}
public record EmbedResponse(List<List<Float>> embeddings, int dimension) {}
@Service
public class EmbeddingClient {
private final RestClient restClient;
public EmbeddingClient(RestClient.Builder builder,
@Value("${embedding.base-url}") String baseUrl) {
this.restClient = builder.baseUrl(baseUrl).build();
}
public List<List<Float>> embed(List<String> texts) {
EmbedResponse response = restClient.post()
.uri("/embed")
.body(new EmbedRequest(texts))
.retrieve()
.body(EmbedResponse.class);
return response.embeddings();
}
}
RestClient (disponible depuis Spring Framework 6.1) envoie EmbedRequest sous forme JSON, puis mappe la réponse au record EmbedResponse avec Jackson. Vous pouvez utiliser le résultat pour stocker dans pgvector ou comparer avec d’autres vecteurs.
8.3. Tester le client avec MockRestServiceServer
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
class EmbeddingClientTest {
@Test
void shouldReturnEmbeddingsFromService() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
server.expect(requestTo("http://localhost:8000/embed"))
.andExpect(method(HttpMethod.POST))
.andExpect(jsonPath("$.texts[0]").value("Đơn hàng bị trễ"))
.andRespond(withSuccess("""
{"embeddings": [[0.1, 0.2, 0.3]], "dimension": 3}
""", MediaType.APPLICATION_JSON));
EmbeddingClient client = new EmbeddingClient(builder, "http://localhost:8000");
List<List<Float>> result = client.embed(List.of("Đơn hàng bị trễ"));
assertThat(result).hasSize(1);
assertThat(result.getFirst()).containsExactly(0.1f, 0.2f, 0.3f);
server.verify();
}
}
MockRestServiceServer.bindTo(builder) doit être appelé avant que le builder ne crée le RestClient. Dans ce test, cela est toujours vrai car le client n’appelle build() que dans le constructeur. Ainsi, le test s’exécute sans avoir besoin du vrai service Python.
9. Erreurs courantes
- Mélanger les vecteurs de plusieurs modèles. Les vecteurs de deux modèles différents résident dans deux espaces différents, même s’ils ont le même nombre de dimensions. Changer de modèle signifie réencoder toutes les données.
- Oublier de normaliser d’un côté. Si les documents sont normalisés mais pas la requête, le score du produit scalaire sera décalé. Utilisez la même configuration
encodedes deux côtés. - Texte trop long. La partie dépassant
max_seq_lengthest tronquée sans avertissement. Divisez les documents en chunks avant de les coder. - Charger le modèle à chaque requête. Cette opération prend plusieurs secondes et beaucoup de RAM. Chargez-le une seule fois au démarrage.
- Le conteneur n’a pas accès à Internet. Les environnements de production bloquent généralement l’accès à Hub. Préchargez le modèle dans l’image, puis définissez
HF_HUB_OFFLINE=1.
Note : L’embedding ne remplace pas la recherche exacte. Pour les numéros de commande, numéros de téléphone ou SKU, la recherche en texte intégral ou les requêtes SQL ordinaires donnent de meilleurs résultats. En pratique, combiner les deux (recherche hybride) est généralement le plus efficace.
10. Conclusion
Vous avez utilisé sentence-transformers pour créer des embeddings en seulement quelques lignes de code. Vous avez aussi calculé la similarité cosinus, construit une recherche sémantique pour une base de FAQ, et ajusté batch_size et device pour une exécution plus rapide. En outre, vous avez appris comment tester les embeddings via leurs propriétés, ainsi que comment encapsuler le modèle dans un service HTTP pour que Spring Boot l’appelle via RestClient.
L’étape logique suivante est de stocker les vecteurs dans une base de données vectorielle comme pgvector, puis de les utiliser comme partie de retrieval dans un pipeline RAG.