FMFelipe MiillerNotes on software & systems
HomeBlogAbout
GitHub

Keep building.

Felipe Miiller · © 2026

MailGitHubGitHubLinkedinGitHub
View source on GitHub
Back to blog

GraphRAG: quando a pergunta é local e quando ela é global

04/10/2026
29 min de leitura
8673 palavras
RAGPythonArquitetura
  • 1. A bifurcação: a mesma base, dois tipos de pergunta
  • 2. Rota local: k-hop, o caminho a partir da semente
  • 3. A consulta recursiva: por que ela e não um laço
  • O corpo da CTE, escrito uma vez. As consultas acima só acrescentam o SELECT.
  • 4. O fan-out: por que 2 saltos num grafo de coocorrência explode
  • 5. Rota local com resposta no nó: devolver a resposta, não o trecho
  • 6. Rota global: o resumo da comunidade, escrito antes da consulta
  • 7. O erro clássico: usar o resumo como se fosse evidência
  • 8. O roteador: a decisão que separa as duas rotas
  • Marcadores de agregação: a pergunta pede o padrão do acervo, não um trecho.
  • Marcadores de generalização: assunto nomeado com pedido de contexto amplo.
  • 9. Medir por ramo, porque o número agregado esconde a falha
  • (pergunta, rota esperada, trecho que responde, comunidade esperada entre os resultados)
  • 10. Onde o grafo não ajuda
  • TL;DR
  • Referências

Lede. A busca vetorial responde "o que este documento diz sobre X". Ela não responde "quais são os temas que dominam este acervo", e nenhum ajuste de modelo conserta essa lacuna. Neste artigo você vê a bifurcação que separa um RAG com grafo de um RAG só vetorial, implementa as duas rotas em SQL e Python (roda offline, sem chave de API), e termina sabendo em que ponto o grafo é desperdício. Onde você está na linha. Este é o passo 8 de 13 — busca local (k-hop) e busca global (comunidades). Antes dele: 04 · Busca híbrida e 07 · Grafo e comunidades. Depois dele: 09 · Orçamento de contexto e 12 · Guia de decisão. Quem leu os anteriores pode ir direto para a seção 3.


1. A bifurcação: a mesma base, dois tipos de pergunta

Você já tem o grafo montado e o vetor de cada entidade indexado. A pergunta chega, e

alguém precisa decidir quem responde. Não é escolha de biblioteca — é escolha de

que pergunta existe na sua base.

"O que este contrato diz sobre prazo de pagamento?"   -> LOCAL
   a resposta está num trecho, perto da entidade

"Quais são os temas que dominam este acervo?"         -> GLOBAL
   a resposta não está em nenhum trecho: é o padrão do todo

A pergunta local tem um trecho que a responde, e esse trecho está a um ou dois saltos

da entidade citada. A busca vetorial acha o trecho, e o grafo serve para dizer quais outras entidades aparecem com ele — o contexto que faz o trecho valer.

A pergunta global não tem trecho. Ela é sobre a forma do acervo, não sobre o

conteúdo de um documento. Nenhum parágrafo responde "quais são os temas que dominam".

Essa resposta só existe depois de olhar o acervo inteiro, agrupar o que se repete e

descrever o grupo.

Forçar a rota errada não dá resultado ruim: dá resultado confiante e vazio. A rota

local sobre "quais são os temas dominantes" devolve os 10 trechos mais parecidos com a

própria pergunta — trechos sobre um assunto qualquer, sem conexão entre si — e o modelo

escreve um parágrafo com cara de análise. A rota global sobre "o que este contrato diz

sobre prazo" devolve o resumo de um grupo de entidades, que fala do assunto em geral e

não diz nada sobre o contrato.

⚠️ Rodar as duas rotas em toda pergunta custa o dobro e acerta metade
O erro mais comum não é escolher a rota errada: é usar o relatório de comunidade

em toda pergunta "porque ele é bonito". O relatório é feito para ser lido por

humano, não citável. Se ele entra na resposta como fonte, você perde

rastreabilidade sem ganhar nada. A seção 7 mostra onde isso quebra.


2. Rota local: k-hop, o caminho a partir da semente

k-hop é o termo técnico para "caminhar N arestas a partir de um ponto de partida":

uma semente mais uma aresta é 1-hop, duas arestas é 2-hop. O "k" é a única alavanca de

custo que você tem neste ramo.

A analogia honesta: é a diferença entre perguntar a alguém "o que está escrito na ficha 412" e perguntar "quem aparece junto com a ficha 412, e quem aparece junto com essas pessoas". A primeira é leitura direta. A segunda é rede de contatos, e cada

salto traz gente nova.

Na prática a busca vetorial entrega as sementes e o grafo expande:

pergunta -> busca vetorial nos rótulos dos nós -> sementes
                                                -> k-hop a partir delas
                                                -> nós a 1, 2, 3 saltos

Vamos montar um acervo pequeno com a mesma anatomia de um acervo real: nós com rótulo,

documentos que os citam, e arestas cujo peso é o número de documentos em que as duas entidades aparecem juntas (coocorrência). Uma aresta de peso 3 significa que três

documentos falam das duas coisas, que é o sinal mais barato de que elas pertencem ao

mesmo assunto. É um acervo de documentação interna, com três assuntos que se cruzam.

Primeiro as fichas da série. O que muda em relação aos outros artigos é a origem do

score: o ramo local não devolve similaridade, devolve distância em saltos, e essa

distância não está na mesma escala do cosseno da busca vetorial.

from __future__ import annotations

import math
import sqlite3
import struct
import zlib
from collections.abc import Sequence
from enum import StrEnum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field


class Contract(BaseModel):
    """Base das fichas da série: campo que não existe é erro, ficha é imutável."""

    model_config = ConfigDict(extra="forbid", frozen=True)


class ScoreSource(StrEnum):
    """De onde veio o número da busca. Guardar a origem é obrigatório."""

    VECTOR = "vector"
    LEXICAL = "bm25"
    GRAPH = "graph"          # achou por relação, não por texto
    COMMUNITY = "community"   # resumo de um grupo do grafo


class Scored(Contract):
    """Um resultado de recuperação, com a origem do score.

    `payload` é onde mora a procedência: de qual semente o nó foi alcançado, por
    qual caminho, e de quais documentos ele tira a citação. Sem isso, a resposta
    final não tem como ser conferida.
    """

    ref_id: str
    score: float
    source: ScoreSource
    payload: dict[str, Any] = Field(default_factory=dict)


DIM = 64


def vetorizar(texto: str, dim: int = DIM) -> list[float]:
    """Vetor determinístico, sem rede, e NÃO semântico.

    São 3-gramas de caractere projetados num vetor, o que dá sobreposição parcial
    ("auditoria" x "auditorias") e deixa uma pergunta sem o nome exato da
    entidade ainda encontrar alguma coisa. Serve para exercitar o encadeamento,
    não a qualidade da busca. Num sistema real, troque pelo modelo do artigo 03.

    O `crc32` é o detalhe que torna o exemplo conferível: `hash()` em Python é
    aleatório por processo, e um exemplo que muda a cada execução não serve para
    ninguém verificar nada.
    """
    limpo = " ".join(texto.lower().split())
    vector = [0.0] * dim
    for i in range(max(len(limpo) - 2, 0)):
        vector[zlib.crc32(limpo[i : i + 3].encode("utf-8")) % dim] += 1.0
    norm = math.sqrt(sum(v * v for v in vector)) or 1.0
    return [v / norm for v in vector]


