RAG Local com Ollama: um assistente que responde sobre seus runbooks sem sair da máquina
No meio de um incidente, ninguém tem paciência para caçar num wiki qual é o procedimento de rollback. A informação existe, está escrita, alguém documentou com carinho seis meses atrás, mas está enterrada em algum runbook que ninguém lembra onde fica. O conhecimento existe e mesmo assim não está disponível na hora que importa.
Esse lab ataca exatamente esse problema com uma técnica chamada RAG, montando um assistente que lê a sua própria documentação e responde perguntas em linguagem natural, citando de qual arquivo tirou cada resposta. O detalhe que torna tudo isso viável para documentação interna: roda 100% na sua máquina, sem API paga e sem mandar um único byte para a nuvem.
O diagrama animado abaixo mostra o lab inteiro em movimento, do IBM Bob gerando os runbooks até a resposta citando a fonte no terminal.

Objetivo do Lab
Construir um assistente de perguntas e respostas sobre uma base de runbooks de DevOps (deploy, incidentes, segurança e observabilidade), usando modelos de IA que rodam localmente via Ollama. Você faz uma pergunta como “como faço o rollback de um deploy que deu errado?” e o assistente busca os trechos relevantes na documentação, monta o contexto e gera a resposta citando os arquivos de origem.
Tudo sobe com um comando de Docker Compose. Nada é instalado nativamente na máquina, nem o modelo, nem o banco vetorial, nem as bibliotecas Python. Ao final do post você tem o projeto completo, arquivo por arquivo, com a explicação de cada decisão de implementação.
O que é RAG, sem enrolação
RAG é a sigla de Retrieval-Augmented Generation, ou geração aumentada por recuperação. A ideia resolve uma limitação real dos modelos de linguagem: eles sabem muito sobre o mundo em geral, mas não sabem nada sobre a sua empresa, seus runbooks, suas convenções internas. Além disso, quando não sabem, tendem a inventar uma resposta plausível.
O RAG contorna isso invertendo a ordem. Em vez de perguntar direto ao modelo e torcer para ele saber, primeiro a gente busca na nossa própria documentação os trechos mais relevantes para a pergunta, e só então entrega esses trechos ao modelo junto com a pergunta, pedindo que ele responda com base naquele material. O modelo deixa de ser a fonte da verdade e passa a ser quem lê e resume a sua fonte da verdade.
O ganho é duplo. A resposta fica ancorada nos seus documentos, então dá para citar a fonte, e o risco de invenção cai bastante, porque o modelo é instruído a responder apenas com o que recebeu e a admitir quando a informação não está lá.
Tecnologias Utilizadas
Antes do código, vale entender cada peça e por que ela está aqui.
Ollama é uma ferramenta que roda modelos de linguagem open source localmente, com uma API HTTP simples. É o que permite usar um LLM na própria máquina sem depender de OpenAI ou qualquer serviço externo. No lab ele roda dois modelos: o llama3.2:3b, um modelo pequeno que gera as respostas e roda até em CPU, e o nomic-embed-text, especializado em transformar texto em vetores.
Embeddings são a peça que faz a busca por significado funcionar. Um modelo de embedding converte um pedaço de texto em uma lista de números (um vetor) que representa o significado daquele texto. Textos com sentido parecido geram vetores próximos no espaço. É isso que permite encontrar o trecho certo mesmo quando a pergunta usa palavras diferentes das que estão no documento. Se você pergunta “como desfazer um deploy” e o runbook fala em “rollback”, a busca por palavra-chave não acha nada, mas a busca por vetor acha, porque os dois textos têm significado próximo.
ChromaDB é um banco de dados vetorial. Ele guarda os vetores gerados pelos embeddings e responde rápido à pergunta “quais são os trechos mais parecidos com este aqui?”. É a memória pesquisável do assistente. No lab ele persiste em disco, então a indexação feita uma vez sobrevive a reinícios.
LangChain é a biblioteca que cola tudo: carrega os documentos, quebra em pedaços, chama o modelo de embedding, conversa com o ChromaDB e monta o prompt final. Ela poupa muito código de integração entre as peças.
Streamlit entrega a interface web em pouquíssimo código Python, sem front-end separado. É onde você digita a pergunta e vê a resposta com as fontes.
Docker Compose orquestra os dois contêineres (Ollama e a aplicação), cuida da ordem de subida e persiste os volumes. É o que torna o lab reproduzível em qualquer máquina com Docker.
Arquitetura
O fluxo tem duas fases. A ingestão acontece uma vez, quando os documentos são indexados. A consulta acontece a cada pergunta.
| Componente | Papel no fluxo | Onde roda |
|---|---|---|
| Ollama | Serve o LLM e o modelo de embeddings | Contêiner ollama |
| Modelo de embeddings | Converte texto em vetor | Dentro do Ollama |
| ChromaDB | Guarda e busca vetores por similaridade | Volume no contêiner app |
| LLM (llama3.2) | Gera a resposta a partir do contexto | Dentro do Ollama |
| App Python | Ingestão, CLI e interface web | Contêiner app |
O mesmo pipeline visto como grafo deixa claro quem chama quem. O Ollama aparece nos dois lados, na ingestão gerando os embeddings e na consulta gerando a resposta.
flowchart TB
Bob([IBM Bob gera os runbooks]):::bob
subgraph Ingestao[Ingestão · uma vez]
direction TB
D[Documentos
.md .pdf .txt] --> C[Chunks]
C --> E[Embeddings]
E --> DB[(ChromaDB)]
end
subgraph Consulta[Consulta · cada pergunta]
direction TB
Q[Pergunta] --> R[Retrieval
top-k similar]
R --> P[Prompt + contexto]
P --> L[LLM · llama3.2]
L --> A[Resposta
com fontes]
end
Bob --> D
DB -.busca por similaridade.-> R
O{{Ollama}} -.embeddings.-> E
O -.geração.-> L
classDef bob fill:#1f6feb,stroke:#388bfd,color:#fff;
Subindo tudo com Docker Compose
O coração da reprodutibilidade é o docker-compose.yml. Ele declara dois serviços. O ollama usa a imagem oficial, expõe a porta padrão e guarda os modelos baixados em um volume nomeado, para não baixar de novo a cada subida. O app é construído a partir do Dockerfile local, só sobe depois que o Ollama passa no healthcheck e recebe toda a configuração por variáveis de ambiente.
services:
ollama:
image: ollama/ollama:latest
container_name: rag-ollama
ports:
- "11434:11434"
volumes:
- ollama_models:/root/.ollama
healthcheck:
test: ["CMD", "ollama", "list"]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
app:
build: .
container_name: rag-app
depends_on:
ollama:
condition: service_healthy
environment:
OLLAMA_HOST: http://ollama:11434
LLM_MODEL: llama3.2:3b
EMBED_MODEL: nomic-embed-text
CHROMA_DIR: /data/chroma
volumes:
- ./docs:/app/docs:ro
- chroma_data:/data/chroma
ports:
- "8501:8501"
command: streamlit run app.py --server.address=0.0.0.0 --server.port=8501
volumes:
ollama_models:
chroma_data:
Dois detalhes que evitam dor de cabeça. O depends_on com condition: service_healthy garante que a aplicação não tente falar com o Ollama antes dele estar pronto, um erro clássico que gera falha intermitente na primeira subida. E a pasta docs é montada como somente leitura (:ro), porque a aplicação só lê os documentos, nunca escreve neles.
O ponto de configuração único
Toda a configuração vive em common.py, lido pela ingestão, pela CLI e pela interface. Centralizar isso evita repetir a criação dos clientes em cada arquivo e mantém um único lugar para trocar o modelo ou o diretório do banco.
import os
from langchain_ollama import OllamaEmbeddings, ChatOllama
from langchain_chroma import Chroma
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
LLM_MODEL = os.getenv("LLM_MODEL", "llama3.2:3b")
EMBED_MODEL = os.getenv("EMBED_MODEL", "nomic-embed-text")
CHROMA_DIR = os.getenv("CHROMA_DIR", "./chroma")
COLLECTION = "runbooks-devops"
def get_embeddings():
return OllamaEmbeddings(model=EMBED_MODEL, base_url=OLLAMA_HOST)
def get_llm():
return ChatOllama(model=LLM_MODEL, base_url=OLLAMA_HOST, temperature=0.1)
def get_vectorstore():
return Chroma(
collection_name=COLLECTION,
embedding_function=get_embeddings(),
persist_directory=CHROMA_DIR,
)
A temperature=0.1 no LLM é uma escolha deliberada. Temperatura controla o quão criativo o modelo é, e num assistente de runbook a gente quer o contrário de criatividade: quer a resposta mais previsível e fiel ao contexto possível. Temperatura baixa deixa o modelo mais determinístico e menos propenso a florear.
A fase de ingestão em detalhe
A ingestão lê os documentos da pasta docs/, quebra cada um em pedaços menores, gera os embeddings e grava no ChromaDB. O diagrama abaixo mostra esse caminho, com o Ollama alimentando a etapa de embeddings.

