Site Logo
Published on

Acervo v0.2: fine-tuneé mi propio modelo y el pipeline se redujo a la mitad

Authors

Segunda entrega de la serie sobre Acervo. Si no leíste el post anterior, arrancá por ahí — explica el problema y por qué RAG no es suficiente.


Lo que quedó pendiente de v0.1

v0.1 probó que la idea central funciona: un grafo de conocimiento puede comprimir conversaciones y mantener el contexto constante. En chats cortos funcionaba bien — el LLM recibía ~400 tokens de grafo en vez de miles de historial crudo.

Pero había cosas que no habíamos probado. La más importante: ¿funciona con conversaciones largas? Chats de 100+ turnos donde cambiás de tema 5 veces, volvés a temas anteriores, corregís información. Eso era la prueba real y todavía no la teníamos.

También nos dimos cuenta de que el pipeline era demasiado complejo para lo que hacía. Dos modelos distintos, cuatro llamadas LLM por turno, tres sincrónicas. El query planner — que antes del chat decidía qué información necesitaba el modelo — estaba de más. Y la extracción con el modelo 3B producía resultados inconsistentes.

La conclusión fue doble: necesitábamos simplificar el pipeline drásticamente, y necesitábamos un modelo que hiciera extracción estructurada de forma confiable.


Paso 1: rediseñar el pipeline

Antes de tocar modelos, lo primero fue sentarse a pensar qué pasos realmente necesitábamos y cuáles sobraban. Agarré un papel y dibujé el flujo ideal.

Diagrama de planificación del pipeline v0.2

El resultado fue este pipeline simplificado:

  • S1 Unified — clasificación de tema + extracción en una sola llamada. Reemplaza tres componentes separados (L3 classifier, query planner, entity extractor) con una única llamada que devuelve todo junto.
  • S2 Gather — busca en el grafo los nodos relevantes. Sin LLM, pura lógica.
  • S3 Agent — el LLM responde al usuario con el contexto enriquecido.
  • S1.5 Graph Update — curación del grafo en background, después de la respuesta. No bloquea al usuario.

De 4 llamadas LLM sincrónicas a 2 sincrónicas + 1 asíncrona. El query planner desapareció. El usuario ya no espera pre-procesamiento antes de recibir su respuesta.

Pero este diagrama tenía un problema implícito: el paso S1 necesitaba un modelo que pudiera hacer topic classification Y entity extraction en una sola llamada, produciendo JSON estructurado de forma confiable. El Qwen 2.5 3B no era lo suficientemente bueno para eso, y el Qwen 3.5 9B tenía el problema de los bloques <think> que rompían la extracción.

Necesitábamos un modelo que hiciera las dos cosas bien. Y eso nos llevó al fine-tuning.


Paso 2: fine-tuning de mi propio modelo

Nunca había hecho fine-tuning. Sabía que existía, conocía la teoría, pero nunca había entrenado un modelo. Decidí aprender haciendo.

La idea era conceptualmente simple: tomar Qwen 3.5 9B como base y entrenarlo con ejemplos de extracción. Que el modelo aprenda que cuando recibe un prompt de extracción devuelve JSON limpio, y cuando recibe un prompt de chat conversa normalmente. Un solo modelo. Dos comportamientos. Determinados únicamente por el system prompt.

Armar el dataset

Lo más difícil no fue el entrenamiento — fue crear los datos de entrenamiento. Necesitaba cientos de ejemplos de conversación → extracción correcta, cubriendo todos los edge cases:

Tipo de ejemplo%Por qué es importante
Hechos sobre entidades existentes30%"Nuestro proyecto tiene 50K usuarios" → no crear entidad nueva, agregar fact al nodo existente
Extracción de entidades nuevas20%La tarea básica: detectar personas, proyectos, tecnologías
Output vacío15%"Gracias, eso es todo" → el modelo NO debe inventar entidades
Cambios de tema10%"Cambiando de tema..." y también cambios implícitos sin anunciar
Subtemas10%Profundizar en un aspecto sin cambiar el topic general
Eventos con participantes5%Reuniones, releases, incidentes — con quién, cuándo, dónde
Correcciones5%"Migramos de React a Vue" → es una corrección, no una entidad nueva
Deduplicación5%"Nuestro proyecto" → mapear al nodo existente, no crear duplicado

Los ejemplos de output vacío fueron los más importantes del dataset. Sin ellos, el modelo alucina entidades en charla casual — ve "gracias" y extrae un nodo "Gratitud (concepto)". Le tuve que enseñar explícitamente cuándo no hacer nada.

Generé 612 ejemplos en 5 dominios: software (35%), negocios (20%), literatura (15%), personal (15%), académico (15%). Bilingüe español/inglés porque así hablo con mis herramientas.

El entrenamiento

Base: Qwen 3.5 9B Método: LoRA via Unsloth Hardware: Mi RTX 5070 Ti, 16GB VRAM Tiempo: ~1 hora 15 minutos Costo: $0 (todo local)