def empacotar(vector: Sequence[float]) -> bytes:
    """Lista de float -> BLOB. Layout nativo do Python: '<' + 'f' por dimensão."""
    return struct.pack(f"<{len(vector)}f", *vector)


def desempacotar(blob: bytes, dim: int = DIM) -> list[float]:
    """BLOB -> lista de float. No PostgreSQL o mesmo caminho usa `float8[]`."""
    return list(struct.unpack(f"<{dim}f", blob))


def cosine(a: Sequence[float], b: Sequence[float]) -> float:
    """Vetores normalizados => o produto interno já É o cosseno."""
    return sum(x * y for x, y in zip(a, b, strict=True))

O esquema. node_chunk é a tabela que amarra a entidade ao documento: é dela que sai

a citação, e é dela que o relatório da seção 6 tira a procedência. O vetor fica num

BLOB, com o empacotamento feito em Python

e desempacotado no caminho de volta.

SCHEMA = """
CREATE TABLE node (node_id TEXT PRIMARY KEY, rotulo TEXT NOT NULL,
    resposta_curta TEXT NOT NULL DEFAULT '', resposta_ref TEXT, community_id TEXT);
CREATE TABLE edge (src TEXT NOT NULL, dst TEXT NOT NULL, peso REAL NOT NULL,
    rank INTEGER NOT NULL, PRIMARY KEY (src, dst));
CREATE TABLE chunk (chunk_id TEXT PRIMARY KEY, doc_id TEXT NOT NULL,
    titulo TEXT NOT NULL, texto TEXT NOT NULL);
CREATE TABLE node_chunk (node_id TEXT NOT NULL, chunk_id TEXT NOT NULL,
    PRIMARY KEY (node_id, chunk_id));
CREATE TABLE community (community_id TEXT PRIMARY KEY, titulo TEXT NOT NULL);
CREATE TABLE community_node (community_id TEXT NOT NULL, node_id TEXT NOT NULL,
    PRIMARY KEY (community_id, node_id));
CREATE TABLE community_report (community_id TEXT PRIMARY KEY,
    relatorio TEXT NOT NULL, evidence_refs TEXT NOT NULL);
CREATE TABLE node_vector (node_id TEXT PRIMARY KEY, vetor BLOB NOT NULL);
CREATE INDEX edge_src ON edge (src);
"""

O acervo. Quem decide a divisão em comunidades é o detector do

python-igraph, executado no

artigo 07; aqui o resultado já vem pronto, porque este artigo usa a divisão, não

reconstrói o método. RESPOSTAS é a frase que cada nó carrega desde a indexação

(seção 5), com o trecho que a sustenta.

DOCUMENTOS: list[tuple[str, str, str, list[str]]] = [
    ("d01", "Prazo de pagamento padrão", "O prazo de pagamento padrão é de 30 dias contados do fechamento da fatura.", ["Faturação", "Prazo de pagamento"]),
    ("d02", "Faturação e emissão de nota", "A fatura é emitida após o fechamento do ciclo. O contrato define o dia de corte.", ["Faturação", "Contrato"]),
    ("d03", "Cláusulas de renovação", "A renovação do contrato exige aceite explícito das duas partes.", ["Contrato", "Renovação"]),
    ("d04", "Renovação automática", "A renovação automática vale quando não há recusa dentro do prazo da fatura anterior.", ["Renovação", "Faturação"]),
    ("d05", "Política de backup", "O backup é protegido por autenticação em dois fatores e roda fora do horário comercial.", ["Backup", "Autenticação"]),
    ("d06", "Acesso remoto", "O acesso remoto é feito por VPN e exige autenticação. Falhas de rede são registradas.", ["Acesso remoto", "Autenticação", "Rede"]),
    ("d07", "Acesso a dados por função", "Cada função enxerga um conjunto de dados. A autenticação define a linha de base.", ["Acesso a dados", "Autenticação"]),
    ("d08", "Retenção de registros", "A retenção de registros é auditada: cada acesso a dado fica registrado por um período fixo.", ["Retenção de registros", "Auditoria", "Acesso a dados"]),
    ("d09", "Auditoria de acesso", "A auditoria registra quem pediu acesso a dado e com qual consentimento.", ["Auditoria", "Acesso a dados", "Consentimento"]),
    ("d10", "Consentimento de tratamento", "O consentimento precisa ser registrado antes do tratamento e sobrevive à auditoria.", ["Consentimento", "Retenção de registros"]),
    ("d11", "Cláusula de proteção de dados", "O contrato contém a cláusula que define a retenção de registros e o consentimento exigido.", ["Contrato", "Retenção de registros", "Consentimento"]),
]

COMUNIDADES: dict[str, tuple[str, list[str]]] = {
    "c1": ("Operação e contratos", ["Faturação", "Prazo de pagamento", "Contrato", "Renovação"]),
    "c2": ("Infraestrutura e acesso", ["Backup", "Acesso remoto", "Autenticação", "Rede"]),
    "c3": ("Conformidade e dados", ["Retenção de registros", "Auditoria", "Acesso a dados", "Consentimento"]),
}

RESPOSTAS: dict[str, tuple[str, str]] = {
    "Faturação": ("A fatura é emitida após o fechamento do ciclo.", "d02#0"),
    "Prazo de pagamento": ("O prazo padrão é de 30 dias a partir do fechamento da fatura.", "d01#0"),
    "Contrato": ("O contrato define o dia de corte, a renovação e a cláusula de dados.", "d02#0"),
    "Renovação": ("A renovação automática vale sem recusa no prazo anterior.", "d04#0"),
    "Backup": ("O backup exige dois fatores e roda fora do horário comercial.", "d05#0"),
    "Acesso remoto": ("O acesso remoto é feito por VPN e exige autenticação.", "d06#0"),
    "Autenticação": ("A autenticação em dois fatores é a linha de base de todo acesso.", "d05#0"),
    "Rede": ("Falhas de rede no acesso remoto são registradas para análise.", "d06#0"),
    "Acesso a dados": ("Cada função enxerga um conjunto de dados por permissão.", "d07#0"),
    "Retenção de registros": ("A retenção de registros é auditada e o período é fixo.", "d08#0"),
    "Auditoria": ("A auditoria registra quem pediu acesso e com qual consentimento.", "d09#0"),
    "Consentimento": ("O consentimento é registrado antes do tratamento.", "d10#0"),
}

E a montagem, em quatro passos explícitos: entidades, documentos, coocorrência, vetores.

