Intermedio 22 minRAG

RAG local con ChromaDB y Ollama: tutorial Python

Implementar un RAG en local con ChromaDB, Ollama y Python implica combinar tres piezas: un almacén vectorial que conserva los datos en disco (ChromaDB), un modelo de embeddings que convierte tus fragmentos en vectores (nomic-embed-text a través de Ollama) y un LLM de chat que responde basándose en los pasajes recuperados. Sin clave de API, sin fugas de datos. Esta guía te lleva de un PDF sin procesar a un chatbot que cita sus fuentes en 22 minutos.

Por Mohamed Meguedmi·Actualización 2026-08-27·Probado en Windows, macOS y Linux

#¿Por qué esta pila para un RAG local?

Muchos tutoriales de RAG comienzan con LangChain o LlamaIndex. Estos frameworks son potentes, pero ocultan lo que sucede bajo el capó. Aquí escribimos el pipeline a mano con solo tres dependencias. Entenderás cada paso y sabrás qué optimizar más adelante.

ChromaDB
Almacén vectorial de código abierto, escrito íntegramente en Python, con modo persistente integrado (SQLite + índice HNSW). No hay que iniciar ningún servidor.
Ollama
Sirve tanto el modelo de embeddings (nomic-embed-text) como el LLM de chat (Qwen 3.5, Granite 4.2, Gemma 4). Punto de acceso HTTP único en localhost:11434.
Python nativo
Algunas funciones, sin framework. Podrás conectar LangChain más adelante si es necesario, pero no es necesario para comenzar.
i
Lo que obtienes
Un script de Python de unas 150 líneas que ingiere los PDF de una carpeta, los divide en fragmentos, los indexa en ChromaDB y responde a preguntas en francés con citas. Todo en local, sin ninguna solicitud saliente.

#Prerrequisitos

El kit Copiloto Local

Esta guía te lleva al modelo. El kit te lleva al copiloto que programa en tu editor.

  • Espacio en línea de por vida
  • PDF + archivos
  • Reembolsado 30 j
Python 3.10+
ChromaDB requiere mínimo 3.10. Verifica con python --version.
Ollama instalado y arrancado
El daemon escucha por defecto en http://localhost:11434. Si empiezas desde cero, sigue primero la guía de instalación de Ollama.
8 GB de RAM
16 GB permiten trabajar con comodidad. El modelo de chat de 9B en Q4 ocupa aproximadamente 6 GB y el modelo de embeddings, alrededor de 300 MB.
No es necesario tener un GPU
La inferencia en CPU funciona, aunque es más lenta. Para la ingestión de un gran corpus, un GPU de 6 GB o más acelera mucho los embeddings.

#1. Instalar ChromaDB y preparar Ollama

Se crea un entorno virtual limpio, se instalan las tres bibliotecas necesarias y se descargan los modelos en Ollama.

Entorno de Python
python -m venv .venv
source .venv/bin/activate  # sous Windows : .venv\Scripts\activate
pip install chromadb ollama pypdf

Tres paquetes: chromadb para el almacenamiento vectorial, ollama para el cliente Python oficial, pypdf para leer PDFs. Eso es todo.

Modelos Ollama
ollama pull nomic-embed-text
ollama pull qwen3.5:9b

nomic-embed-text es un modelo de embeddings de 137M parámetros, multilingüe, que produce vectores de dimensión 768. Ligero, rápido, bueno en francés. Qwen 3.5 9B (6,6 GB, 256k de contexto, multilingüe, Apache 2.0) sirve para el chat final: es la opción de 8 GB por defecto en 2026. Puedes reemplazarlo por granite4.2:8b (más austero en el uso de recursos) o gemma4:12b sin cambiar nada en el código.

→
Comprobar que Ollama responda
Un simple curl http://localhost:11434/api/tags debe listar tus modelos. Si no sale nada, el daemon no está en ejecución: ollama serve en otro terminal.

#2. Configurar el modelo de embeddings

Un embedding es un vector que representa el significado de un texto. Dos textos cercanos semánticamente tienen vectores cercanos. Es el motor del RAG: se buscan los chunks cuyo embedding se parece más al de la pregunta.

embed.py — prueba rápida
import ollama

resp = ollama.embeddings(
    model="nomic-embed-text",
    prompt="Le contrat est résilié de plein droit en cas de manquement grave."
)

vec = resp["embedding"]
print(f"Dimension du vecteur : {len(vec)}")
print(f"5 premières valeurs : {vec[:5]}")

Deberías ver «Dimension du vecteur : 768». Si falla con model not found, es que no se ha ejecutado ollama pull nomic-embed-text.

