Como o benchmark foi feito

Gabarito completo, scripts e resultados da comparação entre busca por palavra, busca por significado e fusão

O que é esta página

É o apêndice metodológico de Embeddings Vetoriais e GraphRAG, para quem quer auditar o experimento em vez de aceitar os números. Aqui estão o gabarito completo das 40 perguntas, os scripts que rodam de ponta a ponta, as consultas exatas enviadas ao banco e o resultado pergunta por pergunta.

Não é preciso ler isto para usar o recurso. É preciso ler para conferir se ele faz o que dizemos.

1 Resumo para o avaliador

Corpus Social_Acceptance — 1.388 conceitos sobre aceitação social de tecnologias de transição energética
Banco ArcadeDB 26.7.3, base social_acceptance, consultada via API HTTP
Modelo de embedding sentence-transformers/all-mpnet-base-v2, 768 dimensões, local
Índices FULL_TEXT (Lucene/BM25) e LSM_VECTOR (cosseno, quantização INT8)
Amostra 40 conceitos sorteados com semente fixa 20260817
Gabarito Uma pergunta por conceito, congelada antes de qualquer busca
Profundidade top-10; RRF com a constante padrão k = 60
Data da medição 17 de agosto de 2026
Procedência dos números

Todos os valores desta página vêm da execução de 17 de agosto de 2026, contra um banco ArcadeDB real. O gabarito, os scripts e a saída bruta estão versionados no repositório — a página lê os resultados direto do arquivo gerado pela medição, de modo que nenhum número aqui é digitado à mão.


2 O desenho, e o que ele protege

A armadilha central em qualquer comparação de busca é escrever a pergunta depois de ver o que cada método devolveu. Quem faz isso consegue provar o que quiser. O desenho abaixo existe para tornar isso impossível.

flowchart TB
    classDef etapaA fill:#E8F5E9,stroke:#2E7D32,stroke-width:2px,color:#1B5E20,font-weight:bold;
    classDef congelado fill:#FFF3E0,stroke:#E65100,stroke-width:2px,color:#E65100,font-weight:bold;
    classDef etapaB fill:#E0F7FA,stroke:#084C54,stroke-width:2px,color:#084C54,font-weight:bold;

    S["1 · Sorteio de 40 conceitos<br/>semente fixa 20260817"]:::etapaA
    Q["2 · Uma pergunta por conceito<br/>vocabulário deliberadamente disjunto"]:::etapaA
    F["3 · Gabarito congelado em arquivo<br/>questions.json"]:::congelado
    R["4 · Só então: as três buscas<br/>BM25 · Vetor · RRF"]:::etapaB
    M["5 · Métricas calculadas<br/>results.json"]:::etapaB

    S --> Q --> F --> R --> M

O passo 3 é o que dá valor aos passos 4 e 5. Como o arquivo é versionado, qualquer pessoa pode verificar que as perguntas não foram ajustadas depois de ver os resultados.

Nada aqui foi inventado para a ocasião. O desenho segue o padrão consolidado de avaliação em Recuperação de Informação, descrito em Manning et al. (2008) — a referência canônica da área, de acesso livre.

  • A estrutura de coleção de teste (documentos + necessidades de informação + julgamentos de relevância) é a do capítulo 8.2, a mesma da coleção Cranfield e das rodadas TREC. Nosso equivalente: os 1.388 conceitos, as 40 perguntas e o conceito-alvo de cada uma.
  • Congelar os julgamentos antes de rodar as buscas é o que separa uma coleção de teste de uma demonstração — a alternativa (julgar depois de ver a saída) mede o avaliador, não o sistema.
  • As métricas (Recall@k, MRR, nDCG) são as do capítulo 8.4. O desconto logarítmico do nDCG, 1/log₂(1+posição), é usado aqui exatamente como definido lá; com um único item relevante por pergunta, o ranking ideal vale 1 e o DCG já é o próprio nDCG.
  • O BM25 que o ArcadeDB executa é o modelo probabilístico do capítulo 11.4.3, com saturação de frequência (k₁) e normalização por tamanho do documento (b) — formalizado em Robertson & Zaragoza (2009).
  • A fusão RRF vem de Cormack et al. (2009), incluindo a constante k = 60.

