Servir embeddings y rerankers con TEI en producción

Sexta pieza de una serie operativa sobre exprimir un cluster LLM on-premise genérico de 4×H100 SXM 80 GB. Si el RAG en CPU argumentaba dónde corre el plano de datos y el zoo de embedders decidía qué modelo sirves, este post mira el motor que los sirve: el servidor de embeddings y rerankers. Las hermanas de esta tanda —la ingesta documental de PDF a chunk indexado, el GitOps del stack de inferencia con Flux y el hardening y secretos del stack soberano— montan el resto del sistema alrededor de esta pieza. El cierre de la serie, el asistente soberano end-to-end con LibreChat + LiteLLM + RAG, sigue en borrador.

TL;DR

Servir embeddings no es model.encode(texto) esparcido por el código de ingesta. Igual que no metes un transformers.generate() dentro de tu API y lo llamas vLLM, tampoco metes el embedder inline: lo pones detrás de un servidor dedicado que hace batching, controla la concurrencia, emite métricas y expone un único contrato HTTP que tanto la ingesta como el query-time reutilizan. En el ecosistema open source de 2026 ese servidor es TEI — Text Embeddings Inference de Hugging Face: un binario en Rust que sirve modelos de embedding, reranker (cross-encoder) y clasificación de secuencias, con batching dinámico por tokens, y que expone endpoints OpenAI-compatibles (/v1/embeddings) además de los nativos /embed, /embed_sparse, /rerank y /predict (repo TEI, docs). Corre en CPU (ONNX Runtime, Intel MKL) y en CUDA (con FlashAttention y cuda graphs), con el mismo contrato a ambos lados, así que el resto del sistema no sabe —ni le importa— qué silicio hay detrás. La pieza técnica que da casi todo el rendimiento es el dynamic batching: TEI agrupa peticiones que llegan por separado en un batch que comparte el coste fijo del forward, controlado por --max-batch-tokens (cuántos tokens caben por batch) y --max-concurrent-requests (backpressure). Agrupar sube el throughput —pasar de servir 1 secuencia a 32 multiplica los tokens/s a coste casi constante hasta saturar el hardware— a cambio de algo de latencia mientras se llena la bandeja. Los números: un encoder de 568M (bge-m3, bge-reranker-v2-m3) ocupa ~1.1 GB en fp16 y ~0.57 GB en int8, así que caben varias réplicas hasta en un slice pequeño de H100; embeber una query corta online cuesta decenas de ms, pero rerankear el top-50 cuesta lineal en candidatos porque un cross-encoder evalúa cada par (query, doc) por separado. El reparto en el cluster 4×H100: TEI-CPU en la flota para la ingesta batch (sin SLA, throughput/€), TEI-GPU en un slice MIG de una H100 para embeddings y rerank online de alto QPS, comunicados con la ingesta y con el gateway LiteLLM por el contrato OpenAI.

La analogía: la prensa de troquelar que solo estampa vectores

Imagina un taller con una prensa de troquelar especializada. No fabrica piezas variadas; hace una sola cosa: coge material plano y, de un golpe, le estampa una forma. En nuestro caso, coge texto y estampa un vector. Y como toda prensa industrial, tiene una propiedad que define todo su comportamiento: el coste del golpe es casi fijo, da igual cuánto material metas en la bandeja.

Bajar la prensa, calentar el troquel, alinear, prensar y levantar cuesta —pongamos— un segundo. Si metes una sola lámina, gastas ese segundo entero en estampar una pieza: un desperdicio. Si llenas la bandeja con treinta láminas y bajas la prensa una vez, gastas casi el mismo segundo y sales con treinta piezas estampadas. El coste por pieza se desploma. La prensa rinde muchísimo más cuando llena la bandeja antes de prensar.

Esto es exactamente el dynamic batching de un servidor de embeddings. El “golpe” es el forward pass del modelo sobre la GPU o la CPU: una operación que carga los pesos, los multiplica por las activaciones y emite los vectores, con un coste fijo grande que no escala con cuántas secuencias proceses a la vez —hasta que saturas el ancho de banda de memoria o las unidades de cómputo—. Servir las peticiones de una en una es estampar una lámina por golpe. Agruparlas en un batch es llenar la bandeja.

