1. Introducción
En el artículo anterior, Un NotebookLM offline en un Intel barato, montamos de cero un "segundo cerebro" privado: un clon mínimo de NotebookLM que lee tus documentos y responde 100% offline en un equipo de poca potencia y bajo consumo (un mini-PC silencioso te sobra), sin enviar nada a la nube y sin pagar un solo token.
Lo dejamos funcionando, pero deliberadamente austero. Y al final prometimos unas cuantas mejoras. Pues bien, este artículo es justo eso: cogemos el mismo código y le ponemos los extras que habíamos dejado pendientes. No vamos a tocar el cerebro (el RAG sigue siendo el mismo), sino a hacerlo más cómodo de usar.
Como siempre, iremos explicando el código línea a línea y apoyándonos en alguna comparación cotidiana, para que también lo siga quien acaba de empezar. ¡Vamos a ello!
2. Un recordatorio de dónde lo dejamos
Para no obligarte a releer el artículo anterior, aquí está el resumen en una frase: usamos LangChain para orquestar, OpenVINO para ejecutar los modelos rápido en la CPU, FAISS como base de datos vectorial en disco, un modelo de embeddings multilingüe para buscar y un SLM (modelo de lenguaje pequeño) cuantizado a INT4 para redactar la respuesta en streaming.
La metáfora que usamos entonces sigue valiendo: el modelo de lenguaje es un experto buenísimo redactando pero con una mesa diminuta (la ventana de contexto), así que sentamos a su lado a un bibliotecario que trocea tus documentos, los ordena por significado y solo le pasa al experto los pocos párrafos relevantes para cada pregunta.
Todo eso no cambia. Lo que cambia es la interfaz: hoy le añadimos tres comodidades.
3. Las tres novedades de un vistazo
Estas son las tres piezas que vamos a añadir, y lo importante es que ninguna toca el núcleo:
- Elegir el modelo desde la web. Un desplegable en la barra lateral para cambiar entre un modelo rápido (0.5B) y uno con más calidad (1.5B), sin editar ningún archivo.
- Cargar bajo demanda y liberar la RAM. En un equipo de poca potencia la memoria es oro, así que mantenemos un solo modelo cargado a la vez: al cambiar de modelo, soltamos el anterior antes de meter el nuevo.
- Subir documentos arrastrando. Añadir archivos desde el navegador en vez de copiarlos a mano a una carpeta. Y, ya que tocamos subidas, lo hacemos con un pequeño cuidado de seguridad.
Vamos una por una.
4. Novedad 1: elegir el modelo desde la web
En la primera versión, cambiar de modelo significaba editar config.py a mano. Ahora queremos elegirlo desde la propia web. El primer paso es declarar qué modelos están disponibles en la configuración:
def llm_dir(model_id: str) -> Path:
"""Carpeta donde se exporta un modelo (una por modelo)."""
return MODELS_DIR / f"llm-{model_id.split('/')[-1].lower()}-int4"
#Modelos seleccionables desde la web (etiqueta -> id de HuggingFace)
AVAILABLE_LLMS = {
"Qwen2.5-0.5B · rápido": "Qwen/Qwen2.5-0.5B-Instruct",
"Qwen2.5-1.5B · calidad": "Qwen/Qwen2.5-1.5B-Instruct",
}
Dos cosas, sencillas pero clave:
AVAILABLE_LLMSes un simple diccionario que asocia una etiqueta bonita (lo que verá el usuario en el desplegable) con el id real del modelo. Añadir un tercer modelo es escribir una línea más aquí.llm_dir()decide en qué carpeta vive cada modelo, derivando el nombre del id. Así, el de 0.5B y el de 1.5B acaban en carpetas distintas (llm-qwen2.5-0.5b-instruct-int4yllm-qwen2.5-1.5b-instruct-int4) y pueden convivir sin pisarse.
Antes había un único LLM_OV_DIR fijo. Ahora cada modelo tiene su propia carpeta, que es lo que nos permitirá tenerlos los dos descargados y saltar de uno a otro.
En la web, esto se convierte en un desplegable en la barra lateral:
st.header("Modelo")
labels = list(config.AVAILABLE_LLMS)
default_label = next(
(l for l, i in config.AVAILABLE_LLMS.items() if i == config.LLM_MODEL_ID),
labels[0],
)
choice = st.selectbox("Modelo de lenguaje", labels, index=labels.index(default_label))
selected_id = config.AVAILABLE_LLMS[choice]
selected_dir = config.llm_dir(selected_id)
Leyéndolo como lo haría el programa:
labelsson las etiquetas del diccionario: lo que aparece en el desplegable.default_labelbusca cuál de ellas corresponde al modelo por defecto deconfig.py, para que el desplegable arranque ya marcando ese.st.selectbox(...)pinta el desplegable y nos devuelve la etiqueta elegida.- Con esa etiqueta sacamos el
selected_id(el id real) y, conllm_dir(), la carpeta donde está exportado ese modelo.
Tres líneas de lógica y el usuario ya puede elegir. Pero aquí aparece un problema muy de equipo modesto: si el usuario salta del modelo pequeño al grande, ¿no acabaremos con los dos a la vez en memoria? En una máquina con poca RAM eso es justo lo que no queremos. Y para entender la solución, una comparación.
5. La panadería de un solo horno