O código carrega cada tipo de arquivo com o loader adequado, quebra em chunks e grava:
from langchain_community.document_loaders import (
DirectoryLoader, TextLoader, PyPDFLoader, UnstructuredMarkdownLoader,
)
from langchain_text_splitters import RecursiveCharacterTextSplitter
from common import get_vectorstore
def carregar_documentos():
loaders = [
DirectoryLoader("docs", glob="**/*.md", loader_cls=UnstructuredMarkdownLoader),
DirectoryLoader("docs", glob="**/*.txt", loader_cls=TextLoader),
DirectoryLoader("docs", glob="**/*.pdf", loader_cls=PyPDFLoader),
]
docs = []
for loader in loaders:
docs.extend(loader.load())
return docs
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=120,
separators=["\n## ", "\n### ", "\n\n", "\n", ". ", " "],
)
chunks = splitter.split_documents(carregar_documentos())
store = get_vectorstore()
store.add_documents(chunks)
A quebra em pedaços (chunking) importa mais do que parece. Um documento inteiro é grande demais para caber no contexto do modelo e, pior, dilui o significado: o vetor de um documento gigante vira uma média borrada de vários assuntos, e a busca perde precisão. Um chunk de tamanho médio mantém cada trecho coeso em torno de um assunto só.
O chunk_overlap=120 faz cada pedaço compartilhar 120 caracteres com o vizinho. Isso resolve o problema da informação que cai bem na fronteira entre dois chunks: sem sobreposição, uma frase importante poderia ser cortada ao meio e não aparecer inteira em nenhum dos dois pedaços. Com sobreposição, ela aparece completa em pelo menos um.
A lista de separators diz ao splitter a ordem de preferência para cortar. Ele tenta cortar primeiro em títulos de seção, depois em parágrafos, depois em linhas, e só corta no meio de uma frase como último recurso. O resultado são chunks que respeitam a estrutura do documento em vez de picotar no caractere 800 na marra.
A fase de consulta em detalhe
Na consulta, a pergunta passa pelo mesmo modelo de embedding e vira um vetor. O ChromaDB devolve os trechos mais próximos, que viram contexto de um prompt enviado ao LLM. O diagrama abaixo mostra o caminho completo, da pergunta até a resposta com fonte.

