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 comunidadeem 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
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:
-
Dois saltos já alcançam a maior parte do acervo. A partir de
Contrato, 1 saltopega 4 nós, 2 saltos pegam 7, e o acervo inteiro (12 nós) está a 3 saltos. O
kquevocê escolher não é "quantos saltos de contexto eu quero": é uma fração do grafo.
-
O teto de grau só faz diferença se existir hub. Com
teto=2os números caempela metade; com
teto=4são idênticos aos de "sem teto", porque nenhum nó desteacervo 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.
-
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 decoocorrê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
edgeoffline (DELETE FROM edge WHERE rank > 4) edeixar 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
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 comolista: 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_idque asustenta. 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 pergunta | Rota | O que custa | Quando não usar |
|---|---|---|---|
| "o que este documento diz sobre X" | local (k-hop) | uma consulta recursiva por consulta, custo cresce com o fan-out | quando o grafo não acrescenta caminho nenhum à resposta |
| "quais são os temas que dominam" | global (reduce) | uma geração por comunidade, na indexação | quando quase toda pergunta da base é local |
| "sobre X, no geral" | mista (mapa + evidência) | uma consulta a mais, e o relatório no contexto | quando ninguém vai ler o mapa |
| pergunta sem resposta no acervo | nenhuma | uma consulta | nunca: 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 parauma 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
classificarcomo a função que exige mais casos de teste do quequalquer 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:
-
O acertou_rota está alto? Se não, o problema é
classificar, e nenhum ajustena recuperação resolve.
-
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_grauouhops. -
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.
-
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.unpackno 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