search
IA LocalInfraestructura9 min lectura

Cómo hice Fine-Tuning de Embeddings (BGE-M3) para mi Infraestructura de Agentes: +20.8% de Precisión y Lecciones de Hardware

Pablo IB

Durante meses usé el modelo BAAI/bge-m3 genérico para la búsqueda semántica y la memoria de mis agentes en OpenViking.

Funcionaba bien para conceptos generales. Pero en cuanto un agente preguntaba por nombres de contenedores internos (CT110, CT204), leyes de gobernanza (Ley Universal 0), o flujos específicos de despliegue (coolify-ops, infisical-manager), el buscador devolvía fragmentos irrelevantes o documentación genérica.

Decidí entrenar un modelo a medida para todo mi portfolio.

El resultado: +20.85% de precisión (MRR@10) y un 91.2% de Recall en el top 10. Pero el camino para ponerlo en producción me enseñó lecciones duras sobre la diferencia entre tener RAM libre y saturar una controladora de almacenamiento.

Esta es la historia técnica completa, con código, scripts y números reales.


1. El problema: El sesgo del modelo genérico

Los modelos de embeddings convierten texto en vectores densos (en el caso de bge-m3, vectores de 1.024 dimensiones). La similitud semántica se mide calculando el producto escalar o la similitud coseno entre el vector de la pregunta y el vector del documento.

Texto / Consulta ──► [ bge-m3 Encoder ] ──► Vector [1024 floats]
                                            Similitud Coseno
Fragmento Código ──► [ bge-m3 Encoder ] ──► Vector [1024 floats]

En un modelo genérico:

  • "Desplegar un agente en Proxmox" se parece vectorialmente a tutoriales de Debian o scripts de virtualization.
  • En mi infraestructura, esa frase significa estrictamente: “usar coolify-ops con la skill proxmox-deploy siguiendo la Ley Universal 0”.

El modelo base no tenía forma de saberlo. Para enseñárselo sin arruinar su capacidad multilingüe, recurrí a Fine-Tuning con LoRA (Low-Rank Adaptation) sobre SentenceTransformers.


2. Construyendo el Dataset: Minado de Hard Negatives con BM25

El éxito de un modelo de embeddings reside en un 80% en la calidad de sus tripletas de entrenamiento: (Query, Positive, Negative).

Si el negative es demasiado fácil (por ejemplo, una receta de cocina frente a un script de bash), el modelo no aprende nada. Necesitas Hard Negatives: fragmentos de código que contienen palabras clave idénticas pero cuya semántica o contexto es incorrecto.

┌────────────────────────────────────────────────────────────────────────────┐
│ Query: "¿Cómo se gestiona el acceso a secretos en el gateway LiteLLM?"     │
├────────────────────────────────────────────────────────────────────────────┤
│ 🟢 Positivo:  docs/litellm-ops/AGENTS.md (Inyección vía Infisical CT204)   │
│ 🔴 Hard Neg:  docs/borg-ops/AGENTS.md    (Gestión de keys en BorgBackup)   │
│ ⚪ Easy Neg:  docs/blog/hugo-sveltia.md  (Configuración de un CMS web)     │
└────────────────────────────────────────────────────────────────────────────┘

El Pipeline de Datos y Scripts Reales

  1. Extracción del Corpus (build_corpus.py): Extrajimos 20.136 fragmentos reales de todo el portfolio (TypeScript, Python, Bash, MDX y reglas de gobernanza AGENTS.md) segmentados en chunks de 100 a 512 tokens.
  2. Generación Sintética de Consultas (gen_queries.py): Usamos un LLM local (qwen3:4b en Ollama con think: false y format: "json") para generar 2 tipos de búsqueda por fragmento:
PROMPT_GENERADOR = """Eres un ingeniero y agente IA del portfolio buscando documentación técnica y de infraestructura.
Lee este fragmento y genera 2 consultas de búsqueda realistas que se responden con él:
1. Una pregunta en lenguaje natural (en español o inglés técnico, con siglas, nombres de CTs, agentes, herramientas o runbooks).
2. Una búsqueda directa por palabras clave o comandos (ej: 'haproxy backend ct110', 'openviking memory config').

Fragmento:
{text}

Responde ÚNICAMENTE en formato JSON: {{"q": ["consulta 1", "consulta 2"]}}"""
  1. Minado de Negativos Difíciles en Paralelo (mine_hard_negatives.py): Indexamos los 20.136 pasajes en un motor BM25 en memoria con ProcessPoolExecutor (16 cores). Para cada consulta, recuperamos los 25 mejores resultados léxicos y seleccionamos como hard negative aquel que tuviera alto solapamiento de palabras pero perteneciera a otro archivo:
