
Nesse artigo, irei explicar uma ferramenta que nasceu de uma pergunta: até onde dá pra tirar uma LLM da decisão de busca e ingestão de dados em um RAG e substituir isso por aritmética pura, sem perder qualidade na resposta final. Meu hardware é da AMD, fora do ecossistema NVIDIA que domina a IA local, o que já limitava minhas opções antes de eu escrever a primeira linha de código, e essa limitação do meu hardware me fez pensar em alternativas pra contornar esse problema. Foi assim que cheguei a uma pesquisa mais aprofundada, que acabou abordando temas e problemas recorrentes em sistemas RAG de forma geral.
O que é RAG?
Para quem não sabe, Retrieval-Augmented Generation, mais conhecido como RAG, é um framework que IAs podem armazenar e buscar dados sem depender exclusivamente de sua memória interna (janela de contexto).

Como um RAG funciona?
Em muitos casos, uma empresa ou desenvolvedor possui documentos ou arquivos extremamente importantes que precisam ser armazenados em bancos de dados, e jogar esses arquivos em uma IA toda vez que precisar extrair informações desses mesmos arquivos é simplesmente ineficiente. É por isso que o RAG atua como um “banco de dados” para extração. Mas, diferente de um banco de dados normal, o RAG é construído especificamente para que a IA consiga saber como e o que extrair de uma base de dados de forma eficiente.
Tipos de RAGs
Há vários tipos de RAGs presentes no mercado atualmente, entre eles estão:
- Vetorial: transforma o texto ingerido em chunks (pedaços de sentenças) e os transforma em vetores de 1024 dimensões, por exemplo, para depois armazená-los em uma base de dados com o chunk de sentença e seu respectivo vetor. Dentro da base de dados vetorial, os chunks que possuem o mesmo significado ficam próximos dos outros e os vetores com significado diferente ficam mais distantes.

- Grafos: utiliza a estrutura de grafo de conhecimento, no qual há nós (entidades) e arestas (relações entre entidades) explícitas.

- Relacional: abordagem de RAG aplicada a dados estruturados em bancos de dados relacionais (SQL, tabelas) em vez de usar textos soltos ou vetores. É excelente para perguntas sobre agregações, filtros temporais, tabelas de dados altamente estruturados, etc.