LoRA es lo que hace esto viable: en vez de reentrenar los 9 mil millones de parámetros, inyecta matrices pequeñas en capas específicas. Solo entrena ~0.5% del modelo. El modelo base queda intacto, la habilidad nueva se agrega encima.

Resultados

20 stress tests diseñados para romper el modelo:

  • "Gracias, eso es todo" → ¿devuelve vacío o inventa?
  • "Nuestro proyecto" con el proyecto ya en el grafo → ¿referencia al existente o duplica?
  • "Migramos de React a Vue" → ¿entiende que es una corrección?
  • "Vi que salió Angular 19, interesante" → ¿crea relación falsa con el proyecto?
  • Párrafo entero de Dune con 9 personajes → ¿produce JSON válido?

Resultado: 100% JSON parse rate, 85% accuracy.

El modelo está publicado en Hugging Face: SandyVeliz/acervo-extractor-qwen3.5-9b. Open source, Apache 2.0.


El descubrimiento: un solo modelo alcanza

El resultado más sorprendente del fine-tuning no fue la accuracy — fue descubrir que no necesitaba dos modelos.

El fine-tuning no reemplaza al modelo base. Le agrega una habilidad. Qwen 3.5 9B sigue sabiendo conversar exactamente igual. Pero ahora, cuando recibe un system prompt de extracción, produce JSON estructurado en vez de prosa con bloques <think>.

# Mismo modelo, mismo endpoint, distinto system prompt:

# Prompt de chat → conversa normalmente
"Sos un asistente de trabajo..."
"El proyecto va bien, desplegamos el auth module ayer."

# Prompt de extracción → JSON limpio
"Extract structured knowledge. Return only valid JSON."
{"entities": [...], "relations": [...], "facts": [...]}

De dos modelos cargados (~14GB VRAM) a uno solo (~6GB). La GPU pasó de 97% a 42%.

Side by side del pipeline viejo y nuevo

El pipeline nuevo: de 4 llamadas a 2+1

Pipeline v0.2
Mensaje del usuario
S1 Unified (sync, fine-tuned model)
  → detecta el tema Y extrae conocimiento en UNA sola llamada
  → corre SIEMPRE, cada turno
Contexto enriquecido con nodos del grafo
Agent (sync, mismo modelo, distinto prompt)
  → responde al usuario con streaming
S1.5 Graph Update (async, NO bloquea la respuesta)
  → curación del grafo + extracción de la respuesta del asistente

S1 Unified es el cambio más grande. Antes, clasificar el tema y extraer entidades eran dos llamadas separadas a dos modelos distintos. Ahora es una sola llamada que devuelve todo:

{
  "topic": {"action": "subtopic", "label": "Deploy a producción"},
  "entities": [
    {"id": "kubernetes", "label": "Kubernetes", "type": "technology",
     "layer": "UNIVERSAL"}
  ],
  "relations": [
    {"source": "orbit", "target": "kubernetes", "relation": "uses_technology"}
  ],
  "facts": [
    {"entity": "orbit", "text": "Considering migration to Kubernetes",
     "speaker": "user"}
  ]
}

¿Por qué existe S1.5?

S1.5 resuelve un problema sutil pero importante. Cuando el usuario envía su mensaje, S1 extrae conocimiento de ese mensaje. Pero la respuesta del LLM también contiene conocimiento — datos, relaciones, hechos que el asistente mencionó. Si no los extraemos, perdemos información.

S1.5 corre después de que el LLM responde, en background, sin que el usuario espere. Pasa la respuesta del asistente por el mismo modelo (por eso "1.5") y extrae el conocimiento que el asistente generó. Además hace curación: merge de duplicados, corrección de tipos, relaciones nuevas entre nodos.

Pero S1.5 tiene un propósito más profundo que la extracción. Al procesar la respuesta asincrónicamente, estamos haciendo que Acervo sea stateless — no necesita estado de sesión, no necesita saber qué pasó antes, no necesita estar corriendo continuamente. El grafo se actualiza y queda listo para el próximo mensaje, venga cuando venga.

Acervo stateless: la idea de fondo

Hacemos Acervo stateless de la misma forma que un LLM es stateless. Un LLM no "recuerda" — recibe todo su contexto en cada llamada y responde como si fuera la primera vez.

Acervo hace lo mismo: en cada turno, lee el grafo, arma el contexto, y se lo da al LLM. No importa si pasaron 5 segundos o 5 días desde el último turno. No importa si el proceso se reinició. El grafo es el estado.

Imaginá que Acervo logra el 100% de lo que queremos. Es un sistema donde el LLM siempre responde como si fuera la primera vez pero con todo el contexto necesario para hacer tareas complejas. No hay sesión. No hay historial que crece. Hay un grafo de conocimiento comprimido que le dice al modelo exactamente lo que necesita saber, en ~400 tokens, siempre.