Onde o nosso desenho diverge do padrão, isso está declarado em Limitações do desenho — sobretudo quanto a quem redigiu as perguntas e à adoção de resposta única por pergunta.

2.1 Etapa 1 — o sorteio

Amostragem aleatória simples sobre os 1.388 conceitos, com semente fixa e ordenação determinística antes do sorteio (sem isso, a ordem de leitura do arquivo mudaria a amostra entre execuções).

#| eval: false
SEED, N = 20260817, 40

names = sorted(concepts)          # ordem determinística ANTES de sortear
rng = random.Random(SEED)
sample = sorted(rng.sample(names, N))

Script completo: case-studies/benchmark_rrf/sample_concepts.py. Rodar de novo com a mesma semente produz exatamente a mesma amostra.

2.2 Etapa 2 — as perguntas

Para cada conceito sorteado, uma pergunta em linguagem natural, seguindo uma regra única e verificável:

Nenhuma palavra de conteúdo do nome do conceito pode aparecer na pergunta.

O objetivo é simular o pesquisador que descreve o fenômeno com as próprias palavras porque não decorou o vocabulário controlado da ontologia. A regra foi conferida por código, não no olho — e a verificação pegou uma violação real (Technology_Uptake × “technology”), corrigida antes de qualquer busca rodar:

Mostrar código
import json, re
from pathlib import Path

BASE = Path("../../case-studies/benchmark_rrf")
gabarito = json.loads((BASE / "questions.json").read_text(encoding="utf-8"))

STOP = {"the","a","an","of","to","in","and","or","that","is","are","how","what","for",
        "not","it","on","who","which","by","from","with","as","at","all","just","out",
        "up","so","be","do","does","get","gets","has","have","they","their","you","we",
        "its","into","than","then","only","more","most","some","several","through",
        "over","about","before","after","when","where","whether","while","why"}

def palavras(texto):
    return {w for w in re.findall(r"[a-z]+", texto.lower()) if w not in STOP and len(w) > 2}

violacoes = [
    p["gold"] for p in gabarito["pairs"]
    if palavras(p["gold"].replace("_", " ")) & palavras(p["question"])
]
print(f"perguntas: {len(gabarito['pairs'])}")
print(f"violações da regra: {len(violacoes)} {violacoes}")
perguntas: 40
violações da regra: 0 []

2.3 Etapa 3 — as três buscas

Todas rodam dentro do ArcadeDB, sem reimplementação em Python. É a diferença entre medir o produto e medir uma aproximação dele.

Busca por palavra (BM25) — índice FULL_TEXT, ordenado pelo score do Lucene:

SELECT name, $score as s FROM Chain
WHERE SEARCH_INDEX('Chain[search_name,ontology_description,reasoning,
                    rgt_element_a,rgt_element_b,theoretical_significance]',
                   '<pergunta>') = true
ORDER BY s DESC LIMIT 10

Busca por significado — índice LSM_VECTOR, vizinhos mais próximos do vetor da pergunta:

SELECT expand(vector.neighbors('Chain[embedding]', :v, 10))

O vetor :v é gerado localmente com o mesmo modelo que embedou o corpus. Usar modelos diferentes nos dois lados produziria vizinhos sem sentido — uma falha silenciosa, porque o banco aceita o vetor e devolve resultados errados sem reclamar.

Fusão (RRF) — a fórmula padrão da literatura de IR (Information Retrieval, a área que estuda sistemas de busca), somando as duas listas sem normalizar escalas:

Mostrar código
def fuse_rrf(rank_lists, k=10, RRF_K=60):
    scores = {}
    for ranking in rank_lists:
        for position, name in enumerate(ranking, start=1):
            scores[name] = scores.get(name, 0.0) + 1.0 / (RRF_K + position)
    return [n for n, _ in sorted(scores.items(), key=lambda kv: -kv[1])][:k]

