Ejecutar modelos embedding con sentence-transformers

1. Descripción general

Un embedding es la forma de convertir una oración en un vector de números. Dos oraciones con significado parecido producirán dos vectores cercanos. Esta es la base de la búsqueda semántica, RAG, agrupamiento de textos y recomendación de contenidos.

En este artículo, usarás la librería sentence-transformers para ejecutar un modelo de embedding de Hugging Face directamente en tu máquina. Al terminar, sabrás cómo crear embeddings, calcular similitud, buscar por semántica, escribir pruebas y exponer el modelo como un servicio HTTP para que aplicaciones Spring Boot lo invoquen.

2. Instalación

sentence-transformers es una librería Python que se ejecuta sobre PyTorch. Deberías crear un entorno virtual:

python -m venv .venv
source .venv/bin/activate
pip install sentence-transformers

Este comando descarga torch, transformers y huggingface_hub. Si tu máquina tiene una GPU NVIDIA, instala primero la versión de torch con CUDA, luego sentence-transformers.

La primera vez que cargues un modelo, la librería lo descargará desde Hugging Face Hub al directorio de caché (por defecto ~/.cache/huggingface). Las siguientes veces se leerá directamente de caché.

3. Tu primer embedding

Comenzamos con all-MiniLM-L6-v2. Es un modelo pequeño (unos 90 MB), rápido, ideal para experimentar con textos en 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())

Explicación:

  • SentenceTransformer(...) carga el modelo y tokenizer desde Hub, o desde caché si ya existen.
  • model.encode(sentences) recibe una lista de cadenas y devuelve un numpy.ndarray. Cada oración corresponde a una fila.
  • get_sentence_embedding_dimension() devuelve el número de dimensiones del vector.

Resultado:

(3, 384)
384

Tienes tres oraciones, cada una se convierte en un vector de 384 dimensiones. Modelos diferentes tendrán un número de dimensiones distinto, por ejemplo 768 o 1024.

3.1. Textos en vietnamita

all-MiniLM-L6-v2 fue entrenado principalmente en inglés. Para vietnamita, deberías usar un modelo multilingüe:

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)

Este modelo también produce vectores de 384 dimensiones. Comprende alrededor de 50 idiomas, incluyendo vietnamita.

4. Calcular similitud

Los vectores solo son útiles cuando los comparas entre sí. La métrica más común es la similitud del coseno. Desde la versión 3, la librería incluye 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) devuelve una matriz torch.Tensor donde la celda [i][j] es la similitud entre la oración i y la oración j. La salida se parece a lo siguiente (los números pueden variar levemente según la versión):

tensor([[1.0000, 0.6123, 0.0412],
        [0.6123, 1.0000, 0.0587],
        [0.0412, 0.0587, 1.0000]])

La diagonal siempre es 1 porque cada oración es idéntica a sí misma. Las dos oraciones sobre envíos tienen una puntuación alta, aunque casi no comparten palabras. La oración sobre cambio de contraseña es prácticamente no relacionada.

4.1. Normalización de vectores

Si planeas guardar vectores en una base de datos vectorial, normalízalos a longitud 1:

embeddings = model.encode(sentences, normalize_embeddings=True)
score = embeddings[0] @ embeddings[1]
print(round(float(score), 4))
0.6123

Cuando los vectores están normalizados, el producto escalar (@) devuelve exactamente el valor de similitud del coseno. Esta operación es más económica, y muchas bases de datos como pgvector, Qdrant o Elasticsearch están optimizadas específicamente para esto.

5. Búsqueda semántica

Un problema real es encontrar la respuesta más apropiada de una base de preguntas frecuentes para una pregunta del cliente. La función util.semantic_search lo hace:

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']]}")

Explicación:

  • Codificas la base de preguntas frecuentes una sola vez. En una aplicación real, este paso se ejecuta al iniciar o cuando cambian los datos.
  • convert_to_tensor=True mantiene el resultado como tensor, evitando conversiones innecesarias con numpy.
  • semantic_search devuelve una lista para cada query. Cada elemento es un diccionario con corpus_id y score, ordenados de mayor a menor.

Ejemplo de salida:

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?

Este método es suficiente para decenas de miles de documentos en memoria. Con millones de documentos, deberías cambiar a una base de datos vectorial con índice ANN.