Imagina una panadería pequeña con un único horno. Ese horno es la RAM de tu equipo: tiene sitio para hornear un pan a la vez, no más.
Tú eliges qué pan hornear:
- Una baguette rápida (nuestro modelo de 0.5B): está lista en un momento y es perfecta para preguntas sueltas. Vuela hasta en equipos muy modestos.
- Una masa madre (el modelo de 1.5B): tarda más y ocupa más horno, pero sale con más cuerpo y matices.
La regla de oro es la del horno: solo cabe un pan dentro. Si quieres hornear la masa madre cuando ya tienes la baguette dentro, no puedes meter las dos: primero sacas la baguette y luego metes la masa madre. Si te empeñas en meter las dos, el horno (la RAM) no da para tanto y todo se viene abajo.
"Cargar un modelo" es exactamente eso: meter un pan al horno. Cuesta su tiempo y ocupa sitio. Pero antes de programar ese vaciado, conviene responder a la pregunta que flota en el aire: ¿de verdad compensa el pan más lento? Es decir, ¿qué ganamos de verdad con el modelo grande?
6. ¿Qué ganamos con el modelo de 1.5B?
Es una duda razonable: si el de 0.5B ya funciona, ¿qué nos aporta el de 1.5B? ¿Entiende imágenes? ¿Hace OCR? Vamos a concretarlo, porque es fácil esperar una magia que no es.

