Un NotebookLM offline en un Intel barato

chatea con tus documentos sin nube y sin pagar tokens

1. Introducción

NotebookLM está muy bien, pero a mí no me termina de gustar subir mis notas, mis contratos y mis ideas a medio cocer a la nube de otra persona.

Así que me hice una pregunta: ¿y si pudiéramos tener lo importante de NotebookLM (chatear con nuestros propios documentos) corriendo 100% offline, en un mini-PC sin ventilador tipo N100 que consume 6 vatios?

Se puede. Y en este lab lo montamos de cero. Al final tendrás un pequeño "segundo cerebro" privado que nunca llama a casa: lee tus documentos, los deja indexados en tu disco y responde sin enviar nada a internet.

Iremos pieza a pieza, y sobre todo iremos explicando el código línea a línea, porque la gracia de este lab no es copiar y pegar, sino entender por qué está cada cosa.

¡Vamos a ello!

2. Prerrequisitos

Para este lab necesitaremos:

  • Un equipo con CPU Intel tipo N100 o N300 (4-8 núcleos, sin GPU dedicada, 8-16 GB de RAM). También vale un portátil normal.
  • Python 3 instalado.
  • Una carpeta con documentos reales: .txt, .md o .pdf.
  • Conexión a internet solo una vez, para descargar y convertir los modelos. Después, se puede tirar del cable.
  • Ganas de dejar de copiar y pegar documentos enteros en un chat de pago.

No necesitamos GPU de gama alta ni cuenta en ningún sitio. El truco para que un modelo de lenguaje sea usable en un N100 es una santísima trinidad: modelo pequeño + cuantización INT4 + OpenVINO (el runtime de inferencia de Intel). Lo veremos más adelante.

3. ¿Qué vamos a montar?

En resumen, estas son las tareas que vamos a realizar:

  • Leer documentos en .txt, .md y .pdf (incluidas tablas).
  • Trocearlos en fragmentos manejables.
  • Convertir cada fragmento en un vector y guardarlo en un índice local (FAISS).
  • Buscar, ante cada pregunta, solo los fragmentos relevantes.
  • Pasarle al modelo pequeño únicamente esos fragmentos y dejar que escriba la respuesta en streaming.
  • Citar de qué documento salió cada respuesta.

La idea visual sería esta:

Pipeline de un RAG local paso a paso
Pipeline de un RAG local paso a paso

4. Qué es un RAG (y por qué cabe en un mini-PC)

RAG son las siglas de Retrieval-Augmented Generation. Nombre pomposo, idea sencilla.

Piénsalo así: un modelo de lenguaje es como un experto buenísimo redactando, pero con dos pegas. La primera, que sólo recuerda lo que estudió en su día. La segunda, que trabaja en una mesa diminuta donde solo le caben unas pocas hojas a la vez; esa mesa es la ventana de contexto. Por eso no puedes plantarle delante un PDF de 300 páginas: no le cabe en la mesa, y aunque le cupiera, leérselo entero sería lento y caro.

¿La solución? No hacer que el experto se memorice toda tu biblioteca, sino sentar a su lado a un buen bibliotecario.

El bibliotecario hace cuatro cosas:

  1. Trocea tus documentos en fragmentos pequeños.
  2. Convierte cada fragmento en un vector (una lista de números que captura su significado) y los guarda en una base de datos vectorial.
  3. Cuando preguntas algo, convierte la pregunta en un vector también, y busca los pocos fragmentos cuyos vectores estén más cerca, es decir, los pasajes más relevantes.
  4. Le pasa al modelo solo esos pasajes y le decimos: "responde usando esto".

El modelo no se lee el libro entero. El bibliotecario encuentra las páginas buenas, y el modelo redacta la respuesta a partir de ellas. Eso es un RAG. Y por eso cabe en un equipo de poca potencia: el modelo trabaja siempre con un puñado de párrafos, no con toda tu biblioteca.

tus docs ──► fragmentos ──► embeddings ──► índice vectorial (FAISS)
                                                  │
pregunta ──► embedding ──► busca los K más cercanos
                                                  │
              el modelo pequeño redacta la respuesta con esos fragmentos

5. El stack en una frase