i
¿Por qué nomic-embed-text?
En benchmarks de francés (MTEB-fr), nomic-embed-text está entre los 5 mejores modelos de menos de 200M de parámetros. Para contenido exclusivamente en francés, mxbai-embed-large suele dar mejores resultados, pero tiene 670M de parámetros. nomic es un excelente equilibrio entre calidad y velocidad para empezar.

#3. Ingestión de PDF en francés

La ingestión hace tres cosas: leer las páginas de un PDF, cortar el texto en fragmentos de tamaño razonable, y almacenar cada fragmento junto con su embedding en ChromaDB en modo persistente.

ingest.py
import os
import chromadb
import ollama
from pypdf import PdfReader

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(name="docs")

def chunk_text(text, size=800, overlap=100):
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + size, len(text))
        chunks.append(text[start:end])
        start += size - overlap
    return chunks

def ingest_pdf(path):
    reader = PdfReader(path)
    name = os.path.basename(path)
    for page_num, page in enumerate(reader.pages):
        text = page.extract_text() or ""
        for i, chunk in enumerate(chunk_text(text)):
            emb = ollama.embeddings(
                model="nomic-embed-text",
                prompt=chunk
            )["embedding"]
            collection.add(
                ids=[f"{name}-p{page_num}-c{i}"],
                embeddings=[emb],
                documents=[chunk],
                metadatas=[{"source": name, "page": page_num + 1}],
            )
    print(f"OK : {name} ingéré ({len(reader.pages)} pages)")

if __name__ == "__main__":
    for f in os.listdir("./pdfs"):
        if f.endswith(".pdf"):
            ingest_pdf(f"./pdfs/{f}")

El chunker divide en bloques de 800 caracteres con 100 de solapamiento. Es un punto de partida: bloques ni demasiado pequeños (falta de contexto) ni demasiado grandes (diluyen la señal). Para contenido jurídico muy denso, reduce a 500. Para manuales técnicos con una maquetación más espaciada, aumenta a 1200.

→
El modo persistente de ChromaDB
PersistentClient(path="./chroma_db") crea un directorio que se conserva tras los reinicios. SQLite almacena los metadatos y un índice HNSW almacena los vectores. No hace falta iniciar un servidor ni usar Docker. Para pasar al modo cliente/servidor más adelante, basta con sustituirlo por HttpClient.

Inicia la ingestión en un directorio ./pdfs/ que contenga tus documentos:

Iniciar la ingestión
mkdir -p pdfs
# placez vos PDF dans ./pdfs/
python ingest.py
!
PDF escaneados = sin texto
pypdf solo extrae el texto nativo. Si tus PDF son imágenes escaneadas, extract_text() devolverá un resultado vacío. En ese caso, hay que recurrir a un OCR (Tesseract, o un modelo de visión como Qwen 3.5 9B, multimodal, a través de Ollama) antes de la ingestión.

#4. Búsqueda top-k en ChromaDB

Una vez indexados los chunks, la búsqueda consiste en generar un embedding de la pregunta y luego pedir a Chroma los k vectores más cercanos según la distancia coseno. Es instantáneo, incluso con 100.000 chunks.

search.py
import chromadb
import ollama

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_collection(name="docs")

def search(question, k=4):
    q_emb = ollama.embeddings(
        model="nomic-embed-text",
        prompt=question
    )["embedding"]
    results = collection.query(
        query_embeddings=[q_emb],
        n_results=k,
    )
    chunks = results["documents"][0]
    metas = results["metadatas"][0]
    return list(zip(chunks, metas))

if __name__ == "__main__":
    hits = search("Quelles sont les conditions de résiliation ?")
    for chunk, meta in hits:
        print(f"[{meta['source']} p.{meta['page']}]")
        print(chunk[:200], "...\n")

k=4 es un buen valor predeterminado. Si el valor es demasiado pequeño, pierdes contexto relevante; si es demasiado grande, ahogas al LLM en ruido y sobrepasas la ventana de contexto. Para preguntas muy precisas, k=2 es suficiente. Para preguntas transversales, sube a 6.

#5. Bucle de chat con citas

Ahora lo ensamblamos todo: buscamos los chunks pertinentes, construimos un prompt con el contexto, lo enviamos a Qwen 3.5 a través de Ollama y pedimos al modelo que cite sus fuentes.

chat.py
import ollama
from search import search

SYSTEM = """Tu es un assistant qui répond uniquement à partir du CONTEXTE fourni.
Si la réponse n'est pas dans le contexte, dis-le clairement.
Cite tes sources entre crochets sous la forme [source.pdf p.X]."""