# mine_hard_negatives.py (Fragmento clave)
scores = bm25.get_scores(tokenize(query))
top_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)[:25]

hard_negatives = []
for idx in top_indices:
    candidate = corpus[idx]
    if candidate["id"] != positive_chunk_id and candidate["file"] != positive_file:
        hard_negatives.append(candidate["text"])
        if len(hard_negatives) >= 3:
            break
  1. Split Ciego: Reservamos 500 tripletas exclusivas para evaluación (eval.jsonl) que el optimizador jamás tocó.

3. Entrenamiento LoRA con MultipleNegativesRankingLoss

Utilizamos la arquitectura SentenceTransformer envolviendo el transformer base (XLM-RoBERTa) con adaptadores LoRA en las matrices de proyección de atención (query, key, value):

# scripts/train.py (Fragmento clave)
import torch
from sentence_transformers import SentenceTransformer, SentenceTransformerTrainer
from sentence_transformers.losses import MultipleNegativesRankingLoss
from peft import LoraConfig, get_peft_model, TaskType

# 1. Cargar modelo base bge-m3
model = SentenceTransformer("BAAI/bge-m3")

# 2. Configurar adaptadores LoRA
peft_config = LoraConfig(
    task_type=TaskType.FEATURE_EXTRACTION,
    r=16,
    lora_alpha=32,
    target_modules=["query", "key", "value"],
    lora_dropout=0.05,
    bias="none",
)

# 3. Función de pérdida contrastiva
loss = MultipleNegativesRankingLoss(model, scale=20.0)

# 4. Entrenar en GPU
trainer = SentenceTransformerTrainer(
    model=model,
    train_dataset=train_dataset,
    loss=loss,
    args=TrainingArguments(
        output_dir="checkpoints/bge-m3-lora",
        num_train_epochs=3,
        per_device_train_batch_size=16,
        learning_rate=2e-4,
        warmup_ratio=0.1,
        fp16=True,
        logging_steps=20,
    ),
)
trainer.train()

Evolución del Entrenamiento (RTX 5080)

El entrenamiento se ejecutó en 3 épocas (867 steps) con un batch size efectivo de 16:

  • Step 20: Loss = 0.6197
  • Step 160: Loss = 0.2829
  • Step 500: Loss = 0.1840
  • Step 867 (Final): Loss = 0.1507

La GPU consumió apenas 4.5 GB de VRAM y tardó menos de 15 minutos en completar el ciclo.


4. La Trampa de la Fusión de Pesos PEFT y Exportación a GGUF

Aquí encontramos el primer obstáculo técnico crítico.

Al guardar un modelo con SentenceTransformersTrainer, los pesos entrenados quedan bajo la estructura interna de PEFT (base_model.model.encoder.layer...). Si intentas exportar esa carpeta directamente a GGUF con el script convert_hf_to_gguf.py de llama.cpp:

  1. El script no reconoce el wrapper de LoRA.
  2. Las capas de atención se inicializan con valores por defecto o incompletos.
  3. El modelo resultante pierde el conocimiento y degrada la búsqueda.

La Solución: Fusión Estricta de Grafos

Para consolidar los pesos adaptados dentro del modelo base, es obligatorio instanciar el grafo PEFT explícito, cargar los tensores y llamar a merge_and_unload():

# scripts/fuse_and_export.py
import torch
from transformers import AutoModel, AutoTokenizer
from peft import PeftModel, LoraConfig

base_path = "BAAI/bge-m3"
lora_path = "checkpoints/bge-m3-lora/checkpoint-867"
output_path = "models/bge-m3-portfolio-fused"

# Cargar base
base_model = AutoModel.from_pretrained(base_path, torch_dtype=torch.float16)
tokenizer = AutoTokenizer.from_pretrained(base_path)

# Envolver en Peft y fusionar
peft_model = PeftModel.from_pretrained(base_model, lora_path)
fused_model = peft_model.merge_and_unload()

