← Voltar ao Blog

GraphRAG para microsserviço: 7 passos para saber quais ADRs quebram seu service

Pergunta que RAG vetorial nunca respondeu direito para mim: “quais ADRs, se eu ignorar, quebram o microsserviço orders-api?”. Rodei essa pergunta contra dois setups no mesmo monorepo simulado de 47 microsserviços e 312 ADRs (Architecture Decision Records em formato MADR). Um setup era RAG vetorial clássico com embedding text-embedding-3-large. O outro era GraphRAG da Microsoft (microsoft/graphrag, release de junho/2026) alimentado com Tree-sitter para o código e um extrator de entidade específico para ADRs.

Resultado: o RAG vetorial trouxe 8 ADRs relevantes “por similaridade de palavra”. Só que 3 desses não afetavam orders-api de fato, e 4 dependências reais (via serviço intermediário payments-adapter) simplesmente não apareceram. Precisão baixa, recall pior. O GraphRAG retornou 11 ADRs, com 9 verdadeiramente ligados a orders-api via caminho SERVICE --DEPENDS_ON--> SERVICE --GOVERNED_BY--> ADR.

Este post é o roteiro em 7 passos de como cheguei nesse setup, com os tradeoffs que quase me fizeram desistir na metade. Escrevo isso porque três projetos de KG que acompanhei em 2026 morreram no passo 2 — extração de entidade — e não quero que o seu morra também.

Aviso de escala: o “monorepo de 47 microsserviços e 312 ADRs” é uma simulação que montei para benchmark. Baseei a topologia em casos públicos (Uber, Airbnb monorepo posts) mas os números específicos são meus. Não é dado de cliente.

Por que a busca vetorial perde neste tipo de pergunta

RAG vetorial é ótimo para “achar documento próximo”. Você embed a pergunta, embed cada ADR, cosine similarity, top-K, joga no LLM. Funciona bem em base de conhecimento textual homogênea.

O problema aparece quando a pergunta é relacional, não semântica:

  • “Qual ADR afeta o microsserviço X?” — precisa saber que X depende de Y, e Y é governado por ADR-042.
  • “Qual decisão de arquitetura foi contradita pela nova ADR-088?” — precisa comparar SUPERSEDES explícito, não similaridade de palavra.
  • “Quais serviços vão precisar rebuild se eu deprecar ADR-015?” — precisa expandir o grafo em N hops a partir do nó ADR.

A busca vetorial responde essas por acidente, quando o texto dos ADRs menciona o nome do serviço literalmente. Basta o autor do ADR ter escrito “aplica-se ao domínio de pagamentos” em vez de “aplica-se a payments-adapter” para o vetor não conectar mais nada.

Passo 1: definir o schema de nó e aresta antes de escrever qualquer código

O erro mais caro que vi (e cometi) é começar pela extração. Você vai extrair coisa demais, coisa errada, e o grafo vira uma nuvem de nós órfãos.

O schema que travei depois de 3 iterações:

Nós:

RótuloDescriçãoPropriedades chave
ServiceMicrosserviçoname, repo_path, owner_team
FileArquivo-fontepath, language, service (fk)
FunctionFunção/métodoname, file, signature
ADRArchitecture Decision Recordid, status, date, title, path
DecisionDecisão granular dentro de ADRtext, category

Arestas:

TipoDireçãoCardinalidade
CONTAINSService → File1
CALLSFunction → FunctionN
DEPENDS_ONService → ServiceN
GOVERNED_BYService → ADRN
SUPERSEDESADR → ADR1
RELATES_TOADR → ServiceN

Deixa GOVERNED_BY e RELATES_TO separados, mesmo parecendo redundante. GOVERNED_BY é derivado explicitamente do frontmatter do MADR (campo applies_to:), RELATES_TO vem da extração LLM do corpo do ADR. Você vai querer diferenciar depois na hora de dar peso.

Passo 2: extração determinística com Tree-sitter (não use LLM aqui)

Este é o passo em que os projetos morrem. A tentação é jogar o repo inteiro na API do GPT-5 e pedir “extraia funções, classes, chamadas”. Não faça isso. Sai caro, sai errado, sai lento.