def ask(question):
    hits = search(question, k=4)
    context = "\n\n".join(
        f"[{m['source']} p.{m['page']}]\n{c}" for c, m in hits
    )
    prompt = f"CONTEXTE :\n{context}\n\nQUESTION : {question}"
    resp = ollama.chat(
        model="qwen3.5:9b",
        messages=[
            {"role": "system", "content": SYSTEM},
            {"role": "user", "content": prompt},
        ],
        options={"temperature": 0.2, "num_ctx": 8192},
    )
    return resp["message"]["content"]

if __name__ == "__main__":
    while True:
        q = input("\nQuestion (vide pour quitter) > ").strip()
        if not q:
            break
        print("\n" + ask(q))

Tres detalles que importan. Primero, temperature=0.2: se busca una respuesta basada en hechos, no creativa. Segundo, num_ctx=8192: la ventana predeterminada de Ollama (2048) es demasiado corta una vez que se introducen 4 fragmentos de 800 caracteres. Tercero, el prompt de sistema obliga al modelo a decir 'no sé' en lugar de generar alucinaciones: es la principal protección contra las alucinaciones en el RAG.

→
Streaming para una mejor experiencia de usuario
Reemplaza ollama.chat por ollama.chat(..., stream=True) y itera sobre la respuesta para mostrar los tokens en tiempo real. Es crucial cuando se integra este código en una interfaz real (FastAPI + WebSocket, o Streamlit).

#6. Ejemplo concreto: chatbot jurídico para contratos

Imaginemos un despacho que quiere consultar 200 contratos de prestación de servicios en PDF. Con la pila anterior, en menos de una hora tenemos un asistente que responde a preguntas del tipo:

Pregunta típica
«¿Qué contratos incluyen una cláusula de no competencia tras la finalización del contrato con una duración superior a 12 meses?»
Lo que sucede
El embedding de la pregunta encuentra los chunks que contienen palabras clave semánticamente cercanas (no competencia, tras la ruptura, duración). Qwen 3.5 lee estos 4 pasajes y responde con los nombres de los archivos correspondientes.
Garantía de confidencialidad
Ningún dato sale del equipo. Sin clave de API. Sin telemetría. Esto es lo que distingue un RAG local de un wrapper de OpenAI.
!
Limitaciones que debes conocer
Un RAG básico responde bien a preguntas específicas («¿cuál es la cláusula X?»), mal a preguntas agregativas («¿cuántos contratos tienen X?»). Para estas últimas, se necesita un agente que consulte la base en varias etapas o un GraphRAG. Es otra historia.

#Solución de problemas

ChromaDB lento durante la ingestión
El cuello de botella es casi siempre la llamada a Ollama para generar embeddings. Comprueba con ollama ps que nomic-embed-text se esté ejecutando en GPU. En CPU, calcula unos 50 chunks por segundo; en GPU, unos 500.
« model not found »
Ollama no encuentra nomic-embed-text. Vuelve a ejecutar ollama pull nomic-embed-text y compruébalo con ollama list.
Respuestas que inventan fuentes
Un modelo 9B todavía alucina a veces. Pasa a mistral-small (24B, ~14 GB, muy bueno en francés) o a qwen3.8:27b si tienes suficiente VRAM. O añade un reranker (cross-encoder) después de ChromaDB para filtrar los falsos positivos.
Embeddings de baja calidad en FR
nomic-embed-text es multilingüe, pero no es óptimo para contenido exclusivamente en francés. Para contenido jurídico o médico, prueba Solon-embeddings-large-0.1 o bge-m3 (que deben cargarse mediante sentence-transformers, fuera de Ollama).
ChromaDB crece sin límite
Cada reindexación añade duplicados. Antes de volver a ingerir un PDF, ejecuta collection.delete(where={"source": name}) para eliminar los fragmentos antiguos.

#Para ir más allá

Tienes un RAG funcional. Estas son las líneas de trabajo que puedes seguir para llevarlo más lejos:

Comparar los modelos de embeddings FR
Nuestra guía «Los mejores modelos de embeddings FR» compara BGE, E5, Solon y nomic en contenido francófono.
Mejorar el chunking
«Estrategias de chunking» detalla el chunking semántico, por títulos de Markdown o por párrafos: a menudo es lo que más mejora la precisión.
Añadir un reranker
« Añadir un reranker al pipeline »: +15% de relevancia colocando un cross-encoder después de Chroma. El paso lógico siguiente.
Búsqueda híbrida
«Búsqueda híbrida BM25 + vectorial» combina la búsqueda léxica y la semántica, y resulta indispensable cuando hay mucha jerga o nombres propios.
¿Esta guía te ha ayudado?

¿Un comentario, un error, una precisión? Avísanos, eso mejora la guía para todos.