Pular para o conteúdo
Categoria: Desenvolvendo com IA13 min de leitura

Construindo um chatbot com RAG e busca vetorial

Por Lucas Andrade ·

Tutorial prático de um chatbot que consulta sua base de conhecimento com embeddings e busca vetorial, mantém histórico, cita fontes e responde sem alucinar.

Construindo um chatbot com RAG e busca vetorial

Um chatbot que só conversa é diversão; um chatbot que responde sobre os seus dados é produto. A diferença está em conectar o modelo de linguagem a uma base de conhecimento por meio de RAG e busca vetorial. Neste tutorial você vai montar, passo a passo, um assistente que recebe perguntas, busca os trechos certos da sua documentação e responde de forma fundamentada — incluindo o tratamento do histórico de conversa, que é o que separa um chatbot de verdade de um Q&A solto.

Ao longo do caminho vamos enfrentar os problemas que só aparecem quando você sai do tutorial e coloca um chatbot na frente de gente real: perguntas de acompanhamento que não fazem sentido sozinhas, conversas que crescem até estourar a janela de contexto, recuperação que traz lixo, e o eterno risco de o modelo inventar. Cada um tem uma solução concreta, e é isso que vamos cobrir.

O que vamos construir

O objetivo é um chatbot que:

  • Recebe perguntas em linguagem natural.
  • Consulta uma base de conhecimento sua (manuais, FAQs, documentos).
  • Responde com base no que encontrou, citando as fontes.
  • Mantém o contexto da conversa entre as mensagens.
  • Diz "não sei" quando a base não cobre a pergunta.

A espinha dorsal é o padrão RAG (Retrieval-Augmented Generation), que combina recuperação de documentos com geração de texto e foi formalizado por Lewis et al. (2020). Se você ainda não montou um pipeline RAG, vale fazer antes o passo a passo de indexação e recuperação em RAG na prática: dê memória e contexto ao seu LLM. Aqui vamos focar na parte conversacional.

A arquitetura do chatbot

usuário → mensagem
        → (reescrita da pergunta com histórico)
        → embedding → busca vetorial → trechos relevantes
        → prompt (sistema + histórico + trechos + pergunta)
        → LLM → resposta → exibe + guarda no histórico

Os dois blocos novos em relação a um RAG simples são a reescrita da pergunta (que usa o histórico) e a gestão do histórico da conversa. Vamos por partes.

Repare que o fluxo tem duas chamadas ao modelo por turno: uma barata, para reescrever a pergunta, e outra para gerar a resposta final. Esse custo extra compensa: sem a reescrita, perguntas de acompanhamento simplesmente não recuperam nada útil, e o chatbot parece "esquecer" o que acabou de ser dito.

Passo 1: indexe a base de conhecimento

Antes de qualquer conversa, sua base precisa estar indexada: documentos divididos em chunks, transformados em embeddings e guardados num banco vetorial. Os embeddings são o que permitem a busca por significado em vez de palavra exata — se a ideia não está clara, leia o que são embeddings? Representando significado em vetores.

from openai import OpenAI
client = OpenAI()

def gerar_embedding(texto: str) -> list[float]:
    r = client.embeddings.create(model="text-embedding-3-small", input=texto)
    return r.data[0].embedding

# na indexação, para cada chunk:
for chunk in chunks:
    vetor = gerar_embedding(chunk.texto)
    banco_vetorial.inserir(id=chunk.id, vetor=vetor, texto=chunk.texto)

O banco vetorial é quem faz a busca por similaridade ser rápida mesmo com milhares de trechos. Para escolher e configurar o seu, veja o que é um banco de dados vetorial?.

O chunking é decisivo

A qualidade das respostas começa no chunking, e é onde a maioria dos chatbots falha silenciosamente. Chunks grandes demais diluem o sinal: a busca traz um bloco enorme com pouca relação direta. Chunks pequenos demais perdem contexto: o trecho recuperado não tem informação suficiente para responder. Pontos de partida que funcionam bem:

  • Tamanho: algo entre 300 e 800 tokens por chunk, ajustado ao seu conteúdo.
  • Sobreposição: uma sobreposição de 10–20% entre chunks vizinhos evita cortar uma frase importante no meio.
  • Respeite a estrutura. Quebre em fronteiras naturais (parágrafos, seções), não no meio de uma frase.
  • Guarde metadados junto do chunk (título do documento, URL, seção). Eles vão alimentar a citação de fontes depois.
def chunk_com_overlap(texto: str, tamanho=600, overlap=100) -> list[str]:
    palavras = texto.split()
    chunks, i = [], 0
    while i < len(palavras):
        chunks.append(" ".join(palavras[i : i + tamanho]))
        i += tamanho - overlap
    return chunks