# Guardar HuggingFace consolidado
fused_model.save_pretrained(output_path)
tokenizer.save_pretrained(output_path)

Una vez fusionado, la conversión a GGUF FP16 es limpia e inmediata:

python3 /tmp/llama.cpp/convert_hf_to_gguf.py models/bge-m3-portfolio-fused/ \
  --outfile models/bge-m3-portfolio-f16.gguf \
  --outtype f16

Resultado: un binario bge-m3-portfolio-f16.gguf de 1.15 GB con sus 389 tensores perfectamente alineados.


5. El Benchmark: Victoria Rotunda del Fine-Tuning

Sometimos el modelo base de fábrica y el modelo fine-tuned a un benchmark riguroso contra las 500 consultas de prueba ciegas:

Métrica de Recuperación (500 queries de test ciegas)
┌───────────────────────────┬─────────────┬─────────────────┬─────────────────┐
│ Métrica                   │ Modelo Base │ Fine-Tuned (FT) │ Mejora Relativa │
├───────────────────────────┼─────────────┼─────────────────┼─────────────────┤
│ MRR@10 (Calidad Ranking)  │ 0.5767      │ 0.6969          │ +20.85% 🚀     │
│ nDCG@10 (Relevancia)      │ 0.6369      │ 0.7496          │ +17.69% 🚀     │
│ Hits@1 (Acierto Top-1)    │ 45.60%      │ 58.00%          │ +12.40% abs     │
│ Hits@3 (Acierto Top-3)    │ 67.20%      │ 78.40%          │ +11.20% abs     │
│ Hits@10 (Recall Top-10)   │ 82.60%      │ 91.20%          │ +8.60% abs      │
└───────────────────────────┴─────────────┴─────────────────┴─────────────────┘
  • Hits@1 subió del 45.6% al 58.0%: Casi 6 de cada 10 veces, el documento exacto es la primera opción devuelta.
  • Recall@10 en el 91.2%: En más del 91% de los casos, la información necesaria está en los primeros 10 resultados.

6. El Despliegue y el Postmortem: La Trampa de la VRAM y la E/S en Proxmox

Con las métricas validadas, procedimos a desplegar el modelo en producción dentro de nuestro cluster Proxmox, en el host servidor rog (equipado con una RTX 3060 Laptop de 6 GB de VRAM).

Aquí es donde la infraestructura nos dio una lección magistral.

¿Qué ocurrió?

  1. Desplegamos el nuevo servicio en el puerto :11437 manteniendo el base en :11434 para conmutar el tráfico con HAProxy.
  2. Ambos servicios se configuraron con contexto de 32k tokens: -c 32768 -b 16384 -np 4.
  3. Al iniciar la reindexación de OpenViking, el host rog colapsó: Ping respondía a 1 ms, pero SSH, Proxmox Web y los contenedores quedaron completamente congelados.
¿Por qué ocurrió si el servidor marcaba RAM libre?

Al realizar el análisis forense junto a platform-ops, descubrimos la confluencia de tres factores:

┌─────────────────────────────────────────────────────────────────────────────┐
│                            HOST ROG (Laptop)                                │
├─────────────────────────────────────────────────────────────────────────────┤
│ 1. VRAM GPU (6 GB Fija):           2 instancias x 2.8 GB = 5.6 GB VRAM fija │
│ 2. RAM Host (32 GB Físicos):       9 CTs con onboot: 1 = 41 GB asignados    │
│ 3. Bus de Disco NVMe:              Stall por ráfaga masiva de escrituras    │
└─────────────────────────────────────────────────────────────────────────────┘
  1. Saturación de VRAM (6 GB físicos): La VRAM de la GPU no se comparte con la RAM del sistema. Dos instancias de llama-server con buffers de 32k tokens reservaban 5.6 GB estáticos. Bajo la carga de miles de vectores concurrentes, la GPU sufrió CUDA Out Of Memory.
  2. Sobreasignación de RAM (41 GB configurados): En /etc/pve/nodes/rog/lxc/, los 9 contenedores tenían onboot: 1. Entre CT123 (Ollama 8GB), CT121 (Paperclip 8GB), CT206 (Agent-Zero 8GB) y el resto, sumaban 41 GB de memoria configurada arrancando simultáneamente.
  3. El Bloqueo de Disco (D-State Sleep): La avalancha de 9 inicios de Linux + escrituras de miles de vectores saturó la cola de la controladora NVMe. Cuando un proceso entra en estado D, queda esperando respuesta de hardware de forma no interrumpible. Por eso el Ping (que vive en las interrupciones del kernel) respondía, pero SSH no podía leer /etc/passwd para autenticar.