Lo primero, lo importante y lo honesto: el modelo de 1.5B sigue siendo un modelo de solo texto. No ve imágenes, no lee fotos ni escaneos y no hace OCR. Un modelo más grande no "ve" más cosas; simplemente razona y redacta mejor sobre el texto que ya le llega. Leer imágenes o escaneos es otra liga (haría falta un modelo de visión o un paso de OCR), y eso es justo lo que dejamos para "lo que viene después".
Entonces, ¿qué ganamos para nuestro cometido —chatear con documentos—? Cosas muy concretas:
- Mejor síntesis cuando la respuesta está repartida. Si lo que preguntas obliga a juntar tres o cuatro fragmentos distintos, el de 1.5B los cose en una respuesta coherente mucho mejor que el pequeño.
- Entiende mejor preguntas complejas o ambiguas. Matices, dobles condiciones, "compara A con B"… donde el de 0.5B se queda corto, el de 1.5B suele acertar.
- Sigue mejor las instrucciones. Respeta más el "responde solo con el contexto", el límite de palabras y el formato que le pidas.
- Resúmenes con más matiz y menos bucles. Los modelos diminutos tienden a repetirse o a quedarse en lo superficial; el de 1.5B redacta más fino.
- Mejor en varios idiomas. El español (y otros) le salen con más naturalidad.
Y un matiz clave que ya vimos en el artículo anterior: el modelo grande no encuentra mejores fragmentos. Qué trozos de tus documentos se recuperan depende del modelo de embeddings (el bibliotecario), que aquí no cambia. El 1.5B mejora el cómo se redacta y se razona la respuesta, no el qué se encuentra. Por eso, si ves que no da con un dato, el problema no es el tamaño del cerebro, sino la búsqueda.
En resumen: cambia al de 1.5B cuando quieras respuestas más redondas y razonadas (resúmenes largos, preguntas que cruzan varias fuentes); quédate con el de 0.5B para preguntas rápidas del día a día. Lo que ninguno de los dos hace —todavía— es leer imágenes. Volvamos entonces al horno: veamos cómo, al cambiar de pan, el código lo vacía primero.
7. Novedad 2: cargar bajo demanda y liberar la RAM
Primero, en core.py, hacemos que nuestra clase del modelo pueda cargar cualquier carpeta, no solo la del modelo por defecto:
class ChatLLM:
def __init__(self, model_dir=None):
from optimum.intel import OVModelForCausalLM
from transformers import AutoTokenizer
model_dir = str(model_dir or config.LLM_OV_DIR)
self.tokenizer = AutoTokenizer.from_pretrained(model_dir)
...
def llm_is_ready(model_dir) -> bool:
"""True si ese modelo ya se ha exportado a OpenVINO."""
return (Path(model_dir) / "openvino_model.xml").exists()
El cambio es pequeño pero habilitador: antes el modelo se cargaba siempre desde una ruta fija; ahora le pasamos model_dir y carga el que le pidamos. Y llm_is_ready() es una comprobación de una línea: ¿existe el archivo openvino_model.xml en esa carpeta? Si existe, ese pan se puede hornear; si no, todavía no lo hemos preparado (lo veremos en el punto 12).
La parte interesante está en la web. Streamlit, por defecto, vuelve a ejecutar todo el script con cada interacción, así que cacheamos el modelo cargado para no rehornearlo en cada pregunta:
#max_entries=1: solo mantenemos cacheado el modelo en uso (libera RAM)
@st.cache_resource(max_entries=1, show_spinner="Cargando el modelo (OpenVINO)...")
def get_llm(model_dir: str):
return core.get_llm(model_dir)
def load_llm(model_dir: str):
"""Carga el modelo elegido y libera el anterior de la RAM al cambiar."""
import gc
if st.session_state.get("model_dir") != model_dir:
get_llm.clear() # suelta el modelo cargado anteriormente...
gc.collect() # ...y libera su memoria antes de cargar el nuevo
st.session_state["model_dir"] = model_dir
return get_llm(model_dir)
Esto es el "horno único" hecho código. Vamos despacio:
@st.cache_resourcees lo que mantiene el horno caliente: una vez cargado el modelo, Streamlit lo guarda y lo reutiliza en cada pregunta, en vez de volver a cargarlo desde cero (que es lento).max_entries=1es la regla del horno: la caché guarda como mucho un modelo. No coleccionamos modelos en memoria.- En
load_llm(), comparamos el modelo que pide el usuario con el que ya teníamos (st.session_state). Si es el mismo, no hacemos nada: el horno ya tiene el pan correcto. - Si ha cambiado, primero
get_llm.clear()saca de la caché el modelo anterior, ygc.collect()le dice a Python que recoja esa memoria ahora, antes de cargar el nuevo. Es el equivalente a sacar la baguette del horno antes de meter la masa madre.
¿Por qué tanto cuidado con un detalle tan pequeño? Porque sin ese clear() + gc.collect(), al cambiar de modelo tendríamos durante un rato los dos modelos en RAM, y en un equipo con poca memoria eso es la diferencia entre que funcione y que el sistema se quede sin memoria. El orden importa: primero vaciar, luego llenar.
8. Novedad 3: subir documentos arrastrando
En la primera versión, para añadir un documento tenías que copiarlo a mano a la carpeta documents/ y luego reindexar. Cómodo para un hacker, menos para todo lo demás. Ahora lo subimos desde el propio navegador:
def save_uploads(files) -> int:
"""Guarda los archivos subidos en la carpeta documents/. Devuelve cuántos."""
config.DOCUMENTS_DIR.mkdir(parents=True, exist_ok=True)
for f in files:
#Path(...).name quita cualquier ruta (sin path traversal)
(config.DOCUMENTS_DIR / Path(f.name).name).write_bytes(f.getbuffer())
return len(files)
Y en la barra lateral, el típico recuadro de "arrastra aquí tus archivos":
uploaded = st.file_uploader(
"Subir documentos",
type=["txt", "md", "pdf"],
accept_multiple_files=True,
)
if uploaded and st.button("➕ Añadir e indexar", use_container_width=True):
n = save_uploads(uploaded)
st.sidebar.success(f"Guardados {n} archivo(s)")
reindex()
Es directo de leer:
st.file_uploader(...)crea la caja de subida. Le decimos que solo acepte.txt,.mdy.pdf, y que permita varios archivos a la vez.- Cuando hay archivos y el usuario pulsa Añadir e indexar,
save_uploads()los escribe en la carpetadocuments/y, acto seguido,reindex()los indexa de forma incremental (la misma lógica del artículo anterior: solo se procesa lo nuevo).
Fíjate en que no duplicamos lógica: subir es solo "dejar el archivo en la carpeta y reindexar". El RAG ni se entera de que el documento ha llegado arrastrando en vez de copiándose a mano. Pero hay una línea de save_uploads() que merece su propio apartado.
9. El portero de la recepción: un detalle de seguridad