Passo 2: recupere os trechos relevantes

Quando chega uma pergunta, você gera o embedding dela e busca os k chunks mais próximos. Essa recuperação densa — comparar vetores em vez de palavras — supera a busca por palavras-chave em perguntas de domínio aberto (Karpukhin et al., 2020).

def recuperar(pergunta: str, k: int = 4) -> list[str]:
    emb = gerar_embedding(pergunta)
    hits = banco_vetorial.buscar(vetor=emb, top_k=k)
    return [h.texto for h in hits]

Escolhendo o k e filtrando por similaridade

O k (quantos trechos trazer) é um trade-off. Poucos trechos podem deixar de fora a resposta; muitos enchem o prompt de ruído e custam mais tokens. Comece com k=4 e ajuste medindo. Mais importante: não use trechos com similaridade baixa só para preencher o top-k. Se o melhor resultado já está distante da pergunta, provavelmente a base não cobre o assunto — e é melhor saber disso para acionar o fallback.

def recuperar_com_limiar(pergunta: str, k=4, minimo=0.35) -> list[str]:
    emb = gerar_embedding(pergunta)
    hits = banco_vetorial.buscar(vetor=emb, top_k=k)
    return [h.texto for h in hits if h.score >= minimo]

Busca híbrida: vetor + palavra-chave

A recuperação densa entende significado, mas tropeça em termos exatos: códigos de produto, números de versão, nomes próprios raros. Uma busca híbrida combina o resultado vetorial com uma busca por palavra-chave (BM25) e funde os rankings. Para um chatbot de suporte cheio de "erro 0x80070057" ou "modelo XJ-42", a busca híbrida costuma ser o que tira a recuperação do "quase certo" para o "certo".

Passo 3: lide com o histórico da conversa

Aqui mora o segredo do chatbot. Imagine o diálogo:

Usuário: Qual o prazo de garantia do produto X?
Bot:     12 meses.
Usuário: E para o Y?

A segunda pergunta — "E para o Y?" — sozinha não busca nada útil no banco vetorial. Ela só faz sentido com o histórico. A solução é reescrever a pergunta usando o contexto da conversa antes de buscar:

def reescrever_pergunta(historico: list[dict], pergunta: str) -> str:
    prompt = (
        "Reescreva a pergunta do usuário como uma pergunta independente, "
        "incorporando o contexto da conversa.\n\n"
        f"Histórico:\n{formatar(historico)}\n\n"
        f"Pergunta: {pergunta}\n\nPergunta independente:"
    )
    r = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
    )
    return r.choices[0].message.content

Assim, "E para o Y?" vira "Qual o prazo de garantia do produto Y?", e a busca vetorial encontra o trecho certo.

Essa técnica tem nome: condensação de pergunta (query condensing) ou reformulação independente do histórico. Um cuidado: se a mensagem do usuário já é autossuficiente (a primeira de uma conversa, por exemplo), a reescrita não deve "inventar" contexto. Instrua o modelo a manter a pergunta como está quando ela já faz sentido sozinha — caso contrário ele pode adicionar restrições que o usuário nunca pediu.

Passo 4: monte o prompt e gere a resposta

Com os trechos recuperados e o histórico em mãos, você monta a chamada final. A instrução de sistema deve ancorar o modelo no contexto e permitir admitir ignorância:

SISTEMA = (
    "Você é um assistente de suporte. Responda usando APENAS o contexto "
    "fornecido. Se a resposta não estiver no contexto, diga que não tem "
    "essa informação. Cite os trechos que usou."
)

def responder(historico, pergunta):
    pergunta_ind = reescrever_pergunta(historico, pergunta)
    contexto = "\n\n".join(recuperar(pergunta_ind))
    mensagens = (
        [{"role": "system", "content": SISTEMA}]
        + historico
        + [{"role": "user",
            "content": f"Contexto:\n{contexto}\n\nPergunta: {pergunta}"}]
    )
    r = client.chat.completions.create(model="gpt-4o-mini", messages=mensagens)
    return r.choices[0].message.content

Repare que enviamos o histórico e os trechos recuperados. O histórico dá continuidade; os trechos dão fundamento. Os detalhes da chamada à API estão em API da OpenAI na prática: primeiros passos para devs.

Citação de fontes que dá para verificar

"Cite os trechos" no prompt é um começo, mas citações em texto livre são fáceis de o modelo inventar. Uma abordagem mais robusta é numerar os trechos recuperados e pedir referências aos números, mantendo no seu código o mapa número→documento. Assim a citação é verificável: você sabe exatamente qual chunk virou qual [1].