A constante 60 não foi ajustada para favorecer o resultado: é o valor proposto no artigo que introduziu o método (Cormack et al., 2009), mantido sem ajuste justamente para não introduzir um grau de liberdade a mais. Vale notar que aquele trabalho já mostrava o efeito que reencontramos aqui — a fusão de rankings supera os métodos isolados, inclusive métodos de aprendizado de ranking.


3 Resultados

Mostrar código
import json
from pathlib import Path

BASE = Path("../../case-studies/benchmark_rrf")
res = json.loads((BASE / "results.json").read_text(encoding="utf-8"))
M = res["metricas"]

Todos os números desta seção são lidos de results.json, o arquivo que o script de medição grava. Nenhum valor foi digitado à mão nesta página.

Mostrar código
import plotly.graph_objects as go

metricas = ["Recall@1", "Recall@3", "Recall@5", "Recall@10", "MRR", "nDCG@5", "nDCG@10"]
CINZA, AZUL, LARANJA = "#94A3B8", "#0E7490", "#E65100"

fig = go.Figure()
for chave, nome, cor in [
    ("bm25", "Só palavra exata", CINZA),
    ("vector", "Só significado", AZUL),
    ("rrf", "Os dois juntos (RRF)", LARANJA),
]:
    valores = [M[chave][m] for m in metricas]
    fig.add_trace(go.Bar(
        y=metricas, x=valores, name=nome, orientation="h",
        marker=dict(color=cor),
        text=[f"{v:.3f}".replace(".", ",") for v in valores],
        textposition="outside", textfont=dict(size=10),
        hovertemplate="%{y} — " + nome + ": %{x:.3f}<extra></extra>",
    ))

fig.update_layout(
    template="plotly_white", barmode="group", height=620,
    xaxis=dict(title="0 = nunca acerta · 1 = sempre acerta", range=[0, 1.12]),
    yaxis=dict(title="", autorange="reversed"),
    legend=dict(orientation="h", yanchor="bottom", y=1.02, x=0),
    font=dict(size=12), margin=dict(l=10, r=10, t=60, b=40), bargap=0.25,
)
fig.show()
Figura 1: As três estratégias nas 40 perguntas do gabarito. Barras maiores são melhores.
Mostrar código
linhas = ["| Métrica | Só palavra | Só significado | Os dois juntos |", "|---|---:|---:|---:|"]
for m in metricas:
    vals = {k: M[k][m] for k in ("bm25", "vector", "rrf")}
    melhor = max(vals, key=vals.get)
    cells = []
    for k in ("bm25", "vector", "rrf"):
        txt = f"{vals[k]:.3f}".replace(".", ",")
        cells.append(f"**{txt}**" if k == melhor else txt)
    linhas.append(f"| {m} | " + " | ".join(cells) + " |")
print("\n".join(linhas))
Métrica Só palavra Só significado Os dois juntos
Recall@1 0,350 0,325 0,375
Recall@3 0,475 0,525 0,675
Recall@5 0,500 0,725 0,800
Recall@10 0,575 0,825 0,875
MRR 0,421 0,471 0,552
nDCG@5 0,433 0,522 0,607
nDCG@10 0,458 0,556 0,631

3.1 Os três achados