Pero la prensa tiene un dilema operativo, y aquí está el corazón del post. Si esperas a tener la bandeja llena del todo antes de prensar, las primeras láminas que llegaron se quedan esperando a que lleguen las demás: latencia. Si prensas en cuanto cae una lámina para no hacer esperar a nadie, vuelves al desperdicio del golpe por pieza: throughput bajo. El servidor de embeddings resuelve esto con una ventana de espera acotada: junta lo que llegue en una ventana de microsegundos a milisegundos, o hasta llenar el cupo de tokens del batch, y entonces prensa. Es la política que convierte un montón de peticiones independientes en pocos golpes eficientes sin que nadie espere de más.

El resto del post es, en el fondo, configurar bien esa prensa: cuántas láminas caben en la bandeja (max-batch-tokens), cuánta cola se tolera antes de rechazar trabajo (max-concurrent-requests), si la prensa vive en la flota barata de CPU (ingesta nocturna, throughput puro) o en un slice de la GPU cara (online, latencia acotada), y por qué el reranker es una prensa distinta cuyo coste crece con el número de piezas.

Qué es TEI, exactamente

Text Embeddings Inference (TEI) es el servidor de inferencia de Hugging Face para la familia de modelos que no genera tokens: embedders, rerankers cross-encoder y clasificadores de secuencias. Es a los encoders lo que vLLM es a los LLM generativos: un servidor de alto rendimiento, escrito en Rust, que se ocupa del tokenizado, el batching, la concurrencia, las métricas y el contrato HTTP, dejando que el código de aplicación solo haga llamadas (repo TEI).

Tres tipos de modelo, tres trabajos:

  • Embedding (bi-encoder): convierte un texto en un vector reutilizable. bge-m3, Snowflake Arctic Embed, multilingual-e5. El vector se guarda en el índice y se reutiliza en cada búsqueda.
  • Reranker (cross-encoder): toma un par (query, documento) y emite un score de relevancia, no un vector. bge-reranker-v2-m3. No produce nada reutilizable: cada par se evalúa de nuevo.
  • Clasificación de secuencias: emite etiquetas/probabilidades sobre un texto. Útil para guardrails ligeros, routing, detección de idioma o toxicidad.

Los endpoints

TEI expone tanto un endpoint OpenAI-compatible como sus endpoints nativos, verificados contra la documentación actual (Quick Tour, DeepWiki):

EndpointPara quéTipo de modelo
/v1/embeddingsEmbeddings con el contrato OpenAI (drop-in para clientes que ya hablan OpenAI)embedder
/embedEmbeddings dense, API nativa de TEIembedder
/embed_sparseEmbeddings sparse (cabeza léxica de modelos tipo bge-m3/SPLADE)embedder
/rerankOrdena una lista de textos por relevancia frente a una queryreranker (cross-encoder)
/predictClasificación de secuencias (etiquetas/scores)clasificador
/embed_allEmbeddings por token (sin pooling)embedder
/similaritySimilitud directa entre textosembedder
/metrics, /health, /info, /tokenize, /decodeOperación, observabilidad y utilidadestodos

La clave operativa está en la primera fila: que TEI hable el contrato OpenAI en /v1/embeddings significa que el embedder es sustituible sin tocar el código cliente. El mismo código que llamaba a text-embedding-3-large de OpenAI apunta a tu TEI on-prem cambiando la base_url, y el gateway (LiteLLM) lo enruta como un proveedor más. El /rerank, en cambio, no forma parte del estándar OpenAI —no existe tal endpoint en su API— así que el gateway lo trata como un endpoint específico de reranking (LiteLLM TEI rerank).

Los backends

