Navegação principal

Exemplos práticos de soluções RAG personalizadas

Exemplos reais mostram como sistemas personalizados de geração aumentada por recuperação resolvem problemas complexos de conhecimento empresarial.

Hoje em dia, a RAG tem por vezes má reputação, quer porque há quem a considere completamente trivial (é fácil começar, mas não tanto ganhar escala), quer porque se pensa que foi ultrapassada pelos «sistemas agênticos» (que, em muitos casos, mal se olha além da superfície, começam rapidamente a parecer-se muito com a RAG…).

Neste artigo, apresentamos um ou dois exemplos práticos para mostrar como lidamos com alguns desafios comuns, nomeadamente:

  • Processar dados que combinam texto e números e perceber por que motivo comprometem uma RAG básica: as palavras-chave colidem e os números não têm significado semântico.

  • Por que motivo é útil criar embeddings a partir de resumos: gerar um pequeno resumo descritivo para cada fragmento e, depois, criar o embedding e fazer consultas com base nesse resumo.

  • Como gerar resumos contextuais: incluir o contexto do documento principal para distinguir estatísticas com estruturas semelhantes.

  • Quando recorrer a código e a modelos Pydantic: quando importa preservar o conteúdo palavra por palavra, combinar código personalizado e/ou um modelo Pydantic com chamadas a LLM para garantir fiabilidade.

Criar soluções RAG personalizadas

Princípios básicos

Os sistemas RAG estão na base de tudo, desde bots de apoio a assistentes internos de conhecimento.

Nos bastidores, normalmente:

  1. Divide-se os documentos de origem em fragmentos

  2. Converte-se cada fragmento num embedding num espaço vetorial

  3. Recuperam-se os fragmentos top-K no momento da consulta

  4. Gera-se uma resposta com base nesses fragmentos

Ferramentas populares — LangChain, LlamaIndex e Filestore da OpenAI — tornam estes passos quase triviais. Contudo, em pipelines reais, surgem dados que não são apenas texto denso, e uma RAG básica pode ter dificuldades. Nas secções seguintes, apresentamos exemplos concretos de desafios relacionados com dados e desenvolvemos gradualmente a solução à medida que introduzimos maior complexidade.

Quando as coisas se complicam

  1. Quando os dados não são apenas texto (algo bastante comum)

Considere o seguinte fragmento de dados num contexto de videojogos:

JSON

{ "Attack": { "Range": { "default": 5, "with_Draconic_Ascension": 5.5, }, "Speed": { "default": "2 seconds", "with_Draconic_Ascension": "2.2 seconds", }, "Damage": { "default": 10, "with_Draconic_Ascension": 12, } }} 

Os embeddings funcionam graças às relações entre palavras aprendidas através do significado semântico e da gramática. Nos dados acima, temos uma combinação de texto e números em que, fora deste contexto específico, os números não têm qualquer relação com as palavras. Podemos, portanto, dizer que este fragmento de dados é essencialmente uma combinação de palavras mais ou menos descritivas, seguidas de números aleatórios.

Isto não seria um problema se este fosse o único tipo de dados disponível, pois ainda poderíamos fazer a recuperação com base nos embeddings das poucas palavras descritivas existentes (ou simplesmente usar text-to-SQL). Mas e se este fragmento estiver perdido entre muitos fragmentos de texto denso que também contêm essas palavras? Por exemplo:

JSON

{"Draconic_Ascension": { "description": "Transform into dragon form. Attack range and speed are increased by 10%, damage is boosted by 20%.", "details": { "activation_conditions": "Can only be activated when HP is below 50%or Fury meter is full.", "visual_effects": "Wings unfurl, scales shimmer with embers, voice lines change to echoing growls.", "lore": "An ancient bloodline awakens. The bearer of the mark channels the soul of the last Flamewing Wyrm, becoming a living storm of fire and fury." } }}

Agora, imagine que queremos recuperar “What is the attack range with Draconic Ascension?” É muito provável que não consigamos recuperar o fragmento relevante, porque este fica perdido no ruído de outros fragmentos que contêm as mesmas palavras-chave.

O problema de fundo é não conseguirmos distinguir bem estes fragmentos de dados, embora contenham tipos de informação diferentes sobre o mesmo tema. Será que podemos enriquecer ou melhorar estes dados de alguma forma? Claro que sim:smile:

  1. Enriqueça os dados através de resumos — sim, leu bem

Em vez de criarmos diretamente o embedding do próprio fragmento, podemos começar por gerar um resumo que descreva os dados e, depois, criar o embedding e fazer a recuperação com base nesse resumo. Na fase de geração, continuaríamos a usar os dados originais associados ao resumo.

Assim, para os dois exemplos de fragmentos acima, geraríamos resumos deste género:

  1. Estatísticas do alcance, da velocidade e dos danos dos ataques (por predefinição e com Draconic Ascension).

  2. Descrição e detalhes de Draconic Ascension, incluindo condições de ativação, efeitos visuais e história.

Em seguida, também aumentamos a consulta para a «alinhar» com o resumo. Por exemplo, transformaríamos “What is the attack range with Draconic Ascension?” em “What is the statistics of attack range with Draconic Ascension?” Isto é particularmente importante quando a consulta de recuperação vem de utilizadores sem formação técnica, que fazem perguntas em linguagem ~~«livre»~~ humana normal, pois não lhes compete saber como funciona uma RAG para maximizar a precisão e a cobertura.

Diagrama que ilustra quando as coisas se complicam.

  1. Não retire as coisas do contexto (um princípio geralmente aplicável à vida)