Use Tree-sitter para tudo que é estrutura sintática:

import tree_sitter_python as tspython
from tree_sitter import Language, Parser

PY_LANGUAGE = Language(tspython.language())
parser = Parser(PY_LANGUAGE)

def extract_functions(source_bytes: bytes) -> list[dict]:
    tree = parser.parse(source_bytes)
    query = PY_LANGUAGE.query("""
        (function_definition
          name: (identifier) @func_name
          parameters: (parameters) @params)
    """)
    captures = query.captures(tree.root_node)
    return [
        {"name": node.text.decode(), "line": node.start_point[0] + 1}
        for node, tag in captures if tag == "func_name"
    ]

Rodou local, custou zero, é reprodutível. Extrai Function, Class, Import, e as arestas CALLS, IMPORTS, CONTAINS. O conteúdo do arquivo nem sai da sua máquina, o que resolve o problema de compliance para código proprietário.

O papel do LLM vem depois, no passo 5, para o dado não estruturado (ADR texto livre).

Passo 3: mapear microsserviço via convenção de path

O grafo até aqui não sabe o que é microsserviço. Ele só tem arquivo e função. Preciso agrupar.

A convenção mais barata é services/<nome>/** no monorepo. Um walker simples:

from pathlib import Path

def service_of(file_path: str, repo_root: Path) -> str | None:
    rel = Path(file_path).relative_to(repo_root)
    parts = rel.parts
    if len(parts) >= 2 and parts[0] == "services":
        return parts[1]
    return None

Criei o nó Service para cada diretório e a aresta CONTAINS para cada arquivo. Simples, mas 100% preciso — sem chamar LLM.

Para DEPENDS_ON entre serviços, usei duas heurísticas: (a) import cruzado entre serviços (from services.payments import ... dentro de services/orders/) e (b) client HTTP configurado (padrão PAYMENTS_URL = os.environ[...]). Cobertura combinada: 91% das dependências no meu benchmark. Os 9% restantes são chamadas via message broker, que exigiram extração manual do arquivo topics.yaml.

Passo 4: parse dos ADRs (frontmatter primeiro, corpo depois)

ADRs no formato MADR (Michael Nygard / adr.github.io) têm frontmatter estruturado:

---
id: ADR-042
title: "Adotar autenticação JWT stateless para orders-api"
status: accepted
date: 2026-03-14
applies_to:
  - orders-api
  - api-gateway
supersedes: ADR-018
---

Este frontmatter é ouro. Extrai direto:

  • ADR com id, title, date, status
  • Aresta GOVERNED_BY para cada serviço em applies_to
  • Aresta SUPERSEDES para o ADR anterior

Sem LLM, sem ambiguidade, sem custo. Se o seu time não escreve applies_to no frontmatter, esta é a primeira mudança de processo que vai destravar tudo. É baratíssimo pedir ao autor do ADR para marcar 2 tags. Um lint no PR que rejeita ADR sem applies_to custa 10 linhas de código.

Passo 5: agora sim, LLM — mas só para o corpo do ADR

O corpo do ADR é texto livre com detalhe importante que o frontmatter não captura: “esta decisão afeta indiretamente o serviço notifications-worker porque muda o formato do payload que ele consome”. O notifications-worker não está em applies_to: — está numa frase.

Aqui o LLM ganha. Prompt simples com output estruturado:

prompt = f"""
Extraia entidades e relações do texto do ADR abaixo.

Retorne JSON com esta estrutura:
{{
  "affected_services": ["nome-do-service", ...],  // serviços mencionados no corpo
  "referenced_adrs": ["ADR-XXX", ...],            // ADRs citados
  "decisions": [                                  // decisões granulares
    {{"text": "...", "category": "security|performance|api|data|other"}}
  ]
}}

Texto do ADR:
---
{adr_body}
---
"""

Rodei com Claude Sonnet 4.6, temperature 0. Custo médio: ~$0.008 por ADR. Para 312 ADRs, deu $2.50. Ficou barato o suficiente para eu reprocessar tudo a cada mudança de schema.

Depois criei arestas RELATES_TO para cada affected_service extraído, e REFERENCES para cada referenced_adrs.

Passo 6: consultar com Cypher, não com “prompt engineering”

Muita gente monta o KG e depois joga tudo no LLM em prosa. Erro. A vantagem do grafo é ter linguagem de consulta declarativa.

A pergunta original (“quais ADRs afetam orders-api?”) vira Cypher direto:

MATCH (s:Service {name: 'orders-api'})
MATCH (s)-[:DEPENDS_ON*0..2]->(dep:Service)
MATCH (dep)-[:GOVERNED_BY|RELATES_TO]->(adr:ADR)
WHERE adr.status = 'accepted'
RETURN DISTINCT adr.id, adr.title, adr.date
ORDER BY adr.date DESC

Traduzindo: pega orders-api, expande até 2 hops nas dependências, coleta todos os ADRs que governam ou se relacionam com aqueles serviços, filtra por status accepted. Roda em ~40ms em Neo4j 5.x com o grafo carregado em memória.

O LLM entra só na última etapa, para gerar a resposta em linguagem natural a partir dos resultados. Assim o LLM não “raciocina sobre grafo” — apenas sumariza o que já veio filtrado pelo Cypher.

Passo 7: fechar o loop com um lint que rejeita ADR sem service explícito

O grafo é um ativo vivo. Se você não travar a qualidade da entrada, ele degrada em 6 meses.

Coloquei um GitHub Action que roda em cada PR que toca docs/adr/*.md:

# Rejeita se o frontmatter não tiver applies_to
python3 tools/lint_adr.py docs/adr/*.md --require applies_to

E outro que atualiza o grafo automaticamente após merge:

on:
  push:
    branches: [main]
    paths:
      - 'docs/adr/**'
      - 'services/**/*.py'
      - 'services/**/*.ts'