Mostrar código
o = res["sobreposicao_top1"]
r10_bm, r10_rrf = M["bm25"]["Recall@10"], M["rrf"]["Recall@10"]
mrr_bm, mrr_rrf = M["bm25"]["MRR"], M["rrf"]["MRR"]
num = lambda v: f"{v:.3f}".replace(".", ",")
pct = lambda v: f"{v * 100:.1f}%".replace(".", ",")
print(f"""
**1. A fusão vence em todas as sete métricas.** Não era garantido: se um método fosse
superior sozinho, a fusão seria custo sem retorno. A posição média da resposta certa
sobe de {num(mrr_bm)} para {num(mrr_rrf)} (**+{(mrr_rrf/mrr_bm-1)*100:.0f}%**), e a taxa de
acerto no top-10 vai de {pct(r10_bm)} para {pct(r10_rrf)}.

**2. O significado sozinho não domina.** No acerto em 1º lugar, a busca vetorial
({num(M['vector']['Recall@1'])}) fica **abaixo** da busca por palavra ({num(M['bm25']['Recall@1'])}).
Num corpus de 1.388 conceitos há muitos vizinhos semânticos, e escolher entre
quase-sinônimos é justamente onde o vetor hesita.

**3. Os erros não se sobrepõem — e é isso que faz a fusão funcionar.**
Dos 40 casos, só **{o['ambos']}** são acertados em 1º lugar pelos dois métodos;
**{o['so_bm25']}** só a palavra exata acerta e **{o['so_vetor']}** só o significado acerta.
Ou seja, {o['so_bm25'] + o['so_vetor']} perguntas são resolvidas por exatamente um dos dois.
Métodos que errassem juntos não teriam nada a ganhar com a fusão.
""")

1. A fusão vence em todas as sete métricas. Não era garantido: se um método fosse superior sozinho, a fusão seria custo sem retorno. A posição média da resposta certa sobe de 0,421 para 0,552 (+31%), e a taxa de acerto no top-10 vai de 57,5% para 87,5%.

2. O significado sozinho não domina. No acerto em 1º lugar, a busca vetorial (0,325) fica abaixo da busca por palavra (0,350). Num corpus de 1.388 conceitos há muitos vizinhos semânticos, e escolher entre quase-sinônimos é justamente onde o vetor hesita.

3. Os erros não se sobrepõem — e é isso que faz a fusão funcionar. Dos 40 casos, só 5 são acertados em 1º lugar pelos dois métodos; 9 só a palavra exata acerta e 8 só o significado acerta. Ou seja, 17 perguntas são resolvidas por exatamente um dos dois. Métodos que errassem juntos não teriam nada a ganhar com a fusão.

Mostrar código
import plotly.graph_objects as go

o = res["sobreposicao_top1"]
rotulos = ["Ambos acertam", "Só palavra exata", "Só significado", "Nenhum dos dois"]
valores = [o["ambos"], o["so_bm25"], o["so_vetor"], o["nenhum"]]
cores = ["#2E7D32", "#94A3B8", "#0E7490", "#CBD5E1"]

fig = go.Figure(go.Bar(
    x=valores, y=rotulos, orientation="h",
    marker=dict(color=cores),
    text=[f"{v} de 40" for v in valores],
    textposition="outside", textfont=dict(size=12),
    hovertemplate="%{y}: %{x} perguntas<extra></extra>",
))
fig.update_layout(
    template="plotly_white", height=300,
    xaxis=dict(title="Perguntas (de 40)", range=[0, max(valores) * 1.35]),
    yaxis=dict(title="", autorange="reversed"),
    showlegend=False, font=dict(size=12),
    margin=dict(l=10, r=10, t=40, b=40), bargap=0.35,
)
fig.show()
Figura 2: Acertos em 1º lugar, por método. As fatias do meio são o motivo de a fusão valer a pena.

3.2 Latência

Mostrar código
import plotly.graph_objects as go

lat = res["latencia_ms"]
fig = go.Figure(go.Bar(
    y=["Com busca por significado<br>(codificar + buscar)", "Só palavra exata"],
    x=[lat["vetor_completo"]["media"], lat["bm25"]["media"]],
    orientation="h", marker=dict(color=["#E65100", "#94A3B8"]),
    text=[f"{lat['vetor_completo']['media']:.0f} ms", f"{lat['bm25']['media']:.0f} ms"],
    textposition="outside", textfont=dict(size=13),
    hovertemplate="%{y}: %{x:.1f} ms<extra></extra>",
))
fig.add_vline(
    x=100, line_width=2, line_dash="dash", line_color="#c0392b",
    annotation_text="100 ms — limiar do 'instantâneo'",
    annotation_position="top", annotation_font=dict(color="#c0392b", size=12),
)
fig.update_layout(
    template="plotly_white", height=280,
    xaxis=dict(title="Milissegundos por pergunta", range=[0, 145]),
    yaxis=dict(title=""), showlegend=False, font=dict(size=12),
    margin=dict(l=10, r=10, t=50, b=40), bargap=0.4,
)
fig.show()
Figura 3: Tempo por pergunta (média de 20 execuções). A linha marca os 100 ms que a literatura de usabilidade trata como limiar do instantâneo.
Mostrar código
nomes = {"bm25": "Busca por palavra (BM25)", "encode": "Codificar a pergunta",
         "knn": "Busca vetorial (kNN)", "vetor_completo": "**Vetor completo** (codificar + buscar)"}