def montar_grafo() -> sqlite3.Connection:
    """Ingestão do grafo: texto -> arestas com peso, numa transação só."""
    con = sqlite3.connect(":memory:")          # em memória: morre com o processo
    con.executescript(SCHEMA)                   # tabelas + índice de `edge.src`
    comunidade_de = {n: cid for cid, (_, ns) in COMUNIDADES.items() for n in ns}
    # `resposta_ref` é o trecho que sustenta a frase do nó, e ele tem que citar
    # o nó: a resposta é extraída do documento, não escrita ao lado dele.
    con.executemany(
        "INSERT INTO node VALUES (?, ?, ?, ?, ?)",
        [(n, n, r, ref, comunidade_de[n]) for n, (r, ref) in RESPOSTAS.items()])
    for doc_id, titulo, texto, entidades in DOCUMENTOS:
        chunk_id = f"{doc_id}#0"                # um chunk por documento, aqui
        con.execute("INSERT INTO chunk VALUES (?, ?, ?, ?)", (chunk_id, doc_id, titulo, texto))
        con.executemany("INSERT INTO node_chunk VALUES (?, ?)", [(n, chunk_id) for n in entidades])
    # Passo 1: coocorrência. Peso = quantos documentos citaram os dois nós.
    # `src < dst` grava cada par uma vez; a linha seguinte grava o sentido inverso.
    con.execute("""
        INSERT INTO edge (src, dst, peso, rank)
        SELECT a.node_id, b.node_id, COUNT(*), 0 FROM node_chunk a
          JOIN node_chunk b ON a.chunk_id = b.chunk_id
         WHERE a.node_id < b.node_id GROUP BY a.node_id, b.node_id""")
    con.execute("INSERT INTO edge (src, dst, peso, rank) SELECT dst, src, peso, 0 FROM edge")
    # Passo 2: rank, a posição de cada vizinho entre os do mesmo nó, por peso. É
    # o que permite teto de grau por consulta sem reescrever a tabela.
    con.execute("""
        UPDATE edge SET rank = (SELECT COUNT(*) FROM edge outro
          WHERE outro.src = edge.src
            AND (outro.peso > edge.peso
                 OR (outro.peso = edge.peso AND outro.dst <= edge.dst)))""")
    # Passo 3: vetor do rótulo. É aqui que a busca vetorial entra no grafo.
    con.executemany(
        "INSERT INTO node_vector VALUES (?, ?)",
        [(rotulo, empacotar(vetorizar(rotulo)))
         for (rotulo,) in con.execute("SELECT rotulo FROM node").fetchall()])
    for cid, (titulo, nodes) in COMUNIDADES.items():
        con.execute("INSERT INTO community VALUES (?, ?)", (cid, titulo))
        con.executemany("INSERT INTO community_node VALUES (?, ?)", [(cid, n) for n in nodes])
    con.commit()
    return con


con = montar_grafo()
print("nós:", con.execute("SELECT COUNT(*) FROM node").fetchone()[0],
      "arestas:", con.execute("SELECT COUNT(*) FROM edge").fetchone()[0],
      "chunks:", con.execute("SELECT COUNT(*) FROM chunk").fetchone()[0])

3. A consulta recursiva: por que ela e não um laço

A travessia k-hop é uma consulta recursiva (CTE — common table expression, uma

subconsulta nomeada que pode se chamar de si mesma) porque "o que está a 2 saltos de

X" é a mesma pergunta a 1 salto, repetida. Um laço em Python também resolveria; a

diferença é onde o trabalho acontece. No SQL, o banco percorre o índice e devolve o

resultado, sem materializar o grafo em memória no seu processo.

A CTE tem duas partes: a âncora (as sementes, salto 0) e o passo recursivo (a

expansão, com o número de saltos como condição de parada). A sintaxe é a mesma nos dois

bancos que importam aqui: o

WITH e a CTE recursiva no SQLite e

as queries with no PostgreSQL

descrevem a mesma construção.

-- ':hops' é o k; ':teto' é o teto de grau por nó.
WITH RECURSIVE saltos(node_id, hop, trilha) AS (
    SELECT s.node_id, 0, '|' || s.node_id || '|' FROM semente s   -- âncora
    UNION ALL
    SELECT e.dst, s.hop + 1, s.trilha || e.dst || '|'             -- um salto
      FROM saltos s JOIN edge e ON e.src = s.node_id
     WHERE s.hop < :hops                            -- k: para de expandir aqui
       AND instr(s.trilha, '|' || e.dst || '|') = 0  -- já visitado: não entra
       AND (:teto IS NULL OR e.rank <= :teto)        -- teto de grau por nó
)
SELECT node_id, hop, trilha FROM saltos;

Três decisões nesse SQL valem uma explicação cada.

RECURSIVE é obrigatório no SQLite. A palavra não é decorativa: sem ela o WITH

não aceita a referência a si mesmo e a consulta nem compila. No PostgreSQL a palavra

existe, e a sintaxe do WITH RECURSIVE ... UNION ALL é a mesma.

A trilha é a guarda de ciclo. Se A e B aparecem juntos num documento, existe

aresta nos dois sentidos — e o passo recursivo volta para onde já foi. A concatenação

do caminho acumulado, buscada dentro da string com instr, resolve. É a parte que

não é portátil: no PostgreSQL o equivalente é strpos('|' || e.dst || '|' IN s.trilha) = 0.

A deduplicação precisa ser explícita. A consulta acima devolve um registro por

caminho, e o mesmo nó aparece tantas vezes quantos caminhos o alcançaram. Daria para

resolver com GROUP BY node_id e MIN(hop), confiando que o banco devolve a linha do

menor salto (os dois bancos fazem isso, mas é comportamento implícito). A

ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY hop), no bloco Python abaixo, deixa

o critério explícito e traz a trilha junto — e o caminho é metade do valor do ramo

local.

# O corpo da CTE, escrito uma vez. As consultas acima só acrescentam o SELECT.
_CTE = """
WITH RECURSIVE saltos(node_id, hop, trilha) AS (
    SELECT s.node_id, 0, '|' || s.node_id || '|' FROM semente s
    UNION ALL
    SELECT e.dst, s.hop + 1, s.trilha || e.dst || '|' FROM saltos s
      JOIN edge e ON e.src = s.node_id
     WHERE s.hop < :hops
       AND instr(s.trilha, '|' || e.dst || '|') = 0
       AND (:teto IS NULL OR e.rank <= :teto)
)
"""
_SEMENTE = "CREATE TEMP TABLE IF NOT EXISTS semente (node_id TEXT PRIMARY KEY)"


def semear(con: sqlite3.Connection, ids: Sequence[str]) -> None:
    """Carga as sementes numa tabela temporária.

    Tabela temporária em vez de um `IN (?, ?, ?)` montado em string: sem
    concatenar valor na consulta e sem limite de quantidade.
    """
    con.execute(_SEMENTE)
    con.execute("DELETE FROM semente")
    con.executemany("INSERT OR IGNORE INTO semente VALUES (?)", [(i,) for i in ids])