Estas son las piezas y para qué sirve cada una:

  • LangChain: orquesta el pipeline (cargar, trocear, embeddings, recuperación). El pegamento.
  • OpenVINO: ejecuta los dos modelos (el de embeddings y el de lenguaje) rápido sobre CPU Intel.
  • FAISS: una base vectorial local, sin servidor, que vive en un par de archivos en disco.
  • multilingual-e5-small: un modelo de embeddings ligerísimo (~118M) y multilingüe (también funciona bien en español).
  • Qwen2.5-0.5B-Instruct: un modelo de lenguaje pequeño, reciente y sorprendentemente capaz que vuela en el N100.

Ahora vamos a montarlo de verdad.

6. Paso 0: una sola configuración para todo

Lo primero es no esparcir parámetros por todo el código. Todo lo ajustable vive en un único config.py, así nunca tienes que ir a buscar entre el código para cambiar un modelo o un tamaño de fragmento:

LLM_MODEL_ID = "Qwen/Qwen2.5-0.5B-Instruct"
EMBED_MODEL_ID = "intfloat/multilingual-e5-small"

DEVICE = "CPU"            # Los intel Serie N no tienen GPU real
CHUNK_SIZE = 1000         # caracteres por fragmento
CHUNK_OVERLAP = 120       # solape para no cortar frases por la mitad
RETRIEVER_K = 3           # cuántos fragmentos pasamos al modelo por pregunta
MAX_NEW_TOKENS = 256      # respuestas cortas -> mucha menos latencia en CPU

Vamos línea a línea con las dos que más vas a notar:

  • RETRIEVER_K = 3: cuánto contexto recuperamos. Más fragmentos = más información, pero también más texto que el modelo tiene que leer (y en CPU eso se paga en tiempo). Tres es un buen equilibrio.
  • MAX_NEW_TOKENS = 256: cómo de larga puede ser la respuesta. En CPU, cada token cuesta tiempo, así que mantenemos las respuestas ajustadas.

¿Por qué esto importa? Porque en un equipo de baja potencia el cuello de botella es el tiempo por token. Estos dos números son tus dos perillas principales para que el asistente vaya ágil o lento.

7. Paso 1: leer tus documentos (txt, Markdown y PDF)

Soportamos texto plano, Markdown y PDF. Texto y Markdown son triviales. Los PDF son los traidores: están pensados para imprimir, no para extraer datos, y con un parser ingenuo las tablas se convierten en ensalada de palabras.

La solución es PyMuPDF4LLM, que extrae los PDF como Markdown e incluso reconstruye las tablas en formato | col | col |, que los modelos pequeños entienden muy bien. Está escrito en C, así que es rápido y ligero en el N100.

def _load_pdf(path):
    from langchain_core.documents import Document
    try:
        if config.PDF_FAST:                 # modo rápido: texto plano, sin tablas
            import pymupdf
            with pymupdf.open(str(path)) as doc:
                text = "\n".join(page.get_text() for page in doc)
        else:                               # por defecto: tablas -> Markdown
            import pymupdf4llm
            text = pymupdf4llm.to_markdown(str(path))
        return [Document(page_content=text, metadata={"source": str(path)})]
    except ImportError:
        from langchain_community.document_loaders import PyPDFLoader
        return PyPDFLoader(str(path)).load()   # plan B si falta la librería

Fíjate en tres detalles:

  • El if config.PDF_FAST es un interruptor: si tienes PDFs enormes y no te importan las tablas, lo pones a True y va mucho más rápido.
  • El metadata={"source": str(path)} es clave: guardamos de qué archivo viene cada fragmento, para poder mostrar citas después ("esta respuesta salió de contrato.pdf").
  • El except ImportError es un plan B elegante: si no está instalada la librería buena, no rompemos, caemos a un lector de PDF más simple.

¿Cuándo tocar este paso? Cuando tus documentos sean sobre todo tablas (deja PDF_FAST = False) o cuando sean PDFs gigantes de texto corrido (ponlo en True).

8. Paso 2: trocear sin cortar frases por la mitad

Antes de archivar nada, el bibliotecario no fotocopia el libro entero: lo va pasando a fichas. En vez de guardar un documento gigante de un tirón, lo cortamos en tarjetas pequeñas y manejables. Y lo cortamos con cuidado: dejando que cada ficha comparta un trocito con la siguiente, para que ninguna frase importante quede partida justo por la línea de corte.

Troceamos los documentos en fragmentos de unos 1000 caracteres con ese poco de solape, para que una frase importante no se quede partida entre dos fragmentos:

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=config.CHUNK_SIZE,
    chunk_overlap=config.CHUNK_OVERLAP,
    add_start_index=True,
)
chunks = splitter.split_documents(docs)