linhas = ["| Etapa | Média | Mediana | p95 |", "|---|---:|---:|---:|"]
for k, nome in nomes.items():
    v = res["latencia_ms"][k]
    fmt = lambda x: f"{x:.1f} ms".replace(".", ",")
    linhas.append(f"| {nome} | {fmt(v['media'])} | {fmt(v['mediana'])} | {fmt(v['p95'])} |")
print("\n".join(linhas))
Etapa Média Mediana p95
Busca por palavra (BM25) 12,8 ms 5,6 ms 27,2 ms
Codificar a pergunta 34,7 ms 35,0 ms 37,2 ms
Busca vetorial (kNN) 12,4 ms 11,7 ms 12,9 ms
Vetor completo (codificar + buscar) 47,1 ms 46,7 ms 50,0 ms

O caminho vetorial custa cerca de 35 ms a mais que a busca por palavra, e o total fica em torno de metade do limiar de percepção. Numa conversa via MCP, esse custo desaparece diante dos segundos que o LLM leva para redigir a resposta.


4 Gabarito completo e resultado pergunta a pergunta

A tabela abaixo é o experimento inteiro, sem recortes: as 40 perguntas, o conceito esperado e a posição em que cada método o colocou. — significa que o alvo não apareceu no top-10.

Mostrar código
def pos(r):
    return "—" if r is None else ("**1º**" if r == 1 else f"{r}º")

linhas = ["| # | Pergunta | Conceito esperado | Palavra | Significado | Fusão |",
          "|--:|---|---|:--:|:--:|:--:|"]
for i, r in enumerate(res["por_pergunta"], start=1):
    linhas.append(
        f"| {i} | {r['question']} | `{r['gold']}` | "
        f"{pos(r['bm25']['rank'])} | {pos(r['vector']['rank'])} | {pos(r['rrf']['rank'])} |"
    )