Mira otra vez esta línea:
(config.DOCUMENTS_DIR / Path(f.name).name).write_bytes(f.getbuffer())
El detalle está en Path(f.name).name. El nombre de un archivo subido lo decide quien lo sube, no nosotros. Y un nombre puede ser perfectamente normal (notas.pdf) o puede venir con trampa, como ../../algo/importante. Esos ../../ son una forma de decir "sube dos carpetas y escribe allí", fuera de documents/. Si guardáramos el archivo tal cual, alguien podría usar esa "dirección" para escribir donde no debe. Ese truco tiene nombre: path traversal.
La comparación: subir un archivo es como entregar un paquete en recepción. El paquete trae un nombre, pero puede traer también una "dirección de entrega". Nuestro portero tiene una norma simple: se queda solo con el nombre del paquete y tira la dirección a la basura. Da igual lo que ponga: el paquete se deja en nuestra estantería (documents/) y en ningún otro sitio.
Eso es exactamente lo que hace Path(f.name).name: de ../../algo/importante se queda solo con importante, descartando toda la parte de ruta. Una llamada minúscula que cierra un agujero clásico. Cuando aceptes archivos de fuera, nunca confíes en su nombre tal cual: quédate solo con la última parte.
10. Exportar varios modelos de una vez
Para poder elegir entre dos modelos en la web, antes hay que tenerlos los dos descargados. Así que el script de descarga ahora acepta varios modelos de golpe:
def main(argv: list[str]) -> int:
#Modelos a exportar: los que se pasen por argumento, o el de config.
#Ejemplo: python download_models.py "Qwen/Qwen2.5-1.5B-Instruct"
llm_ids = argv[1:] or [config.LLM_MODEL_ID]
for model_id in llm_ids:
export(
model_id,
config.llm_dir(model_id),
["--weight-format", "int4", "--group-size", "128",
"--ratio", "1.0", "--task", "text-generation-with-past"],
)
...
La idea, paso a paso:
llm_ids = argv[1:] or [config.LLM_MODEL_ID]: si pasas ids por la línea de comandos, exporta esos; si no pasas nada, exporta el deconfig.py. Lo mejor de los dos mundos.- El
forrecorre cada modelo y lo cuantiza a INT4 en su carpeta (conllm_dir(), la misma función del punto 4).
Para tener los dos modelos listos y poder cambiar entre ellos en la web, basta con una orden:
python download_models.py "Qwen/Qwen2.5-0.5B-Instruct" "Qwen/Qwen2.5-1.5B-Instruct"
Recuerda lo del artículo anterior: cuantizar (preparar la "maleta" INT4) es el único momento que necesita internet y bastante RAM temporalmente. Una vez hecho, desenchufas la red para siempre.
11. Un inciso: ¿qué es un guardarraíl?
Antes del siguiente truco conviene explicar una palabra que se oye mucho en IA: guardarraíl (en inglés guardrail), o "barrera de seguridad".