¿Por qué RecursiveCharacterTextSplitter y no cortar a lo bruto cada 1000 caracteres? Porque es listo con dónde corta: intenta primero por saltos de párrafo, luego por frases, y solo en último caso por palabras. Así los fragmentos quedan semánticamente enteros.

  • chunk_overlap=120 hace que el final de un fragmento se repita al principio del siguiente. Cuesta un poco de espacio, pero evita perder el contexto en las costuras.
  • add_start_index=True guarda en qué posición del documento empezaba cada fragmento, útil para depurar y para citas más finas.

Tres líneas, y el problema del troceo está resuelto.

9. Paso 3: embeddings acelerados con OpenVINO

Aquí aparecen dos palabros: embedding y base de datos vectorial. Vamos con la analogía del bibliotecario.

Imagina que el bibliotecario no coloca las fichas por orden alfabético, sino por significado: pone juntas las que hablan de lo mismo. Las cláusulas de un contrato en una zona, las cifras de las facturas en otra, las notas de reuniones en otra. Para eso, a cada ficha le asigna unas coordenadas según lo que significa: eso es el embedding (una lista de números que sitúa el texto en un "mapa del significado"). Y el mapa entero, con todas esas coordenadas guardadas para poder buscar en él, es la base de datos vectorial.

¿Dónde está la gracia? Que dos textos que significan algo parecido caen cerca en el mapa, aunque no usen las mismas palabras. Así, cuando llega una pregunta, la colocamos también en el mapa y miramos qué fichas tiene al lado.

En el código, cada fragmento se convierte en ese vector con el modelo de embeddings. El detalle importante para un N100: lo pasamos por OpenVINO y embebemos en lotes (batches), lo que hace la indexación drásticamente más rápida:

from langchain_community.embeddings import OpenVINOEmbeddings

OpenVINOEmbeddings(
    model_name_or_path=str(config.EMBED_OV_DIR),
    model_kwargs={"device": config.DEVICE},
    encode_kwargs={
        "normalize_embeddings": True,
        "mean_pooling": True,
        "batch_size": config.EMBED_BATCH_SIZE,   # lote mayor = indexa más rápido
    },
)

Esto es "la memoria del bibliotecario". Línea a línea:

  • normalize_embeddings=True: deja todos los vectores con la misma escala, para que comparar "cercanía" sea justo.
  • mean_pooling=True: combina los vectores de cada palabra en uno solo por fragmento (lo que espera este tipo de modelo e5).
  • batch_size: cuántos fragmentos procesamos a la vez. Más grande = indexa más rápido, pero usa más RAM. En un N100 con 16 GB, 16 va sobrado.

Un modelo de embeddings pequeño y multilingüe es el punto dulce: lo bastante bueno para encontrar los pasajes correctos, lo bastante ligero para correr en una patata.

10. Paso 4: el índice FAISS, actualizado de forma incremental

FAISS es perfecto aquí: es matemática de vectores pura y rápida, vive en un par de archivos en disco y no necesita ningún servidor de fondo.

El detalle bonito es el indexado incremental. Volver a embeber toda tu biblioteca cada vez que sueltas un archivo nuevo es doloroso. Así que guardamos un pequeño manifest.json con lo que ya está indexado, y solo embebemos lo nuevo:

new_files = [f for f in current if f not in manifest]
changed_or_removed = any(manifest.get(f) != current.get(f) for f in manifest)

if not index_exists() or changed_or_removed:
    #primera vez, o algo cambió/se borró -> reconstruir
    FAISS.from_documents(chunks, embeddings).save_local(str(config.INDEX_DIR))
    _save_manifest(current)
elif new_files:
    #solo añadimos los fragmentos nuevos al índice existente (¡rápido!)
    store = load_index(embeddings)
    store.add_documents(chunks)
    store.save_local(str(config.INDEX_DIR))
    _save_manifest(current)

Sigamos la lógica como si fuéramos el programa:

  • new_files mira qué archivos hay ahora que no estaban en el manifiesto: lo nuevo.
  • changed_or_removed detecta si algo que ya estaba indexado cambió o desapareció.
  • Si es la primera vez, o si algo cambió/se borró, reconstruimos entero (porque un borrado obliga a rehacer el índice para no dejar basura).
  • Si solo hay archivos nuevos, añadimos únicamente esos al índice existente. Esto es lo rápido: metes un documento y solo ese documento se embebe.

