Benchmark de embeddings: 10 modelos compiten por entender el DOF (y gana uno de 0.6B)
Comparamos 10 modelos de embedding en velocidad, memoria y calidad de recuperación sobre documentos reales del DOF. Probamos cuantización int8, binaria y truncado Matryoshka. Los leaderboards públicos no predijeron al ganador.
Después del chunker, el modelo
En los posts anteriores resolvimos cómo dividir los documentos del DOF: construimos un chunker por patrón y lo validamos contra Chonkie. También hicimos una primera batalla de embeddings en 2025 con tres modelos comerciales; ahora repetimos el ejercicio en serio, con 10 modelos open corriendo local. El siguiente problema: ¿con qué modelo convertimos ~1 millón de chunks a vectores?
La elección importa por tres razones:
- Calidad: el embedding determina qué tan bien el sistema encuentra el decreto correcto para una pregunta.
- Costo: embedder el corpus completo se hace una vez, pero re-indexar con otro modelo cuesta días de cómputo. Hay que elegir bien a la primera.
- Almacenamiento: 1M chunks × 1,024 dimensiones × 4 bytes = 4 GB de vectores. La cuantización puede bajar eso a 1 GB… si no destruye la calidad (ya habíamos explorado esto en las proyecciones de almacenamiento).
Evaluamos 10 modelos en dos ejes —velocidad y calidad de recuperación— más un tercer experimento de cuantización y dimensiones. Todo el código y los reportes están en el PR #57. Este post cuenta los resultados.
El setup
Hardware: MacBook Pro M3 (36 GB RAM) con MPS (Metal Performance Shaders). El servidor de producción es un Hetzner con CPU Ryzen, pero la Mac embeddea 4-6x más rápido, así que la generación de embeddings se hará ahí.
Velocidad: 100 archivos DOF (1,378 chunks), batch 32, vía sentence-transformers.
Calidad: 50 documentos, 100 queries sintéticas (50 títulos de documento + 50 “primeras 20 palabras”, simulando queries de lenguaje natural), métricas estándar: Recall@k, MRR, NDCG con similitud coseno.
Muestra determinística (seed 42, archivos ordenados): reproducible en cualquier máquina.
Los 10 competidores
| Modelo | Params | Dims | Por qué entra |
|---|---|---|---|
| pplx-embed-context-v1-0.6b | 0.6B | 1,024 | Nuestro candidato original: contextual (late chunking), ONNX local |
| pplx-embed-v1-0.6b | 0.6B | 1,024 | Su hermano no-contextual |
| F2LLM-v2-1.7B | 1.7B | 2,048 | Fuerte en MTEB(Law) |
| F2LLM-v2-0.6B | 0.6B | 1,024 | El mismo, en tamaño chico |
| jina-embeddings-v5-text-small | 0.6B | 1,024 | Soporta binary quantization |
| jina-embeddings-v5-text-nano | 0.2B | 768 | El más chico de todos |
| Octen-Embedding-0.6B | 0.6B | 1,024 | Top-15 mundial en RTEB multilingual |
| Nemotron-3-Embed-1B | 1.1B | 2,048 | NVIDIA, dims altas |
| harrier-oss-v1-0.6b | 0.6B | 1,024 | Debut open de Microsoft |
| Qwen3-Embedding-0.6B | 0.6B | 1,024 | El más descargado del leaderboard (10.5M) |
Resultado 1: la tabla maestra
| Modelo | Dims | Chunks/s | Recall@1 | Recall@5 | MRR |
|---|---|---|---|---|---|
| F2LLM-v2-1.7B | 2,048 | 1.7 | 0.500 | 0.620 | 0.542 |
| pplx-embed-v1 | 1,024 | 3.3 | 0.450 | 0.610 | 0.512 |
| pplx-embed-context-v1 | 1,024 | 3.2 | 0.420 | 0.650 | 0.511 |
| F2LLM-v2-0.6B | 1,024 | 3.7 | 0.440 | 0.590 | 0.500 |
| jina-v5-text-small | 1,024 | 2.8 | 0.410 | 0.560 | 0.464 |
| harrier-oss-v1-0.6b | 1,024 | 3.6 | 0.360 | 0.590 | 0.464 |
| Octen-0.6B | 1,024 | 3.6 | 0.410 | 0.530 | 0.455 |
| Qwen3-Embedding-0.6B | 1,024 | 3.7 | 0.410 | 0.510 | 0.449 |
| jina-v5-text-nano | 768 | 11.3 | 0.380 | 0.530 | 0.443 |
| Nemotron-3-Embed-1B | 2,048 | 2.7 | 0.300 | 0.440 | 0.359 |
Tres historias aquí.
F2LLM-v2-1.7B es el mejor en calidad absoluta. Recall@1 de 0.500: la mitad de las veces, el documento correcto aparece en primer lugar. Pero es el más lento (1.7 chunks/s) y pesa el doble por vector (2,048 dims).
F2LLM-v2-0.6B es la revelación. MRR 0.500 — solo 4 puntos abajo de su hermano grande — con 3x menos parámetros y 2.2x más velocidad. La mejor calidad-por-tamaño de todo el benchmark. Agregamos este modelo precisamente para tener la comparación a tamaño igual contra el 1.7B, y resultó ser el hallazgo más útil.
pplx se mantiene fuerte. pplx-embed-v1 (0.512) y pplx-embed-context-v1 (0.511) son #2 y #3. Ojo: context-v1 tiene el mejor Recall@5/@10 (0.650/0.700) y todavía no hemos activado su superpoder: el late chunking contextual, donde los chunks del mismo documento se embeddeen viéndose entre sí (algo que ya exploramos con encabezados estructurados como contexto). Aquí se evaluó como embedder estándar chunk-por-chunk, así que su techo real es más alto.
Resultado 2: los leaderboards públicos no predijeron nada
Antes de correr el benchmark local, analizamos el MTEB leaderboard usando el dataset mteb/results (8.5M scores), filtrando por RTEB multilingual y MTEB(Law). El ranking público decía:
- Octen-0.6B: top-15 mundial en RTEB multilingual (74.94)
- Qwen3-Embedding: la familia más descargada (anuncio oficial), arriba de Octen y jina en RTEB
- Nemotron-3-Embed-1B: 73.66 en RTEB, sólido
En nuestro corpus de español legal mexicano:
- Octen-0.6B quedó media tabla (0.455, #7 de 10)
- Qwen3-0.6B quedó debajo de Octen y jina (0.449), invirtiendo el orden del leaderboard
- Nemotron-1B fue el peor de todos (0.359), y encima lento y con el doble de dims
- Los modelos pplx ni siquiera aparecen en el leaderboard, y aquí son #2 y #3
La explicación: los tasks de MTEB(Law) son en inglés, alemán y chino (AILA, LegalBench, GerDaLIR, LeCaRD). Un modelo que gana en ley inglesa no necesariamente entiende un decreto fiscal mexicano. Lección: el leaderboard sirve para shortlistear candidatos, pero la decisión se toma con un eval sobre tu propio dominio.
Resultado 3: la cuantización int8 es gratis
El tercer experimento: sobre los mismos embeddings fp32, aplicamos transformaciones post-hoc y re-medimos calidad:
- int8: cuantización escalar por vector (4x menos bytes)
- binary: signo, 1 bit por dimensión (32x menos bytes)
- mrl_768: truncado Matryoshka a 768 dimensiones
Δ MRR vs full fp32:
| Modelo | int8 | binary | mrl_768 |
|---|---|---|---|
| pplx-embed-context-v1 | +0.0 | -2.8 | +0.3 |
| pplx-embed-v1 | +0.0 | -2.7 | -3.0 |
| F2LLM-v2-1.7B | +0.0 | -2.3 | -0.5 |
| F2LLM-v2-0.6B | +0.1 | -4.2 | +0.2 |
| jina-v5-text-small | +0.0 | +0.5 | -1.1 |
| jina-v5-text-nano | +0.0 | -2.5 | (768 nativo) |
| harrier-oss | +0.0 | -1.2 | -2.2 |
| Octen-0.6B | +0.0 | -1.8 | -2.2 |
| Qwen3-0.6B | +0.0 | -2.9 | -0.9 |
| Nemotron-1B | +0.3 | -2.0 | -0.8 |
Las barras verdes (int8) están todas pegadas al cero: la compresión 4x es gratis. La única barra roja positiva es jina-v5-text-small, el único modelo entrenado con binary quantization.
int8 no cuesta nada. Entre +0.0 y +0.3 puntos de MRR en los 10 modelos: la reducción 4x de almacenamiento viene sin pérdida medible de calidad. Esto valida la arquitectura planeada: sqlite-vec guardando vectores int8, con distancia L2 equivalente a coseno. No hay razón para guardar fp32 en producción.
Binary solo funciona donde está entrenado. jina-v5-text-small es el único modelo que mejora con binarización (+0.5 pts) — Jina entrena sus modelos con soporte de binary quantization, y se nota. 128 bytes por vector: todo el corpus cabría en ~128 MB de vectores (ver proyecciones de almacenamiento). harrier (-1.2) y Octen (-1.8) degradan poco; el resto pierde 2-4 puntos. Para los F2LLM, prohibido binarizar (-4.2).
Truncar a 768 no compensa. Casi todos pierden 0.5-3 puntos al cortar dimensiones. Ni siquiera Qwen3 — que sí está entrenado con Matryoshka — sale ileso (-0.9). Las excepciones curiosas: pplx-context (+0.3) y F2LLM-0.6B (+0.2) no pierden nada, aunque eso es ruido estadístico. La conclusión práctica: si int8 te da 4x gratis, no tiene sentido pagar calidad por otro 25% de espacio. Mejor int8 a dimensiones nativas.
La frontera de Pareto
Gráfica generada con scripts/plot_embedding_benchmark.py a partir de los reportes del benchmark (PR #57). El tamaño del punto es proporcional a los parámetros; el color indica las dimensiones del vector.
No hay un solo ganador; hay cuatro, según la prioridad:
- Máxima calidad: F2LLM-v2-1.7B (MRR 0.542) — si aceptamos ~7 días de indexación del corpus completo
- Calidad por tamaño: F2LLM-v2-0.6B (0.500 a 3.7 chunks/s) — 94% de la calidad del grande a mitad de precio de almacenamiento
- Balance + late chunking: pplx-embed-context-v1 — con la ventaja contextual todavía sin explotar
- Escala extrema: jina-v5-small binario (128 B/vec) o jina-v5-nano (11.3 chunks/s, corpus en ~25 horas)
Estimados para el corpus completo (~1M chunks, int8)
| Modelo | Tiempo de embedding | Vectores |
|---|---|---|
| jina-v5-text-nano | ~25 h | 0.75 GB |
| harrier / Qwen3 / Octen / F2LLM-0.6B | ~75 h | 1 GB |
| pplx-v1 / context-v1 | ~85 h | 1 GB |
| jina-v5-small | ~99 h | 1 GB |
| F2LLM-v2-1.7B | ~163 h | 2 GB |
Lecciones
-
El tamaño no es la calidad. F2LLM-0.6B casi empata a su hermano 1.7B; jina-nano (0.2B) queda a 13% de modelos 3x más grandes. En embeddings, el entrenamiento pesa más que los parámetros.
-
Los benchmarks públicos son en inglés. MTEB(Law) evalúa ley inglesa/alemana/china. Para español jurídico mexicano, el orden se invierte. El eval local no es opcional.
-
int8 siempre. Cero pérdida, 4x ahorro. Es la decisión más fácil de todo el proyecto.
-
La cuantización binaria es una feature del modelo, no del formato. Solo funciona donde el entrenamiento la contempló (jina). Binarizar embeddings ajenos cuesta 2-4 puntos de MRR.
-
Medir velocidad en el hardware real. La Mac M3 embeddea 4-6x más rápido que el servidor Hetzner (CPU). La estrategia: generar embeddings en la Mac, servir búsquedas en Hetzner.
Código y benchmarks
- Scripts:
scripts/compare_embeddings.pyyscripts/evaluate_retrieval.py(PR #57) - Reporte unificado:
reports/embedding_comparison_full.md - Análisis del MTEB leaderboard:
reports/embedding_model_candidates.md - Comparación Mac vs Hetzner:
reports/macos_vs_hetzner.md
Siguientes pasos
- Late chunking real para pplx-embed-context-v1: su ventaja contextual aún no está activada en este eval (ya dimos un primer paso con encabezados estructurados)
- Probar los candidatos Tier 1 del análisis MTEB que faltan: Qwen3-Embedding-4B, Octen-Embedding-4B (el mejor ≤4B en RTEB), y dinghy-law-0.6b (especializado en ley)
- Medir latencia de búsqueda sqlite-vec con vectores int8
- Decisión final de producción y generación del corpus completo
Comentarios