Executando model embedding com sentence-transformers
1. Visão geral
Embedding é a forma de converter uma frase em um vetor numérico. Duas frases com significado similar resultam em dois vetores próximos um do outro. Esta é a base de buscas semânticas, RAG, agrupamento de textos e recomendação de conteúdo.
Neste artigo, você usará a biblioteca sentence-transformers para executar um model de embedding do Hugging Face direto na sua máquina. Após a leitura, você saberá como criar embeddings, calcular similaridade, fazer busca semântica, escrever testes e disponibilizar o model como um serviço HTTP para que sua aplicação Spring Boot o chame.
2. Instalação
sentence-transformers é uma biblioteca Python que roda sobre PyTorch. Você deve criar um ambiente virtual separado:
python -m venv .venv
source .venv/bin/activate
pip install sentence-transformers
Este comando baixa torch, transformers e huggingface_hub. Se sua máquina tiver GPU NVIDIA, instale primeiro a versão torch com CUDA, depois instale sentence-transformers.
Na primeira vez que você carrega um model, a biblioteca baixa-o do Hugging Face Hub para o diretório de cache (por padrão ~/.cache/huggingface). Nas próximas vezes, o model é lido direto do cache.
3. Criando seu primeiro embedding
Começaremos com all-MiniLM-L6-v2. Este é um model pequeno (cerca de 90 MB), rápido e adequado para experimentar com textos em inglês:
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())
Explicação:
SentenceTransformer(...)carrega o model e o tokenizer do Hub, ou do cache se já existir.model.encode(sentences)recebe uma lista de strings e retorna umnumpy.ndarray. Cada sentença corresponde a uma linha.get_sentence_embedding_dimension()informa o número de dimensões do vetor.
Resultado:
(3, 384)
384
Você tem três sentenças, cada uma se torna um vetor de 384 dimensões. Modelos diferentes terão números de dimensões diferentes, por exemplo 768 ou 1024.
3.1. Textos em português
all-MiniLM-L6-v2 foi treinado principalmente em inglês. Para português, você deve usar um model multilíngue:
model = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)
vectors = model.encode([
"Pedido foi entregue à unidade de transporte.",
"Seu pacote saiu do armazém ontem à noite.",
])
print(vectors.shape)
(2, 384)
Este model também produz vetores de 384 dimensões. Ele compreende cerca de 50 idiomas, incluindo português.
4. Calculando similaridade
Vetores só são úteis quando você os compara entre si. A medida mais comum é cosine similarity. A partir da versão 3, a biblioteca oferece 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) retorna uma matriz torch.Tensor, onde a célula [i][j] é a similaridade entre a sentença i e a sentença j. A saída parece ser algo como:
tensor([[1.0000, 0.6123, 0.0412],
[0.6123, 1.0000, 0.0587],
[0.0412, 0.0587, 1.0000]])
A diagonal sempre é 1 porque cada sentença é idêntica a si mesma. As duas sentenças sobre envio têm pontuação alta, mesmo que praticamente não compartilhem palavras. A sentença sobre mudar senha é praticamente não relacionada.
4.1. Normalizando vetores
Se você pretende armazenar vetores em um banco de dados vetorial, normalize-os para comprimento 1:
embeddings = model.encode(sentences, normalize_embeddings=True)
score = embeddings[0] @ embeddings[1]
print(round(float(score), 4))
0.6123
Quando o vetor já está normalizado, o produto escalar (@) fornece o valor correto da cosine similarity. Esta operação é mais barata e muitos bancos de dados como pgvector, Qdrant e Elasticsearch a otimizam especificamente.
5. Busca semântica
Um problema do mundo real é encontrar a resposta mais apropriada em um banco de FAQ para a pergunta de um cliente. A função util.semantic_search faz isso:
from sentence_transformers import SentenceTransformer, util
model = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)
faq = [
"Como rastrear meu pedido?",
"Qual é a política de reembolso?",
"Esqueci minha senha de login.",
"Qual é a taxa de envio para o exterior?",
]
faq_embeddings = model.encode(faq, convert_to_tensor=True, normalize_embeddings=True)
query = "Onde está meu pedido agora?"
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']]}")
Explicação:
- Você codifica todo o FAQ uma vez. Em uma aplicação real, este passo roda na inicialização ou quando os dados mudam.
convert_to_tensor=Truemantém o resultado como um tensor, então você não precisa converter para frente e para trás com numpy.semantic_searchretorna uma lista para cada consulta. Cada elemento é um dicionário comcorpus_idescore, ordenados em ordem decrescente.
Saída de exemplo:
0.721 Como rastrear meu pedido?
0.388 Qual é a taxa de envio para o exterior?
Esta abordagem é suficiente para dezenas de milhares de documentos residindo na memória. Com milhões de documentos, você deve migrar para um banco de dados vetorial com índice ANN.
Observação: Alguns modelos como E5 ou BGE requerem um prefixo adicional, por exemplo
"query: "para perguntas e"passage: "para documentos. Omitir o prefixo pode reduzir significativamente a qualidade da busca. Leia a model card antes de usar. Você pode passarprompt="query: "paraencode.
6. Otimizando performance
encode tem alguns parâmetros que afetam diretamente a velocidade:
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
deviceescolhe entre CPU ou GPU. Em GPU, a velocidade pode ser dezenas de vezes mais rápida.batch_sizepadrão é 32. Em GPU, aumentar para 64–256 geralmente ajuda a executar mais rápido, desde que você não fique sem VRAM.- Sempre envie a lista inteira para
encode. Chamarencodeuma sentença por vez em um loopforé muitas vezes mais lento.
Cada model também tem um limite de comprimento de entrada, verifique model.max_seq_length. Para all-MiniLM-L6-v2, esse valor é 256 tokens. Texto que exceder isso é silenciosamente truncado, sem aviso. Portanto, documentos longos precisam ser divididos (chunking) antes de serem codificados.
7. Escrevendo testes para embeddings
Embeddings são números reais, então você não pode comparar cada valor a um número fixo. Teste propriedades em vez de valores específicos. Instale 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
A fixture tem scope="module" então o model é carregado apenas uma vez para todo o arquivo. O terceiro teste detecta se alguém acidentalmente muda para um model inadequado.
8. Chamando o model de uma aplicação Spring Boot
A maioria do seu backend pode ser escrita em Java. Uma forma simples é envolver o model em um pequeno serviço HTTP com FastAPI e chamá-lo a partir do Spring Boot.
8.1. Serviço de embedding com 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
O model é carregado uma vez quando o módulo inicia, não é recarregado para cada requisição. O endpoint usa def em vez de async def. A razão é que encode é uma tarefa blocking, e FastAPI executará a função def em um thread pool separado em vez de bloquear o event loop.
8.2. Cliente em Spring Boot
Dependência 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 (disponível a partir do Spring Framework 6.1) envia o EmbedRequest como JSON, depois mapeia a resposta para o record EmbedResponse usando Jackson. Você pode usar o resultado para armazenar em pgvector ou comparar com outro vetor.
8.3. Testando o cliente com 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("Pedido atrasado"))
.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("Pedido atrasado"));
assertThat(result).hasSize(1);
assertThat(result.getFirst()).containsExactly(0.1f, 0.2f, 0.3f);
server.verify();
}
}
MockRestServiceServer.bindTo(builder) deve ser chamado antes do builder criar o RestClient. Neste teste, isso sempre é verdade porque o client chama build() apenas dentro do constructor. Por isso, o teste roda sem precisar do serviço Python real.
9. Erros comuns
- Misturando vetores de múltiplos modelos. Vetores de dois modelos diferentes residem em dois espaços diferentes, mesmo que tenham o mesmo número de dimensões. Trocar de model significa codificar todos os dados novamente.
- Esquecer de normalizar de um lado. Se os documentos estiverem normalizados mas a consulta não, o escore de produto escalar será impreciso. Use a mesma configuração
encodeem ambos os lados. - Texto muito longo. Texto que excede
max_seq_lengthé truncado silenciosamente. Divida os documentos antes de codificar. - Carregando o model em cada requisição. Esta operação leva alguns segundos e muita RAM. Carregue apenas uma vez na inicialização.
- Container sem acesso à rede. Ambientes de produção geralmente bloqueiam o acesso ao Hub. Baixe o model antecipadamente para a imagem e defina
HF_HUB_OFFLINE=1.
Observação: Embeddings não podem substituir buscas exatas. Para códigos de pedido, números de telefone ou SKU, busca full-text ou consultas SQL normais produzem resultados melhores. Na prática, combinar ambos (hybrid search) costuma ser mais eficaz.
10. Conclusão
Você usou sentence-transformers para criar embeddings com apenas algumas linhas de código. Você também calculou cosine similarity, construiu busca semântica para um banco de FAQ e ajustou batch_size e device para executar mais rápido. Além disso, aprendeu a testar embeddings através de propriedades e como envolver o model em um serviço HTTP para que o Spring Boot o chame através de RestClient.
O próximo passo natural é armazenar os vetores em um banco de dados vetorial como pgvector e usá-los como parte da retrieval em um pipeline RAG.