Añade un documento, solo ese se procesa. Cambia uno, reconstruimos para no mentir. Simple y rápido.

11. Paso 5: el cerebro, un LLM pequeño que escribe en streaming

Aquí ocurre la magia de los Intel. Cargamos Qwen2.5 cuantizado a INT4 (así los pesos son diminutos) a través de OpenVINO y, sobre todo, emitimos la respuesta token a token (streaming) para que la interfaz nunca parezca congelada:

class ChatLLM:
    def __init__(self, model_dir=None):
        from optimum.intel import OVModelForCausalLM
        from transformers import AutoTokenizer
        self.tokenizer = AutoTokenizer.from_pretrained(str(config.LLM_OV_DIR))
        self.model = OVModelForCausalLM.from_pretrained(
            str(config.LLM_OV_DIR), device=config.DEVICE, ov_config=config.LLM_OV_CONFIG
        )

    def stream(self, question, context):
        from threading import Thread
        from transformers import TextIteratorStreamer
        inputs = self._build_inputs(question, context)
        streamer = TextIteratorStreamer(self.tokenizer, skip_prompt=True,
                                        skip_special_tokens=True)
        Thread(target=self.model.generate,
               kwargs=self._generate_kwargs(inputs, streamer)).start()
        for token in streamer:
            yield token        # <-- los tokens salen según se generan

Lo importante de stream(), paso a paso:

  1. _build_inputs arma la entrada (pregunta + contexto recuperado).
  2. TextIteratorStreamer es una cola: el modelo va metiendo tokens por un lado y nosotros los sacamos por el otro.
  3. Lanzamos model.generate en un hilo aparte (Thread). Si no, generate bloquearía hasta tener la respuesta entera y no podríamos ir mostrando texto.
  4. El bucle for token in streamer: yield token va soltando cada trocito según aparece. Eso es el efecto "máquina de escribir".

Y un par de detalles que hacen que un modelo pequeño se comporte:

El prompt es una barrera de seguridad. Le decimos que responda sólo con el contexto, y que dé una respuesta completa dentro de un presupuesto de palabras derivado del límite de tokens. Así, en vez de cortarse a mitad de frase al llegar al tope, planifica una respuesta corta y terminada:

SYSTEM_PROMPT = (
    "Eres un asistente que responde EXCLUSIVAMENTE con la información del "
    "contexto proporcionado. Si la respuesta no está en el contexto, dilo "
    "claramente. Da una respuesta COMPLETA y autocontenida en un máximo de "
    "{max_words} palabras y TERMINA SIEMPRE tus frases.\n\n"
    "Contexto:\n{context}"
)

Se acabaron los las líneas repetidas. Los modelos diminutos adoran repetirse. Una línea lo doma:

no_repeat_ngram_size=config.NO_REPEAT_NGRAM,   # bloquea n-gramas repetidos

Esto impide que el modelo repita la misma secuencia de N palabras, que es justo el bucle típico de los modelos pequeños cuando se quedan sin ideas.

12. Paso 6: el bucle RAG completo

Una vez tienes un recuperador (retriever) y un SLM, el bucle RAG de verdad es casi decepcionantemente corto:

docs = retriever.invoke(question)          # 1. busca los fragmentos relevantes
context = core.format_context(docs)        # 2. los junta en un bloque de contexto
for token in llm.stream(question, context):    # 3. emite la respuesta fundamentada
    print(token, end="", flush=True)
sources = core.sources_of(docs)            # 4. muestra de dónde salió

Eso es todo. Recuperar → meter en el prompt → generar → citar. Cuatro pasos:

  1. retriever.invoke(question) busca en FAISS los RETRIEVER_K fragmentos más cercanos a la pregunta.
  2. format_context los cose en un único bloque de texto que entra en el prompt.
  3. llm.stream redacta la respuesta usando solo ese bloque.
  4. sources_of saca los nombres de archivo de los metadata que guardamos en el paso 1: las citas.

Todo lo demás es interfaz y fontanería.

13. Paso 7: dos interfaces por el precio de una

Como toda la lógica vive en core.py, las interfaces son finas. Tenemos una versión de terminal para hackers (chat.py) y una versión web con Streamlit (app_web.py) que vuelca las respuestas en una burbuja de chat:

docs = retriever.invoke(question)
context = core.format_context(docs)
answer = st.write_stream(llm.stream(question, context))   # streaming en vivo
st.caption("Fuentes: " + ", ".join(core.sources_of(docs)))