def candidatos_vetoriais(con: sqlite3.Connection, pergunta: str) -> list[tuple[float, str]]:
    """Cada nó com o cosseno da pergunta contra o seu rótulo, do maior para o menor.

    Devolve o *perfil* inteiro, e não só o top-k, porque o perfil é informação:
    é dele que sai o sinal de "esta pergunta nomeia um assunto" (seção 8). Num
    sistema real, isto é o índice vetorial do artigo 03, com HNSW; aqui é
    comparação exata em SQLite, porque são doze nós.
    """
    consulta = vetorizar(pergunta)
    pares = [
        (cosine(consulta, desempacotar(blob)), node_id)
        for node_id, blob in con.execute("SELECT node_id, vetor FROM node_vector")
    ]
    return sorted(pares, key=lambda par: (-par[0], par[1]))   # desempate estável


def semente_por_busca_vetorial(
    con: sqlite3.Connection, pergunta: str, k: int = 2, minimo: float = 0.15
) -> list[str]:
    """A entrada do ramo local: o que a busca vetorial achou nos rótulos.

    `minimo` é o piso de similaridade, e ele é o que decide se a pergunta sem
    assunto devolve vazio. Sem piso, todo vetor tem um vizinho mais próximo, e o
    k-hop sempre sai com uma semente errada em vez de dizer que não achou.
    """
    return [node_id for score, node_id in candidatos_vetoriais(con, pergunta)
            if score >= minimo][:k]


def buscar_k_hop(
    con: sqlite3.Connection, sementes: Sequence[str], hops: int = 2, teto_grau: int | None = 4
) -> list[Scored]:
    """Ramo local: as entidades a até `hops` saltos das sementes.

    O score é 1/(1+hop): decresce com a distância, e isso é uma convenção nossa,
    não uma similaridade. Nunca some esse número com o da busca vetorial — junte
    por rank, como o artigo 04 faz.
    """
    if not sementes:
        return []
    semear(con, sementes)
    linhas = con.execute(
        _CTE + """SELECT node_id, hop, trilha FROM (
                     SELECT node_id, hop, trilha,
                            ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY hop) AS rn
                       FROM saltos) WHERE rn = 1 ORDER BY hop, node_id""",
        {"hops": hops, "teto": teto_grau},
    ).fetchall()
    return [
        Scored(
            ref_id=node_id, score=1.0 / (1 + hop), source=ScoreSource.GRAPH,
            payload={
                "hop": hop,
                # A semente é a primeira parada do caminho. Guardar é o que
                # permite responder "por que este nó apareceu" sem refazer a
                # consulta depois.
                "caminho": (caminho := trilha.strip("|").split("|")),
                "semente": caminho[0],
            },
        )
        for node_id, hop, trilha in linhas
    ]

Rode a bifurcação com o acervo montado:

pergunta_local = "documentos sobre auditoria de acesso a dado"
print("perfil:", [(n, round(s, 3)) for s, n in candidatos_vetoriais(con, pergunta_local)[:4]])
sementes = semente_por_busca_vetorial(con, pergunta_local, k=2)
print("sementes:", sementes)

local = buscar_k_hop(con, sementes, hops=2, teto_grau=4)
for item in local:
    print(item.payload["hop"], item.ref_id, round(item.score, 3),
          "<-", " > ".join(item.payload["caminho"]))

A primeira linha é hop 0: a própria semente. As seguintes são o que a busca vetorial

não podia dar — entidades que não aparecem na pergunta e mesmo assim importam,

porque aparecem nos mesmos documentos.


4. O fan-out: por que 2 saltos num grafo de coocorrência explode

Fan-out é a quantidade de nós que um salto abre. Numa árvore binária ele cresce

como 2^k, e todo mundo aprendeu a olhar para isso. Numa travessia de grafo de

coocorrência é pior, porque o grafo tem ciclos e hubs: dois nós que aparecem juntos em

muitos documentos viram uma aresta forte, e essa aresta forte é a que conecta tudo com

tudo. No acervo da seção 2, Contrato aparece com Faturação, com Renovação e com

Retenção de registros — três comunidades diferentes a um salto.

Este é o número que importa mais que qualquer similaridade: quantos caminhos o passo

recursivo percorre, com e sem teto de grau.

def fanout(
    con: sqlite3.Connection, semente: str, saltos: int, teto_grau: int | None
) -> tuple[dict[int, int], int]:
    """Nós distintos alcançados por profundidade, e o total de caminhos percorridos.

    Os dois números são diferentes e os dois importam: `por_hop` diz o tamanho do
    resultado (que a deduplicação da window function entrega ao chamador) e
    `caminhos` diz o trabalho que o banco fez para chegar nele.
    """
    semear(con, [semente])
    por_hop = dict(con.execute(
        _CTE + "SELECT hop, COUNT(DISTINCT node_id) FROM saltos GROUP BY hop ORDER BY hop",
        {"hops": saltos, "teto": teto_grau}).fetchall())
    caminhos = con.execute(
        _CTE + "SELECT COUNT(*) FROM saltos", {"hops": saltos, "teto": teto_grau}).fetchone()[0]
    return por_hop, caminhos


for semente in ("Contrato", "Acesso a dados", "Rede"):
    for teto in (None, 2, 4):
        por_hop, caminhos = fanout(con, semente, 3, teto)
        print(f"{semente:<15} teto={str(teto):<5} por hop={por_hop} caminhos={caminhos}")

Três leituras desse resultado:

  1. Dois saltos já alcançam a maior parte do acervo. A partir de Contrato, 1 salto

    pega 4 nós, 2 saltos pegam 7, e o acervo inteiro (12 nós) está a 3 saltos. O k que

    você escolher não é "quantos saltos de contexto eu quero": é uma fração do grafo.

  2. O teto de grau só faz diferença se existir hub. Com teto=2 os números caem

    pela metade; com teto=4 são idênticos aos de "sem teto", porque nenhum nó deste

    acervo tem mais de 4 vizinhos. Se o teto não muda nada no seu grafo, o seu grafo não

    tem hub — e o fan-out que sobra é problema de outro lugar.

  3. O número de caminhos cresce mais rápido que o de nós (29 caminhos para 12 nós, a

    partir de Contrato), porque a deduplicação só acontece na window function externa.

    Se a consulta está lenta, o culpado é a explosão combinatória, não o agrupamento.

⚠️ hops é a variável mais perigosa do ramo local
Ela não tem teto natural. 1 salto é quase sempre seguro; 3 saltos em grafo de

coocorrência costuma devolver o acervo inteiro, e o orçamento do artigo 09

transforma esse "tudo" em "alguns nós, e nenhum deles é o certo". Se você não usa

teto de grau, coloque pelo menos um teto de profundidade — e registre o total de

caminhos por consulta. Quando ele crescer, o grafo mudou de tamanho e a consulta

está mais cara sem ninguém ter percebido.

Uma alternativa é podar edge offline (DELETE FROM edge WHERE rank > 4) e

deixar a consulta sem filtro: você troca parametrização por velocidade, e a

tabela encolhe. Numa consulta recursiva, índice não ajuda dentro dela — tabela

menor ajuda.


5. Rota local com resposta no nó: devolver a resposta, não o trecho

Um erro comum do ramo local é tratar o nó como apontador: o nó diz "este assunto

existe e está ligado naqueles", e o sistema devolve os trechos dos documentos vizinhos

para o modelo ler. Funciona, e custa caro — você pagou por N documentos inteiros para

