Intermediário 22 minRAG

RAG local com ChromaDB e Ollama: tutorial Python

Fazer RAG localmente com ChromaDB, Ollama e Python envolve três componentes que se encaixam: um armazenamento vetorial que mantém os dados em disco (ChromaDB), um modelo de embeddings que transforma seus blocos de texto em vetores (nomic-embed-text via Ollama) e um LLM de chat que responde com base nos trechos encontrados. Sem chave de API, sem vazamento de dados. Este guia leva você de um PDF bruto a um chatbot que cita suas fontes em 22 minutos.

Por Mohamed Meguedmi·Atualização 2026-08-27·Testado no Windows, macOS e Linux

#Por que essa stack para um RAG local

Muitos tutoriais de RAG começam com LangChain ou LlamaIndex. Esses frameworks são poderosos, mas escondem o que acontece por trás. Aqui, escrevemos o pipeline manualmente com apenas três dependências. Você entenderá cada etapa e saberá o que otimizar mais tarde.

ChromaDB
Armazenamento de vetores de código aberto, puramente em Python, com modo persistente integrado (SQLite + índice HNSW). Sem necessidade de iniciar um servidor.
Ollama
Serve tanto o modelo de embeddings (nomic-embed-text) quanto o LLM de chat (Qwen 3.5, Granite 4.2, Gemma 4). Ponto de acesso HTTP único em localhost:11434.
Python nativo
Algumas funções, sem framework. Você poderá conectar o LangChain mais tarde, se necessário, mas isso não é necessário para começar.
i
O que você obtém
Um script Python de cerca de 150 linhas que ingere os PDFs de uma pasta, os divide em blocos, os indexa no ChromaDB e responde a perguntas em francês com citações. Tudo localmente, sem nenhuma requisição de saída.

#Pré-requisitos

O kit Copiloto Local

Este guia leva você ao modelo. O kit leva você ao copiloto que programa no seu editor.

  • Espaço online vitalício
  • PDF + arquivos
  • Atualizações vitalícias
Python 3.10+
ChromaDB exige pelo menos 3.10. Verifique com python --version.
Ollama instalado e iniciado
O daemon escuta por padrão em http://localhost:11434. Se você está começando do zero, siga primeiro o guia de instalação do Ollama.
8 GB de RAM
16 GB confortáveis. O modelo de chat 9B em Q4 ocupa ~6 GB, o modelo de embeddings ~300 MB.
Um GPU não é obrigatório
A inferência na CPU funciona, mas é mais lenta. Para a ingestão de um grande corpus, uma GPU de 6 GB ou mais acelera muito os embeddings.

#1. Instalar o ChromaDB e preparar Ollama

Criamos um ambiente virtual limpo, instalamos as três bibliotecas necessárias e baixamos os modelos no Ollama.

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

Três pacotes: chromadb para o armazenamento vetorial, ollama para o cliente Python oficial, pypdf para ler PDFs. Isso é tudo.

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

nomic-embed-text é um modelo de embeddings de 137M de parâmetros, multilíngue, que gera vetores de dimensão 768. Leve, rápido, bom em francês. Qwen 3.5 9B (6,6 GB, 256k de contexto, multilíngue, Apache 2.0) é usado para o chat final: é a escolha padrão para 8 GB em 2026. Você pode substituí-lo por granite4.2:8b (mais econômico) ou gemma4:12b sem alterar nada no código.

→
Verificar se o Ollama responde
Um simples curl http://localhost:11434/api/tags deve listar seus modelos. Se nada for exibido, o daemon não está rodando: execute ollama serve em outro terminal.

#2. Configurar o modelo de embeddings

Um embedding é um vetor que representa o significado de um texto. Dois textos semanticamente próximos têm vetores próximos. É o motor do RAG: procuramos os chunks cujo embedding se assemelha mais ao da pergunta.

embed.py — teste rápido
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]}")

Você deve ver Dimensão do vetor: 768. Se ocorrer uma falha com a mensagem model not found, é porque o comando ollama pull nomic-embed-text não foi executado.

i
Por que nomic-embed-text
Em benchmarks FR (MTEB-fr), o nomic-embed-text está no top 5 dos modelos com menos de 200M de parâmetros. Para francês puro, o mxbai-embed-large geralmente faz melhor, mas pesa 670M. O nomic é um excelente compromisso qualidade/velocidade para começar.

#3. Ingestão de PDF em francês

A ingestão faz três coisas: ler as páginas de um PDF, dividir o texto em pedaços de tamanho razoável e armazenar cada pedaço com seu embedding no ChromaDB em 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}")

O chunker divide em blocos de 800 caracteres com 100 de sobreposição. É um ponto de partida: nem muito pequeno (falta de contexto) nem muito grande (dilui o sinal). Para conteúdo jurídico muito denso, reduza para 500. Para manuais técnicos com texto mais espaçado, aumente para 1200.

→
O modo persistente do ChromaDB
PersistentClient(path="./chroma_db") cria um diretório que persiste após reinicializações. O SQLite armazena os metadados, e um índice HNSW armazena os vetores. Não é necessário iniciar um servidor nem usar Docker. Para passar para o modo cliente/servidor mais tarde, basta substituir por HttpClient.