La clave es st.write_stream: acepta nuestro generador de tokens y va pintando el texto según llega. st.write_stream + nuestro generador = efecto "ChatGPT escribiendo", pero 100% local. Abres http://localhost:8501 y ya estás chateando con tus documentos.

Respuesta del asistente con sus fuentes citadas
Respuesta del asistente con sus fuentes citadas

Lo interesante de diseño: no duplicamos lógica. La terminal y la web llaman a las mismas funciones de core.py. Si mañana añades una tercera interfaz, vuelve a ser un archivo fino.

14. Paso 8: descargar y cuantizar los modelos

Antes de un último concepto raro: cuantizar. Imagina que el modelo original es un baúl enorme (cada peso guardado con mucho detalle, en 16 bits) que no entra por la puerta del mini-PC. Cuantizar a INT4 es rehacer el equipaje en una maleta de mano: guardamos cada peso con menos detalle (4 bits en vez de 16), tiramos lo superfluo y conservamos lo importante. El resultado ocupa una fracción y, sobre todo, sí cabe y corre con soltura en el N100.

El único momento en el que necesitas internet es para descargar y convertir los modelos al formato de OpenVINO. Hacemos justo esa "maleta": cuantizamos el modelo de lenguaje a INT4 con cuantización data-free (solo pesos, sin dataset de calibración: más ligera y rápida):

optimum-cli export openvino \
  --model Qwen/Qwen2.5-0.5B-Instruct \
  --weight-format int4 --group-size 128 --ratio 1.0 \
  --task text-generation-with-past  models/llm-...-int4

Qué hace cada flag:

  • --weight-format int4: guarda los pesos en 4 bits en vez de 16. El modelo ocupa una fracción y corre mucho mejor en CPU.
  • --group-size 128 y --ratio 1.0: controlan el grano de la cuantización (cuántos pesos comparten escala). 1.0 = cuantizamos todo.
  • --task text-generation-with-past: exporta el modelo con caché de atención (KV cache), imprescindible para generar texto rápido token a token.

Cuando esto corre una vez, desenchufas la red y todo el sistema queda offline para siempre.

15. Dos batallas reales

Porque nunca sale a la primera, dos tropiezos que me costaron tiempo, por si te ahorran a ti:

  • El export murió por falta de memoria (OOM) en mi NAS. Cuantizar un modelo necesita varios GB de RAM temporalmente, mucho más que ejecutarlo. Solución: añadir algo de swap, o exportar en una máquina más potente y copiar el resultado (que es diminuto).
  • iostream error al exportar. Resultó que /tmp era un tmpfs en RAM (muy común en cajas Proxmox/NAS). OpenVINO escribía ahí su modelo temporal, lo llenaba y se moría. Apuntar TMPDIR a un disco real lo arregló al instante.

La app en sí va de maravilla. Es la preparación de modelos (una sola vez) la que tiene hambre.

16. Lo que viene en las próximas versiones

Esta primera versión es deliberadamente mínima y por tanto tendrá limitaciones. Lo que viene después (y por qué):

  • Elegir varios modelos desde la interfaz.
  • Subir archivos arrastrando. Añadir documentos desde la web en vez de soltarlos en una carpeta.
  • OCR para PDFs escaneados y capturas. Ahora mismo las imágenes y escaneos entran vacíos (solo texto). Un paso de OCR ligero en la ingesta (por ejemplo RapidOCR sobre OpenVINO) leería el texto atrapado en imágenes.

17. ¡Quiero el flujo completo y el código!

La receta sería:

  1. Instalar dependencias y descargar/cuantizar los modelos a INT4 con OpenVINO (paso único con internet).
  2. Soltar tus documentos en la carpeta documents/.
  3. Indexar: leer, trocear, embeber y guardar en FAISS de forma incremental.
  4. Preguntar: recuperar los K fragmentos relevantes, pasárselos al modelo y leer la respuesta en streaming, con sus fuentes.
  5. Elegir interfaz: terminal (chat.py) o web (app_web.py).
  6. Desenchufar la red y disfrutar de un NotebookLM privado de 6 vatios.

No es magia: modelo pequeño + INT4 + OpenVINO, pegado con LangChain y FAISS. Coge el código, mete tus propios documentos y empieza a chatear con tu segundo cerebro. Las mejoras (cambio de modelo, subidas, OCR) las cubriré en el próximo artículo.

Respecto al código, lo podéis encontrar en nuestro repositorio de Github: sttokens_mynotebookslm