TEI corre en CPU y en GPU con el mismo contrato HTTP, lo que es exactamente la propiedad que el reparto CPU/GPU del RAG necesita (docs TEI, hardware):

  • CPU: imagen cpu-*. Backend ONNX Runtime (recomendado) o Intel MKL. Aprovecha las rutas de cómputo entero (AVX-512+VNNI, AMX en Xeon de 4ª gen) cuando el modelo está en int8. Es el motor de la ingesta batch.
  • CUDA: imágenes específicas por arquitectura (cuda-*, con variantes para distintas compute capabilities). Usa FlashAttention y cuda graphs para exprimir la GPU. Requiere compute capability ≥ 7.5 (Volta queda fuera) y drivers compatibles con CUDA 12.2+. Es el motor del online de alto QPS.
  • También hay soporte Metal (Apple Silicon) y experimental ROCm (AMD Instinct), menos relevantes para el caso on-prem de este post.

La regla de oro: un mismo bge-m3 servido por TEI-CPU y por TEI-GPU expone /v1/embeddings idéntico. El cliente no se entera del silicio. Eso es lo que permite poner la ingesta en CPU y el online en GPU sin reescribir nada.

Por qué un servidor dedicado y no llamar al modelo inline

La tentación, sobre todo en el prototipo, es cargar el embedder en el proceso de la aplicación y llamar a model.encode() directamente. Funciona en el demo y se rompe en producción por cinco razones, todas resueltas por el servidor dedicado:

  1. Batching. Tu código de ingesta procesa documentos; tu código de query embebe una query a la vez. Inline, cada encode() es un forward independiente —un golpe de prensa por lámina—. Un servidor dedicado junta peticiones de orígenes distintos (ingesta + varias queries concurrentes) en un solo batch y amortiza el coste fijo. Esto es lo que de verdad multiplica el throughput, y no lo puedes hacer si el modelo vive dentro de cada proceso aislado.

  2. Concurrencia y backpressure. Bajo carga, ¿qué pasa cuando llegan 10.000 peticiones a la vez? Inline, te quedas sin memoria o encolas sin control. TEI tiene --max-concurrent-requests: por encima de ese límite rechaza en vez de degradar a todos, que es la forma correcta de gestionar la sobrecarga (CLI args TEI).

  3. Métricas. TEI expone /metrics en formato Prometheus: latencias, tamaño de batch, tokens/s, peticiones en cola. Inline no tienes nada de esto sin instrumentar a mano cada encode(). Para observar el plano de datos necesitas ese endpoint.

  4. Un contrato HTTP reutilizable. El mismo servidor lo consume la ingesta batch, el query-time online y cualquier otro servicio que necesite vectores. Una sola copia del modelo en memoria, un solo punto de versión, un solo lugar donde cambiar el modelo. Inline, cada servicio carga su propia copia y la versión deriva sola.

  5. Separación de ciclo de vida. El embedder se actualiza, se reinicia o se escala sin tocar la aplicación. Si mañana cambias bge-m3 por Snowflake Arctic, cambias el contenedor del servidor, no el código de N servicios. (Recuerda la trampa del drift del embedder: cambiar de embedder obliga a reindexar; tener un único servidor hace ese cambio atómico y auditable.)

Es el mismo argumento por el que nadie sirve un LLM con transformers.generate() en producción: se sirve con un motor dedicado. El embedder no es distinto.

Dynamic batching: la mecánica de la prensa