def formatar_contexto(trechos: list[dict]) -> str:
    return "\n\n".join(
        f"[{i+1}] (fonte: {t['fonte']})\n{t['texto']}"
        for i, t in enumerate(trechos)
    )
# o prompt pede: "Cite as fontes pelo número, ex.: [1], [2]."

Na hora de exibir, você troca cada [1] por um link para o documento real. O usuário clica, confere, confia.

Passo 5: controle o tamanho do contexto

Conversas longas estouram a janela de contexto e disparam o custo. Você não pode mandar o histórico inteiro para sempre. Estratégias:

  • Janela deslizante: mantenha só as últimas N mensagens.
  • Resumo: condense as mensagens antigas num resumo curto.
  • Seleção por relevância: traga do histórico só o que importa para a pergunta atual.

Decidir o que entra no prompt a cada turno é uma disciplina própria, hoje chamada de engenharia de contexto: o novo prompt engineering. Num chatbot, ela é decisiva tanto para qualidade quanto para custo.

Na prática, uma combinação funciona bem: mantenha as últimas 4–6 mensagens na íntegra (a janela deslizante) e um resumo rolante das anteriores. O resumo é regerado a cada poucos turnos a partir do resumo anterior mais as mensagens novas, de modo que ele nunca cresce sem limite. Você preserva a continuidade da conversa sem pagar por reenviar dezenas de mensagens a cada turno.

Passo 6: combata a alucinação

Mesmo com RAG, o modelo pode inventar. Reforce as defesas:

  • Instrução explícita de usar só o contexto (já no prompt de sistema).
  • Citação de fontes, para o usuário verificar.
  • Resposta de fallback ("não encontrei essa informação na base") quando a recuperação volta vazia ou irrelevante.
  • Filtro de confiança: se a similaridade dos melhores trechos for baixa, prefira admitir que não sabe.

Vale entender que a alucinação não é um bug que se "conserta": é uma propriedade de como o modelo gera texto, prevendo o próximo token plausível. RAG ataca a causa fornecendo material verdadeiro para o modelo se ancorar, mas a ancoragem é uma instrução, não uma garantia. Por isso a defesa é em camadas: recuperação boa, instrução firme, citação verificável e o fallback honesto. Nenhuma sozinha basta.

Reordenação (reranking) para subir a qualidade

A busca vetorial é rápida, mas aproximada: o trecho mais relevante nem sempre é o primeiro do top-k. Uma etapa de reranking melhora isso. A ideia é recuperar um conjunto maior (digamos top-20) com a busca vetorial barata e, depois, reordenar esses candidatos com um modelo mais preciso — um cross-encoder ou um LLM — que avalia a relevância de cada trecho em relação à pergunta. Você fica com os 3 ou 4 melhores de verdade para o prompt.

def recuperar_e_rerankear(pergunta: str, n=20, k=4) -> list[str]:
    candidatos = recuperar(pergunta, k=n)        # rápido e abrangente
    pontuados = reranker.pontuar(pergunta, candidatos)  # preciso e caro
    pontuados.sort(key=lambda x: x.score, reverse=True)
    return [c.texto for c in pontuados[:k]]

O custo extra do reranking compensa quando a base é grande e a precisão importa — suporte, jurídico, médico. Para um FAQ pequeno, a busca vetorial pura já basta. Como sempre, meça antes de adicionar complexidade.

Testando o chatbot

Teste em três níveis:

  • Recuperação: as perguntas certas trazem os trechos certos no top-k?
  • Reescrita: perguntas de acompanhamento ("e o Y?") viram perguntas independentes corretas?
  • Resposta: o bot responde com fundamento e admite ignorância quando deve?

Monte um roteiro de conversas de teste, incluindo perguntas encadeadas e perguntas fora da base, e rode sempre que mexer no pipeline.

Métricas que importam na recuperação

Para a etapa de recuperação, duas métricas são úteis e baratas de calcular se você tiver um conjunto de perguntas com a resposta esperada:

  • Recall@k — em que fração das perguntas o trecho certo apareceu no top-k? Se isso está baixo, o problema é chunking ou embedding, não o prompt.
  • Precisão da reescrita — para um conjunto de perguntas de acompanhamento, a reescrita produziu uma pergunta independente correta? Erros aqui se propagam para tudo.

Separar a avaliação por etapa evita o erro de culpar o modelo de geração por uma falha que, na verdade, nasceu na recuperação.

Streaming, latência e a sensação de conversa