O código da CLI é curto porque o common.py já fez o trabalho pesado:
from common import (
get_llm, get_vectorstore, montar_contexto, fontes_unicas, PROMPT_TEMPLATE,
)
def responder(pergunta, k=4):
store = get_vectorstore()
docs = store.similarity_search(pergunta, k=k) # busca por vetor
contexto = montar_contexto(docs)
prompt = PROMPT_TEMPLATE.format(contexto=contexto, pergunta=pergunta)
resposta = get_llm().invoke(prompt).content
print(resposta.strip())
print("Fontes:", ", ".join(fontes_unicas(docs)))
O similarity_search(pergunta, k=4) é onde a mágica acontece. Ele converte a pergunta em vetor com o mesmo modelo de embedding usado na ingestão (isso é fundamental, os dois lados precisam falar a mesma língua vetorial) e pede ao ChromaDB os quatro trechos cujos vetores estão mais próximos. Quatro é um equilíbrio: contexto suficiente para responder bem, sem encher o prompt de ruído que confunde o modelo.
O prompt que segura a invenção
A parte que separa um assistente confiável de um gerador de respostas plausíveis está no prompt. Ele monta o contexto com a origem de cada trecho e dá a regra explícita ao modelo.
PROMPT_TEMPLATE = """Você é um assistente técnico de DevOps. Responda à \
pergunta usando somente o contexto abaixo, extraído da documentação interna. \
Se o contexto não contiver a resposta, diga que não encontrou a informação na \
documentação, sem inventar. Responda em português, de forma objetiva, e cite \
os arquivos de origem ao final.
Contexto:
{contexto}
Pergunta: {pergunta}
Resposta:"""
def montar_contexto(docs):
blocos = []
for d in docs:
origem = d.metadata.get("source", "desconhecido")
blocos.append(f"[Fonte: {origem}]\n{d.page_content}")
return "\n\n---\n\n".join(blocos)
Duas instruções fazem o trabalho. A primeira manda responder somente com base no contexto. A segunda manda admitir quando a informação não está lá, em vez de preencher a lacuna. Sem a segunda instrução, o modelo tende a inventar uma resposta que parece certa, e num runbook isso é perigoso. Marcar cada trecho com [Fonte: arquivo] no contexto é o que permite o modelo citar de onde tirou a resposta no final.
Ingestão idempotente no entrypoint
O entrypoint.sh garante que tudo esteja pronto antes da aplicação subir: baixa os modelos no Ollama e faz a ingestão inicial, mas só se o banco ainda estiver vazio.
#!/usr/bin/env bash
set -euo pipefail
echo "[entrypoint] Garantindo modelos no Ollama..."
curl -s "${OLLAMA_HOST}/api/pull" -d "{\"name\": \"${LLM_MODEL}\"}" > /dev/null
curl -s "${OLLAMA_HOST}/api/pull" -d "{\"name\": \"${EMBED_MODEL}\"}" > /dev/null
if [ ! -f "${CHROMA_DIR}/chroma.sqlite3" ]; then
echo "[entrypoint] Banco vazio. Rodando ingestão inicial..."
python ingest.py
else
echo "[entrypoint] Banco já existe. Pulando ingestão."
fi
exec "$@"
Isso segue a mesma lógica de qualquer automação de infraestrutura bem feita: rodar duas vezes deve levar ao mesmo estado, sem duplicar dados nem quebrar o que já funcionava. O ollama pull é naturalmente idempotente, se o modelo já existe ele apenas confirma. E a ingestão só roda quando o arquivo do ChromaDB ainda não existe, então reiniciar o contêiner não reindexa tudo de novo à toa.
A interface web
O app.py monta a interface em Streamlit. Além da resposta, ele mostra num expander os trechos exatos que foram recuperados, o que é ótimo para entender por que o modelo respondeu daquele jeito.
import streamlit as st
from common import get_llm, get_vectorstore, montar_contexto, fontes_unicas, PROMPT_TEMPLATE
store, llm = get_vectorstore(), get_llm()
pergunta = st.text_input("Pergunte sobre a documentação de infraestrutura:")
if pergunta:
docs = store.similarity_search(pergunta, k=4)
contexto = montar_contexto(docs)
resposta = llm.invoke(PROMPT_TEMPLATE.format(contexto=contexto, pergunta=pergunta)).content
st.markdown(resposta)
st.info("Fontes: " + ", ".join(fontes_unicas(docs)))
with st.expander("Ver os trechos recuperados"):
for i, d in enumerate(docs, 1):
st.markdown(f"**Trecho {i}** · `{d.metadata.get('source')}`")
st.text(d.page_content)
Mostrar os trechos recuperados não é enfeite. Quando a resposta parece estranha, o expander revela se o problema foi recuperação ruim (trouxe o trecho errado) ou geração ruim (trouxe o certo mas o modelo respondeu mal). Essa distinção é o primeiro passo para melhorar um RAG.
Executando o lab
Pré-requisito é só Docker e Docker Compose. Para subir tudo:
docker compose up --build
Na primeira vez o Ollama baixa os dois modelos (alguns GB) e a ingestão roda automaticamente. Depois a interface fica em http://localhost:8501.
Antes de confiar na interface, vale um teste rápido pela linha de comando:
docker compose run --rm app python rag.py "como faço o rollback de um deploy?"
A resposta aponta o kubectl rollout undo e cita runbook-deploy.md como fonte, exatamente o trecho que está na documentação. Se você perguntar algo que não está em nenhum runbook, o assistente responde que não encontrou a informação, em vez de inventar. Esse é o teste que mais importa: um RAG que inventa é pior que documentação nenhuma, porque dá uma resposta confiante e errada.
Ao trocar os documentos em docs/, reindexe com um comando:
docker compose run --rm app python ingest.py
Para que serve no mercado
Esse padrão está em todo lugar hoje, com nomes diferentes. Um time de SRE aponta o RAG para a base de runbooks e transforma a documentação de incidente em algo consultável em linguagem natural durante a madrugada. Um time de suporte aponta para a base de conhecimento e reduz o tempo de primeira resposta. Uma área de compliance aponta para políticas internas e responde dúvidas de auditoria citando o documento exato.
A versão local tem um apelo específico: privacidade. Muita documentação interna não pode ser enviada para uma API de terceiros por questão contratual ou regulatória. Rodando o modelo na própria infraestrutura, o dado nunca sai do perímetro. É a diferença entre poder usar IA sobre a documentação sensível e não poder.
Conclusão
O que esse lab mostra não é um modelo mais inteligente, é um jeito melhor de usar um modelo que você já tem. A inteligência aqui está na arquitetura: separar a recuperação da geração, ancorar a resposta em fontes reais, instruir o modelo a admitir o que não sabe e baixar a temperatura para priorizar fidelidade sobre criatividade. Isso transforma um gerador de texto genérico em um assistente que responde sobre o seu material específico e mostra de onde tirou cada afirmação.
E o fato de tudo caber em dois contêineres, sem custo de API e sem dado saindo da máquina, tira a IA do território de prova de conceito cara e coloca ela como uma ferramenta que qualquer time pode subir num sábado à tarde e apontar para a própria documentação.
Referências
- Ollama: https://github.com/ollama/ollama
- ChromaDB: https://github.com/chroma-core/chroma
- LangChain: https://python.langchain.com
- Nomic Embed: https://ollama.com/library/nomic-embed-text