usar uma frase de cada um.

A alternativa é o nó com resposta pronta: na indexação, cada nó recebe uma frase

curta que responde "o que este assunto é". Na consulta, o nó devolve essa frase, e o

caminho que a CTE trouxe vira a explicação de por que ela importa.

def respostas_prontas(con: sqlite3.Connection, sc: list[Scored]) -> list[Scored]:
    """Troca o trecho cru pela resposta que o nó carrega.

    O que muda: o bloco enviado ao modelo passa a ser a frase do nó, com o
    caminho que a trouxe até a pergunta. O que NÃO muda: a citação continua
    apontando para um documento real, e a resposta do nó tem prazo de validade,
    porque foi extraída na indexação.
    """
    saida: list[Scored] = []
    for item in sc:
        linha = con.execute(
            "SELECT resposta_curta, resposta_ref FROM node WHERE node_id = ?",
            (item.ref_id,)).fetchone()
        if linha is None or not linha[0]:
            continue                                # nó sem resposta não entra
        # A evidência da frase é o trecho que ela veio (`resposta_ref`); os
        # demais que citam o nó entram como contexto secundário.
        citam = [c for (c,) in con.execute(
            "SELECT chunk_id FROM node_chunk WHERE node_id = ? ORDER BY chunk_id",
            (item.ref_id,))]
        saida.append(
            Scored(ref_id=item.ref_id, score=item.score, source=item.source,
                   payload={**item.payload, "resposta": linha[0],
                            "evidence_refs": [linha[1]] + [c for c in citam if c != linha[1]],
                            "tipo": "resposta_de_no"})
        )
    return saida


crus, prontos = 0, 0
for item in respostas_prontas(con, [a for a in local if a.payload["hop"] > 0]):
    crus += len(con.execute(
        "SELECT texto FROM chunk WHERE chunk_id = ?", (item.payload["evidence_refs"][0],)
    ).fetchone()[0])
    prontos += len(item.payload["resposta"])
print("caracteres com os trechos crus:", crus)
print("caracteres com as respostas do nó:", prontos)

O número sai menor, e essa é a economia: a mesma informação sem o parágrafo em volta.

O que você não pode fazer é jogar a resposta do nó no prompt sem as evidências — a

frase do nó é uma afirmação, e quem a sustenta é o documento. Por isso evidence_refs

viaja dentro do payload: é ela que permite citar.

E o prazo de validade precisa ficar explícito, porque a resposta do nó é uma cópia

do documento feita em outro momento. Se o d01 muda o prazo de 30 para 15 dias e

ninguém reindexa, a resposta do nó continua dizendo 30 — e o sistema passa a ter duas

fontes, uma atualizada e uma congelada, sem que nada tenha falhado. O conserto é

invalidação por content_hash, reindexando o nó junto, e é assunto de idempotência no

artigo 13.


6. Rota global: o resumo da comunidade, escrito antes da consulta

A pergunta global não tem trecho que a responda. Ela tem agregado: a resposta nasce

de olhar o acervo inteiro, agrupar o que se repete e descrever o grupo. Esse resumo se

chama community report, e a diferença que importa é quando ele é escrito: offline,

na indexação, uma vez por comunidade. Ele não depende da pergunta, e é por isso que dá

para gerá-lo em lote — mil comunidades viram mil chamadas de uma vez, no pipeline de

indexação, e a consulta não paga nada por isso.

A estratégia é o map-reduce descrito em

From Local to Global: o map produz um relatório

por comunidade, o reduce os junta para responder à pergunta. O que vale levar daqui

é que um resumo por comunidade é mais aproveitável do que um resumo do acervo inteiro, porque a resposta global precisa do caminho entre comunidades, e um resumo

único não guarda esse caminho. A

implementação de referência é a que

popularizou o termo.

O código gera os relatórios por template, e não por modelo, para rodar sem rede. A

diferença estrutural é zero; a diferença de custo é toda.

def gerar_relatorios(con: sqlite3.Connection) -> int:
    """Um relatório por comunidade, gerado uma vez, na indexação.

    Num sistema real é uma chamada ao modelo por comunidade, com entrada os nós
    e os documentos do grupo. O preço é o número de vezes o tamanho da entrada,
    pago na indexação — e por isso a etapa de grafo do artigo 07 é o que decide
    se esse preço é aceitável.
    """
    for cid, titulo in con.execute(
        "SELECT community_id, titulo FROM community ORDER BY community_id").fetchall():
        rows = con.execute(
            """SELECT n.node_id, n.resposta_curta FROM community_node cn
                 JOIN node n ON n.node_id = cn.node_id
                WHERE cn.community_id = ? ORDER BY n.node_id""", (cid,)).fetchall()
        # Quais documentos sustentam a comunidade: os que citam mais nós dela.
        # É esta lista que vira `evidence_refs`, e é ela que o modelo precisa
        # devolver no relatório para que ele possa ser citado depois.
        sustentam = con.execute(
            """SELECT c.chunk_id, c.titulo, COUNT(*) AS n FROM community_node cn
                 JOIN node_chunk nc ON nc.node_id = cn.node_id
                 JOIN chunk c ON c.chunk_id = nc.chunk_id
                WHERE cn.community_id = ? GROUP BY c.chunk_id, c.titulo
                ORDER BY n DESC, c.chunk_id LIMIT 3""", (cid,)).fetchall()
        con.execute("INSERT OR REPLACE INTO community_report VALUES (?, ?, ?)", (
            cid,
            f"A comunidade {titulo} reúne {len(rows)} assuntos: "
            f"{', '.join(n for n, _ in rows)}. Sobre eles, o acervo registra: "
            f"{'; '.join(r for _, r in rows if r)} Os documentos que mais sustentam "
            f"este grupo são: {', '.join(f'{t} ({n})' for _, t, n in sustentam)}.",
            "|".join(chunk_id for chunk_id, _, _ in sustentam)))
    con.commit()
    return con.execute("SELECT COUNT(*) FROM community_report").fetchone()[0]


print("relatórios:", gerar_relatorios(con))

O ramo global na consulta, então, é curto: recuperar relatórios, ranquear, e não

devolver o texto do relatório como se fosse trecho.