La Auditoría SMART

Tras reiniciar y cambiar un cable Ethernet que generaba pérdidas en montajes NFS, auditamos el SSD con smartctl:

  • Health: PASSED
  • Available Spare: 100% (0 bloques defectuosos)
  • Desgaste: 2%
  • Errores de Integridad: 0

El disco físico estaba intacto: el fallo fue puramente de arquitectura de concurrencia y contención de recursos.


7. Arquitectura Final en Producción

Para garantizar estabilidad absoluta y máximo rendimiento, desacoplamos el servicio de embeddings de rog y lo alojamos en la RTX 5080 (16 GB de VRAM) del portátil:

┌────────────────────────────────────────────────────────────────────────────┐
│                    Portátil Local (RTX 5080 - 16 GB VRAM)                  │
├────────────────────────────────────────────────────────────────────────────┤
│  🟢 Ollama (:11436):        Modelos de visión y uso general                │
│  🚀 FT Server (:11438):     llama-server -m bge-m3-portfolio-f16.gguf      │
│                             -c 49152 -b 12288 --ubatch-size 12288 -ngl 99  │
│                             Consumo: 1.5 GB VRAM (~9%) | 62°C | 0% CPU     │
└────────────────────────────────────────────────────────────────────────────┘
                                     │ Tailscale (100.68.58.120:11438)
┌────────────────────────────────────────────────────────────────────────────┐
│                       Cluster Proxmox (CT110 HAProxy)                      │
├────────────────────────────────────────────────────────────────────────────┤
│  OpenViking (openviking.lan) ──► HAProxy ──► Embedder RTX 5080 (:11438)    │
│  Base Vectorial: 8.172 vectores | 54.860 tareas procesadas | Zero Errors   │
└────────────────────────────────────────────────────────────────────────────┘

La Unidad Systemd Final

[Unit]
Description=Llama.cpp Server for FINE-TUNED bge-m3 embeddings on RTX 5080
After=network-online.target

[Service]
Type=simple
Environment=LD_LIBRARY_PATH=/usr/lib/ollama/cuda_v13:/usr/lib/ollama
ExecStart=/usr/lib/ollama/llama-server \
  -m /home/pablo/dev/pi-agents/infrastructure/openviking-ops/finetune/models/bge-m3-portfolio-f16.gguf \
  --port 11438 \
  --host 0.0.0.0 \
  --threads 8 \
  -c 49152 \
  -b 12288 \
  --ubatch-size 12288 \
  -ngl 99 \
  -np 4 \
  --embeddings
Restart=always
RestartSec=5
User=pablo

[Install]
WantedBy=multi-user.target

8. Lecciones Aprendidas

  1. El Fine-Tuning de Embeddings es la optimización con mayor ROI para agentes: En menos de 2 horas de trabajo obtuvimos un +20.85% de precisión, resolviendo problemas de recuperación que ningún ajuste de prompt conseguía solucionar.
  2. Siempre fusiona PEFT antes de GGUF: No confíes en scripts de conversión automáticos si usas adaptadores LoRA. Haz un merge_and_unload() explícito y valida la similitud de pesos.
  3. Monitorea los buffers estáticos de contexto: En llama-server, multiplicar slots (-np 4) por contextos grandes (32k) reserva gigabytes de VRAM inelástica antes de recibir la primera petición.
  4. El arranque escalonado es vital en Proxmox: Nunca dejes todos tus contenedores con onboot: 1 sin orden de inicio (startup: order=X,up=Y). La tormenta de E/S puede congelar una controladora NVMe sana.
  5. Aislar para vencer: Separar el motor de inferencia de embeddings en una GPU con holgura (RTX 5080) permitió procesar 54.000 tareas de vectorización a 30 ms/vector sin afectar la CPU ni el escritorio.

El modelo fine-tuned ya está en producción, OpenViking vuela con sus 8.172 vectores actualizados y el cluster Proxmox respira con un load de 0.08. Si estás construyendo agentes con RAG local sobre tu propio código, hacer fine-tuning a tus embeddings no es un lujo: es el verdadero salto de calidad.