Nota: Algunos modelos como la familia E5 o BGE requieren prefijos adicionales, por ejemplo "query: " para preguntas y "passage: " para documentos. Sin el prefijo, la calidad de búsqueda puede degradarse significativamente. Revisa la tarjeta del modelo antes de usarlo. Puedes pasar prompt="query: " a encode.

6. Optimización del rendimiento

encode tiene varios parámetros que afectan directamente la velocidad:

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
  • device elige entre CPU o GPU. En GPU, la velocidad puede ser decenas de veces más rápida.
  • batch_size es 32 por defecto. En GPU, aumentarlo a 64–256 generalmente acelera el proceso, siempre que no agotes la VRAM.
  • Siempre pasa toda la lista a encode. Llamar encode en cada oración dentro de un bucle for es muchísimo más lento.

Cada modelo tiene un límite de longitud de entrada, consulta model.max_seq_length. Con all-MiniLM-L6-v2, este valor es 256 tokens. El texto que excede este límite es silenciosamente truncado, sin advertencia. Por lo tanto, documentos largos deben ser divididos antes de codificar.

7. Pruebas unitarias para embeddings

Los embeddings son números reales, así que no puedes comparar valores fijos exactamente. Prueba propiedades en lugar de valores específicos. Instala 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 tiene scope="module" de modo que el modelo se carga una única vez para todo el archivo. La tercera prueba detecta cuando alguien accidentalmente cambia a un modelo inadecuado.

8. Invocar modelos desde Spring Boot

Es probable que la mayor parte de tu backend esté escrito en Java. Una forma simple es envolver el modelo en un pequeño servicio HTTP usando FastAPI, e invocarlo desde Spring Boot.

8.1. Servicio de embedding con 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

El modelo se carga una sola vez cuando se inicia el módulo, no se recarga para cada solicitud. El endpoint usa def en lugar de async def. La razón es que encode es una operación bloqueante, y FastAPI ejecutará la función def en un pool de threads separado en lugar de bloquear el event loop.

8.2. Cliente en Spring Boot

Dependencia 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 desde Spring Framework 6.1) envía EmbedRequest como JSON, luego mapea la respuesta en el record EmbedResponse usando Jackson. Puedes usar el resultado para guardarlo en pgvector o compararlo con otros vectores.

8.3. Pruebas del cliente con 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) debe ser llamado antes de que el builder cree RestClient. En esta prueba, siempre es así porque el cliente solo llama build() dentro del constructor. De este modo, la prueba se ejecuta sin necesidad del servicio Python real.

9. Errores comunes

  • Mezclar vectores de múltiples modelos. Los vectores de dos modelos diferentes viven en espacios diferentes, incluso si tienen el mismo número de dimensiones. Cambiar de modelo significa recodificar todos los datos.
  • Olvidar normalizar en un lado. Si los documentos están normalizados pero la query no, el producto escalar será incorrecto. Usa la misma configuración de encode en ambos lados.
  • Texto demasiado largo. La parte que excede max_seq_length se trunca sin advertencia. Divide los documentos antes de codificar.
  • Cargar el modelo en cada solicitud. Esta operación toma varios segundos y mucha RAM. Cárgalo una sola vez al iniciar.
  • Container sin acceso a la red. El entorno de producción generalmente bloquea el acceso a Hub. Descarga el modelo de antemano en la imagen, luego establece HF_HUB_OFFLINE=1.

Nota: Los embeddings no reemplazan la búsqueda exacta. Para códigos de pedido, números telefónicos o SKU, la búsqueda de texto completo o consultas SQL tradicionales dan mejores resultados. En la práctica, combinar ambos suele ser más efectivo.

10. Conclusión

Has usado sentence-transformers para crear embeddings con solo unas pocas líneas de código. También has calculado similitud del coseno, construido una búsqueda semántica para una base de preguntas frecuentes y ajustado batch_size y device para acelerar la ejecución. Además, aprendiste a probar embeddings mediante propiedades y envolviste el modelo como un servicio HTTP para que Spring Boot lo invoque con RestClient.

El siguiente paso lógico es guardar vectores en una base de datos vectorial como pgvector, usándolos luego en la etapa de recuperación de un pipeline RAG.

huggingfacesentence-transformersembeddingpythonspring-boot