def buscar_global(
    con: sqlite3.Connection, pergunta: str, k: int = 2, reduzir_todas: bool = False
) -> list[Scored]:
    """Ramo global: os relatórios de comunidade mais próximos da pergunta.

    O score é a contagem de assuntos da comunidade que a pergunta nomeia. É de
    propósito: em pergunta global, importa qual grupo de assuntos o usuário está
    descrevendo, não qual frase se parece com a pergunta. Num sistema real, este
    ranqueamento é o vetor do relatório (artigo 03), não uma contagem.

    `reduzir_todas` é o passo `reduce` do map-reduce: quando a pergunta pede
    agregação sem nomear assunto, a resposta é a redução de todos os relatórios,
    ordenados pelo peso. Sem esse passo, "quais são os temas dominantes" não tem
    resposta — porque a pergunta não aponta para lugar nenhum do grafo.
    """
    palavras = {p.strip(".,?!:;") for p in pergunta.lower().split() if len(p) > 3}
    candidatos: list[Scored] = []
    for cid, titulo, relatorio, refs in con.execute(
        """SELECT r.community_id, c.titulo, r.relatorio, r.evidence_refs
             FROM community_report r JOIN community c ON c.community_id = r.community_id
            ORDER BY r.community_id""").fetchall():
        # Compara em minúsculas dos dois lados: um assunto chamado "Auditoria"
        # é o mesmo que a palavra "auditoria" da pergunta.
        assuntos = {n.lower() for (n,) in con.execute(
            "SELECT node_id FROM community_node WHERE community_id = ?", (cid,))}
        acertos = len(assuntos & palavras) + len(set(titulo.lower().split()) & palavras)
        if acertos == 0 and not reduzir_todas:
            continue
        # O peso é o número de documentos distintos que sustentam a comunidade.
        # É o desempate: quando a pergunta não aponta para lugar nenhum, o
        # "quem domina o acervo" é quem tem mais documento.
        (peso,) = con.execute(
            """SELECT COUNT(DISTINCT nc.chunk_id) FROM community_node cn
                 JOIN node_chunk nc ON nc.node_id = cn.node_id
                WHERE cn.community_id = ?""", (cid,)).fetchone()
        candidatos.append(
            Scored(ref_id=cid, score=float(acertos), source=ScoreSource.COMMUNITY,
                   payload={"titulo": titulo, "relatorio": relatorio, "peso": peso,
                            "evidence_refs": [r for r in refs.split("|") if r],
                            "tipo": "mapa_de_comunidade"}))
    candidatos.sort(key=lambda s: (-s.score, -s.payload["peso"], s.ref_id))
    return candidatos[:k]


globais = buscar_global(con, "quais são os temas que dominam este acervo", k=3,
                        reduzir_todas=True)
for item in globais:
    print(item.ref_id, item.payload["titulo"], "peso", item.payload["peso"],
          item.payload["evidence_refs"])
print("sem resultado:",
      buscar_global(con, "resumo geral do acervo sobre equipamentos de escritório"))

A ordem da primeira lista é a resposta da pergunta global: em empate de assunto (a

pergunta é "quais são os temas que dominam", e nenhum desses sete palavras é um

assunto do grafo), o desempate é o peso — quantos documentos distintos sustentam a

comunidade. É assim que uma pergunta sem assunto vira uma resposta ordenada em vez de

uma lista sem critério.

A última linha é o outro resultado que importa: quando a pergunta pede agregação mas

nomeia um assunto que não existe no grafo, o ramo global devolve vazio. A resposta

correta não é "não sei", é "esta pergunta não é do tipo que eu atendo" — e é por isso

que o roteador precisa distinguir os dois casos: agregação sem assunto (reduce de

tudo) e assunto sem agregação (ramo local).


7. O erro clássico: usar o resumo como se fosse evidência

Aqui está o erro que faz GraphRAG perder a confiança de quem leu. O relatório é um

texto gerado por um modelo, a partir de outros documentos, sem estar ancorado em nenhum

deles — ou, quando está ancorado, sem expor de qual documento cada afirmação veio.

O caminho tentador é colar o relatório no prompt e pedir a resposta. Funciona, e a

resposta sai com cara de análise de acervo. Aí você entrega ao usuário algo que ninguém

consegue auditar, porque a "fonte" é um resumo que ninguém escreveu — a citação da

resposta aponta para c1, que não tem autor, data nem linha, e é impossível de abrir

para conferência.

O conserto é uma distinção que o código precisa fazer explícita: o relatório entra como

mapa, e a evidência é o documento que ele referencia.

def resolver_evidencia(con: sqlite3.Connection, sc: list[Scored]) -> list[Scored]:
    """CERTO: o relatório é mapa, e a evidência é o documento que ele referencia.

    O relatório fica no contexto como orientação de navegação, e cada
    `evidence_refs` é resolvido em trecho de documento, que entra como evidência.
    A citação da resposta aponta para o documento; o relatório fica como a
    explicação de por que ele foi lido.
    """
    saida: list[Scored] = []
    for item in sc:
        for chunk_id in item.payload.get("evidence_refs", []):
            linha = con.execute(
                "SELECT doc_id, titulo, texto FROM chunk WHERE chunk_id = ?", (chunk_id,)
            ).fetchone()
            if linha is None:
                continue
            doc_id, titulo, texto = linha
            saida.append(
                Scored(ref_id=chunk_id, score=item.score, source=ScoreSource.VECTOR,
                       payload={"doc_id": doc_id, "titulo": titulo, "texto": texto,
                                "via": item.ref_id,          # de qual comunidade viemos
                                "origem": item.source.value})  # por que o lemos
            )
    return saida


def citacoes_resolviveis(con: sqlite3.Connection, refs: Sequence[str]) -> bool:
    """Toda referência da resposta aponta para um trecho que existe de verdade?

    É o teste mais barato do artigo, e o único que impede o relatório de virar
    citação: se a resposta cita `c1` como documento, é isto que reprova.
    """
    return all(con.execute("SELECT 1 FROM chunk WHERE chunk_id = ?", (r,)).fetchone()
               for r in refs)


evidencias = resolver_evidencia(con, globais)
print("resolvidos:", [e.ref_id for e in evidencias])
print("evidências resolvíveis:", citacoes_resolviveis(con, [e.ref_id for e in evidencias]))
print("o relatório como citação é resolvível:",
      citacoes_resolviveis(con, [globais[0].ref_id]))   # False: c1 não é um chunk

A penúltima linha é True e a última é False, e essa diferença é o artigo inteiro. A

resposta saiu do mesmo material nos dois casos; a diferença é que uma delas pode ser

conferida por um humano, documento a documento, e a outra não.

💡 Formate o relatório como mapa, com a fonte ao lado de cada afirmação
Quando o relatório entra no prompt, não entre como prosa corrida. Entre como

lista: assunto, o que o acervo diz sobre ele, e de qual documento isso veio. O

modelo continua lendo um resumo, e cada afirmação carrega o chunk_id que a

sustenta. Custa alguns caracteres a mais e transforma um texto não-confirmável

em um texto auditável — que é a diferença entre um relatório que ninguém

questiona e um relatório que ninguém pode usar.


8. O roteador: a decisão que separa as duas rotas

Até aqui as duas rotas existem e as duas funcionam. Falta a decisão — e a decisão não é

difícil de descrever, é difícil de medir.

O roteador mais barato é um conjunto de regras sobre a pergunta. Ele não é inteligente

e não pretende ser: ele é auditável, e cada regra dá para testar com uma pergunta que

a deveria acionar. Um classificador treinado nas suas próprias perguntas funciona

melhor, mas erra de um jeito que ninguém entende — e errar na rota é pior do que errar

na recuperação, porque o erro se propaga inteiro.

import re
from enum import StrEnum


class Rota(StrEnum):
    """As três respostas para "quem responde esta pergunta"."""

    LOCAL = "local"     # entidades e trechos perto da pergunta
    MISTA = "mista"     # relatório como mapa + evidências locais
    GLOBAL = "global"   # relatório como resposta