- Agêntico: o LLM decide se, onde e quantas vezes busca, valida o que voltou e corrige (self-RAG, corrective RAG, roteamento entre fontes. Apresenta ganho de +30% de precisão nas respostas, porém com 2–5x mais latência. É o padrão utilizado comercialmente por empresas atualmente.

- Híbrido: utiliza-se de várias técnicas de RAG para construir um sistema robusto que é capaz de rotear diferentes tipos de arquivo para mais de um RAG e utilizar técnicas de busca vetorial, rerank e query rewriting/decomposition, por exemplo.

O grande problema dos RAGs
Apesar da eficiência que eles podem proporcionar em ambientes de produção, RAGs comumente possuem problemas graves que podem matar sua precisão e recall na busca de dados para geração de respostas ao usuário:
- Alucinação do LLM mesmo depois da recuperação correta de dados: mesmo com documentos relevantes recuperados, o LLM ignora o contexto em favor do seu conhecimento (treinamento) ou inventa informações. Chamamos isso de “knowledge conflict”.
- Problema de limite de chunking (Chunking Boundary Problem): conteúdos semanticamente relacionados ficam divididos entre chunks diferentes, causando perda de informação crítica. Uma sentença importante pode não caber inteiramente em um chunk e acaba truncada em 2 ou mais chunks.
- Efeito “Lost-in-the-Middle” (Liu et al., 2023): LLMs processam melhor informações no início e no fim da janela de contexto (memória da LLM). Dados que são recuperados no meio são frequentemente ignorados, reduzindo drasticamente a qualidade da resposta.
- Conhecimento obsoleto na base de dados: A base de conhecimento do RAG é um “retrato da verdade” num ponto específico no tempo. Sem atualização contínua, o RAG cita fatos desatualizados ou políticas antigas.
- Desvio semântico em embeddings genéricos: modelos que transformam texto em vetores podem encontrar conteúdos semelhantes em tópicos, mas podem não trazer informações factuais importantes. Uma palavra-chave pode levar a documentos sobre o tema, mas não necessariamente responder à pergunta específica.
- Destruição de estruturas e tabelas: extração padrão de texto elimina layouts especiais de tabelas, diagramas e estruturas, perdendo relacionamentos críticos entre dados. Um PDF tabulado vira sopa de texto.
- Segmentação Inadequada (Poor Chunking Strategy): dividir textos em chunks muito grandes (perdem especificidade) ou muito pequenos (perdem contexto) prejudica a recuperação de informações de maneira precisa.
- Falta de avaliação e monitoramento contínuo: muitas implementações não têm métricas (precision, recall, F1), testes automatizados ou feedback loop. Falhas silenciosas persistem até que usuários enfrentem problemas. Sem avaliação, é impossível saber qual camada (busca, geração, dados) está quebrada.
Como nasceu meu projeto a partir desses problemas?
No mundo corporativo, eu percebi que muitas soluções deixavam furos que matavam a performance da ingestão e busca de arquivos em diferentes tipos de RAGs. Isso ocorre porque, afinal, cada tipo de arquivo funciona melhor para um tipo de RAG diferente e nunca um tipo de RAG para todos os tipos de arquivo.
No mercado, já existem ferramentas como LlamaIndex e LangChain, que oferecem estruturas de indexação prontas e integrações que eu não tenho. Não queria fazer um substituto para elas, pois o que me incomodava era outra coisa. Nessas ferramentas, as decisões ficam escondidas. Você não consegue ver para onde e por que um pedaço de informação foi para um lugar e não para outro. Quando uma resposta errada vem, não tem como saber em qual camada da ferramenta quebrou. Eu queria um sistema em que a decisão fosse visível e auditável, mesmo que isso custasse funcionalidades.
Além disso, havia a limitação de GPU. Eu tenho uma placa de vídeo AMD e quase todo o ecossistema de IA local assume CUDA, que, consequentemente, envolve placas de vídeo NVIDIA, que eu não possuo. Usar uma IA da nuvem resolveria, mas quebraria a premissa do projeto. Empresas escolhem rodar local por dois motivos concretos: documentos internos não saem da infraestrutura delas, e modelos de fronteira via API custam por chamada, um valor que cresce com o uso.
Construir esse tipo de projeto assume um risco imediato. Modelos de IA locais são menores e, portanto, ruins para tomarem decisões de onde e o que pegar de um RAG. Eu não podia fazer o que os frameworks fazem, que é delegar as escolhas ao LLM e torcer para que busquem a informação certa. Em produção, um sistema que decide diferente a cada execução não pode ser testado nem auditado.
Foi assim que a restrição virou princípio: usar matemática onde der, e reservar o modelo para o que só ele faz; gerar as respostas ao usuário.
É esse princípio que ataca quatro dos oito problemas listados acima e deixa outros em aberto. Antes de mostrar quais, vale entender a forma do sistema, porque é dela que saem as decisões.
Como o PolyRAG funciona?
O PolyRAG é um sistema de perguntas e respostas sobre documentos. Você joga arquivos em uma pasta, sejam planilhas, textos, PDFs e imagens, e ele decide sozinho onde guardar cada pedaço de informação entre três bases diferentes.
O primeiro é o RAG Relacional, que guarda dados tabulados/estruturados, e as perguntas viram SQL. O RAG vetorial guarda texto livre, recuperando por proximidade de significado. O RAG de grafo guarda regras e dependências, em que o que importa não é o parecido, e sim o conectado.
A decisão de onde guardar não é realizada por arquivo, e sim por pedaço. Um documento de normas internas, no meu corpus de teste, terminou com três chunks no grafo e um no vetorial, porque três declaravam relações entre entidades e o quarto era texto corrido. Tratar o arquivo como unidade seria mais simples e estaria errado, porque um documento real possui diferentes tipos de informação.
Um roteador, dois momentos
O sistema tem um único momento de decisão que é usado em dois momentos diferentes:
- Na ingestão, ele recebe um pedaço pequeno de arquivo e responde: “Onde eu guardo isso?”.
- Na busca, ele recebe a busca do usuário e responde: “Onde eu procuro isso?”.
Portanto, a matemática e as referências são as mesmas para uma engine só.
Os três estágios
A decisão de qual base de dados (RAG) escolher passa por 3 fases, do mais barato para o mais caro. Ela para no primeiro que conseguir decidir.
- Heurística (estágio 1): Análise de densidade de vírgulas e proporção de caracteres numéricos para dizer se aquilo se trata de uma tabela. Uma planilha é tabular por definição e nem chega a virar vetor. Custo baixo e aparece só na ingestão.

-
Geometria (estágio 2): Todo texto vira um vetor de 1024 dimensões. Para cada rota existem várias frases-exemplo escritas à mão, do tipo “qual o total de receita do trimestre” para a rota relacional. Tanto essas frases quanto a pergunta do usuário viram vetores, e o cosseno entre eles diz o quanto se parecem. Cada rota fica com a nota da sua frase mais parecida.
Só que a rota de maior nota não vence automaticamente. Ela precisa vencer por uma margem, uma distância mínima para a segunda colocada. Nota alta com margem apertada significa que duas rotas disputaram de perto, e isso é dúvida, não decisão.
Essas frases não descrevem o que está guardado em cada base. Elas descrevem para que cada base serve.

-
Evidência das bases (estágio 3): O problema do estágio 2 é que não consigo prever o que será inserido. Por exemplo, não sei que o cliente vai enviar um documento com uma seção chamada “Banco de Dados Órion”.
Então, quando a margem fica apertada, eu pergunto para as bases. Cada uma devolve os títulos das seções que ela guarda, e eu comparo a pergunta com esses títulos. Se o grafo tem uma seção com aquele nome, ele ganha, e ninguém precisou configurar nada.
Essa parte se mantém sozinha. Ingerir um documento novo ensina o roteador sobre ele.

Por que a margem, e não a nota?
No estágio 2, foi intuitivo pensar, em primeiro momento, que a decisão teria que se basear em uma “nota base” para que a rota vença. O problema é que, a partir de vários testes feitos, trechos eram recuperados com pontuações abaixo da nota mínima e parecidas, girando em torno de 0.418 e 0.465. Nenhuma nota separa esse aglomerado, porque a nota bruta mede intensidade, não certeza.
O que separa realmente é a margem, que mede a distância entre a primeira e a segunda colocada. Quando a margem é grande, isso significa que uma rota ganhou com folga. Se for pequena, significa que há incerteza, e é exatamente o caso que precisa de mais informação. A regra final tem três faixas:
- Nota alta com margem confortável decide na hora;
- Nota baixa demais cai no vetorial, que é o único destino que aceita qualquer texto;
- O resto vai para o estágio 3.
É um classificador de vizinho mais próximo com regra de rejeição, um conceito antigo de machine learning. A decisão inteira leva 8,8 milissegundos.
O grafo e porque ele usa o algoritmo do Google
Essa é a base mais intrigante das três. Entidades e relações extraídas dos documentos viram nós e arestas, e a busca usa Personalized PageRank.
O PageRank original (Page & Brin, 1998) mede importância global. Imagine um turista andando pelo grafo ao acaso, seguindo uma aresta qualquer a cada passo. De vez em quando ele se teletransporta para um nó aleatório, para não ficar preso num beco. A fração de tempo que ele passa em cada nó é o PageRank daquele nó.
A versão personalizada muda uma coisa só: o teletransporte deixa de ir para qualquer nó e passa a voltar sempre para as entidades que apareceram na pergunta. O ranking deixa de significar “importante em geral” e passa a significar “relevante para esta pergunta”.
É isso que resolve perguntas de múltiplos saltos. Se a pergunta cita um fornecedor, a relevância escorre pelas arestas até o contrato que depende dele, e dali até a política que rege o contrato. Nenhuma regra foi programada para isso. A propagação no grafo faz o salto sozinha.
Se a informação estiver em duas bases diferentes, o que fazer?
Às vezes, uma mensagem faz 2 perguntas ao mesmo tempo, cada uma sobre uma base diferente. Por exemplo: “Qual foi a receita do Sudeste e quem aprova uma compra de oitenta mil reais?”. Na primeira parte, a base relacional responde. A segunda parte só o grafo responde.
O sistema resolve isso em 3 passos.
Primeiro, ele separa a mensagem em pedaços. O sistema corta a mensagem nos pontos de interrogação, ponto e vírgula, e em palavras como “e” e “também”, mas só aceita o corte se os dois lados resultantes forem perguntas de verdade. Isso evita separar por engano frases onde “e” não liga 2 perguntas.
Segundo, cada pedaço passa pelo roteador sozinho, como se fosse a mensagem inteira. No exemplo, o pedaço da receita vai pro roteador e sai “relational”. O pedaço da aprovação vai pro roteador e sai “graph”.
Terceiro, o sistema busca nas 2 bases ao mesmo tempo, e cada base recebe só o pedaço que é dela, não a mensagem toda. Isso é importante: se a base relacional recebesse a mensagem inteira, ela ia tentar gerar SQL a partir de uma frase que também fala sobre aprovação de compras, e essa consulta sairia errada ou seria bloqueada pelos guardas de segurança.
No final, as 2 respostas voltam separadas, cada uma rotulada com a base de onde veio ([SQL] ou [GRAPH]), e o modelo recebe as duas junto com um aviso de que são respostas a perguntas diferentes.
A stack por baixo: local, sem CUDA
Nesse projeto, optei por utilizar o motor de inferência llama.cpp, compilado com a API universal Vulkan, a mesma que roda jogos em qualquer placa de vídeo, e é o que permite rodar os modelos em uma placa de vídeo AMD sem depender de CUDA e de qualquer hardware da NVIDIA. Três processos separados do llama.cpp sobem em portas diferentes, cada um servindo um modelo, todos falando com a mesma estrutura de API compatível com OpenAI. Por esse motivo, o resto do backend conversa com esses processos usando o SDK oficial da OpenAI, apontando para o localhost, em vez de usar uma integração proprietária.
São 3 modelos, cada um com propósito diferente:
- Qwen3.5–4B: escreve as respostas finais e traduz a pergunta em SQL.
- GLM-OCR: lê imagens e devolve texto, incluindo tabelas em formato JSON.
- BGE-M3: converte qualquer texto em um vetor de 1024 dimensões.
Os três somam algo em torno de ~6 GB de VRAM na placa. O GLM-OCR fica ocioso e consome bem pouco de memória de vídeo quando não está em uso. Isso resulta em um consumo menor de VRAM na maior parte do tempo. É importante ressaltar que a seleção dos modelos foi realizada devido a limitações de hardware e que, em um projeto real e escalável, com equipamentos mais avançados, é viável optar por modelos que oferecem um desempenho superior. Nenhum dos três participa do roteamento, que é aritmética pura rodando na CPU.
As três bases de dados existem porque cobrem três formatos de informação diferentes, não porque três bancos parecem mais impressionantes que um só. E cada um usa uma operação matemática distinta, todas visíveis no código em vez de estarem escondidas dentro de uma biblioteca, como LlamaIndex e LangChain fazem.
A base de tudo: texto vira geometria
O BGE-M3 converte qualquer texto em um vetor de 1024 números. A semelhança entre dois textos é o cosseno do ângulo entre seus vetores:

O produto escalar a · b multiplica cada posição de ambos os vetores e soma. ‖a‖ é a norma L2, o comprimento do vetor, que faz parte do Teorema de Pitágoras generalizado para 1024 dimensões:

A divisão por ‖a‖ × ‖b‖ é a normalização, e ela existe porque o comprimento do vetor não deve influenciar a comparação. Um documento longo e um parágrafo curto sobre o mesmo assunto geram vetores que apontam quase para a mesma direção, mas com comprimentos bem diferentes. Sem dividir pelas normas, o texto mais longo teria vantagem só por ser maior. O cosseno descarta o comprimento e mede só a direção, que é onde o significado está.
Existem duas formas equivalentes de fazer isso, e o projeto usa as duas. No Qdrant, eu não normalizo nada: a coleção é declarada com distância cosseno e o banco faz a divisão internamente. No roteador, no cache e no grafo, eu normalizo o vetor uma vez, no momento em que ele é criado, dividindo cada posição pelo comprimento do vetor.

Cada uma das 1024 posições é dividida pelo mesmo número, o comprimento do vetor. O resultado, que eu chamo de u, aponta exatamente para a mesma direção do original e tem comprimento 1.
Exemplo em 2 dimensões, para ver acontecendo:

Conferindo:

E é isso que permite a simplificação. Quando os dois vetores comparados já passaram por essa conta, ‖a‖ e ‖b‖ valem 1, o denominador do cosseno vira 1 × 1, e sobra só o numerador:

Roteador: vizinho mais próximo com regra de rejeição
Cada rota tem um conjunto de frases-exemplo, e a nota da rota é cosseno contra a frase mais parecida dela, não a média:
nota(rota) = max cos(pergunta, âncora), para toda âncora daquela rota.
Usar o máximo e não a média é o que torna isso um classificador vizinho mais próximo (1-NN). A média puniria uma rota por ter âncoras cobrindo assuntos variados, que é exatamente o que uma rota bem descrita deveria ter. Depois vem a margem e as três faixas: aceita com nota ≥ 0.45 e margem ≥ 0.08, cai no vetorial com nota < 0.35, e o resto vai para o estágio 3.
Qdrant: busca aproximada porque o exato não escala
O Qdrant guarda os chunks de texto e os indexa com HNSW (Hierarchical Navigable Small World), um grafo de navegação em camadas. A camada de cima tem poucos nós ligando regiões distantes do espaço, e as de baixo vão refinando. A busca começa no topo e desce, o que troca custo linear O(N) por algo perto de O(log N). É uma troca deliberada de exatidão por velocidade: o índice encontra cerca de 99% dos vizinhos que a busca exaustiva devolveria.
CAG: a mesma conta, usada como corte binário
CAG, definido como Cache Augmented Generation, é um cache semântico que guarda pares de vetor da pergunta e resposta já dada num índice FAISS do tipo IndexFlatIP, tudo isso armazenado na memória RAM. Aqui a busca é exaustiva e exata, comparando contra todos os vetores guardados, o contrário do que é feito no Qdrant. O motivo é o tamanho: com poucos milhares de entradas, o processamento na CPU leva microssegundos, e um índice aproximado só adicionaria erro sem ganhar tempo.
Um acerto de cache exige cosseno ≥ 0.80. Só que o threshold sozinho serve a resposta errada. Exemplo: as perguntas “qual a receita do Sudeste” e “qual a receita do Nordeste” diferem em somente uma palavra entre si e possuem scores de 0.771 e 0.911, respectivamente. Subir o threshold não resolve, porque paráfrases legítimas da mesma pergunta possuem pontuações similares, e as duas faixas se sobrepõem.
A saída foi parar de decidir com um sinal só. Além da similaridade de cosseno, eu extraio da pergunta os nomes próprios e os números que ela contém, e um acerto de cache passou a exigir que essas duas listas sejam iguais. “Sudeste” e “Nordeste” são palavras diferentes, então as duas perguntas de receita deixam de casar, por mais próximos que os vetores estejam. Já “soma das vendas” e “total das vendas” não têm nome nem número diferente entre si, então continuam sendo a mesma pergunta e o cache responde na hora.
Isso resolve o caso em que a pergunta nomeia aquilo que procura. Quando ela não nomeia nada, o sistema recusa o cache em vez de confiar apenas no limiar, porque nessa faixa o cosseno já se mostrou incapaz de separar. Errar para o lado do cache miss custa alguns segundos de GPU; errar para o outro lado entrega um número errado com cara de resposta certa.
Grafo: HippoRAG 2 e o passeio aleatório enviesado

Na ingestão, o Qwen3.5–4B extrai triplas de sujeito, relação e objeto de cada trecho, no formato do OpenIE descrito pelo HippoRAG 2 (arXiv 2502.14802). Cada tripla vira uma aresta dirigida do sujeito para o objeto, mais duas arestas ligando cada uma dessas entidades ao trecho de texto em que foram mencionadas. Essas arestas de entidade para trecho são o que permite a pontuação escorrer de volta até um texto recuperável.
Na busca, comparo a pergunta inteira contra o nome de todas as entidades do grafo e pego as 5 melhores acima de 0.45. Esses são os nós-semente.
Com as sementes definidas, roda o Personalized PageRank. A leitura é um passeio aleatório enviesado: alguém andando pelo grafo, seguindo uma aresta ao acaso a cada passo, com probabilidade (1−d) de parar e voltar direto para uma das sementes, em vez de se teletransportar para um nó qualquer do grafo, que é o que o PageRank original (Page & Brin, 1998) faz. A fração do tempo que esse passeio para em cada nó, no longo prazo, é a pontuação daquele nó para essa pergunta específica.

Onde p(v) é a probabilidade de voltar para o nó v no teletransporte, proporcional à similaridade daquela semente com a pergunta, e zero para todo nó que não é semente. Uma entidade que casou a 0.70 puxa mais que uma que casou a 0.46. O d vale 0,85, o valor clássico do paper original. O ranking não mede importância geral do grafo, mede relevância para esta pergunta, e, como a pontuação escorre pelas arestas a cada iteração, ela alcança nós a dois ou três saltos de distância que a pergunta nunca citou. O multi-hop não é uma regra que eu escrevi, é consequência da propagação.
O que eu segui do paper e o que eu mudei
Reimplementar não é copiar. Mantive o desenho central do HippoRAG 2: triplas extraídas por OpenIE viram nós, as passagens de texto entram no mesmo grafo como nós próprios, e a recuperação é um Personalized PageRank semeado pela pergunta. Mantive também o limite de 5 nós-semente, que é o mesmo do paper, e a temperatura zero na extração. Três coisas eu mudei, e cada mudança tem motivo.
- A primeira foi remover o LLM do caminho de busca. O HippoRAG 2 usa o que os autores chamam de recognition memory: depois de recuperar as triplas candidatas, um LLM filtra quais são de fato relevantes antes de semear o passeio. Eu tirei essa etapa. O projeto inteiro é construído sobre a ideia de que a decisão deve ser determinística, e uma filtragem por modelo reintroduz exatamente a imprevisibilidade que eu estava tentando eliminar. Hoje a semeadura é só cosseno, e o caminho de busca inteiro roda sem nenhuma chamada de modelo até a geração da resposta.

- A segunda questão foi como resolver a situação em que a mesma entidade aparece com nomes diferentes. O paper liga nós sinônimos com arestas quando a similaridade dos embeddings passa de 0.8. Eu canonizo o nome antes de criar o nó: removo artigos iniciais, troco underline por espaço e normalizo o espaçamento. É uma solução mais pobre que a do paper e resolve o caso que eu de fato tinha, que era o modelo escrever “A Fábrica Beta” num trecho e “Fabrica_Beta” no seguinte. Sem isso, o grafo se parte em ilhas desconectadas e o multi-hop para de funcionar.

- A terceira foi onde injetar relevância direta no ranking. O paper faz isso nas sementes: além dos nós de frase, todas as passagens entram como semente, com peso igual à similaridade do embedding multiplicada por um fator de 0.05. Eu faço depois, fundindo os dois rankings com RRF. São dois mecanismos diferentes para o mesmo problema, que é impedir que a topologia do grafo decida sozinha. A minha versão tem uma vantagem que não é de qualidade, e sim de diagnóstico: as duas ordenações ficam separadas e medíveis, e foi assim que eu descobri que, neste corpus, o cosseno sozinho acertava mais que o PageRank sozinho.

O visual do projeto
Por cima de tudo isso fica um FastAPI, com WebSockets para streaming de tokens e para transmitir a telemetria. Cada etapa do pipeline gera um span do OpenTelemetry, com atributos como a rota escolhida, a margem da decisão e se o cache acertou ou errou. Um exporter próprio publica esses spans em JSON pelo WebSocket, e o frontend desenha isso ao vivo como uma linha do tempo para o usuário. Dá pra ver tudo isso funcionando nas telas reais do projeto. Cada print abaixo mostra uma parte diferente.
- Chat com 2 rotas: A mensagem faz 2 perguntas, e o badge mostra as 2 rotas que ela ativou: relational + graph. Cada metade da resposta vem da base certa.

- Corpus: quantos itens cada base guarda hoje: relacional, vetorial e grafo, além dos arquivos já processados.

- Telemetria: A lista de traces do OpenTelemetry, com o tempo de cada etapa do pipeline e a decisão do roteador em cada uma.

- Servers: O painel que liga e desliga cada servidor llama.cpp sem precisar abrir uma janela de terminal.

Quais problemas ainda persistem?
Nem todos os problemas que eu citei sobre RAGs no início do artigo foram resolvidos com esse projeto que eu fiz. Três pontos ainda ficaram abertos, que são:
- O cache semântico CAG ainda possui um problema crítico. Para que uma resposta chegue ao usuário pelo cache, existem hoje 2 verificações, como havia explicado antes. A primeira mede a similaridade da pergunta com o conteúdo do cache para ver se encontra o conteúdo similar àquela pergunta; se a semelhança der acima de 0.8, ele valida. Já a segunda verificação mede se nomes próprios e números da pergunta batem exatamente com o conteúdo sendo analisado no cache. Ambas as verificações precisam ser verdadeiras para que a resposta saia do cache para ser enviada ao usuário. O problema ocorre em situações em que há uma pergunta do tipo “Qual é a população da região Sudeste?” e o conteúdo do cache contém informações sobre a “população da região Nordeste”. O corte por similaridade de cosseno entre essas 2 informações é alto, pois as sentenças são parecidas, mesmo que signifiquem coisas diferentes. Então poderia haver casos em que o usuário pergunte algo e receba uma informação do cache totalmente diferente.
Existem pesquisas que foram realizadas para tentar contornar esse problema. Uma delas usa uma curva suave, chamada sigmoide, no lugar do corte fixo em 0.8, para decidir se a similaridade encontrada é alta o bastante para aquele caso específico. Esse método é chamado de vCache, e ele ajusta essa curva observando, ao longo do tempo, se as respostas aceitas para aquela pergunta guardada estavam certas ou erradas. A ideia central é boa: em vez de usar sempre o mesmo 0.8 para toda a pergunta, cada entrada do cache passa a ter o seu próprio limite, calibrando pelo histórico dela mesma.
O problema é que essa curva continua olhando para o mesmo único número, que é a similaridade de cosseno entre as duas perguntas. E é exatamente aí que o método esbarra no mesmo obstáculo de antes. Quando uma pergunta troca uma informação por outra parecida, mas diferente, o cosseno entre as duas frases fica muito próximo do cosseno de uma pergunta que apenas foi reescrita com outras palavras, perguntando a mesma coisa. Como os dois casos produzem quase o mesmo valor de similaridade, não existe curva, por mais bem ajustada que seja, capaz de separar as duas situações usando só esse número. Trocar um corte reto por uma curva suave muda a maneira como a decisão é feita, não a informação disponível para tomar essa decisão. E o problema do cache aqui não é a forma do corte, é a falta de um segundo dado para comparar. - Existe ainda um problema no estágio 3, que é quando uma base sabe que uma entidade existe, mas isso não significa que ela tem a resposta sobre isso. Por exemplo, a pergunta “o que motivou a criação do Sistema Atlas” é uma pergunta sobre a história da empresa, e essa resposta está no texto corrido, guardado na base vetorial. Porém, o grafo tem uma seção chamada exatamente “Sistema Atlas”, então, quando o estágio 3 compara a pergunta com os títulos de seção de cada base, a evidência aponta para o grafo, porque o nome bate. O grafo sabe que o Sistema Atlas existe, mas não tem a resposta sobre por que ele foi criado.
Eu tentei corrigir isso adicionando uma margem própria para a evidência, exigindo que ela vencesse por uma diferença mínima antes de poder contrariar o que o estágio 2 já tinha decidido. Isso funcionou no conjunto de perguntas que eu usei para ajustar os parâmetros, levando o roteamento de 90% para 95% de acerto. Mas, no conjunto de perguntas que eu nunca tinha usado antes, o resultado foi o contrário: caiu de 95% para 90%. Os dois conjuntos apresentaram 6 erros em 80 perguntas, tanto antes quanto depois da mudança, mas as perguntas que geraram os erros eram diferentes. Por isso eu voltei atrás nessa correção. - O corpus de demonstração que eu uso também é pequeno demais para comparar cosseno com Personalized PageRank de forma confiável. Eu medi e o cosseno puro, comparando só o texto dos trechos, acertou mais que o PageRank no ranking: 88% contra 66% de acerto no primeiro lugar. Isso não quer dizer que usar o PageRank foi a escolha errada. O grafo tem só 11 trechos, e, como o sistema já devolve os 5 primeiros resultados de qualquer busca, metade da base inteira já sai em qualquer pergunta, então esse recall não está medindo recuperação de verdade. Continuo fundindo os dois rankings porque cada um acerta perguntas que o outro erra, mas só um corpus bem maior conseguiria mostrar se o PageRank compensa o custo dele nesse caso.
Conclusão
O projeto começou de uma restrição, não de uma ideia. Eu tenho uma GPU AMD, então boa parte do ecossistema de IA local não estava disponível pra mim antes de eu escrever a primeira linha de código. Isso me tirou o caminho mais fácil, que seria deixar um modelo decidir tudo, e me fez perguntar quanto de uma decisão pode ser feito com aritmética antes de precisar de um modelo de verdade.
Eu medi essa pergunta em cada parte do projeto, e a resposta foi maior do que eu esperava no início. O roteamento inteiro, nos 3 estágios, hoje é feito só com cosseno e margem. O juiz que eu tinha colocado no meio da decisão foi removido, porque a geometria sozinha acertava mais do que ele. A extração de entidades da pergunta, que eu tinha copiado do desenho original do HippoRAG, também saiu, porque comparar a pergunta inteira contra o grafo funcionava melhor do que tentar adivinhar quais palavras da pergunta eram nomes. E até a atualização do que cada base guarda virou parte do mesmo princípio: o roteador relê os cabeçalhos de cada base depois de toda ingestão, não só quando o sistema liga. Isso deixou pro modelo só o que só ele consegue fazer, que é escrever a resposta final e ler texto de dentro de imagem.
Isso não significa que o PolyRAG não tenha falha. As 3 limitações que eu descrevi acima são reais, e eu decidi documentar elas em vez de esconder, porque isso é a mesma ideia que me fez construir o roteador desse jeito: um sistema que esconde por que errou não serve pra nada, e um texto que só mostra o que deu certo tem o mesmo problema.
O código, as medições e as duas versões do conjunto de avaliação estão no repositório: github.com/diegormirhan/polyrag.