Aquí está el motor del rendimiento. TEI hace token-based dynamic batching: en vez de procesar peticiones de una en una o agruparlas por número fijo de peticiones, las agrupa por presupuesto de tokens (README TEI, Discussion #367).

Dos perillas mandan:

  • --max-batch-tokens (por defecto depende de la build, típicamente 16384): el total de tokens que caben en un batch. Con max-batch-tokens=1000 te entran 10 peticiones de 100 tokens, o una de 1000. La doc lo dice claro: este número debería ser el mayor posible hasta que el modelo se vuelva compute-bound. Es el tamaño de la bandeja de la prensa, medido en tokens, no en piezas —porque el coste real escala con tokens, no con número de textos—.
  • --max-concurrent-requests (por defecto 512): cuántas peticiones admite en vuelo antes de rechazar. Es el control de backpressure, no de batching: protege de avalanchas devolviendo error en vez de encolar indefinidamente.

La política: TEI mantiene una cola interna. Cuando hay trabajo, llena un batch hasta el cupo de max-batch-tokens (o hasta que no hay más peticiones esperando) y lo manda al modelo de un golpe. Las peticiones que llegan mientras el batch anterior se procesa esperan en la cola y entran en el siguiente. Cuidado con un detalle operativo real: si max-batch-tokens es menor que la longitud máxima de entrada del modelo, puedes provocar un bucle donde una petición larga nunca cabe en un batch (Issue #723); el cupo de tokens tiene que ser al menos tan grande como la secuencia más larga que admitas.

El número: throughput con batching vs sin batching

Modelicemos la prensa. Sea $C$ el coste fijo de un forward pass (cargar pesos, lanzar kernels) y $c$ el coste marginal por secuencia dentro del batch. Para un batch de tamaño $B$, el tiempo del golpe es aproximadamente:

$$t(B) \approx C + c \cdot B$$

mientras el batch quepa en el hardware (memory-bandwidth-bound, que es el régimen normal de un encoder pequeño en GPU). El throughput —secuencias por segundo— es:

$$\text{throughput}(B) = \frac{B}{t(B)} = \frac{B}{C + c \cdot B}$$

Pongamos números concretos, orientativos pero del orden correcto para bge-m3 (568M) en una GPU sirviendo secuencias de ~256 tokens. Digamos $C = 4$ ms de coste fijo por golpe y $c = 0.5$ ms por secuencia marginal.

Sin batching ($B=1$):

$$t(1) = 4 + 0.5 = 4.5 \text{ ms} \quad\Rightarrow\quad \text{throughput} = \frac{1}{4.5\text{ms}} \approx 222 \text{ sec/s}$$

Con batching ($B=32$):

$$t(32) = 4 + 0.5 \times 32 = 20 \text{ ms} \quad\Rightarrow\quad \text{throughput} = \frac{32}{20\text{ms}} = 1600 \text{ sec/s}$$

El batch de 32 multiplica el throughput por ~7.2× respecto a servir de una en una, porque el coste fijo $C$ —que con $B=1$ era el 89% del tiempo— se reparte entre 32. Y el límite asintótico, cuando $B \to \infty$, es $1/c = 2000$ sec/s: la prensa no puede ir más rápido que su coste marginal, y nos quedamos en el 80% de ese techo ya con $B=32$. Subir a $B=128$ daría ~1969 sec/s, una mejora marginal a cambio de bastante más latencia y memoria. El rendimiento del batching tiene rendimientos decrecientes: la mayor parte de la ganancia está en pasar de 1 a unas decenas.

El trade-off de latencia es la otra cara. La petición que llegó primera al batch de 32 no ve su respuesta hasta que el golpe entero termina: espera lo que tarda en llenarse la bandeja más los 20 ms del forward. Si las 32 peticiones llegaron en una ventana de, digamos, 5 ms, la primera petición sufre ~5 ms de espera de batching + 20 ms de cómputo = 25 ms de latencia, frente a los 4.5 ms que habría tenido sirviéndose sola. La latencia p99 empeora —es el precio de la bandeja llena—, pero a cambio el sistema absorbe 7× más carga con el mismo silicio. Por eso la configuración correcta depende del caso de uso: en ingesta batch (sin usuario esperando) maximizas max-batch-tokens y te da igual la latencia; en online (usuario esperando) acotas la ventana de espera para mantener la p99 bajo control aunque sacrifiques algo de throughput.

Dynamic batching por tokens en TEIPeticionesllegan por separadoquery A · 80 tokquery B · 120 tokingesta · 256 tokingesta · 256 tokquery C · 60 tokCola internallena bandeja hastamax-batch-tokensventana acotada o cupo llenobatchUN forward passGPU (CUDA, FlashAttn)o CPU (ONNX/AMX int8)coste C + c·B"un golpe de prensa"Respuestasvector Avector Bvectores ingestavector CEl trade-off:batch grande → throughput alto (coste fijo C repartido entre B) pero p99 peor (la 1.ª espera a llenar)batch=1 → latencia mínima por petición pero throughput pésimo (un golpe por lámina)Ingesta: maximiza max-batch-tokens. Online: acota la ventana de espera para proteger la p99.

Deployment: compose para embedder y reranker

TEI se despliega como contenedor. Los argumentos clave: --model-id (el modelo del Hub), --pooling (cómo agrega los tokens en el vector: cls, mean, splade, last-token), --dtype (precisión), y las perillas de batching ya vistas. Dos servicios distintos —el embedder y el reranker son modelos y endpoints distintos—, cada uno con su contenedor.

Embedder (bge-m3) en GPU

# docker-compose: TEI sirviendo bge-m3 como embedder, en GPU
services:
  tei-embed:
    image: ghcr.io/huggingface/text-embeddings-inference:cuda-1.9
    command:
      - --model-id=BAAI/bge-m3
      - --pooling=cls
      - --dtype=float16
      - --max-batch-tokens=16384        # bandeja grande: la prensa rinde
      - --max-concurrent-requests=512   # backpressure: rechaza por encima
    ports: ["8081:80"]
    volumes: ["./hf-cache:/data"]
    deploy:
      resources:
        reservations:
          devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]
    # expone /v1/embeddings (OpenAI), /embed, /embed_sparse, /metrics

Reranker (bge-reranker-v2-m3) en GPU

  tei-rerank:
    image: ghcr.io/huggingface/text-embeddings-inference:cuda-1.9
    command:
      - --model-id=BAAI/bge-reranker-v2-m3   # cross-encoder, ~568M
      - --dtype=float16
      - --max-batch-tokens=16384
      - --max-concurrent-requests=256
    ports: ["8082:80"]
    volumes: ["./hf-cache:/data"]
    deploy:
      resources:
        reservations:
          devices: [{driver: nvidia, count: 1, capabilities: [gpu]}]
    # expone /rerank — se invoca SOLO sobre top-k (20/50), no sobre cientos

bge-reranker-v2-m3 es un cross-encoder de ~568M construido sobre bge-m3, multilingüe, que toma (query, documento) y emite un score de relevancia directamente, sin producir vector reutilizable (model card, BGE docs). No lleva --pooling porque no emite embedding: emite un escalar.

CPU para la ingesta batch

Para la flota CPU, basta cambiar la imagen y el dtype; el contrato HTTP es idéntico:

  tei-embed-cpu:
    image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.6
    command:
      - --model-id=BAAI/bge-m3
      - --pooling=cls
      - --dtype=float16          # en CPU, ONNX int8 si el export lo soporta
      - --max-batch-tokens=8192  # bandeja menor: la CPU satura antes
    ports: ["8083:80"]
    volumes: ["./hf-cache:/data"]
    # backend ONNX/MKL: aprovecha AVX-512+VNNI / AMX en Xeon de 4ª gen

Un cliente OpenAI cualquiera embebe contra el endpoint sin saber qué hay detrás:

from openai import OpenAI
client = OpenAI(base_url="http://tei-embed:80/v1", api_key="-")  # api_key ignorada
r = client.embeddings.create(model="BAAI/bge-m3", input=["consulta del usuario"])
vec = r.data[0].embedding   # mismo contrato que OpenAI; el silicio es invisible

Y el rerank, por su endpoint nativo (no OpenAI):

import requests
docs = ["chunk recuperado 1", "chunk recuperado 2", "...top-20 del retrieval..."]
r = requests.post("http://tei-rerank:80/rerank",
                  json={"query": "consulta del usuario", "texts": docs})
ranked = r.json()   # [{index, score}, ...] ordenado por relevancia

Integración: cómo lo consumen la ingesta y el gateway

El servidor TEI tiene dos clientes con perfiles opuestos, y ese es justo el motivo de centralizarlo:

La ingesta (batch, sin SLA) llama a /v1/embeddings o /embed con lotes grandes de chunks. Aquí interesa el throughput: la ingesta empuja miles de textos y TEI los agrupa en batches grandes contra el max-batch-tokens alto. Es el caso de la prensa con la bandeja llena. La ingesta luego escribe los vectores (dense + sparse) en el vector DB —el pipeline concreto de PDF a chunk indexado es la pieza hermana en preparación de esta serie; mientras, el esquema PostgreSQL + Qdrant en microservicios cubre la estructura del pipeline—.

El gateway (query-time, online) consume TEI por dos rutas distintas:

  1. Embeber la query: una sola llamada a /v1/embeddings con un texto corto. El gateway —LiteLLM u otro router L7— lo trata como un proveedor de embeddings OpenAI más, enrutado por modelo. Esa query embebida va al vector DB para la búsqueda híbrida.
  2. Rerankear el top-k: tras recuperar 20-50 candidatos del retriever, el gateway llama a /rerank del servidor reranker con la query y la lista de candidatos, y se queda con los mejores. Como /rerank no es estándar OpenAI, LiteLLM lo expone por su contrato de reranking propio (LiteLLM rerank con TEI).

El patrón completo en query-time: embeber query (TEI dense) → buscar (vector DB, hybrid retrieval) → rerankear top-k (TEI rerank) → mandar query + top-5 al LLM. Dos endpoints TEI distintos —uno para dense, otro para rerank—, ambos detrás del gateway, ambos sustituibles.

Dimensionado: memoria, réplicas y presupuesto de latencia

Footprint de un encoder de 568M

El footprint de pesos de bge-m3 (568M params) depende solo de la precisión:

$$\text{fp16: } 568 \times 10^6 \times 2 \text{ B} \approx 1.14 \text{ GB} \qquad \text{int8: } 568 \times 10^6 \times 1 \text{ B} \approx 0.57 \text{ GB}$$

A esto se suma el espacio de activaciones temporales del batch (proporcional a max-batch-tokens × dimensión oculta) y, en TEI, las estructuras de FlashAttention/cuda graphs. En la práctica, un bge-m3 en fp16 se sirve cómodamente en ~6 GB de VRAM incluso a batch alto, y en int8 en CPU el modelo entero (0.57 GB) cabe en caché y RAM de cualquier servidor sin pestañear, activando las rutas AMX/VNNI que lo hacen viable —exactamente el argumento del RAG en CPU—.

¿Cuántas réplicas caben? Si reservamos un slice de 10 GB de una H100 para servir embeddings online, con un footprint efectivo de ~2.5 GB por réplica (pesos fp16 + activaciones + overhead), caben del orden de 3-4 réplicas de bge-m3 en ese slice, repartiendo el QPS entre ellas. En int8 el footprint baja y caben más, a costa de un punto largo de calidad. Para el reranker, el footprint de pesos es el mismo (568M), pero su patrón de batch es distinto (pares query-doc), así que conviene dimensionarlo por separado.

Presupuesto de latencia: query corta vs rerank de top-50

Aquí está la asimetría fundamental entre embedder y reranker, y es lo que decide el dimensionado.

Embeber una query corta (online): es un forward sobre un texto de decenas de tokens. En GPU, dentro de un batch dinámico, son decenas de ms incluyendo la espera de batching —el orden del ejemplo numérico de antes, ~5-25 ms—. Es barato y constante: una query es una query, no importa cuántos documentos haya en el corpus. Por eso embeber online cabe holgadamente en un presupuesto de latencia interactivo.

Rerankear el top-50 (online): un cross-encoder no produce un vector reutilizable; evalúa cada par (query, documento) en un forward. El coste es lineal en el número de candidatos:

$$\text{coste}_{\text{rerank}} \propto k \times \text{forward}(\text{query} + \text{doc})$$

Rerankear top-50 son 50 forwards de pares (query + doc), donde cada par es más largo que la query sola (query + chunk de ~256 tokens). TEI los agrupa en batches —ahí el batching ayuda otra vez—, pero el trabajo total es ~50× el de embeber una query. Si embeber una query cuesta del orden de 10-20 ms, rerankear 50 candidatos cuesta del orden de cientos de ms según el batch y el hardware. De ahí la regla operativa repetida en toda la serie: recall amplio y barato en el retriever, rerank de precisión sobre POCOS candidatos. Rerankear top-20/50 cabe en el presupuesto online; rerankear cientos a alto QPS no, y ahí es donde el reranker reclama GPU dedicada o un slice más grande. El detalle de por qué el cross-encoder es caro pero preciso está en reranker e hybrid retrieval.

El presupuesto de latencia query-time, sumado:

EtapaCoste típico (orden)Escala con
Embeber query (TEI dense)~10-20 msconstante (1 texto corto)
Búsqueda híbrida (vector DB, HNSW+sparse)single-digit mstamaño del índice (sublineal)
Rerank top-k (TEI cross-encoder)~100-300 mslineal en k
Generación (LLM, fuera de TEI)segundostokens de salida

El reranker es la etapa más cara del plano de datos, y la única que escala mal con el ancho de la recuperación. Dimensiónalo como el componente crítico.

Aplicado al cluster genérico 4×H100

Bajemos al cluster de la serie: 4×H100 SXM 80 GB más una flota CPU genérica (Xeon con AMX, NUCs). TEI vive en dos sitios según el perfil de carga, y el contrato OpenAI idéntico es lo que permite que cada sitio sea sustituible.

TEI-CPU en la flota → ingesta batch. El re-embebido del corpus es trabajo throughput-bound sin SLA de latencia: su sitio es la flota CPU con bge-m3 en int8 (ONNX/AMX). Aquí maximizas max-batch-tokens porque nadie espera, y la prensa rinde con la bandeja a tope. Ninguna H100 debería gastar un ciclo re-indexando un corpus que cambia una vez al día —es exactamente el mal reparto que el RAG en CPU desmonta—. La flota CPU escala horizontal y barata; el corpus se trocea y se reparte entre nodos.

TEI-GPU en un slice MIG → embeddings y rerank online de alto QPS. El query-time tiene SLA de latencia y, si el sistema sirve muchas peticiones por segundo, necesita el throughput y la baja latencia de la GPU. Pero un encoder de 568M no merece una H100 entera: ocupa ~2.5 GB efectivos, y dejarle 80 GB es desperdiciar el 97% de la tarjeta. La respuesta correcta es un slice MIG: particionar una H100 en instancias aisladas por hardware y dar a TEI uno de los slices pequeños (un 1g.10gb, por ejemplo), dejando los slices grandes para la generación o para otros tenants. El embedder y el reranker online viven ahí, con aislamiento de memoria y cómputo garantizado por el particionado. El cómo del particionado —MIG, MPS, time-slicing y cuándo usar cada uno— está en compartir una GPU; la lectura para TEI es directa: MIG da el aislamiento duro que un servicio online de latencia acotada quiere, mientras que la ingesta batch en CPU ni siquiera toca la GPU.

El reparto, en una frase: la ingesta exprime throughput/€ en CPU con la bandeja llena; el online exprime latencia/QPS en un slice MIG de H100; ambos hablan el mismo /v1/embeddings y /rerank, así que mover carga de un lado a otro es cambiar una URL en el gateway.

Si en algún momento necesitas un embedder de 7B (gte-Qwen2, NV-Embed) que bge-m3 no alcanza en calidad, eso ya no es trabajo de encoder pequeño: arrastra el perfil de coste de un LLM y se sirve donde se sirven los 7B —con vLLM --task embed en un slice grande de GPU—, no con TEI-CPU. Pero es la excepción puntual, no la carga base.

Conclusión

TEI es la prensa de troquelar del plano de datos del RAG: un servidor especializado que solo estampa vectores y scores, y que rinde muchísimo más cuando llena la bandeja antes de prensar. La pieza técnica que da el rendimiento es el batching dinámico por tokens, controlado por max-batch-tokens y max-concurrent-requests, con un trade-off claro entre throughput y latencia p99 que se resuelve distinto en ingesta (bandeja a tope, sin SLA) y en online (ventana acotada, p99 protegida). La virtud arquitectónica es el contrato OpenAI-compatible en /v1/embeddings, que hace el embedder sustituible sin tocar el código, más los endpoints nativos /embed_sparse y /rerank para la cabeza sparse de bge-m3 y para el cross-encoder bge-reranker-v2-m3. Los números mandan el dimensionado: un encoder de 568M cabe en gigas, no en decenas de gigas, así que varias réplicas viven en un slice MIG; embeber una query es barato y constante, pero rerankear es lineal en candidatos y es la etapa crítica del presupuesto de latencia. En el cluster 4×H100, el reparto correcto es TEI-CPU para la ingesta batch y TEI-GPU en un slice MIG para el online de alto QPS —dos prensas, dos silicios, un solo contrato—.

Ver también

Referencias