print("\n".join(linhas))
# Pergunta Conceito esperado Palavra Significado Fusão
1 how ready are people to actually put panels on their roof, not just say they like the idea Adoption_Willingness — 2º 4º
2 the gains people think they will get that offset the downsides of a project Benefit — 10º —
3 the big money you have to put down at the start before anything is built Capital_Cost — 6º —
4 residents taking civic action and participating out of a sense of collective duty Citizenship_Behavior 2º 1º 1º
5 the ecological emergency that pushes governments to act fast Climate_Crisis — 1º 2º
6 binding government commitments to cut emissions by a certain date Climate_Target 1º 3º 2º
7 framing the situation as so pressing that other concerns like fairness get pushed aside Climate_Urgency_Narrative — 1º 3º
8 how past imperial rule still shapes which communities carry the burden today Colonial_History — 1º 2º
9 what the town hosting the wind farm materially gets out of it Community_Benefit 3º 5º 1º
10 the signed contract between the developer and residents setting out what is owed to them Community_Benefit_Agreement — 1º 2º
11 locals collectively pushing back against an installation, often dismissed as just not in my backyard Community_Opposition 1º 3º 1º
12 whether a new system can slot into what is already installed without breaking it Compatibility 8º 2º 1º
13 tracking how much power the house is using in real time through connected devices Consumption_Monitoring 1º 1º 1º
14 the surrounding circumstances that frame how everything else is interpreted Context 1º 1º 1º
15 having several workable technical arrangements to choose from instead of only one Design_Flexibility — 1º 3º
16 whether householders have the skills to actually operate the app and the control panel Digital_Competency 4º 4º 1º
17 the ethics of who ends up carrying the costs and who collects the gains Distributive_Justice — 4º 8º
18 the underlying forces that analysts use to build alternative futures Driver 2º — 4º
19 the people who buy in first, before the general public does Early_Adoption 1º — 2º
20 what nature provides for free that a new installation might destroy Ecosystem_Service — 1º 2º
21 the whole systemic shift away from coal and gas toward clean sources Energy_Transformation — 6º —
22 the incumbent coal oil and gas regime that locks in existing infrastructure Fossil_Fuel 1º 3º 1º
23 what a household uses to keep the house warm in winter Heating_Type 6º 5º 4º
24 planning and building the physical networks that carry power around Infrastructure_Development — 3º 5º
25 how a programme is structured administratively and who gets decision authority Institutional_Design 7º 6º 2º
26 whether there are enough buyers and investors to make deployment happen Market_Demand — — —
27 people recognising that something is wrong with the current setup in the first place Problem_Perception — 4º 8º
28 getting citizens actively involved in how a project gets developed Public_Engagement 3º 4º 1º
29 attachment to local identity and what makes an area distinctive Regional_Value 1º 4º 2º
30 fitting clean generation into the systems and society we already have Renewable_Energy_Adaptation — — —
31 deliberate wording and framing used to play up or play down a risk Rhetorical_Strategy 1º 1º 1º
32 comparing several possible future pathways to see how outcomes differ Scenario_Analysis 1º — 2º
33 connected appliances and control interfaces inside the house Smart_Home_Technology 1º 1º 1º
34 shared expectations in a community about what counts as acceptable behaviour Social_Norm — 1º 3º
35 how many megawatts of photovoltaic generation are installed in a jurisdiction Solar_Capacity 1º 2º 1º
36 judging a solution across environmental economic and social dimensions at once Sustainability 1º — 1º
37 how fast and how widely a new system spreads through a population Technology_Uptake — 4º 8º
38 the way the scholarly literature splits into separate disconnected clusters Thematic_Fragmentation 1º 1º 1º
39 generating electricity from the movement of ocean currents Tidal_Energy 1º 2º 1º
40 how attitudes shift over months and years rather than staying fixed Time 2º — 4º

4.1 Onde cada método brilhou sozinho

Mostrar código
so_bm25 = [r for r in res["por_pergunta"]
           if r["bm25"]["rank"] == 1 and r["vector"]["rank"] != 1][:3]
so_vec  = [r for r in res["por_pergunta"]
           if r["vector"]["rank"] == 1 and r["bm25"]["rank"] != 1][:3]

print("**Só a busca por palavra acertou em 1º** — a pergunta carrega um termo que está quase literal na descrição:\n")
for r in so_bm25:
    print(f"- *\"{r['question']}\"* → `{r['gold']}`  \n  (significado devolveu: {', '.join('`'+t+'`' for t in r['vector']['top3'])})")
print("\n**Só a busca por significado acertou em 1º** — a pergunta descreve o fenômeno sem nomeá-lo:\n")
for r in so_vec:
    print(f"- *\"{r['question']}\"* → `{r['gold']}`  \n  (palavra exata devolveu: {', '.join('`'+t+'`' for t in r['bm25']['top3']) if r['bm25']['top3'] else '*nada*'})")

Só a busca por palavra acertou em 1º — a pergunta carrega um termo que está quase literal na descrição:

  • “binding government commitments to cut emissions by a certain date” → Climate_Target
    (significado devolveu: Decarbonization_Target, Emission_Target, Climate_Target)
  • “locals collectively pushing back against an installation, often dismissed as just not in my backyard” → Community_Opposition
    (significado devolveu: Nimby_Perception, Protest, Community_Opposition)
  • “the people who buy in first, before the general public does” → Early_Adoption
    (significado devolveu: Stakeholder, Consumer_Type, Entrepreneur)