Um chatbot é uma interface de tempo real, e o usuário sente cada segundo de espera. Com duas chamadas ao modelo por turno (reescrita + geração), a latência acumula. Três técnicas mantêm a conversa fluida:

  • Streaming da resposta final. Transmita os tokens conforme o modelo os gera. O usuário começa a ler na hora, e a percepção de velocidade melhora muito mesmo que o tempo total seja o mesmo.
  • Paralelize o que dá. A reescrita precede a recuperação, mas você pode pré-aquecer conexões e cachear embeddings de perguntas frequentes.
  • Feedback de estado. Enquanto recupera, mostre "consultando a base..." em vez de um spinner mudo. Honestidade sobre o que está acontecendo reduz a ansiedade da espera.
def responder_stream(historico, pergunta):
    pergunta_ind = reescrever_pergunta(historico, pergunta)
    contexto = "\n\n".join(recuperar(pergunta_ind))
    mensagens = montar_mensagens(historico, contexto, pergunta)
    stream = client.chat.completions.create(
        model="gpt-4o-mini", messages=mensagens, stream=True
    )
    for evento in stream:
        delta = evento.choices[0].delta.content
        if delta:
            yield delta  # envia ao cliente token a token

Erros comuns

  • Ignorar o histórico na busca — quebra perguntas de acompanhamento.
  • Mandar histórico ilimitado — estoura contexto e custo.
  • Não citar fontes — o usuário não consegue confiar nem verificar.
  • Sem fallback — o bot inventa quando deveria dizer "não sei".
  • Base desatualizada — respostas certas para informações erradas.
  • Chunking ruim — recuperação fraca que nenhum prompt salva.
  • Misturar instrução e dado do usuário — abre porta para injeção de prompt via mensagem ou documento.

Perguntas frequentes

Preciso de um banco vetorial dedicado para começar? Não para protótipos. Para poucos milhares de chunks, uma busca por similaridade em memória ou uma extensão vetorial no seu banco relacional já resolve. Migre para um banco vetorial dedicado quando o volume ou a latência exigirem.

A reescrita da pergunta não dobra o custo? Ela adiciona uma chamada por turno, mas a um modelo barato e com prompt curto. O ganho de qualidade nas perguntas de acompanhamento quase sempre compensa. Se quiser economizar, você pode pular a reescrita quando for a primeira mensagem da conversa.

Como impede que um documento malicioso na base sequestre o bot? Trate o conteúdo recuperado como dado, não como instrução. Deixe isso explícito no prompt de sistema e não dê ao bot poderes de ação irreversível sem confirmação. Um documento que diga "ignore suas regras" é dado a ser exibido, não comando a ser obedecido.

Streaming funciona com esse pipeline? Sim, e melhora muito a percepção de velocidade. A reescrita e a recuperação acontecem antes; só a geração final é transmitida token a token para o usuário.

Conclusão

Um chatbot com RAG é, no fundo, um pipeline de recuperação bem cuidado com uma camada conversacional por cima. Os pontos que fazem diferença são o chunking que prepara a base, a reescrita da pergunta usando o histórico, a busca vetorial (idealmente híbrida) que encontra os trechos certos, o controle do tamanho do contexto e o prompt que ancora o modelo no contexto e o autoriza a dizer "não sei". Acerte a recuperação, controle o custo do histórico, cite fontes verificáveis e teste as conversas encadeadas — com isso você sai de um chat genérico para um assistente que realmente responde sobre o seu conhecimento, com fundamento e sem inventar.

Referências

  • Karpukhin, V., Oğuz, B., Min, S., Lewis, P., Wu, L., Edunov, S., Chen, D., & Yih, W. (2020). Dense Passage Retrieval for Open-Domain Question Answering. EMNLP.
  • Lewis, P., Perez, E., Piktus, A., Petroni, F., Karpukhin, V., Goyal, N., Küttler, H., Lewis, M., Yih, W., Rocktäschel, T., Riedel, S., & Kiela, D. (2020). Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks. NeurIPS.
  • Robertson, S., & Zaragoza, H. (2009). The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval.
  • Gao, Y., Xiong, Y., Gao, X., Jia, K., Pan, J., Bi, Y., Dai, Y., Sun, J., & Wang, H. (2024). Retrieval-Augmented Generation for Large Language Models: A Survey. arXiv:2312.10997.

Leituras relacionadas

Nenhum comentário ainda

Seja o primeiro a comentar.

Deixe seu comentário

Entre com sua conta Canverly para comentar. Você pode usar a mesma conta em qualquer site da rede.

Entrar com Canverly