Como Criar uma Base de Conhecimento com IA para a Sua Equipa em 48 Horas
No mês passado, a nossa equipa de produto de 35 pessoas estava completamente a afogar-se em documentação. Tínhamos wikis, Google Docs, páginas de Confluence, bases de dados no Notion, conversas no Slack com decisões importantes enterradas algures, e uma pasta partilhada com centenas de PDFs onde ninguém encontrava nada. Soa familiar?
Passei um fim de semana a construir uma base de conhecimento com IA que permite a qualquer membro da equipa fazer perguntas em linguagem natural e obter respostas precisas extraídas de toda a nossa documentação. Tempo total de desenvolvimento: cerca de 48 horas. Custo mensal de funcionamento: 153 $. Em menos de uma semana, a equipa passou do "não consigo encontrar esse documento" para "pergunta à base de conhecimento".
Aqui está exactamente como construí o sistema, passo a passo, com estimativas de tempo para que possa planear o seu próprio projecto.
O que vamos construir
A arquitectura chama-se RAG — Geração Aumentada por Recuperação. Em vez de pedir a um modelo de IA que saiba tudo (o que não acontece), fornecemos-lhe os nossos próprios documentos e deixamos que os pesquise para responder perguntas. Pense nisso como dar ao ChatGPT acesso ao cérebro da sua empresa.
O stack tecnológico:
- Python 3.11+ — a espinha dorsal
- LangChain — framework de orquestração para ligar LLMs a dados
- ChromaDB — base de dados vectorial para armazenar embeddings de documentos
- OpenAI Embeddings (text-embedding-3-small) — converte texto em vectores pesquisáveis
- OpenAI GPT-4o-mini — gera respostas a partir do contexto recuperado
- Streamlit — interface web rápida e limpa
No final deste tutorial, terá um sistema funcional onde os utilizadores escrevem uma pergunta, o sistema encontra os fragmentos de documentos mais relevantes e um LLM sintetiza uma resposta com citação das fontes.
Horas 0-2: Configuração do ambiente e dependências
Primeiro, vamos montar a estrutura do projecto. Crie um novo directório e configure um ambiente virtual:
mkdir team-knowledge-base
cd team-knowledge-base
python -m venv venv
source venv/bin/activate # No Windows: venv\Scripts\activate
pip install langchain langchain-openai langchain-community
pip install chromadb
pip install streamlit
pip install python-dotenv
pip install pypdf docx2txt unstructured
pip install tiktoken
Crie um ficheiro .env para a sua chave de API:
OPENAI_API_KEY=sk-a-sua-chave-aqui
E configure a estrutura do projecto:
team-knowledge-base/
├── .env
├── app.py # Frontend em Streamlit
├── ingest.py # Pipeline de processamento de documentos
├── query_engine.py # Lógica de consulta RAG
├── config.py # Configuração partilhada
├── documents/ # Coloque os seus documentos aqui
│ ├── pdfs/
│ ├── markdown/
│ └── text/
└── chroma_db/ # Armazenamento da base de dados vectorial
Verificação de tempo: isto deverá demorar cerca de 30 minutos se já estiver familiarizado com ambientes Python, ou até 2 horas se precisar de instalar o Python de raiz e configurar as chaves de API.
Horas 2-8: Pipeline de ingestão de documentos
Esta é a parte mais crítica de todo o sistema. A forma como divide os seus documentos em fragmentos determina a qualidade das respostas. Aprendi isto da pior maneira — na minha primeira tentativa usei fragmentos ingénuos de 1000 caracteres e as respostas eram péssimas.
A estratégia de fragmentação que realmente funciona
Depois de testar cinco abordagens diferentes de fragmentação, eis o que funcionou melhor para a nossa documentação em formatos mistos:
# config.py
CHUNK_SIZE = 1500 # caracteres por fragmento
CHUNK_OVERLAP = 200 # sobreposição entre fragmentos
EMBEDDING_MODEL = "text-embedding-3-small"
LLM_MODEL = "gpt-4o-mini"
COLLECTION_NAME = "team_knowledge"
CHROMA_DIR = "./chroma_db"
Porquê 1500 caracteres com 200 de sobreposição? Através dos testes, descobri que:
- Demasiado pequeno (500 chars): As respostas carecem de contexto. O modelo recebe um fragmento de frase e não consegue sintetizar uma resposta útil.
- Demasiado grande (3000+ chars): A precisão da recuperação diminui. Quando um fragmento abrange vários temas, é recuperado para consultas em que apenas parte dele é relevante, acrescentando ruído ao contexto.
- 1500 chars: O ponto ideal para parágrafos de documentação típicos. A maioria das ideias completas cabe nesta janela.
- 200 chars de sobreposição: Evita que ideias que atravessam os limites entre fragmentos se percam. Fundamental para listas numeradas e instruções passo a passo.
Aqui está o script de ingestão:
# ingest.py
import os
from dotenv import load_dotenv
from langchain_community.document_loaders import (
PyPDFLoader,
TextLoader,
Docx2txtLoader,
UnstructuredMarkdownLoader,
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from config import *
load_dotenv()
def load_documents(docs_dir="./documents"):
'Carrega todos os tipos de documentos suportados da árvore de directórios.'
documents = []
loaders_map = {
".pdf": PyPDFLoader,
".txt": TextLoader,
".docx": Docx2txtLoader,
".md": UnstructuredMarkdownLoader,
}
for root, dirs, files in os.walk(docs_dir):
for file in files:
ext = os.path.splitext(file)[1].lower()
if ext in loaders_map:
file_path = os.path.join(root, file)
try:
loader = loaders_map[ext](file_path)
docs = loader.load()
# Adicionar metadados de origem
for doc in docs:
doc.metadata["source"] = file_path
doc.metadata["filename"] = file
documents.extend(docs)
print(f" Carregado: {file} ({len(docs)} páginas/secções)")
except Exception as e:
print(f" Erro ao carregar {file}: {e}")
return documents
def chunk_documents(documents):
'Divide os documentos em fragmentos sobrepostos.'
splitter = RecursiveCharacterTextSplitter(
chunk_size=CHUNK_SIZE,
chunk_overlap=CHUNK_OVERLAP,
length_function=len,
separators=["\n\n", "\n", ". ", " ", ""]
)
chunks = splitter.split_documents(documents)
print(f" {len(documents)} documentos divididos em {len(chunks)} fragmentos")
return chunks
def create_vector_store(chunks):
'Gera embeddings dos fragmentos e armazena-os no ChromaDB.'
embeddings = OpenAIEmbeddings(model=EMBEDDING_MODEL)
vector_store = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
collection_name=COLLECTION_NAME,
persist_directory=CHROMA_DIR,
)
print(f" Base de dados vectorial criada com {len(chunks)} embeddings")
return vector_store
if __name__ == "__main__":
print("Passo 1: A carregar documentos...")
docs = load_documents()
print(f" Total de documentos carregados: {len(docs)}")
print("Passo 2: A fragmentar documentos...")
chunks = chunk_documents(docs)
print("Passo 3: A criar a base de dados vectorial...")
store = create_vector_store(chunks)
print("Concluído! A base de conhecimento está pronta.")
O RecursiveCharacterTextSplitter é fundamental aqui. Tenta dividir primeiro por quebras de parágrafo (\n\n), depois por quebras de linha, de seguida por frases e por fim por palavras. Isto significa que os seus fragmentos irão quase sempre conter ideias completas em vez de ficarem cortados a meio de uma frase.
Como lidar com diferentes tipos de documentos
Uma nota rápida sobre os tipos de documentos, porque isto criou-me problemas logo no início:
- PDFs: O PyPDF funciona para a maioria dos documentos, mas PDFs digitalizados precisam de OCR. Se tiver documentos digitalizados, adicione
pip install pytesseracte utilizeUnstructuredPDFLoaderem alternativa. - Google Docs: Exporte primeiro como .docx. A API do Google Drive pode automatizar este processo, mas para o MVP a exportação manual é suficiente.
- Confluence: Exporte os espaços como HTML e use
UnstructuredHTMLLoader. Lida com formatação aninhada surpreendentemente bem. - Conversas do Slack: Este é o mais difícil. Escrevi um pequeno script que usa a API do Slack para exportar conversas guardadas como ficheiros de texto. Isso é um tutorial separado, mas o essencial é incluir o contexto da conversa (nome do canal, participantes, data) como metadados.
Horas 8-16: Motor de consultas
Agora vem a parte interessante — fazer com que a base de conhecimento responda efectivamente às perguntas.
# query_engine.py
import os
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
from config import *
load_dotenv()
# Prompt personalizado que impõe a citação de fontes
PROMPT_TEMPLATE = (
"És um assistente útil para uma equipa de produto.\n"
"Usa o seguinte contexto para responder à pergunta.\n"
"Se não souberes a resposta com base no contexto, diz-o honestamente.\n"
"Cita sempre os documentos de onde estás a retirar a informação.\n\n"
"Contexto:\n{context}\n\n"
"Pergunta: {question}\n\n"
"Resposta (cita as tuas fontes):"
)
def get_query_engine():
'Inicializa e devolve o motor de consulta RAG.'
embeddings = OpenAIEmbeddings(model=EMBEDDING_MODEL)
vector_store = Chroma(
collection_name=COLLECTION_NAME,
persist_directory=CHROMA_DIR,
embedding_function=embeddings,
)
retriever = vector_store.as_retriever(
search_type="mmr", # Relevância Marginal Máxima
search_kwargs={
"k": 6, # Devolver os 6 fragmentos mais relevantes
"fetch_k": 20, # Considerar os 20 primeiros antes da filtragem MMR
"lambda_mult": 0.7, # Equilíbrio entre diversidade e relevância
}
)
llm = ChatOpenAI(model=LLM_MODEL, temperature=0.1)
prompt = PromptTemplate(
template=PROMPT_TEMPLATE,
input_variables=["context", "question"]
)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
return_source_documents=True,
chain_type_kwargs={"prompt": prompt}
)
return qa_chain
def query(question):
'Consulta a base de conhecimento e devolve a resposta + fontes.'
engine = get_query_engine()
result = engine.invoke({"query": question})
sources = set()
for doc in result.get("source_documents", []):
sources.add(doc.metadata.get("filename", "Desconhecido"))
return {
"answer": result["result"],
"sources": list(sources),
}
Porquê MMR em vez de pesquisa simples por similaridade
Quero destacar a definição search_type="mmr" porque faz uma diferença enorme. A pesquisa por similaridade padrão devolve os 6 fragmentos mais similares à sua consulta — mas esses 6 fragmentos podem ser todos do mesmo documento a dizer mais ou menos a mesma coisa. Isso é redundante e desperdiça a janela de contexto.
O MMR (Relevância Marginal Máxima) equilibra relevância com diversidade. Escolhe o primeiro fragmento baseando-se puramente na similaridade e, para cada fragmento seguinte, penaliza os que são demasiado similares aos já seleccionados. O resultado são 6 fragmentos que são todos relevantes mas que cobrem aspectos diferentes do tema.
O parâmetro lambda_mult=0.7 controla este equilíbrio: 1.0 é similaridade pura (sem diversidade), 0.0 é diversidade pura (pode não ser relevante). Descobri que 0.7 é o ponto ideal para consultas de documentação.
Horas 16-24: Frontend em Streamlit
Está na altura de tornar isto utilizável por pessoas que não têm um terminal aberto o dia todo.
# app.py
import streamlit as st
from query_engine import query, get_query_engine
import time
st.set_page_config(
page_title="Base de Conhecimento da Equipa",
page_icon="search",
layout="wide"
)
st.title("Base de Conhecimento da Equipa")
st.markdown("Pergunte o que quiser sobre a nossa documentação, processos ou decisões.")
# Inicializar o motor uma única vez
if "engine" not in st.session_state:
with st.spinner("A carregar a base de conhecimento..."):
st.session_state.engine = get_query_engine()
# Histórico do chat
if "messages" not in st.session_state:
st.session_state.messages = []
# Mostrar histórico do chat
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
if msg.get("sources"):
with st.expander("Fontes"):
for src in msg["sources"]:
st.markdown(f"- {src}")
# Entrada
if prompt := st.chat_input("O que gostaria de saber?"):
st.session_state.messages.append({"role": "user", "content": prompt})
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
with st.spinner("A pesquisar na base de conhecimento..."):
start = time.time()
result = query(prompt)
elapsed = time.time() - start
st.markdown(result["answer"])
st.caption(f"Tempo de resposta: {elapsed:.1f}s")
if result["sources"]:
with st.expander("Fontes"):
for src in result["sources"]:
st.markdown(f"- {src}")
st.session_state.messages.append({
"role": "assistant",
"content": result["answer"],
"sources": result["sources"],
})
# Barra lateral
with st.sidebar:
st.header("Sobre")
st.markdown(
"Esta base de conhecimento pesquisa em toda a documentação "
"da equipa para responder às suas perguntas."
)
st.markdown("---")
st.markdown("**Dicas para melhores resultados:**")
st.markdown("- Seja específico nas suas perguntas")
st.markdown("- Pergunte sobre um tema de cada vez")
st.markdown("- Se a resposta não for a esperada, reformule a pergunta")
Execute com streamlit run app.py e terá uma base de conhecimento funcional com interface de chat.
Horas 24-36: Torná-la realmente boa
A versão básica funciona, mas eis o que adicionei para a tornar adequada para produção com uma equipa de 35 pessoas:
1. Filtragem por metadados
Nem todos os documentos têm o mesmo valor. Uma especificação de produto da semana passada é mais relevante do que uma de há dois anos. Adicionei metadados de data aos fragmentos e modifiquei o recuperador para filtrar opcionalmente por intervalo de datas ou categoria de documento.
# Em ingest.py, ao carregar documentos:
doc.metadata["ingested_date"] = datetime.now().isoformat()
doc.metadata["category"] = determine_category(file_path) # Com base na estrutura de pastas
2. Ciclo de feedback
Adicionei botões de polegar para cima/baixo a cada resposta e registei-os numa base de dados SQLite. Após uma semana, tinha dados suficientes para identificar quais os documentos que estavam a causar respostas deficientes (normalmente porque estavam desactualizados ou mal estruturados) e quais os padrões de consulta que precisavam de melhor tratamento.
3. Re-ingestão automática
Configurei uma tarefa cron que executa o script de ingestão todas as noites sobre uma pasta sincronizada do Google Drive. Quando alguém actualiza um documento, a base de conhecimento capta as alterações em menos de 24 horas. Para a pasta partilhada, usei o rclone para sincronizar com o servidor antes de a ingestão ser executada.
4. Controlo de acesso
Para a nossa equipa, isto não era uma preocupação relevante (todos têm acesso a todos os documentos), mas se precisar, o Streamlit suporta autenticação via streamlit-authenticator ou pode colocá-lo atrás de um proxy OAuth como o oauth2-proxy.
Horas 36-48: Testes, afinação e implementação
Metodologia de testes
Preparei um conjunto de teste com 50 perguntas cujas respostas conhecia. De seguida categorizei as respostas da base de conhecimento:
- Correctas e bem referenciadas: 38 em 50 (76%)
- Parcialmente correctas (ideia certa, faltam detalhes): 7 em 50 (14%)
- Incorrectas: 2 em 50 (4%)
- Respondeu correctamente "não sei": 3 em 50 (6%)
76% de precisão na primeira tentativa foi suficiente para lançar. Depois de afinar o tamanho dos fragmentos, a sobreposição e o prompt, cheguei aos 84%. Os erros restantes deveram-se sobretudo a perguntas ambíguas ou a documentos com informação contraditória (o que é um problema de documentação, não de IA).
Implementação
Implementei numa VM pequena na nuvem (4 GB de RAM, 2 vCPUs — cerca de 20 $/mês). A configuração:
- Aplicação Streamlit a correr atrás de um proxy inverso nginx
- SSL via Let's Encrypt
- Serviço systemd para reinício automático
- ChromaDB armazenado em disco (sem necessidade de servidor de base de dados separado)
Análise de custos
Eis o que custa manter o sistema para uma equipa de 35 pessoas:
| Item | Custo mensal |
|---|---|
| API OpenAI — Embeddings (re-ingestão + consultas) | 23 $ |
| API OpenAI — GPT-4o-mini (respostas às consultas) | 85 $ |
| VM na nuvem (4 GB RAM) | 20 $ |
| Domínio + SSL | 5 $ |
| Google Drive (armazenamento de documentos partilhados) | 20 $ |
| Total | 153 $ |
Isso representa cerca de 4,37 $ por pessoa por mês. Para comparar, ferramentas comerciais de bases de conhecimento com IA como o Guru AI ou o Glean custam entre 15 e 25 $ por utilizador por mês. Estamos a poupar entre 370 e 720 $/mês em comparação com soluções comerciais prontas a usar, e temos controlo total sobre os nossos dados.
Lições aprendidas (pela via difícil)
O tamanho dos fragmentos importa mais do que a escolha do modelo. Passei horas a testar diferentes LLMs quando o verdadeiro problema era a minha estratégia de fragmentação. Fragmentos maus produzem respostas más. Nenhum modelo consegue compensar uma recuperação deficiente.
Os metadados não são opcionais. Quando um utilizador pergunta "qual é a nossa política de devoluções?" e a base de conhecimento devolve a política de 2023 em vez da versão actualizada de 2026, isso é pior do que não devolver nada. Os metadados de data e a atribuição de fontes são essenciais.
As pessoas fazem perguntas de formas que não se antecipa. Parti do princípio que as pessoas perguntariam coisas como "Qual é o processo de implementação do serviço X?" Em vez disso, perguntavam "como faço push do código?" e "onde corre o X?". A engenharia de prompts para lidar com perguntas informais e vagas deu mais trabalho do que esperava.
Comece com 20 documentos, não com 2.000. Inicialmente tentei ingerir tudo de uma vez. Os custos de embeddings dispararam, a qualidade era inconsistente e a depuração era penosa. Comece pequeno, valide as respostas e depois escale.
A resposta "não sei" é uma funcionalidade. Treine o modelo (através do system prompt) para dizer "não tenho informação sobre isso na base de conhecimento" em vez de inventar. Os utilizadores confiam mais no sistema quando ele é honesto sobre as suas limitações.
Problemas comuns e soluções
"As respostas são demasiado genéricas." Aumente o número de fragmentos recuperados de 4 para 6-8 e certifique-se de que os seus fragmentos são suficientemente grandes para conter ideias completas. Verifique também se a informação relevante está efectivamente no seu conjunto de documentos.
"Continua a citar o documento errado." Os seus embeddings podem estar desactualizados. Execute novamente o pipeline de ingestão. Verifique também se tem documentos duplicados ou versões diferentes do mesmo documento no sistema.
"É lento." O principal estrangulamento é normalmente a chamada ao LLM, não a recuperação. Mude para GPT-4o-mini se ainda não o fez (é mais rápido e barato do que o GPT-4o com qualidade quase idêntica para tarefas de perguntas e respostas). Faça também cache das perguntas mais frequentes.
"Alucina." Baixe a temperatura para 0.0-0.1 e adicione instruções explícitas no prompt: "Responde apenas com base no contexto fornecido. Se o contexto não contiver a resposta, diz-o." Isto reduz as alucinações de forma drástica.
O que vem a seguir
Desde que implementei isto há três semanas, a nossa equipa fez mais de 1.200 perguntas. As consultas mais populares são sobre processos de implementação, documentação de API e "quem tomou esta decisão e porquê" (razão pela qual ingerir as conversas de decisão do Slack foi tão valioso).
A seguir, planeio adicionar: memória conversacional (para que as perguntas de acompanhamento funcionem de forma natural), integração com o Slack (fazer perguntas directamente num canal) e detecção automática de documentos obsoletos (assinalar documentos que não foram actualizados há mais de 6 meses mas que continuam a ser citados).
Construir isto levou um fim de semana, mas a poupança de tempo acumula-se todos os dias. Se a sua equipa passa mais de 30 minutos por semana à procura de informação, este sistema paga-se quase de imediato.