jobs:
  rebuild-kg:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: python3 tools/rebuild_kg.py --incremental

Custo por rebuild incremental: ~$0.30 em LLM (só reprocessa ADRs modificados). Custo por rebuild full (raro): ~$3. Barato o suficiente para deixar em produção.

Onde eu quase desisti (e por que você não deveria)

No passo 2 achei que Tree-sitter ia cobrir 100%. Não cobre. Chamadas dinâmicas (getattr, factory patterns, dependency injection) não aparecem no AST. Passei 3 dias tentando resolver isso com o LLM antes de aceitar que a cobertura de 85% é ok — o Cypher expande via DEPENDS_ON* e pesca a maior parte do que o AST perdeu.

No passo 5 quase gastei $150 tentando fazer o LLM extrair “todos os conceitos” do corpo do ADR. Não funciona. Você extrai barulho. Cortando o prompt para 3 categorias específicas (affected_services, referenced_adrs, decisions) o resultado ficou útil e barato.

E o resultado final vs o RAG vetorial baseline:

MétricaRAG vetorialGraphRAG (este pipeline)
Precisão em “quais ADRs afetam service X”62%89%
Recall (ADRs realmente relevantes)55%82%
Latência p50380ms470ms
Custo por consulta$0.002$0.004
Custo de indexação inicial (312 ADRs + 47 services)$8$14

O GraphRAG custa quase o dobro por consulta e por indexação. Em compensação, ganha 27 pontos de precisão em uma pergunta que o time faz toda semana antes de refatorar. Para nós valeu. Para você, depende de quantas vezes por semana alguém pergunta “isso quebra o quê?”.

Se o seu monorepo tem menos de 10 serviços e menos de 30 ADRs, honestamente fica no vetor + grep. GraphRAG começa a pagar quando o grafo de dependência tem mais de 3 hops médios entre serviços, o que costuma acontecer a partir de ~20-30 microsserviços.

Já escrevi sobre esse contraste com foco em custo em GraphRAG vs RAG clássico: 4 projetos, quando vale 7x o custo, e sobre a jornada de construir a base de conhecimento em 3 meses em O banco de conhecimento de 300 nós em 3 meses e MCP com 27k tokens perde para KG de 8x menos — este post é o “próximo passo” prático de ambos, focado no caso ADR × microsserviço.

ken imoto · WebRTC & Voice AI engineer · kenimoto.dev · TabNews