research: benchmark hybrid (lexical+dense+graph) recall vs current FTS
Viktor asked to enhance the memory system with 'semantics' — remember concepts (not just tokens) linked in a graph — and to prove, by benchmarking against the current system, that it actually improves recall. A multi-phase research workflow (18 agents) did landscape research, an adversarially-reviewed integration design, a stratified eval set over the real 5,452-memory corpus, and a head-to-head prototype-vs-current benchmark. Result: hybrid (lexical FTS + dense embeddings, RRF-fused) beats FTS on every overall metric, driven by a robust paraphrase win (recall@10 +0.350). Recommend adopting lexical+dense; the concept graph is DEFERRED. Post-run adversarial review correction (applied to all docs before commit): the prototype's fusion config structurally barred the graph leg from the ranked top-k, so the 'graph contributes nothing' ablation was a math artifact, NOT an empirical result — the graph is UNEVALUATED, not disproven (deferred on cost+uncertainty). Multi-hop deltas are not statistically significant. Glossary in CONTEXT.md; framing in ADR-0001-0003; findings in ADR-0004-0006 + docs/research/. Privacy: the corpus/queries/qrels/results are the user's real memories and stay gitignored (data/, cache/, results/, build_eval_set.py); only harness code, aggregate numbers, and synthetic examples are committed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7439540f8f
commit
1cc8a2b378
23 changed files with 3428 additions and 0 deletions
100
benchmarks/harness/metrics.py
Normal file
100
benchmarks/harness/metrics.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Retrieval metrics with BINARY relevance.
|
||||
|
||||
Conventions
|
||||
-----------
|
||||
- `ranked`: list of memory ids, best-first, as returned by a retriever.
|
||||
- `relevant`: set of relevant memory ids for the query (from qrels).
|
||||
- All functions are pure and operate on a single query; the runner aggregates
|
||||
(macro-average over queries).
|
||||
|
||||
Definitions
|
||||
-----------
|
||||
recall@k = |relevant ∩ ranked[:k]| / |relevant|
|
||||
(fraction of all relevant items retrieved within the top k)
|
||||
MRR = 1 / rank_of_first_relevant (0 if none retrieved at all)
|
||||
nDCG@k = DCG@k / IDCG@k with binary gains (gain=1 for relevant)
|
||||
DCG@k = sum over i in [1..k] of rel_i / log2(i + 1)
|
||||
IDCG@k is the DCG of the ideal ranking (all relevant first),
|
||||
capped at min(|relevant|, k) ones.
|
||||
|
||||
Notes
|
||||
-----
|
||||
- nDCG uses the standard log2(rank+1) discount (Järvelin & Kekäläinen 2002);
|
||||
with binary gains this is the common IR convention also used by BEIR/pytrec_eval.
|
||||
- MRR is reported as the reciprocal rank of the FIRST relevant hit, which for a
|
||||
single query equals the per-query reciprocal-rank that the runner averages.
|
||||
- Duplicate ids in `ranked` are de-duplicated keeping first occurrence, so a
|
||||
retriever cannot inflate recall by repeating an id.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterable, Sequence
|
||||
|
||||
MemoryId = int
|
||||
|
||||
|
||||
def _dedup_keep_order(ranked: Sequence[MemoryId]) -> list[MemoryId]:
|
||||
seen: set[MemoryId] = set()
|
||||
out: list[MemoryId] = []
|
||||
for x in ranked:
|
||||
if x not in seen:
|
||||
seen.add(x)
|
||||
out.append(x)
|
||||
return out
|
||||
|
||||
|
||||
def recall_at_k(ranked: Sequence[MemoryId], relevant: Iterable[MemoryId], k: int) -> float:
|
||||
rel = set(relevant)
|
||||
if not rel:
|
||||
# Undefined; treat as 0 contribution. Runner should never pass empty.
|
||||
return 0.0
|
||||
top = _dedup_keep_order(ranked)[:k]
|
||||
hits = sum(1 for x in top if x in rel)
|
||||
return hits / len(rel)
|
||||
|
||||
|
||||
def reciprocal_rank(ranked: Sequence[MemoryId], relevant: Iterable[MemoryId]) -> float:
|
||||
rel = set(relevant)
|
||||
if not rel:
|
||||
return 0.0
|
||||
for i, x in enumerate(_dedup_keep_order(ranked), start=1):
|
||||
if x in rel:
|
||||
return 1.0 / i
|
||||
return 0.0
|
||||
|
||||
|
||||
def dcg_at_k(ranked: Sequence[MemoryId], relevant: Iterable[MemoryId], k: int) -> float:
|
||||
rel = set(relevant)
|
||||
top = _dedup_keep_order(ranked)[:k]
|
||||
dcg = 0.0
|
||||
for i, x in enumerate(top, start=1):
|
||||
if x in rel:
|
||||
dcg += 1.0 / math.log2(i + 1)
|
||||
return dcg
|
||||
|
||||
|
||||
def ndcg_at_k(ranked: Sequence[MemoryId], relevant: Iterable[MemoryId], k: int) -> float:
|
||||
rel = set(relevant)
|
||||
if not rel:
|
||||
return 0.0
|
||||
dcg = dcg_at_k(ranked, rel, k)
|
||||
ideal_hits = min(len(rel), k)
|
||||
idcg = sum(1.0 / math.log2(i + 1) for i in range(1, ideal_hits + 1))
|
||||
if idcg == 0.0:
|
||||
return 0.0
|
||||
return dcg / idcg
|
||||
|
||||
|
||||
def per_query_metrics(ranked: Sequence[MemoryId], relevant: Iterable[MemoryId]) -> dict[str, float]:
|
||||
"""All headline metrics for one query."""
|
||||
rel = set(relevant)
|
||||
return {
|
||||
"recall@5": recall_at_k(ranked, rel, 5),
|
||||
"recall@10": recall_at_k(ranked, rel, 10),
|
||||
"ndcg@10": ndcg_at_k(ranked, rel, 10),
|
||||
"mrr": reciprocal_rank(ranked, rel),
|
||||
}
|
||||
|
||||
|
||||
METRIC_NAMES = ("recall@5", "recall@10", "ndcg@10", "mrr")
|
||||
Loading…
Add table
Add a link
Reference in a new issue