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 unnumpy.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=Truemantiene el resultado como tensor, evitando conversiones innecesarias con numpy.semantic_searchdevuelve una lista para cada query. Cada elemento es un diccionario concorpus_idyscore, 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 pasarprompt="query: "aencode.
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
deviceelige entre CPU o GPU. En GPU, la velocidad puede ser decenas de veces más rápida.batch_sizees 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. Llamarencodeen cada oración dentro de un buclefores 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
encodeen ambos lados. - Texto demasiado largo. La parte que excede
max_seq_lengthse 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.