Vejamos agora outro cenário: lidar com inúmeros fragmentos de dados que parecem iguais, como os seguintes:

Plain Text

# Chunk one{ "Attack": { "Range": { "default": 9, }, ... }}# Chunk two{ "Attack": { "Range": { "default": 6, }, ... }}# Chunk three{ "Attack": { "Range": { "default": 7, }, ... }}

Mantendo a mesma abordagem, imagine perguntar “what is character X’s attack range?” Com estes resumos que acabámos de gerar, estaríamos a jogar um jogo de adivinhação baseado na sorte, pois também seriam muito semelhantes. Então, como poderíamos distingui-los?

A resposta é simples: fornecer contexto. Poderíamos simplesmente incluir no fragmento de dados uma referência ao respetivo documento principal — neste caso, por exemplo, {”character”: “X”}. Assim, conseguiríamos recuperar com precisão os dados certos da personagem X, mesmo tendo os mesmos dados para as personagens Y e Z.

Contudo, uma abordagem melhor e mais generalizável seria gerar um resumo contextual do fragmento. Ou seja, em vez de gerarmos um resumo apenas do próprio fragmento de dados, poderíamos fornecer tanto o documento principal como o fragmento para gerar um resumo contextual geral que explique como o fragmento se enquadra no documento principal. Por exemplo:

  1. Este fragmento fornece estatísticas detalhadas de… para a personagem X. O fragmento enquadra-se no documento completo ao mostrar a força de X em termos de velocidade de ataque…

  2. Este fragmento fornece estatísticas detalhadas de… para a personagem Y. O fragmento enquadra-se no documento completo ao mostrar as estatísticas reforçadas de Y com a sua habilidade especial…

  3. Este fragmento fornece estatísticas detalhadas de… para a personagem Z. O fragmento enquadra-se no documento completo ao mostrar que as estatísticas de Z são adequadas para desempenhar o papel de tanque em partidas por equipas…

Este método (parcialmente inspirado na Anthropic) pode parecer excessivo para o exemplo acima, mas é muito eficaz para fragmentos que poderiam ser mal interpretados «fora do contexto». Além disso, proporciona uma abordagem unificada aplicável a todos os fragmentos, mantendo o pipeline de engenharia organizado.

Diagrama que ilustra quando as coisas se complicam.

  1. Quando é preciso ser ~~obcecado pelo controlo~~ rigoroso

Normalmente, recebemos os dados em blocos completos e dividimo-los em fragmentos para um sistema RAG. Neste exemplo, mostramos algo um pouco diferente: dados divididos em fragmentos inadequados, que são secções aleatórias de um fragmento lógico e que, na verdade, precisam de voltar a ser agrupadas. Um fragmento lógico é um bloco de conteúdo que, por natureza, deve permanecer junto, como uma subsecção de um documento ou um parágrafo coerente.

Diagrama que ilustra quando as coisas se complicam.

A nossa primeira tentativa com estes dados consiste em enviar tudo numa chamada a um LLM, pedir-lhe que agrupe os elementos como considerar adequado e, depois, devolva o conteúdo agrupado. Um LLM deve ser bastante bom nisto, certo? Bem, sim e não.

Descobrimos, também em várias outras ocasiões, que os LLM tendem a facilitar e não são fiáveis quando é necessário obter o conteúdo completo e exato, sobretudo quando o contexto é longo. O que faz todo o sentido. Mas isso inviabilizava este caso de utilização específico, porque precisávamos do conteúdo exato, palavra por palavra: sem resumos e sem omitir qualquer parte do conteúdo original. Não podemos deixar escapar nenhum pormenor.

Naturalmente, a parte do «sim» era o excelente trabalho que fazia a compreender a semântica e as estruturas dos fragmentos divididos. Isto se não se recusasse a reproduzir o conteúdo exato. Raios:/

Como poderíamos, então, tirar partido daquilo em que um LLM é bom, evitando aquilo em que não é fiável? Recorremos ao nosso velho amigo: o código (leia-se: uma função Python personalizada). E a um modelo Pydantic «mais simples é impossível». Eis a solução:

  • Percorrer as secções, mantendo um fragmento lógico atual

  • Em cada secção, perguntar ao LLM: esta secção pertence ao fragmento lógico atual? Responder sim ou não (seguindo o modelo Pydantic).

  • Se sim, anexar a secção ao fragmento; se não, devolver o fragmento lógico atual, dado estar completo, e iniciar um novo com essa secção.

Diagrama que ilustra quando as coisas se complicam.

Naturalmente, usamos aqui um pouco mais de tokens do que numa única passagem por todo o conteúdo. Contudo, neste caso de utilização específico, em que preservar o conteúdo exato é a principal prioridade, o pequeno custo adicional compensou plenamente.

É uma solução muito simples, mas segue um princípio importante: quando é necessário rigor, não devemos depender apenas dos LLM, pois são, afinal, probabilísticos.

É possível usar código ou funções personalizados e modelos Pydantic para obter resultados previsíveis e fiáveis, sem deixar de aproveitar as capacidades dos LLM.

Conclusão

Criar uma solução de IA generativa é tanto um desafio de engenharia como de IA. Esperamos que estes exemplos sirvam de inspiração para enfrentar os seus próprios desafios. Para saber mais sobre soluções de IA generativa centradas na engenharia, consulte o nosso artigo sobre a conceção de sistemas agênticos baseados em routers.

Autor

Cynthia Yu