Ajustar gratis un modelo médico de 7B: QLoRA, FAISS y salvaguardas de seguridad
Un pipeline sin coste en una Kaggle T4: datos sintéticos, QLoRA, RAG sobre textos clásicos, una capa verificada, salvaguardas de emergencia fijadas en código, publicación en GGUF y los bugs que aparecieron.
Los chatbots médicos suelen fallar de una de dos maneras. O se inventan tratamientos, o mezclan la medicina tradicional y la basada en la evidencia en un guiso peligroso. El objetivo aquí era un modelo que mantuviera ambos mundos estrictamente separados, mostrara de dónde sale su información y se apartara cuando alguien describe una emergencia. Se llama Expert Vaidya, separa la medicina ayurvédica de las guías clínicas modernas y se construyó entero sin gastar nada.
El hardware fueron dos GPU Tesla T4 del plan gratuito de Kaggle. El modelo es uno de 7B con pesos abiertos, y el conocimiento procede de textos clásicos de dominio público. El resultado funciona sin conexión en un portátil de 16 GB y se instala con un solo comando. Este es el pipeline completo.
Las piezas
- Modelo base: Qwen2.5-7B-Instruct, ajustado con Unsloth y QLoRA
- Datos de entrenamiento: 52 ejemplos estructurados, 2 escritos a mano y 50 sintéticos que superaron un filtro de calidad
- Recuperación: un índice FAISS sobre 6.765 fragmentos extraídos de 1.492 páginas del Sushruta Samhita (de dominio público en Archive.org), más una base de conocimiento verificada de 22 entradas procedentes de la OMS (WHO), la ADA, el NICE y PubMed Central
- Seguridad: código de salvaguardas en Python puro, sin ningún modelo, que revisa cada pregunta antes de que llegue al LLM
- Distribución: un archivo GGUF Q4_K_M (4,68 GB) publicado en Hugging Face y en el registro de Ollama
Paso 1: datos sintéticos baratos
Los datos médicos etiquetados cuestan más de lo que permite un presupuesto de aficionado, así que un modelo más pequeño, Qwen2.5-3B en 4 bits, generó los ejemplos sobre 50 temas médicos bajo un contrato de salida estricto. Cada respuesta tenía que incluir una sección ayurvédica, una moderna y una histórica, y todo lo incompleto se descartaba automáticamente:
python has_all_3 = all(x in response for x in ["Ayurvedic", "Modern", "Historical"])
if has_all_3 and len(response) > 200:
keep(example)
Los 50 ejemplos generados pasaron el filtro. Con las 2 semillas escritas a mano suman 52 filas de entrenamiento. Es un conjunto diminuto, pero el objetivo no era enseñar hechos, sino formato y disciplina. Los hechos vienen de la recuperación.
Paso 2: QLoRA en T4 gratuitas
Unsloth, en las dos T4 de Kaggle, entrenó solo adaptadores LoRA:
model = FastLanguageModel.get_peft_model(
model, r=16,
target_modules=["q_proj","k_proj","v_proj","o_proj",
"gate_proj","up_proj","down_proj"],
lora_alpha=16, lora_dropout=0,
use_gradient_checkpointing="unsloth",
)
Solo se movió el 0,53 % de los pesos: 40.370.176 parámetros entrenables de un total de 7.655.986.688, 5 épocas, 35 pasos, un tamaño de lote efectivo de 8 y una pérdida final de entrenamiento de 1,0493. Tardó menos de 10 minutos. Aquí no se aprendió medicina. El modelo aprendió a responder siempre con la estructura de cinco partes y a no mezclar nunca los dominios.
Paso 3: 1.492 páginas en FAISS
El formato por sí solo no fundamenta las respuestas. Dos traducciones al inglés del Sushruta Samhita de dominio público (812 y 680 páginas) se obtuvieron de Archive.org. PyMuPDF extrajo el texto, el divisor recursivo de LangChain lo cortó en fragmentos de 500 caracteres con un solapamiento de 100 y all-MiniLM-L6-v2 generó los embeddings. El resultado fueron 6.765 fragmentos, normalizados y cargados en un IndexFlatIP de FAISS.
En cada consulta, el sistema recupera los top-k fragmentos, pero aplica un umbral de relevancia, una puntuación de al menos 0,3 a 0,4, para que las páginas irrelevantes nunca lleguen a la ventana de contexto.
Paso 4: una capa verificada
Las traducciones antiguas escaneadas de libros impresos son un OCR con mucho ruido, así que junto al primer índice hay un segundo, curado a mano. Contiene 22 entradas revisadas manualmente, cada una con su fuente y una puntuación de confianza. Entre las fuentes están el Charaka Samhita, el Sushruta Samhita y el Ashtanga Hridaya, las guías de la OMS de 2023 y 2024, los ADA Standards of Care 2025, las pautas del NICE sobre señales de alarma (NG185 y NG173) y tres ensayos de PubMed Central: Gymnema PMC2915138, Boswellia PMC3251738 y Ashwagandha PMC3573577. Lo completa una tabla de interacciones entre hierbas y fármacos para la warfarina y la medicación para la diabetes. La recuperación solo conserva las entradas con verified == True y confidence >= 0.8.
Paso 5: las salvaguardas actúan primero
La decisión de diseño clave es que las emergencias nunca llegan al modelo. Python puro examina la entrada antes que nada:
warnings, disclaimer = safety_guard.generate_safety_response(user_question)
if warnings and "EMERGENCY" in warnings[0]:
return warnings[0] + disclaimer # LLM never runs
Vigila 14 indicadores de emergencia, como dolor en el pecho, signos de ictus, anafilaxia y frases suicidas, además de 7 patrones peligrosos de automedicación como "stop taking", "replace insulin" o "double the dose". Un mensaje sobre dolor en el pecho devuelve un aviso de triaje con números de emergencia (112, 911, 999), exactamente igual cada vez.
Paso 6: cuantizar, publicar, desplegar
Tras fusionar los pesos de LoRA, Unsloth generó un GGUF de 4,68 GB en formato Q4_K_M, que se subió a Hugging Face y al registro de Ollama. Ahora cualquiera puede ejecutar el modelo:
bash ollama run tejaas6/expert-vaidya
El prompt de sistema, la temperatura y el comportamiento de las salvaguardas van dentro del modelo. No hace falta configurar nada ni tener clave de API, y funciona completamente sin conexión.
Qué salió mal
Esta es la parte que poca gente cuenta:
- Un bucle de enumeración infinito. Con temperatura baja, el modelo llegó a producir cientos de variantes de "hyaluronan polymer...". Configurar
no_repeat_ngram_size=3lo detuvo. - Un fallo al subir a Hugging Face. Un archivo de 4,4 GB provocaba un ImportError de
XetProgressReporter. La solución fue fijarHF_HUB_DISABLE_XET=1para saltarse la vía de transferencia xet y limpiar los módulos en caché antes de volver a importar. - El /tmp que desaparece en Kaggle. Unsloth envió la exportación GGUF a
/tmpporque el directorio de trabajo andaba corto de espacio, y/tmpno se conserva como salida del kernel. Unshutil.copyexplícito a/kaggle/workingantes de comprimir lo resolvió. - Fugas de idioma. Qwen a veces colaba caracteres chinos en las respuestas. Un posprocesador con expresiones regulares y una regla de solo inglés en el prompt lo limpiaron.
Resultados
- Una pregunta sobre diabetes cita la clasificación Prameha del Charaka y los umbrales diagnósticos de la OMS y la ADA, cada uno por separado.
- Al preguntar si se puede tomar Gymnema junto con fármacos para la diabetes, señala el riesgo acumulado de hipoglucemia y el protocolo de control.
- Describir un dolor opresivo en el pecho se salta el LLM por completo y devuelve solo indicaciones de triaje.
- Una enfermedad inventada recibe una negativa, no un tratamiento inventado.
Dónde encontrarlo
Puede descargar el modelo del registro de Ollama, y los pesos GGUF están en Hugging Face con el nombre Expert-Vaidya-Qwen7B-GGUF. Es un proyecto educativo de investigación. No es consejo médico ni una herramienta de diagnóstico.