Embeddings en 2026: las tres familias (denso, esparso, multi-vector), el zoo de modelos y la decisión que importa en producción
Este post abre la subsaga de datos dentro del pipeline LLMOps de seis etapas entrando a la pieza que sostiene el retrieval de tres capas: el embedder. Si el bibliotecario de la curación decidía qué entra al índice y el comité de la cátedra decidía qué sale a la cara del modelo, este post mira al de en medio: el cartógrafo que dibuja el mapa por el que se busca.
TL;DR
La conversación sobre embeddings se ha simplificado en producción hasta el punto de que “qué embedder usas” recibe siempre la misma respuesta: OpenAI text-embedding-3-large en el demo, bge-m3 en la versión “sovereign-ready”. Bajo esa simplificación se esconde el hecho de que un embedder es tres modelos distintos a la vez —denso single-vector, esparso aprendido (SPLADE) y multi-vector late-interaction (ColBERT)— y que en 2026 los modelos punteros no compiten en la misma familia: gte-Qwen2-7B-instruct y NV-Embed-v2 rompen MTEB en single-vector denso, SPLADE-v3 y la cabeza esparsa de bge-m3 dominan el descriptor léxico aprendido, Jina-ColBERT-v2 y ColNomic-7B son lo más fuerte en multi-vector multilingüe, y Snowflake Arctic Embed L 2.0 se ha colado como el favorito multilingüe pequeño con Matryoshka decente. Este post desmonta las tres familias con sus matemáticas (InfoNCE con τ, MaxSim, FLOPS regularization de SPLADE, Matryoshka Representation Learning), repasa el zoo de modelos open source con dimensión, licencia y nicho de cada uno, plantea el problema específico del español multilingüe —que reduce la lista de modelos viables a menos de seis—, cuenta el coste real de almacenamiento por millón de chunks con int8 / binario / TurboQuant, describe cómo se sirven on-premise con TEI, Infinity y vLLM --task embed, fija el hardware mínimo en una RTX 4090 y el bueno en un cluster 4×H100, lista las siete trampas operativas que tiran la calidad sin aviso (drift del corpus, normalización olvidada, dimensión Matryoshka mal elegida, hard negatives ausentes, chat template colado en el embedder, tokenizer drift, MTEB overfit) y cierra con un stack license-clean para producción soberana.
La analogía: tres bibliotecarios fichando el mismo libro
Una biblioteca técnica recibe un libro nuevo y antes de meterlo en las estanterías tiene que generarle un identificador buscable. En la biblioteca conviven tres bibliotecarios con tres oficios distintos, y los tres fichan el mismo libro a la vez:
Bibliotecario A — el temático. Lee el libro entero y le pone una sola etiqueta RFID rica. Esa etiqueta es un vector de 1.024 números donde cada coordenada codifica un eje semántico latente (que el bibliotecario nunca verbaliza: lo ha aprendido leyendo cien millones de libros previos). Dos libros sobre Kubernetes en producción acabarán con etiquetas RFID muy cercanas en el espacio aunque uno hable de Linkerd y el otro de Cilium, porque comparten ejes temáticos. Para buscar, comparas la etiqueta de la pregunta con la del libro y devuelves los más próximos por coseno. Es rápido, escala a millones, y se pierde matices finos. Este es el embedder denso single-vector: bge-m3 en modo dense, gte-Qwen2-7B-instruct, Snowflake Arctic Embed L 2.0, multilingual-e5-large-instruct.
Bibliotecario B — el léxico. No resume nada. Lo que hace es escribir en una ficha la lista pesada de términos relevantes del libro, expandida con sinónimos del campo. El libro de Kubernetes lleva en su ficha “kubernetes 4.2, linkerd 3.8, cilium 3.7, service-mesh 4.1, sidecar 3.2, mtls 2.9, ebpf 2.6, k8s 4.0…” — cada término con un peso. La gracia es que la expansión la hace el modelo: si tu pregunta dice “service mesh” y el libro original solo decía “Linkerd”, la ficha del bibliotecario B sí registró “service-mesh 4.1” porque entendió la relación. Para buscar, intersectas la ficha de la pregunta con la del libro a la vieja usanza: índice invertido, posting lists. Es decisivo cuando el lector escribe pocas palabras muy concretas (nombres de producto, errores, jerga). Este es el embedder esparso aprendido: SPLADE-v3 o la cabeza sparse de bge-m3. Es el sucesor moderno de BM25, no su rival; veremos por qué.
Bibliotecario C — el copista. Se rinde a resumir. Coge cada palabra de cada página del libro y le pone una mini-RFID de 128 dimensiones. Acaba con un libro de 30.000 tokens convertido en 30.000 mini-RFIDs. Cuando buscas algo, el bibliotecario compara cada palabra de tu pregunta con cada palabra del libro y se queda con el máximo por cada palabra de la pregunta, sumándolos. Captura matices que los otros dos pierden por construcción (nombres propios, números, formulaciones específicas), pero su sistema de archivado es un orden de magnitud mayor en espacio en disco. Es ColBERT-v2 / Jina-ColBERT-v2 / ColNomic-7B: late interaction, MaxSim.
Y luego está el bibliotecario polivalente que hace los tres trabajos en una sola pasada: bge-m3. Un solo modelo de 568 M parámetros que devuelve simultáneamente la etiqueta RFID temática (1.024-d), la lista pesada de términos (esparsa) y el conjunto de mini-RFIDs por token (128-d). Esa es la razón por la que se ha consolidado como el embedder estándar de RAG on-premise multilingüe: un único model.encode(chunk) produce las tres salidas que alimentan el retrieval híbrido sin orquestar tres modelos distintos.
El mismo chunk indexado por tres bibliotecarios. El polivalente bge-m3 ejecuta los tres en una sola pasada de 568 M parámetros.
Qué es realmente un embedding
Un embedding de texto es una función f : texto → ℝᵈ entrenada para que dos textos “semánticamente parecidos” produzcan vectores cercanos en ese espacio. La parte importante no es “vector” —cualquier hash de longitud fija lo es— sino qué significa cercanía. La cercanía se define implícitamente por la pérdida con la que el modelo se entrena.
Casi todos los embedders modernos se entrenan con InfoNCE (también llamado Multiple-Negatives Ranking Loss en sentence-transformers):
\mathcal{L}_{\text{InfoNCE}} = -\log \frac{\exp(\text{sim}(q, d^+)/\tau)}{\sum_{d \in \mathcal{B}} \exp(\text{sim}(q, d)/\tau)}
Para cada pareja (query, doc⁺) el modelo tiene que asignar al positivo más similitud que a todos los demás documentos del batch B, donde los demás documentos hacen de in-batch negatives gratis. La temperatura τ (típica 0.02–0.07, casi siempre 0.05) controla cuánto se afila la distribución: τ baja → el modelo se vuelve más exigente con el positivo pero más inestable. Tamaño de batch |B| grande → muchos más negativos por gradiente → modelo más informado. Por eso los embedders se entrenan con batch ≥ 1.024 en clusters de H100 con AllGather entre nodos para apilar todos los negativos del cluster como un solo batch efectivo.
A los negativos in-batch se les añade hard negatives mining: documentos seleccionados a propósito porque están “casi en la respuesta” (típicamente los siguientes 10-100 vecinos de un retrieval BM25 / dense previo). Sin hard negatives el modelo aprende a discriminar lo trivial y la calidad real en BEIR / MTEB se hunde 5-10 puntos.
Esto importa porque la familia de embedder depende de qué entras a sim(·,·):
| Familia | sim(q, d) | Salida del modelo |
|---|---|---|
| Denso single-vector | producto escalar de dos vectores 1024-d normalizados | f(q), f(d) ∈ ℝ^d |
| Esparso aprendido | producto escalar de dos vectores 30.522-d (≈80 no-cero cada uno) | f(q), f(d) ∈ ℝ^V, `V = |
| Multi-vector | Σᵢ maxⱼ ⟨qᵢ, dⱼ⟩ (MaxSim) | `f(q) ∈ ℝ^{ |
La pérdida es la misma en los tres casos —InfoNCE— pero la geometría del espacio cambia, y con ella la calidad, el coste de almacenamiento y la latencia de búsqueda.
Las tres familias en detalle
Denso single-vector — el cartógrafo
El embedder lee el chunk completo, lo pasa por un transformer codificador (XLM-RoBERTa, BERT, Mistral-decoder con prompt) y agrega las representaciones de tokens en un único vector mediante:
- CLS pooling: usa el embedding del token
[CLS]. Estándar en BERT-base. - Mean pooling: media simple de los embeddings de todos los tokens. Estándar en
multilingual-e5-large-instruct,bge-m3dense,Snowflake Arctic Embed L 2.0. - Last-token pooling: para embedders basados en decoder-LLM (
e5-mistral-7b-instruct,gte-Qwen2-7B-instruct,NV-Embed-v2) que toman el token final como agregado. Funciona porque el modelo es causal y el último token “ha visto” todo el contexto. - Latent-attention pooling: novedad de
NV-Embed-v2. Una capa de atención learnable que pondera tokens en vez de promediarlos. +2-3 puntos MTEB sobre mean pooling.
Tras el pooling se normaliza a norma 1: v ← v / ‖v‖₂. Con vectores normalizados, coseno y producto escalar coinciden:
\cos(q, d) = \frac{q \cdot d}{\|q\|\|d\|} = q \cdot d, \qquad \|q - d\|^2 = 2 - 2\,(q \cdot d)
Por eso casi todos los vector DB indexan por inner product y dejan al usuario que normalice antes (Qdrant, Faiss IP, Milvus). Si olvidas normalizar el vector de la query pero los del corpus sí están normalizados, el retrieval se degrada en silencio: la magnitud de la query distorsiona el ranking. Es el bug número uno en RAG en producción.
Ejemplo numérico mínimo, dos vectores 4-d normalizados:
q = [0.5, 0.5, 0.5, 0.5] ‖q‖ = 1
d₁ = [0.6, 0.4, 0.5, 0.5] ‖d₁‖ = 1.005, normalizado [0.597, 0.398, 0.498, 0.498]
q · d₁ = 0.5·0.597 + 0.5·0.398 + 0.5·0.498 + 0.5·0.498 = 0.995
Cercanía cuasi-1, como esperábamos. Si d₁ no estaba normalizado, q · d₁ = 1.0 y aparecería más cerca que cualquier d perfectamente alineado pero con norma < 1.005. La normalización no es un detalle: es el contrato del espacio.
Esparso aprendido — el descriptor léxico
SPLADE-v3 (Naver, marzo 2024) ha consolidado la versión moderna del bibliotecario léxico. Internamente es un BERT pequeño (~110 M parámetros, base DistilBERT/BERT) que produce, para cada token de entrada, una distribución sobre todo el vocabulario (30.522 dimensiones en BERT WordPiece), y luego hace max-pool sobre los tokens:
w_j = \max_{i \in \text{seq}} \log\bigl(1 + \text{ReLU}(W_{ij})\bigr)
donde Wᵢⱼ es el logit del token de entrada i para el término del vocabulario j. El log(1+ReLU) satura los logits altos (evita que una sola palabra domine el vector) y la ReLU corta los negativos. El resultado es un vector de 30.522 dimensiones del que típicamente quedan 50-200 entradas no nulas.
La parte clave es la regularización FLOPS que se añade a la pérdida durante el entrenamiento:
\mathcal{L}_{\text{FLOPS}} = \lambda \cdot \sum_{j=1}^{V} \bar{w}_j^2, \qquad \bar{w}_j = \frac{1}{|B|}\sum_{i \in B} w_{ij}
Penaliza el coste esperado de los posting lists: si una palabra del vocabulario aparece en promedio en muchos documentos, sumarla a un nuevo documento penaliza el doble. El modelo aprende a generar vectores esparsos por construcción.
¿Esto qué le pasa al texto “Linkerd 3.8 introduce mTLS por defecto”? Que el modelo no solo escribe los términos literales — escribe también, con peso menor pero no cero, “service-mesh”, “kubernetes”, “tls”, “sidecar”, “envoy”, “istio” (su competidor, también semánticamente relacionado), “encryption”, “k8s”. Esa expansión semántica del documento es lo que diferencia SPLADE de BM25. BM25 solo sabe lo que estaba literalmente en el texto; SPLADE sabe lo que un experto añadiría como descriptor.
En la práctica SPLADE-v3 vence a BM25 por 3-6 puntos MRR@10 en MS MARCO y domina BEIR zero-shot. El coste es ~2-4× la latencia de query de BM25 sobre el mismo índice invertido, mitigable con podas estáticas.
Para el caso multilingüe, bge-m3 en su cabeza sparse es la única opción mantenible: SPLADE-v3 está entrenado en inglés y los ports multilingües están en estado experimental.
Multi-vector — el copista
ColBERT-v2 (Stanford, NAACL 2022) introdujo el paradigma de late interaction. En vez de comprimir el documento a un solo vector, lo deja como una matriz (|d|, k) con un vector de k dimensiones por cada token. La similitud entre query y documento se calcula token-a-token y se agrega con MaxSim:
s(q, d) = \sum_{i \in q} \max_{j \in d} \langle q_i, d_j \rangle
Lo que se está computando: para cada palabra de la query, encuentra el token del documento que mejor le encaja y suma esa similitud. La suma es sobre la query, no sobre el documento. Esto permite que un documento de 30.000 tokens compita justo con otro de 200, porque la query siempre suma |q| términos.
¿Por qué le da más calidad que el dense single-vector? Porque el resumen a un vector pierde información sobre dónde estaba cada idea. Si la query es “qué versión introdujo mTLS por defecto en Linkerd”, el resumen denso del documento solo sabe que el chunk va de “Linkerd y mTLS”; el copista de ColBERT puede emparejar “qué versión” con “3.8” porque guarda el embedding del token 3.8 por separado. En BEIR / out-of-domain, late interaction supera a single-vector entre +2 y +6 nDCG@10 con el mismo backbone.
El precio es el almacenamiento. Por documento, un dense single-vector de 1024 dimensiones en fp16 ocupa 1024 × 2 = 2 KB. ColBERT-v2 con tokens de 128 dimensiones para un chunk de 256 tokens ocupa 256 × 128 × 2 = 65.536 B ≈ 64 KB: 32× más espacio. Con la compresión residual nbits=2 de ColBERT-v2 baja a ~16 KB (8×). Jina-ColBERT-v2 añade Matryoshka sobre las dimensiones del token (truncable a 128 / 96 / 64), bajando otro 50%.
Para 1 millón de chunks:
| Familia | Por doc | Total |
|---|---|---|
| Denso fp32 (1024-d) | 4.096 B | 4,0 GB |
| Denso fp16 / halfvec | 2.048 B | 2,0 GB |
| Denso int8 (SQ) | 1.024 B | 1,0 GB |
| Denso binario (1 bit/d) | 128 B | 128 MB |
| SPLADE (≈ 80 términos × 8 B) | ~640 B | ~640 MB |
| ColBERT fp16 (256 tok × 128 d) | 65.536 B | 64 GB |
ColBERT residual nbits=2 | ~16.000 B | ~16 GB |
| Jina-ColBERT-v2 (MRL token 64) | ~8.000 B | ~8 GB |
ColBERT en producción on-premise se reserva para corpus de hasta unos pocos millones de chunks, o se aplica solo como reranker sobre los top-100 del primer comité (denso + esparso), como se describe en el post de reranker.
Matryoshka — la dimensión truncable
Una palanca operativa que ha cambiado la conversación sobre embeddings entre 2024 y 2026 es Matryoshka Representation Learning (Kusupati et al., NeurIPS 2022). El truco: durante el entrenamiento, además de la pérdida sobre el vector completo de D dimensiones, se calcula la misma pérdida sobre prefijos del vector:
\mathcal{L}_{\text{MRL}} = \sum_{k \in \{64, 128, 256, 512, 1024\}} \alpha_k \cdot \mathcal{L}_{\text{InfoNCE}}\bigl(\text{emb}[:k]\bigr)
Las primeras k dimensiones del embedding se entrenan para ser, por sí solas, un embedding válido. En inferencia, si quieres un embedding más barato, truncas el vector: el primer cuarto es ya un embedding utilizable. Sin Matryoshka, truncar destroza la geometría: las primeras 256 dimensiones de un embedding entrenado solo en 1024-d no codifican nada coherente.
Degradación típica en MTEB nDCG@10 al truncar un embedder MRL:
| Truncado | Pérdida promedio |
|---|---|
| 1024 → 512 | -1 a -2 puntos |
| 1024 → 256 | -3 a -5 puntos |
| 1024 → 128 | -5 a -8 puntos |
| 1024 → 64 | -8 a -12 puntos |
Modelos MRL nativos en 2026 (los que te permiten elegir la dimensión en runtime sin reentrenar):
jina-embeddings-v3(1024 → 32, paso fino, CC-BY-NC-4.0)jina-embeddings-v4(2048 → 128, multimodal texto+imagen, CC-BY-NC-4.0)nomic-embed-text-v2-moe(768 → 256, Apache 2.0)Snowflake-arctic-embed-l-v2.0(1024 → 256, Apache 2.0)mxbai-embed-large-v1ymxbai-embed-2d-large-v1(este último también truncable en profundidad de capa)Stella_en_1.5B_v5(paso múltiple 512 / 768 / 1024 / 2048 / 4096 / 6144 / 8192, MIT, solo inglés)text-embedding-3-large(OpenAI, 3072 → 256, API only)voyage-3family (1024 → 256 / 512 / 1024 / 2048, API only)
Recomendación de producción: usa siempre un modelo MRL aunque no truques al inicio, porque la decisión de cuantización futura te la simplifica. Y, sobre todo, evalúa el truncado con tu corpus real: la degradación promedio MTEB de “-3 puntos” se vuelve “-12 puntos” en un dominio nicho.
El zoo de modelos open source 2026
Lo que sigue es una ficha técnica por modelo. Verificación al 2026-06: HuggingFace cards + papers de referencia + leaderboard MTEB / MMTEB.
Denso single-vector
| Modelo | Params | Dim | Tokens | Idiomas | Licencia | Distintivo |
|---|---|---|---|---|---|---|
BAAI/bge-m3 (dense) | 568 M | 1024 | 8192 | 100+ | MIT | Tri-modo (dense + sparse + colbert) en un forward. Estándar de facto on-prem multilingüe. |
Snowflake/snowflake-arctic-embed-l-v2.0 | 568 M | 1024 (MRL → 256) | 8192 | ~100 | Apache 2.0 | Entrenado para multilingüe + inglés sin degradar ninguno. MIRACL 55.8. |
intfloat/multilingual-e5-large-instruct | 560 M | 1024 | 512 | ~100 | MIT | Baseline multilingüe veterano. Ventana corta. |
intfloat/e5-mistral-7b-instruct | 7.1 B | 4096 | 4096 | inglés | MIT | Primer decoder-as-embedder en romper MTEB. Inglés. |
Alibaba-NLP/gte-Qwen2-7B-instruct | 7 B | 3584 | 32.768 | 100+ | Apache 2.0 | Único con contexto 32k. Top MTEB-en, fuerte multilingüe. |
nvidia/NV-Embed-v2 | 7.85 B | 4096 | 32.768 | inglés | CC-BY-NC-4.0 | Latent-attention pooling. Calidad top. License blocker en prod. |
Linq-AI-Research/Linq-Embed-Mistral | 7 B | 4096 | 4096 | inglés | CC-BY-NC-4.0 | Top retrieval MTEB ago-2024. No comercial. |
NovaSearch/stella_en_1.5B_v5 | 1.54 B | 8192 (MRL múltiple) | 8192 | inglés | MIT | Pequeño + MRL rico. Inglés. |
BAAI/bge-multilingual-gemma2 | 9 B | 3584 | 8192 | 100+ | Gemma | Calidad alta, licencia Gemma restringe redistribución. |
BAAI/bge-en-icl | 7 B | 4096 | 8192 | inglés | MIT-style | In-context-learning de ejemplos en el prompt. |
mixedbread-ai/mxbai-embed-large-v1 | 335 M | 1024 (MRL) | 512 | inglés | Apache 2.0 | MRL + binary nativo. Ventana corta. |
jinaai/jina-embeddings-v3 | 570 M | 1024 (MRL → 32) | 8192 | 89 | CC-BY-NC-4.0 | LoRA por tarea. No comercial sin licencia. |
nomic-ai/nomic-embed-text-v2-moe | 475 M / 305 M activos | 768 (MRL → 256) | 512 | ~100 | Apache 2.0 | Primer MoE general-purpose en embeddings. |
Esparso aprendido
| Modelo | Params | Vocab | Tokens | Idiomas | Licencia | Distintivo |
|---|---|---|---|---|---|---|
naver/splade-v3 | 110 M | 30.522 | 512 | inglés | CC-BY-NC-SA-4.0 | SOTA sparse aprendido. No comercial. |
BAAI/bge-m3 (sparse head) | 568 M | XLM-R vocab | 8192 | 100+ | MIT | La única opción multilingüe license-clean. |
Multi-vector (late interaction)
| Modelo | Params | Dim/token | Tokens | Idiomas | Licencia | Distintivo |
|---|---|---|---|---|---|---|
colbert-ir/colbertv2.0 | 110 M | 128 | 512 | inglés | MIT | El paper original, base de todo. |
jinaai/jina-colbert-v2 | 560 M | 128 / 96 / 64 (MRL) | 8192 | 89 | Apache 2.0 | El multi-vector multilingüe license-clean. |
nomic-ai/colnomic-embed-multimodal-7b | 7 B | 3584 (Qwen2-VL) | — | ~100 | Apache 2.0 | Multi-vector multimodal texto+imagen. Vidore-v2 SOTA open. |
El leaderboard, con cautela
El MTEB / MMTEB (Massive Multilingual Text Embedding Benchmark, Enevoldsen et al., arxiv 2502.13595) es el termómetro estándar. Top retrieval en MMTEB a mediados de 2026 está dominado por Qwen3-Embedding-8B (~70.6 multilingual avg) y Llama-Embed-Nemotron-8B. Por debajo, los modelos de 7B (gte-Qwen2-7B, NV-Embed-v2) y los de 568M (bge-m3, Snowflake-Arctic-L-2.0) compiten por tarea.
Trampa: MTEB se ha empezado a saturar por dataset contamination. Cuanto más alto el ranking, más probable es que el modelo haya visto en entrenamiento subconjuntos de los datasets de evaluación. La regla en producción: el leaderboard es para descartar modelos malos, no para elegir el mejor. La decisión final se toma sobre un eval set propio del dominio, generado con la receta de LLM-as-judge o de evals.
El problema concreto del español multilingüe
Para un cliente español que sirve documentación corporativa, jurídica o de soporte en castellano (y muchas veces catalán / portugués / inglés mezclados), el zoo de embedders se acota a menos de seis modelos viables. Las exclusiones operativas:
- Modelos solo en inglés:
e5-mistral-7b-instruct,stella-en-1.5B-v5,Linq-Embed-Mistral,mxbai-embed-large-v1,NV-Embed-v2,SPLADE-v3. Reducen el rendimiento en castellano por debajo del nivel aceptable: traducir la query al inglés antes de buscar es una vía, pero introduce latencia, drift de tokenización y otra dependencia de modelo. - Modelos con licencia no comercial:
jina-embeddings-v3,jina-embeddings-v4,NV-Embed-v2,Linq-Embed-Mistral. Sirven para PoC, pero a producción comercial exigen acuerdo explícito con el vendor. Salvo que tengas la licencia firmada, hay que excluirlos. - Modelos con licencia Gemma:
bge-multilingual-gemma2. Permitido para uso interno, complicado redistribuir pesos a un cliente.
Los que quedan, ordenados por orden de elección práctica en producción soberana:
BAAI/bge-m3— MIT, 568 M, 100+ idiomas (incluyendo castellano y catalán, entrenados explícitamente), 8.192 tokens, tri-modo dense+sparse+colbert. Default razonable. Cabe en una RTX 4090. Lo sirveTEIyInfinitynativamente.Snowflake/snowflake-arctic-embed-l-v2.0— Apache 2.0, mismo tamaño, Matryoshka explícito, mejor MIRACL/CLEF que bge-m3 en algunas tareas multilingües, sin cabeza sparse. Si la prioridad es MMTEB puro en castellano.intfloat/multilingual-e5-large-instruct— MIT, 560 M, baseline veterano. Ventana de 512 tokens es su gran limitación: documentos largos hay que partirlos antes. Si lo que ya tienes en producción funciona, no migres por moda.Alibaba-NLP/gte-Qwen2-7B-instruct— Apache 2.0, contexto 32k, calidad alta en castellano (Qwen2 está bien entrenado en español). Si los chunks son largos (más de 4k tokens) y dispones de GPU para servirlo (no entra en 4090; sí en H100). Cabe junto a un LLM en un H100 80GB con cuidado.nomic-ai/nomic-embed-text-v2-moe— Apache 2.0, 305 M activos, MRL, ~100 idiomas. Si la latencia y el coste por token mandan: el MoE le da throughput desproporcionado para su calidad.jinaai/jina-colbert-v2— Apache 2.0, multi-vector multilingüe, como reranker o como retrieval principal en corpus pequeño (< 1 M chunks). El único multi-vector license-clean en castellano.
La regla del pulgar: bge-m3 como dense+sparse en primera línea, jina-colbert-v2 como tercera capa de reranking cuando lo amerita el caso de uso, y Snowflake Arctic L 2.0 como alternativa si el eval específico del corpus prefiere su geometría.
Servir embeddings on-premise
Tres motores se reparten el panorama de servir embeddings on-prem en 2026, con perfiles distintos.
Text Embeddings Inference (TEI) — el estándar
huggingface/text-embeddings-inference es un servidor escrito en Rust con backend Candle / ONNX, FlashAttention integrado y batching dinámico por tokens. Expone una API OpenAI-compatible /v1/embeddings y soporta los tres modos de bge-m3 simultáneamente desde la versión 1.5. Para producción multilingüe es el default obvio.
# values.yaml — TEI sirviendo bge-m3 multilingüe sobre RTX 4090
image: ghcr.io/huggingface/text-embeddings-inference:1.5
args:
- --model-id=BAAI/bge-m3
- --pooling=cls
- --max-batch-tokens=16384
- --max-concurrent-requests=512
- --dtype=float16
resources:
limits:
nvidia.com/gpu: 1
Throughput orientativo con bge-m3, fp16, secuencia 512 tokens, batch 32:
- RTX 4090 (24 GB): ~8–15 k tokens/s
- A100 80 GB: ~60 k tokens/s sostenidos
- H100 80 GB: ~40–80 k tokens/s, con
fp8~50% adicional
(Los rangos son aproximados y dependen de batch real, longitud media de secuencia y compilación con FA2/FA3.)
Infinity — el flexible
michaelfeil/infinity (MIT) es un servidor FastAPI multi-modelo capaz de cargar bge-m3, Snowflake Arctic, Jina-v3, Nomic, ColPali, CLAP y rerankers simultáneamente desde la misma API estilo OpenAI. Backend PyTorch + Optimum (ONNX/TensorRT) o CTranslate2. Útil cuando necesitas servir varios embedders distintos (uno para texto, otro para código, otro para imágenes) detrás de un único endpoint, o cuando el modelo todavía no tiene soporte en TEI.
vLLM --task embed — para los embedders 7B
Cuando el embedder es realmente un LLM-decoder convertido en embedder (e5-mistral-7b-instruct, gte-Qwen2-7B-instruct, NV-Embed-v2, Stella-1.5B), el lugar natural para servirlo es vLLM, que ya tiene en producción la pila de PagedAttention y continuous batching:
vllm serve Alibaba-NLP/gte-Qwen2-7B-instruct \
--task embed \
--dtype bfloat16 \
--max-model-len 32768 \
--trust-remote-code
vLLM detecta el pooling correcto (last-token en los basados en Qwen / Mistral) y expone /v1/embeddings compatible con OpenAI. Para clusters de inferencia que ya están corriendo vLLM con un LLM en otro puerto, es la forma natural de servir el embedder sin levantar otro stack.
fastembed — el liviano
qdrant/fastembed carga bge-small, MiniLM, ColBERT y BM25/SPLADE sparse en ONNX-CPU. No es competitivo en throughput contra TEI/Infinity con GPU, pero es la opción correcta cuando hay que servir embeddings en un nodo NUC sin GPU (ver entornos mixtos NVIDIA + Intel) o cuando el embedder forma parte del cliente (preview en una UI, scoring previo en un edge).
Almacenamiento, cuantización y el cálculo del corpus
El embedding no se queda en memoria del embedder: vive en el índice del vector DB y se materializa cada vez que ingestas un nuevo chunk. El cálculo del coste de almacenamiento es lo que decide la dimensión final, no la calidad MTEB. Para 1 millón de chunks con embedder denso a 1024-d:
fp32 : 1.024 dims × 4 B × 1 M = 4.096 MB ≈ 4,0 GB
fp16/halfvec: 1.024 dims × 2 B × 1 M = 2.048 MB ≈ 2,0 GB
int8 (SQ) : 1.024 dims × 1 B × 1 M = 1.024 MB ≈ 1,0 GB
binario : 128 B × 1 M ≈ 128 MB
Las opciones de cuantización en orden de uso real (mid-2026):
- halfvec (
fp16): el default en pgvector 0.7+ y en cualquier vector DB serio. Pérdida MTEB nula, 2× compresión. Siempre actívalo. - Scalar Quantization int8 (SQ): cada componente del vector se mapea a
int8con un min/max global. Pérdida típica de recall@10: 0–1%. 4× compresión. El default de Qdrant, soportado en Milvus y Weaviate. - Cuantización binaria:
bit = sign(v_i - μ_i). 32× compresión bruta. Pérdida en frío 5–15%. Mitigada con rotación previa Hadamard / TurboQuant (Qdrant 1.18, dic 2025): pre-multiplica por una matriz ortogonal aleatoria que reparte la energía entre dimensiones antes de binarizar. Tras TurboQuant la pérdida cae a 1–3%. Combina además con rescoring sobre losfp16originales para los top-100 candidatos. - Product Quantization (PQ): el clásico de FAISS. Hasta 64× compresión, pérdida 2–5%. Más complejo de operar (requiere entrenar codebook); en 2026 ha cedido terreno a binary + rescoring.
Un corpus de 100 millones de chunks (cifra real de un RAG corporativo grande) con bge-m3 denso:
| Formato | Total |
|---|---|
| fp32 | 400 GB |
| fp16 | 200 GB |
| int8 | 100 GB |
| binario + Hadamard | 12,5 GB |
La diferencia entre 200 GB y 12,5 GB es la diferencia entre necesitar un nodo dedicado de vector DB con 8 NVMe en RAID y poder caber en RAM de un solo nodo. Para corpus grandes, la cuantización ya no es una optimización: es la única forma de operar.
El integration con vector DB
Los vector DB de 2026 se han convertido en DBs híbridos que indexan los tres tipos a la vez. El mapa rápido:
| Vector DB | Híbrido nativo | Multi-vector / ColBERT | Cuantización |
|---|---|---|---|
| Qdrant ≥1.10 | RRF/DBSF en query_points con dense + sparse + colbert en una colección | Sí, nativo (one-shot MaxSim) | SQ int8, binaria, TurboQuant 1.18 |
| Weaviate | hybrid(alpha=0.75) BM25 + dense, named vectors multi-target | Sí, como named vector multi-vector | PQ, SQ, BBQ rotacional 8-bit |
| Milvus ≥2.4 | Multi-vector + sparse en schema; 2.5 añade BM25 full-text nativo | Multi-vector field, MaxSim orquestado desde cliente | SQ, PQ, CAGRA GPU |
| pgvector 0.7+/0.8 | halfvec, sparsevec, bit; HNSW para los tres | No nativo (workaround tabla separada) | binary_quantize(), halfvec, rescoring con <#> exacto |
| Elasticsearch / OpenSearch | sparse_vector (ELSER, SPLADE) + dense_vector HNSW; RRF | OpenSearch 3.x sí | ES 9: int8_hnsw por defecto, BBQ binary quantization |
Para producción soberana on-prem en castellano, la combinación más fácil de operar en 2026 es Qdrant + bge-m3: una sola colección indexa los tres modos del mismo modelo, el query híbrido con RRF se hace en una llamada, la cuantización TurboQuant baja el corpus a niveles manejables, y el operador es un binario Go con backups simples a S3/MinIO. pgvector + bge-m3 es la otra opción razonable cuando ya tienes Postgres con HA y no quieres meter una segunda DB en el inventario operativo; pierdes multi-vector nativo, pero ganas SQL transversal sobre los chunks.
Los parámetros de HNSW que sí o sí hay que tocar:
M: conexiones por nodo en el grafo. 16–32 típico. Más alto → más recall, más RAM. Para corpus pequeños (<1 M)M=16; para corpus medianos (10 M)M=24; para corpus grandesM=32+IVF-PQoM=32+binary.ef_construction: ancho de búsqueda durante la construcción. 100–400. Más alto → grafo mejor, construcción más lenta. Construye conef_construction=400aunque sea lento; lo pagas una vez.ef_search: ancho durante la query. 50–200. La perilla principal del trade-off recall/latencia en runtime. Empieza en 64 y mide.
Implicaciones para inferencia on-premise
El embedder no comparte hardware con el LLM tan cómodamente como podría parecer. Las cuentas:
bge-m3(568 M) ocupa unos568 × 2 = 1.136 MBenfp16para los pesos, más el KV cache de batch, más activaciones temporales. En la práctica se sirve cómodamente en 6–8 GB de VRAM incluso a batch alto. Cabe junto a un LLM de 7B-Q4 en una RTX 4090.gte-Qwen2-7B-instructrequiere~14 GB fp16solo de pesos. No cabe junto a un LLM 7B en una 4090; en una H100 80 GB sí, con cuidado en el batching simultáneo.jina-colbert-v2(560 M) ocupa~1.1 GBde pesos, pero el almacenamiento del índice multi-vector es el coste real: 8 GB por millón de chunks aun con Matryoshka y compresión.
En la RTX 4090 (24 GB)
Stack mínimo realista para un RAG en castellano con corpus <1 M chunks:
GPU 24 GB ┐
├─ TEI bge-m3 (dense + sparse + colbert) │ ~6 GB VRAM, ~12 k tok/s
└─ vLLM Qwen2.5-7B-Instruct AWQ Q4 │ ~8 GB VRAM, ~80 tok/s
CPU/RAM ┐
├─ Qdrant con bge-m3 dense + sparse + colbert │ ~3 GB RAM por M chunks
└─ FastAPI gateway (LiteLLM)
Sirve unas decenas de QPS de RAG con calidad multilingüe decente. Es la configuración de PoC y de despliegue para una sede pequeña.
En el cluster 4×H100 80 GB
Para el caso producción con varios millones de chunks y SLO de p99 < 500 ms:
H100 #1 (80 GB) ── vLLM Qwen3-72B-Instruct AWQ + Qwen2.5-7B speculative ┐
H100 #2 (80 GB) ── vLLM gte-Qwen2-7B-instruct (embedding 32k ctx) │ LLM + embed grande
H100 #3 (80 GB) ── TEI bge-m3 multi-tenant + jina-colbert-v2 reranker │ embed mediano
H100 #4 (80 GB) ── Hold-out para canary / shadow │ ver post canary
┘
Qdrant cluster (3 nodos CPU + NVMe) ── 100 M chunks indexados (binary + TurboQuant + rescoring)
Esta configuración separa el LLM grande del embedder grande (que comparten arquitectura Qwen2 pero compiten por VRAM si se les pone en la misma GPU) y deja un H100 entero para variantes en canary. El bge-m3 cabe sobrado con el reranker en una sola H100, sirviendo decenas de miles de requests/min.
Las siete trampas operativas del embedder
No normalizar el vector de la query. Coseno y producto escalar coinciden solo cuando ambos vectores son unitarios. Si en el cliente olvidas
v ← v / ‖v‖₂, los resultados están “casi bien” — los top-1 siguen siendo correctos en queries triviales, los top-10 ya no — y nadie se da cuenta hasta que la calidad de RAG cae 8 puntos. Solución: bake la normalización en el adapter del embedder, no en el cliente.Chat template colado en el embedder. Algunos embedders basados en LLM (
e5-mistral-7b-instruct,gte-Qwen2-7B) esperan un prompt instructivo concreto antes del texto a embeber ("Instruct: Retrieve relevant passages\nQuery: ..."). Olvidarlo deja el rendimiento ~5 puntos MTEB por debajo. Solución: leer elusage_templatede la model card y meterlo en el wrapper del embedder.Dimensión Matryoshka mal elegida. El default de muchos clientes Qdrant / pgvector es
dim=768. Si tu embedder es MRL nativo a 1024 → 768, OK. Si es 1024 sin MRL, truncar a 768 destroza el espacio (perdida típica -8 puntos MTEB). Solución: usar eldimnativo del modelo y truncar solo cuando el almacenamiento manda, y solo en modelos MRL.Hard negatives ausentes en fine-tuning. Cuando se fine-tunea el embedder con datos propios (lo cual debería ser práctica estándar para RAG corporativo), si la mini-batch solo lleva positivos y negativos in-batch del mismo dominio, el modelo aprende que cualquier cosa fuera del dominio es negativo, pero dentro del dominio no discrimina. Solución: minar hard negatives con BM25 / dense del propio corpus antes de fine-tunear.
Drift del corpus sin reindexar. Cuando reentrenas o reemplazas el embedder pero solo aplicas el modelo nuevo a chunks nuevos, acabas con el índice mezclando dos geometrías incompatibles. Los chunks del modelo viejo y del nuevo no son comparables por coseno. Solución: cada cambio de embedder es una reindexación completa del corpus, planificada como un retrain operativo.
Tokenizer drift entre cliente y modelo. El cliente Python que prepara las queries usa su propio tokenizer (a veces
tiktokenpor defecto) y trunca a 8.192 tokens. El embedder usa XLM-R con sentencepiece y trunca a 8.192 de su propio tokenizer. Las queries largas se truncan de manera diferente; los embeddings del corpus son consistentes pero los de query no. Solución: usar el tokenizer del modelo en el cliente o en el wrapper.MTEB overfit como guía de elección. El leaderboard MTEB se ha vuelto métrica contaminada: hay evidencia de que modelos punteros han visto en entrenamiento subconjuntos de los datasets de evaluación. El modelo +0,5 puntos sobre el segundo no es necesariamente mejor para tu dominio. Solución: un eval set propio del dominio (100-300 query-doc pairs etiquetadas) ejecutado con la receta de evals decide.
Stack license-clean para producción soberana
Pongamos por escrito el final recomendado. Para una organización española sirviendo RAG corporativo on-prem bajo ENS / ISO 42001 / EU AI Act, con corpus 1-50 M chunks en castellano + inglés + catalán:
| Capa | Componente | Licencia | Justificación |
|---|---|---|---|
| Embedder dense | BAAI/bge-m3 | MIT | Multilingüe robusto, 8k tokens, license-clean, servido por TEI |
| Embedder sparse | bge-m3 sparse head | MIT | Misma pasada que el dense, no requiere segundo modelo |
| Reranker capa 2 | BAAI/bge-reranker-v2-m3 | MIT | Cross-encoder multilingüe del mismo equipo |
| Reranker capa 3 (opcional) | jinaai/jina-colbert-v2 | Apache 2.0 | Multi-vector multilingüe license-clean |
| Servidor embed | TEI + Infinity para multi-modelo | Apache 2.0 / MIT | Stack soportado |
| Vector DB | Qdrant (preferido) o pgvector 0.8 | Apache 2.0 / PostgreSQL | Híbrido nativo + cuantización |
| Cuantización | int8 SQ + binary + TurboQuant + rescoring | Apache 2.0 | Reduce corpus 16×–32× con < 3% pérdida |
| Hardware mínimo | RTX 4090 24 GB | — | Para PoC y sedes pequeñas |
| Hardware producción | Cluster 4×H100 80 GB | — | Para RAG con SLO p99 < 500 ms |
El stack alternativo, si MMTEB explícito en castellano pesa más que la tri-modalidad de bge-m3: sustituir bge-m3 por Snowflake/snowflake-arctic-embed-l-v2.0 (Apache 2.0, MRL → 256) y añadir explícitamente SPLADE-v3 o BM25 puro para la capa sparse. Pierde la elegancia del único forward, gana 1-2 puntos en MIRACL castellano.
Conclusión
El embedder es la pieza más fácil de simplificar mal en un RAG y la que más decide la calidad real cuando todo lo demás está en su sitio. Las tres familias (denso, esparso, multi-vector) no son tres opciones a elegir sino tres oficios que bge-m3 ejecuta en una sola pasada y que el retrieval híbrido consume en paralelo. La matemática que importa es modesta —InfoNCE con τ, MaxSim, FLOPS regularization, MRL— pero las trampas operativas son numerosas y silenciosas: normalización olvidada, chat template ausente, dimensión Matryoshka mal elegida, tokenizer drift. Para producción soberana en castellano la lista de modelos viables cabe en menos de una decena, y la decisión real se reduce a “bge-m3 o Snowflake Arctic L 2.0”, con jina-colbert-v2 añadido como capa tres cuando la calidad fina justifica el coste. El stack license-clean cabe en una RTX 4090 para PoC y se escala a un cluster 4×H100 para producción real.
Ver también
- El pipeline LLMOps de seis etapas — dónde encaja la pieza de datos / retrieval.
- RAG corpus curation — qué entra al índice antes de que el embedder lo vea.
- Reranker y hybrid retrieval — qué hace el comité que consume los embeddings.
- Ontologías y knowledge graphs en LLMOps — la capa de tipos que enriquece el embedding con metadatos consultables; los chunks no son solo vectores sino instancias tipadas contra TBox.
- Evals para LLMs — cómo decides si un embedder es realmente mejor que el actual.
- Capacity planning para inferencia LLM — dimensionar GPU para servir embedder + LLM en el mismo cluster.
Referencias
- Chen et al. M3-Embedding: Multi-Linguality, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation. arXiv:2402.03216. https://arxiv.org/abs/2402.03216
- Sturua et al. Jina Embeddings v3: Multilingual Embeddings With Task LoRA. arXiv:2409.10173. https://arxiv.org/abs/2409.10173
- Günther et al. Jina Embeddings v4: Universal Embeddings for Multimodal Multilingual Retrieval. arXiv:2506.18902. https://arxiv.org/abs/2506.18902
- Nussbaum et al. Nomic Embed v2: Multilingual Mixture of Experts. arXiv:2502.07972. https://arxiv.org/abs/2502.07972
- Wang et al. Improving Text Embeddings with Large Language Models (E5-Mistral). arXiv:2401.00368.
- Yu et al. Arctic-Embed 2.0: Multilingual Retrieval Without Compromise. Snowflake, 2024-12. https://www.snowflake.com/blog/arctic-embed-2-multilingual/
- Lee et al. NV-Embed: Improved Techniques for Training LLMs as Generalist Embedding Models. arXiv:2405.17428.
- Khattab y Zaharia. ColBERT: Efficient and Effective Passage Search via Contextualized Late Interaction over BERT. SIGIR 2020.
- Santhanam et al. ColBERTv2: Effective and Efficient Retrieval via Lightweight Late Interaction. arXiv:2112.01488.
- Jha et al. Jina-ColBERT-v2: A General-Purpose Multilingual Late Interaction Retriever. arXiv:2408.16672.
- Lassance et al. SPLADE-v3. arXiv:2403.06789.
- Kusupati et al. Matryoshka Representation Learning. NeurIPS 2022, arXiv:2205.13147.
- Enevoldsen et al. MMTEB: Massive Multilingual Text Embedding Benchmark. arXiv:2502.13595.
- Hugging Face Text Embeddings Inference. https://github.com/huggingface/text-embeddings-inference
- Michael Feil. Infinity. https://github.com/michaelfeil/infinity
- Qdrant. TurboQuant 1.18 release notes. https://qdrant.tech/articles/turboquant-quantization/
- pgvector. Release notes 0.7 / 0.8. https://github.com/pgvector/pgvector
- Hugging Face. Embedding Quantization. https://huggingface.co/blog/embedding-quantization