Inicie a ingestão em um diretório ./pdfs/ contendo seus documentos:

Iniciar a ingestão
mkdir -p pdfs
# placez vos PDF dans ./pdfs/
python ingest.py
!
PDFs escaneados = sem texto
pypdf extrai apenas o texto nativo. Se seus PDFs forem imagens digitalizadas, extract_text() retornará vazio. Nesse caso, é necessário usar OCR (Tesseract ou um modelo de visão como o Qwen 3.5 9B, multimodal, via Ollama) antes da ingestão.

#4. Busca top-k no ChromaDB

Após a indexação dos chunks, a busca consiste em gerar um embedding da pergunta e depois pedir ao Chroma os k vetores mais próximos pela distância de cosseno. É instantâneo, mesmo com 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 é um bom valor padrão. Se o valor for muito baixo, você perde contexto relevante; se for muito alto, você sobrecarrega o LLM com ruído e ultrapassa o limite da janela de contexto. Para perguntas muito precisas, k=2 é suficiente. Para perguntas transversais, aumente para 6.

#5. Loop de conversa com citações

Agora vamos montar: buscamos os chunks relevantes, construímos um prompt com o contexto, enviamos para Qwen 3.5 via Ollama, e pedimos ao modelo para citar suas fontes.

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))

Três detalhes que importam. Primeiro, temperature=0.2: queremos uma resposta factual, não criativa. Segundo, num_ctx=8192: a janela padrão do Ollama (2048) é curta demais quando se injetam 4 blocos de 800 caracteres. Terceiro, o prompt de sistema força o modelo a dizer 'não sei' em vez de alucinar — esse é o principal mecanismo de proteção contra alucinações do RAG.

→
Streaming para uma melhor experiência do usuário
Substitua ollama.chat por ollama.chat(..., stream=True) e itere sobre a resposta para exibir os tokens à medida que forem gerados. Isso é essencial assim que esse código é integrado a uma interface real (FastAPI + WebSocket, ou Streamlit).

#6. Exemplo prático: chatbot jurídico para contratos

Imaginemos um escritório que deseja consultar 200 contratos de prestação de serviços em PDF. Com a stack acima, em menos de uma hora já temos um assistente respondendo perguntas do tipo:

Pergunta típica
« Quais contratos incluem uma cláusula de não concorrência com duração superior a 12 meses após a rescisão? »
O que está acontecendo
O embedding da pergunta localiza os chunks que contêm palavras-chave semanticamente próximas (não concorrência, pós-ruptura, duração). O Qwen 3.5 lê esses 4 trechos e responde com os nomes dos arquivos correspondentes.
Garantia de confidencialidade
Nenhum dado sai do dispositivo. Sem chave de API. Sem telemetria. Isso diferencia um RAG local de um wrapper OpenAI.
!
Limitações que você deve conhecer
Um RAG básico responde bem a perguntas específicas (“qual é a cláusula X”), mas mal a perguntas de agregação (“quantos contratos têm X”). Para estas últimas, é necessário um agente que consulte a base em várias etapas ou um GraphRAG. Isso já é outra história.

#Solução de problemas

ChromaDB lento na ingestão
O gargalo é quase sempre a chamada de embeddings para o Ollama. Verifique se nomic-embed-text está rodando na GPU com o comando ollama ps. Na CPU, espere cerca de 50 blocos por segundo; na GPU, cerca de 500.
« model not found »
Ollama não encontra nomic-embed-text. Execute novamente ollama pull nomic-embed-text e verifique com o comando ollama list.
Respostas que inventam fontes
Um modelo 9B ainda alucina de vez em quando. Mude para mistral-small (24B, ~14 GB, muito bom em francês) ou qwen3.8:27b se você tiver VRAM suficiente. Ou adicione um reranker (cross-encoder) após o ChromaDB para filtrar falsos positivos.
Embeddings de baixa qualidade em FR
nomic-embed-text é multilíngue, mas não é ideal para conteúdo exclusivamente em francês. Para conteúdo jurídico ou médico, teste Solon-embeddings-large-0.1 ou bge-m3 (a carregar via sentence-transformers, fora do Ollama).
ChromaDB cresce sem limite
Cada reindexação adiciona duplicatas. Antes de ingerir novamente um PDF, execute collection.delete(where={"source": name}) para remover os blocos antigos.

#Para se aprofundar

Você tem um RAG funcional. Aqui estão os próximos passos naturais para levá-lo mais longe:

Comparar modelos de embeddings FR
Nosso guia « Os melhores modelos de embeddings FR » compara BGE, E5, Solon e nomic em conteúdo francês.
Melhorar o chunking
« Estratégias de chunking » detalha o chunking semântico, por títulos em markdown ou por parágrafos — muitas vezes o que traz mais precisão.
Adicionar um reranker
« Adicionar um reranker ao pipeline »: +15% de relevância ao colocar um cross-encoder após o Chroma. O próximo passo lógico.
Busca híbrida
“Busca híbrida BM25 + vetorial” combina busca lexical e semântica, sendo indispensável sempre que houver muito jargão ou muitos nomes próprios.
Este guia ajudou você?

Um comentário, um erro ou uma observação? Avise-nos; isso ajuda a melhorar o guia para todos.