Piensa en las barreras de una bolera para que aprendan los niños: se levantan sobre las canaletas para que la bola no se cuele por el canal. Esas barreras no lanzan la bola por ti ni eligen a qué bolos apuntar; simplemente impiden el peor resultado. Un guardarraíl es exactamente eso en un programa: una regla sencilla que no hace el trabajo, pero evita que las cosas se vayan por el desagüe.
Ya nos topamos con uno en el artículo anterior, aunque no lo llamamos así: el prompt que obliga al modelo a responder solo con el contexto y a terminar siempre las frases es un guardarraíl. No mejora la respuesta, pero evita el peor resultado (que se invente cosas o se corte a mitad de frase).
Los guardarraíles son baratos y agradecidos: un par de líneas que convierten un programa frágil (que se rompe a la primera) en uno robusto (que falla con elegancia y te dice qué hacer). El siguiente es justo de ese tipo.
12. Un guardarraíl en la práctica: avisar si el modelo no está
¿Y si el usuario elige en el desplegable un modelo que todavía no ha descargado? En vez de reventar con un error feo, la web lo detecta y le dice exactamente qué hacer. Aquí entra el llm_is_ready() del punto 7:
if not core.llm_is_ready(selected_dir):
st.warning(
f"**{choice}** aún no está descargado. Expórtalo una vez con:\n\n"
f"```\npython download_models.py \"{selected_id}\"\n```"
)
st.stop()
Sencillo pero importante para que la experiencia no se rompa:
llm_is_ready(selected_dir)mira si ese modelo se ha exportado (si existe suopenvino_model.xml).- Si no está, mostramos un aviso con el comando exacto para descargarlo y
st.stop()detiene la página ahí, sin intentar cargar un modelo que no existe.
Es la barrera de la bolera en acción: en vez de que la bola se cuele por el canal (la app "explota"), la frenamos y te decimos cómo seguir. El usuario lee el aviso, ejecuta una línea y vuelve.
13. Cómo queda la interfaz
Juntando las tres novedades, la barra lateral pasa a tener su caja para subir archivos, su botón de indexar y su desplegable de modelo, mientras el chat sigue exactamente igual de simple:
Y lo bonito del diseño es lo que no se ve: toda esta comodidad se ha construido sin tocar el RAG. La lógica de leer, trocear, embeber, buscar y responder es la misma del artículo anterior. Los extras viven en la interfaz, en config.py y en cuatro funciones de app_web.py. El cerebro no se ha enterado.
14. ¿Qué viene después?
Con esto cerramos las dos promesas grandes de la primera entrega (elegir modelo y subir arrastrando). Lo que queda en la lista para próximas versiones es, precisamente, lo que el modelo de texto no puede hacer por sí solo:
- OCR para PDFs escaneados y capturas. Ahora mismo las imágenes entran vacías (solo texto). Un paso de OCR ligero en la ingesta (por ejemplo RapidOCR sobre OpenVINO) leería el texto atrapado en las imágenes.
- Describir gráficos con un modelo de visión diminuto. Arrancar momentáneamente un micro-VLM para poner en palabras lo que dice una gráfica, guardar esa descripción en el índice y volver a un sistema de solo texto, rapidísimo.
- Más velocidad. Probar la GPU integrada del equipo y técnicas de decoding especulativo.
15. ¡Quiero el código!
La receta de esta entrega, en seis pasos:
- Exportar los modelos que quieras ofrecer (una sola vez, con internet):
python download_models.py "Qwen/Qwen2.5-0.5B-Instruct" "Qwen/Qwen2.5-1.5B-Instruct". - Abrir la web con
streamlit run app_web.py. - Arrastrar tus documentos a la caja de subida y pulsar Añadir e indexar.
- Elegir el modelo en el desplegable: rápido (0.5B) para el día a día, calidad (1.5B) para respuestas con más cuerpo.
- Chatear. Al cambiar de modelo, el anterior se suelta solo de la RAM.
- Desenchufar la red y seguir disfrutando de un NotebookLM privado y de muy bajo consumo, ahora más cómodo.
No hay magia nueva: el mismo modelo pequeño + INT4 + OpenVINO de siempre, con tres comodidades que viven en la interfaz. Coge el código, mete tus documentos y sigue chateando con tu segundo cerebro.
El código de esta versión con los extras lo iremos publicando junto al del artículo anterior, sttokens_mynotebookslm.