# Marcadores de agregação: a pergunta pede o padrão do acervo, não um trecho.
AGREGACAO = re.compile(
    r"\b(quais (os |as )?(temas|assuntos|categorias)|tem[aá]rio|panorama|padr[õo]es"
    r"|dominam|resumo (d[oa]|geral)|vis[ãa]o geral|como um todo)\b", re.IGNORECASE)
# Marcadores de generalização: assunto nomeado com pedido de contexto amplo.
GENERALIZACAO = re.compile(
    r"\b(de forma geral|no geral|em termos gerais|o panorama|contexto)\b", re.IGNORECASE)


def classificar(
    con: sqlite3.Connection, pergunta: str, hits: list[Scored]
) -> tuple[Rota, str]:
    """Decide a rota e devolve o motivo.

    O motivo importa mais que a rota. Uma decisão sem motivo não é auditável:
    quando ela erra, você não sabe se a regra estava errada ou se o padrão da
    pergunta estava. O motivo vai para o log junto com a pergunta.
    """
    tem_semente = any(
        con.execute("SELECT 1 FROM node WHERE node_id = ?", (h.ref_id,)).fetchone()
        for h in hits)
    if not tem_semente:
        # k-hop sem semente não é um ramo local lento: é um ramo local morto.
        # A resposta aqui é o fallback vetorial do artigo 01, não o grafo.
        return Rota.LOCAL, "nenhuma entidade localizada: cai no vetorial puro"
    if AGREGACAO.search(pergunta):
        return Rota.GLOBAL, "a pergunta pede agregação do acervo"
    if GENERALIZACAO.search(pergunta):
        return Rota.MISTA, "assunto nomeado com pedido de contexto amplo"
    return Rota.LOCAL, "a pergunta nomeia o assunto: a evidência está nos trechos"


def atender(con: sqlite3.Connection, pergunta: str) -> dict[str, object]:
    """A bifurcação completa, com as três saídas possíveis."""
    hits = [Scored(ref_id=n, score=1.0, source=ScoreSource.VECTOR, payload={})
            for n in semente_por_busca_vetorial(con, pergunta, k=2)]
    rota, motivo = classificar(con, pergunta, hits)
    if rota is Rota.GLOBAL:
        # Rota global é sempre `reduce`: a pergunta pediu agregação, então a
        # resposta é a redução de todos os relatórios, ordenados por peso.
        selecionados = buscar_global(con, pergunta, k=3, reduzir_todas=True)
    else:
        alcance = buscar_k_hop(con, [h.ref_id for h in hits], hops=2, teto_grau=4)
        locais = respostas_prontas(con, [a for a in alcance if a.payload["hop"] > 0])
        if rota is Rota.MISTA:
            # O relatório entra como mapa e os trechos como evidência. É a rota
            # que a maioria dos sistemas de produção acaba usando, porque
            # responde "sobre este assunto, no geral" sem perder citação.
            selecionados = [*buscar_global(con, pergunta, k=1), *locais]
        else:
            selecionados = [*locais, *hits]
    # Ordena dentro de cada origem e concatena. Somar a distância em saltos com o
    # cosseno seria ruído: o `source` existe para impedir isso, e a fusão de
    # verdade é RRF, do artigo 04.
    ordenados: list[Scored] = []
    for origem in ("vector", "graph", "community"):
        ordenados.extend(sorted((s for s in selecionados if s.source.value == origem),
                                key=lambda s: (-s.score, s.ref_id)))
    return {"rota": rota.value, "motivo": motivo, "resultados": ordenados}

Rode as três perguntas e olhe o campo motivo:

for pergunta in (
    "o que os documentos sobre auditoria de acesso dizem",
    "quais são os temas que dominam este acervo",
    "auditoria de acesso, no contexto geral",
    "como faço bolo de cenoura",
):
    r = atender(con, pergunta)
    print(r["rota"], "|", r["motivo"], "|", [i.ref_id for i in r["resultados"]][:6])

E existe um segundo sinal, que não é regra de texto: o perfil da busca vetorial.

Rode o mesmo perfil para uma pergunta que nomeia assunto e para uma que não nomeia:

for pergunta in ("documentos sobre prazo de pagamento padrão",
                 "quais são os temas que dominam este acervo"):
    perfil = candidatos_vetoriais(con, pergunta)
    print(pergunta, "->", [(n, round(s, 3)) for s, n in perfil[:4]],
          "folga:", round(perfil[0][0] - perfil[3][0], 3))

A primeira tem pico (0.771 contra 0.326 do quarto colocado, folga de 0.445) e a

segunda tem perfil chapado (0.383 até 0.363, folga de 0.020). A pergunta que nomeia

assunto concentra a similaridade em poucos nós; a pergunta global espalha. Esse é um

sinal calculado, e não um padrão de palavra — e ele custa uma consulta que você já faz.

Não é uma regra geral: é uma propriedade do seu acervo e do seu modelo de vetor, e

ela precisa ser medida nas suas perguntas antes de virar regra de produção. As palavras

marcadas acima são o ponto de partida auditável; o perfil é o sinal que sobrevive a

perguntas que ninguém pensou em escrever.

Tipo de perguntaRotaO que custaQuando não usar
"o que este documento diz sobre X"local (k-hop)uma consulta recursiva por consulta, custo cresce com o fan-outquando o grafo não acrescenta caminho nenhum à resposta
"quais são os temas que dominam"global (reduce)uma geração por comunidade, na indexaçãoquando quase toda pergunta da base é local
"sobre X, no geral"mista (mapa + evidência)uma consulta a mais, e o relatório no contextoquando ninguém vai ler o mapa
pergunta sem resposta no acervonenhumauma consultanunca: responda com o melhor trecho mesmo assim

⚠️ O roteador é a parte que precisa de teste, não de prompt
Se a rota errar, todo o resto do sistema herda o erro: a resposta global para

uma pergunta local sai agregada e sem citação; a resposta local para uma

pergunta global sai como lista de trechos desconexos. Nada disso é culpa do

prompt. Trate classificar como a função que exige mais casos de teste do que

qualquer prompt da sua aplicação — e meça por ramo, porque o número agregado

esconde exatamente a falha que o roteador produz.


9. Medir por ramo, porque o número agregado esconde a falha

Um coverage@k único, calculado sobre todas as perguntas, é inútil para avaliar

GraphRAG. Os dois ramos têm comportamentos diferentes, e você precisa do número de

cada um separado — mais o tamanho de cada um, porque um ramo que atende duas perguntas

por mês não justifica o custo dele.

def cobre(evidencia: set[str], trecho_certo: str) -> float:
    """1.0 se o trecho que respondia à pergunta entrou no contexto.

    A cobertura se mede um nível abaixo do resultado: no ramo local o `ref_id`
    é o nó, não o trecho. O que responde à pergunta é o chunk que está em
    `evidence_refs`.
    """
    return 1.0 if trecho_certo in evidencia else 0.0


