Como Criar uma Base de Conhecimento com IA para a Sua Equipa em 48 Horas

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 pytesseract e utilize UnstructuredPDFLoader em 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:

ItemCusto 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 + SSL5 $
Google Drive (armazenamento de documentos partilhados)20 $
Total153 $

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.