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

Construindo um chatbot com RAG e busca vetorial

Por Schematize Blog ·

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.

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:

    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:

      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:

        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:

          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:

            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:

              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:

                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

                  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

                    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