# (pergunta, rota esperada, trecho que responde, comunidade esperada entre os resultados)
CASOS: list[tuple[str, Rota, str, str]] = [
    ("o que os documentos sobre auditoria de acesso dizem", Rota.LOCAL, "d08#0", ""),
    ("documentos sobre prazo de pagamento padrão", Rota.LOCAL, "d01#0", ""),
    ("auditoria de acesso, no contexto geral", Rota.MISTA, "d09#0", "c3"),
    ("quais são os temas que dominam este acervo", Rota.GLOBAL, "d08#0", "c3"),
    ("como faço bolo de cenoura", Rota.LOCAL, "", ""),   # caso negativo
]


def medir(casos: Sequence[tuple[str, Rota, str, str]]) -> list[dict[str, object]]:
    """Uma linha por caso, com o que cada coluna responde."""
    linhas = []
    for pergunta, rota_esperada, trecho_certo, comunidade in casos:
        r = atender(con, pergunta)
        resultados = r["resultados"]
        evidencia = {c for s in resultados for c in s.payload.get("evidence_refs") or [s.ref_id]}
        linhas.append({
            "rota_esperada": rota_esperada.value,
            "rota_escolhida": r["rota"],
            "acertou_rota": r["rota"] == rota_esperada.value,
            "coverage": cobre(evidencia, trecho_certo) if trecho_certo else None,
            "comunidade_certa": (comunidade in {i.ref_id for i in resultados}
                                 if comunidade else None),
            "vazio": not resultados,
        })
    return linhas


for linha in medir(CASOS):
    print(linha)

Leia o resultado com quatro perguntas, e nessa ordem:

  1. O acertou_rota está alto? Se não, o problema é classificar, e nenhum ajuste

    na recuperação resolve.

  2. Nos casos de rota acertada, coverage está alto? Rota certa com coverage baixo

    é problema de grafo: a semente estava certa e a expansão não trouxe a evidência. O

    ajuste é teto_grau ou hops.

  3. O caso negativo devolveu vazio? Este é o mais importante, e é o que quase

    ninguém testa. Uma pergunta sem resposta no acervo precisa sair vazia, e "vazio" é um

    estado do sistema que exige resposta própria: a interface mostra "não encontrei" e o

    log registra. Neste acervo de exemplo a resposta é não — "como faço bolo de

    cenoura" voltou com resultados, porque qualquer frase em português tem 3-gramas em

    comum com algum rótulo e o piso de 0.15 não filtra. É a taxa de falso positivo da

    busca, medida com perguntas que não têm resposta, que diz onde o piso tem que ficar —

    e ela é invisível no número agregado.

  4. Os casos por rota estão equilibrados? Se 95% das perguntas são locais, o ramo

    global está sendo pago sem uso. É esse número que decide se o relatório continua

    sendo gerado na indexação.

E sobre o método, para ser honesto: estes números valem para este acervo de exemplo,

com 12 nós, 11 documentos e 3 comunidades. Ele existe para exercitar o encadeamento. A

única medição que vale para o seu sistema é a que você faz com as suas 50 perguntas, e

ela só existe depois do artigo 11. Até lá, o

que existe é raciocínio, não número.

💡 Meça o custo por ramo, não o custo do sistema
A pergunta que decide o orçamento do GraphRAG não é "quanto custa a consulta",

é "quanto custa aquele ramo, vezes quantas perguntas ele atende". Um

relatório que custa uma geração por comunidade na indexação e atende duas

perguntas por mês nunca se paga. Anote por ramo: perguntas roteadas, chamadas

feitas, caracteres entrados no contexto. Sem esses três números lado a lado,

qualquer discussão de custo vira opinião.


10. Onde o grafo não ajuda

GraphRAG não é superior ao RAG vetorial. É mais caro, e é melhor num recorte

restrito de perguntas, e pior no resto. Um sistema que roda os dois ramos em toda

pergunta está pagando duas vezes para obter, em média, o resultado de uma.

A base não tem pergunta por relação. Se as perguntas da sua base são "o que diz o

documento 12", não existe caminho que importe, e o grafo é um índice de coocorrência

que ninguém consulta. O custo é a construção — a extração de entidades do artigo 05 é a

etapa mais cara da trilha toda — e o benefício é zero.

O acervo cabe inteiro no orçamento. Uma comunidade com cinquenta nós e trinta

documentos não precisa de k-hop: os trinta documentos cabem na janela, e mandar todos é

mais barato do que decidir o que mandar. O grafo entra quando o acervo é grande o

suficiente para não caber, e esse limiar depende do seu orçamento, não do seu grafo.

A base muda todo dia. O grafo é a peça mais cara de reconstruir: extração, linking,

validação e detecção de comunidade, do começo ao fim. Com o acervo mudando, você refaz

o relatório de comunidade antes de terminar a primeira geração. Nesse caso a ordem se

inverte: comece pelo vetorial e pelo híbrido, que são incrementais, e só construa o

grafo quando o volume de pergunta por relação justificar reconstruí-lo.

E há um quarto caso, que não é desperdício e sim o inverso: a pergunta é global e você não tem comunidade. Aí o relatório é a resposta e não há atalho — ou você agrupa,

ou você não responde. Ele é caro porque faz esse trabalho uma vez e reaproveita em

todas as perguntas globais, e é por isso que a seção 6 insiste em gerá-lo offline: a

pergunta global é rara, e é a sustentação dela que não é.


TL;DR

  • Duas perguntas, dois caminhos. "O que este documento diz sobre X" é local e a

    busca vetorial resolve. "Quais são os temas que dominam o acervo" é global e só a

    comunidade resolve. Rodar as duas rotas sempre é pagar duas vezes para ter, em

    média, o resultado de uma.

  • k-hop é custo sem teto natural: 1 salto é quase sempre seguro, 3 saltos em grafo

    de coocorrência devolve o acervo inteiro. O teto de grau por nó é a única alavanca de

    custo do ramo local, e o total de caminhos por consulta é o número que mostra quando

    ele falhou.

  • Nó com resposta pronta é a economia do ramo local: a mesma informação sem o

    parágrafo em volta — com o prazo de validade que vem junto, porque é cópia extraída

    na indexação.

  • community report é mapa, nunca evidência. Ele entra no contexto como

    orientação; a citação sai do trecho que ele referencia. O teste de uma linha

    (citacoes_resolviveis) separa um relatório auditável de um relatório decorativo.

  • O relatório só compensa com volume de pergunta global, e o perfil da busca

    vetorial (pico ou chapado) diz se a pergunta é global antes de qualquer classificador.


Referências

  • From Local to Global: A Community-Based Approach to Query-Focused Summarization — o artigo que define o relatório por comunidade e a estratégia map-reduce, base da seção 6
  • GraphRAG no repositório da Microsoft — a implementação de referência da indexação offline que gera um relatório por comunidade
  • WITH Clause (CTE) — SQLite — a consulta recursiva que faz o k-hop, e por que a palavra RECURSIVE é obrigatória
  • Queries with Common Table Expressions — PostgreSQL — a mesma CTE no outro banco, para você portar sem surpresa
  • Datatype 3: Blob — SQLite — onde o vetor fica guardado quando você usa struct.pack/struct.unpack no lugar de um índice vetorial dedicado
  • Community detection — python-igraph — a detecção de comunidade que o artigo 07 executa e que aqui só é consumida