Só a busca por significado acertou em 1º — a pergunta descreve o fenômeno sem nomeá-lo:

  • “residents taking civic action and participating out of a sense of collective duty” → Citizenship_Behavior
    (palavra exata devolveu: Energy_Citizenship, Citizenship_Behavior, Pro-Environmental_Behavior)
  • “the ecological emergency that pushes governments to act fast” → Climate_Crisis
    (palavra exata devolveu: Pacification, Participation_Quality, Participatory_Governance)
  • “framing the situation as so pressing that other concerns like fairness get pushed aside” → Climate_Urgency_Narrative
    (palavra exata devolveu: Media_Framing, Research_Framing, Social_Comparison)

5 O que deu errado, e não foi escondido

5.1 A fusão pode perder um alvo que um dos métodos achou

Mostrar código
perdas = [r for r in res["por_pergunta"]
          if r["rrf"]["rank"] is None and (r["bm25"]["rank"] or r["vector"]["rank"])]
print(f"Em **{len(perdas)} das 40 perguntas**, o alvo aparecia no top-10 de um método isolado "
      f"mas sumiu do top-10 da fusão:\n")
for r in perdas:
    b = r["bm25"]["rank"] or "—"
    v = r["vector"]["rank"] or "—"
    print(f"- `{r['gold']}` — palavra: {b} · significado: {v} · fusão: fora do top-10")

Em 3 das 40 perguntas, o alvo aparecia no top-10 de um método isolado mas sumiu do top-10 da fusão:

  • Benefit — palavra: — · significado: 10 · fusão: fora do top-10
  • Capital_Cost — palavra: — · significado: 6 · fusão: fora do top-10
  • Energy_Transformation — palavra: — · significado: 6 · fusão: fora do top-10

Não é defeito de implementação: é como o RRF funciona. Quando o alvo está fundo em uma só lista (6ª a 10ª posição) e o outro método contribui dez candidatos diferentes, a soma dos pesos empurra o alvo para além do corte. É o preço de fundir sem normalizar escalas — e o motivo de a fusão vencer no agregado sem vencer em toda pergunta individual.

5.2 Conceitos genéricos são intrinsecamente ambíguos

Quatro alvos sorteados — Context, Driver, Benefit e Time — são rótulos genéricos num corpus de 1.388 itens. A pergunta “the surrounding circumstances that frame how everything else is interpreted” casa legitimamente com dezenas de conceitos.

Eles foram mantidos na amostra. Removê-los depois de ver que dão resultado ruim seria exatamente a contaminação que o gabarito congelado existe para impedir. Eles puxam as métricas para baixo, e isso é honesto.

5.3 Limitações do desenho

1. Quem escreveu as perguntas conhecia a resposta. As 40 perguntas foram escritas por um modelo de linguagem (Claude Opus 5) olhando a descrição de cada conceito. Mesmo com a regra de vocabulário disjunto, quem conhece o alvo tende a descrevê-lo de um jeito recuperável. Esta é a limitação mais séria desta medição — e a literatura é explícita sobre ela: Manning, Raghavan e Schütze recomendam que as necessidades de informação sejam “projetadas por especialistas do domínio” e alertam contra usar “combinações aleatórias de termos de busca” como substituto de necessidades reais (2008, Capítulo 8.5). Perguntas coletadas de pesquisadores reais, sem acesso à ontologia, seriam mais fidedignas — e provavelmente produziriam números piores.

2. Um só corpus, um só idioma. Tudo aqui é Social_Acceptance, em inglês, com all-mpnet-base-v2. Outro idioma, outro modelo ou outra densidade conceitual mudam os números. Meça no seu projeto antes de assumir o mesmo ganho.

3. Resposta única por pergunta. O gabarito admite um só conceito certo. Boa parte dos “erros” são vizinhos que um pesquisador aceitaria numa conversa. Um gabarito com múltiplas respostas válidas daria números mais altos e mais realistas — ao custo de um julgamento subjetivo sobre o que conta como aceitável. O problema é conhecido: o julgamento de relevância humano “não é um dispositivo que reporta de forma confiável um padrão-ouro”, sendo “bastante idiossincrático e variável” (2008, Capítulo 8.5), razão pela qual as coleções de referência costumam medir a concordância entre avaliadores (estatística kappa) em vez de supor um gabarito único e óbvio.