De librería a proxy

En v0.1, Acervo era una librería Python que se importaba y se usaba con prepare() / process(). Funcionaba, pero requería cambiar código de tu aplicación para integrarla.

En v0.2 encontramos que lo mejor era convertirla en un proxy transparente. Se sienta entre tu app y el LLM. Desde la perspectiva de tu código, hacés una sola llamada HTTP normal. El proxy intercepta, enriquece el contexto, y reenvía.

Flujo bidireccional del proxy
Tu app  →  POST /v1/chat/completions  →  Acervo proxy (:9470)
                                       S1: topic + extracción
                                       context build (grafo → tokens)
                                       POSTLLM (:1234)
                                       stream response ←
                                       S1.5 async: curación del grafo
Tu app  ←  stream response  ←──────────────────

Zero cambios de código. Solo redirigís el base_url de tu aplicación al proxy. Compatible con OpenAI y Anthropic API format.

Pero Acervo sigue siendo también una librería instalable — la API prepare() / process() sigue disponible para integraciones más profundas. La idea es que sea como Git o Claude Code: una herramienta que se instala en cualquier proyecto y se adapta al workflow que ya tenés.

Los datos viven en .acervo/ en tu directorio de proyecto, siguiendo el patrón de .git/:

mi-proyecto/
├── .acervo/
│   ├── graph/
│   │   ├── nodes.json
│   │   └── edges.json
│   ├── vectordb/
│   └── config.toml
├── src/
└── ...

Lo que aprendí del fine-tuning

Tres lecciones que no encontré en ningún tutorial:

1. Los datos de "no hacer nada" son los más importantes. El 15% del dataset son ejemplos donde el output correcto es arrays vacíos. Sin ellos, el modelo alucina entidades en cada turno. Enseñarle cuándo NO extraer es tan difícil como enseñarle cuándo sí.

2. El formato del prompt es un contrato. Si entrenás con el template "EXISTING NODES: [...]\nTOPIC HINT: ...\nUSER: ...", el modelo en producción necesita recibir exactamente ese formato. Cambiar una palabra puede degradar la accuracy un 30%. Es un contrato implícito entre el training y la inferencia que nadie te dice pero te enterás cuando algo no funciona.

3. Fine-tuning es absurdamente accesible. Una GPU de consumidor. Una hora. $0. Un dataset de 612 ejemplos. Y el resultado es un modelo que responde mejor para tu tarea específica que modelos 10x más grandes. No porque sea más inteligente — porque sabe exactamente qué formato querés.


Comparación: v0.1 → v0.2

v0.1v0.2
Modelos2 (9B chat + 3B extraction)1 (fine-tuned, hace ambos)
VRAM~14 GB~6 GB
LLM calls por turno3-4 (sincrónicas)2 sync + 1 async
Extracción accuracyNo medida85%
JSON parse rateVariable100%
IntegraciónSolo librería PythonProxy transparente + librería
EstadoDependiente de sesiónStateless (grafo es el estado)

Lo que todavía no funciona al 100%

Aún no probamos con conversaciones realmente largas. v0.2 mejoró la arquitectura pero no hicimos el benchmark de 100+ turnos con múltiples cambios de tema. Esa es la prueba definitiva y es lo primero que viene en v0.3.

Duplicados por nombre. "Orbit" y "Orbit App" terminan como dos nodos distintos. S1.5 debería mergearlos pero no siempre lo logra.

Facts a veces vacíos. El modelo extrae entidades y relaciones bien, pero a veces donde debería extraer un hecho específico ("tiene 50K usuarios"), devuelve facts: []. El training data estaba sub-representado en esos casos.

El grafo todavía es plano. No hay jerarquía: Batman y Superman son nodos sueltos sin conexión al universo DC.


Lo que viene en v0.3

Benchmarks reproducibles. Un script que corre 100 turnos comparando full history vs sliding window vs RAG vs Acervo. La prueba real que nos debemos.

Segundo round de fine-tuning con datos reales de uso. Los fallos de las primeras semanas van a alimentar el próximo training.

Instalación fácil. Docker Compose con un solo comando. Que alguien pueda probar Acervo en 2 minutos.

Y la feature que más me entusiasma: chunk_refs — que los nodos del grafo apunten a chunks de la vector DB. Hoy el grafo tiene el resumen comprimido. Con chunk_refs, si el resumen no alcanza, el sistema va a buscar el texto completo — pero solo los chunks que el nodo referencia, no los miles de toda la base. El grafo como índice del RAG. No lo reemplaza — lo hace 20x más eficiente.


El estado actual

v0.2 está publicada en PyPI (pip install acervo). El modelo fine-tuneado está en Hugging Face bajo Apache 2.0. Todo corre 100% local con 6GB de VRAM.


Si te interesa el tema de memoria para IA o compresión de contexto, seguime — voy a seguir documentando el proceso completo, incluyendo lo que no funciona.