4. Quarenta perguntas mostram um padrão, não uma distribuição. A conclusão qualitativa (os erros não se sobrepõem) é robusta; os decimais não devem ser tratados como constantes do sistema. Para calibrar: as primeiras rodadas do TREC — a referência da área — usaram 50 necessidades de informação cada (2008, Capítulo 8.2). Com 40, este experimento fica na mesma ordem de grandeza das coleções consagradas, mas na margem inferior; mais perguntas dariam estimativas mais estáveis.


6 Reproduzir do zero

# 1. ArcadeDB no ar, com a base social_acceptance carregada
D:\ArcadeDB\start-arcadedb-bolt.ps1

# 2. Dependências
pip install "synesis-graph[embeddings]"

# 3. Sorteio (regrava sample.json — idêntico, a semente é fixa)
cd case-studies/benchmark_rrf
python sample_concepts.py

# 4. Medição (regrava results.json)
python run_benchmark.py
Arquivo Papel
sample_concepts.py Etapa 1 — sorteia os 40 conceitos com semente fixa
sample.json Saída do sorteio, com as descrições
questions.json O gabarito congelado — 40 pares (pergunta, conceito)
run_benchmark.py Etapa 3 — roda as buscas e calcula as métricas
results.json Saída da medição — alimenta todos os gráficos desta página

As buscas são somente leitura: rodar o benchmark não altera o banco.


7 Referências

O desenho, as métricas e os dois métodos de busca seguem a literatura estabelecida de Recuperação de Informação. Para quem quiser conferir as definições na fonte:

Manning et al. (2008) — Introduction to Information Retrieval. Cambridge University Press. Disponível em acesso livre em nlp.stanford.edu/IR-book. Os capítulos que sustentam este experimento:

Capítulo O que cobre Onde entra aqui
6 — Scoring, term weighting & the vector space model Modelo de espaço vetorial Base conceitual da busca por significado
8.2 — Standard test collections Estrutura de coleção de teste; TREC com 50 necessidades por rodada O desenho do gabarito e a discussão sobre o tamanho da amostra
8.4 — Evaluation of ranked retrieval results Precision@k, MAP, nDCG e seu desconto logarítmico As métricas usadas
8.5 — Assessing relevance Variabilidade do julgamento humano, concordância entre avaliadores (kappa) As limitações 1 e 3
11.4.3 — Okapi BM25 Modelo probabilístico com saturação (k₁) e normalização por tamanho (b) O que o índice FULL_TEXT do ArcadeDB executa

Robertson & Zaragoza (2009) — tratamento completo do arcabouço probabilístico de relevância e do BM25 pelos autores do modelo.

Cormack et al. (2009) — o artigo que introduziu o Reciprocal Rank Fusion, origem da fórmula e da constante k = 60 usadas aqui.

O que a literatura corrobora, e o que não

Corrobora: a estrutura da coleção de teste, a exigência de congelar os julgamentos antes da medição, as três métricas e sua interpretação, os dois métodos de busca e a fórmula da fusão. Nenhum desses elementos é uma invenção local.

Não corrobora — e não poderia: os valores obtidos. Nenhuma referência prevê o desempenho num corpus específico; o resultado de um corpus não transfere para outro. É por isso que a recomendação prática continua sendo medir no seu próprio projeto.


Voltar para Embeddings Vetoriais e GraphRAG — a página que explica o que estes números significam na prática.

Referências

Cormack, G. V., Clarke, C. L. A., & Buettcher, S. (2009). Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. Proceedings of the 32nd International ACM SIGIR Conference on Research and Development in Information Retrieval, 758–759. https://doi.org/10.1145/1571941.1572114
Manning, C. D., Raghavan, P., & Schütze, H. (2008). Introduction to Information Retrieval. Cambridge University Press. https://nlp.stanford.edu/IR-book/information-retrieval-book.html
Robertson, S., & Zaragoza, H. (2009). The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, 3(4), 333–389. https://doi.org/10.1561/1500000019