Skip to content

Commit 0286bc6

Browse files
authored
Merge pull request #61 from vektori-ai/exp/locomo-bench
Exp/locomo bench
2 parents 83901e1 + d5e5562 commit 0286bc6

7 files changed

Lines changed: 114 additions & 51 deletions

File tree

README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,22 @@ Search hits Facts, graph discovers Episodes, traces back to source Sentences. SQ
5353

5454
## Benchmarks
5555

56-
Tested on long-horizon memory benchmarks — hundreds of turns, real user details buried deep in history.
56+
> **What is F1?** F1 is the harmonic mean of precision (what fraction of the model's answer was correct) and recall (what fraction of the correct answer the model actually covered). A high F1 means the answer is both accurate and complete. Most memory system vendors report LLM-judge accuracy instead — a separate LLM scores each answer as correct or not, which is easier to run but less granular.
5757
58-
| Benchmark | Vektori | Mem0 | Zep | Supermemory | Letta |
59-
|-----------|---------|------|-----|-------------|-------|
60-
| LoCoMo | **66%** | 66% | 58%† | ~70% | ~83% |
61-
| LongMemEval-S | **73%** || 64% | 85% ||
58+
Tested on long-horizon memory benchmarks — hundreds of turns, real user details buried deep in history.
6259

63-
†Zep's self-reported score is 75%; independently re-evaluated at 58%. Scores across systems are not always directly comparable — model choice (GPT-4o vs GPT-4.1-mini vs local) significantly affects results.
60+
| System | LoCoMo | LongMemEval-S | DMR | F1 avg (LoCoMo)† | Search p95 | Total p95 |
61+
|--------|--------|---------------|-----|-----------------|-----------|----------|
62+
| Vektori | 66% | 73% |||||
63+
| Mem0 | 66.88% ||| 41.0 | 0.200s | 1.440s |
64+
| Zep | 75.14%‡ | 71.2% | 94.8% || 0.778s | 2.926s |
65+
| Supermemory || 81.6% |||||
66+
| Letta | 74.0% ||||||
6467

65-
We used gemini-2.5-flash-lite because of token cost, better models imporve accuracy a lot. Benchmarks at L1 level
68+
†F1 = harmonic mean of precision (how much of the answer was correct) and recall (how much of the correct answer was covered). Only Mem0 publishes token-level F1; 41.0 is the average across single-hop 38.72, multi-hop 28.64, open-domain 47.65, temporal 48.93.
69+
‡Zep self-reported; Mem0's paper measured Zep at 65.99% on the same run. Latency from Mem0's paper. Model choice significantly shifts all scores — we used gemini-2.5-flash-lite for cost.
6670

67-
On LoCoMo and longmemEval, **the retrieved context contains the answer in 95% of questions** — the gap to 66% is a synthesis problem, not a retrieval one. Actively working on closing it, exploring RL.
71+
On LoCoMo and LongMemEval, **the retrieved context contains the answer in 95% of questions** — the gap to 66% is a synthesis problem, not a retrieval one. Actively working on closing it, exploring RL.
6872

6973
Still improving — PRs and evals welcome. Run your own: [`/benchmarks`](benchmarks/)
7074

benchmarks/locomo/locomo_judge.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
logger = logging.getLogger("locomo_judge")
2525

2626
VALID_VERDICTS = {"CORRECT", "PARTIALLY_CORRECT", "WRONG", "ABSTAINED"}
27-
DEFAULT_JUDGE_MODEL = "gemini:gemini-2.5-flash-lite"
27+
DEFAULT_JUDGE_MODEL = "gemini:gemini-3-flash-preview"
2828

2929
ABSTENTION_PHRASES = (
3030
"i don't have that information",
@@ -72,11 +72,14 @@
7272
- For date answers, accept equivalent formats. These are always CORRECT:
7373
"2023-10-21" = "21 October 2023" = "October 21, 2023"
7474
"2022-11-10" = "10 November 2022" = "November 10, 2022"
75-
- Absolute dates that equal a relative expression are CORRECT. Verify the arithmetic before marking wrong:
76-
"July 14, 2023" = "The Friday before 15 July 2023" (July 14 is a Friday)
77-
"11 August 2023" = "The Friday before 14 August 2023" (August 11 is a Friday)
75+
- Absolute dates that equal a relative expression are CORRECT. To check: compute the calendar date the relative expression describes, then compare. If they land on the same day (±0), it is CORRECT. Show your arithmetic before concluding.
76+
"July 14, 2023" = "The Friday before 15 July 2023" (July 14 is a Friday ✓)
77+
"11 August 2023" = "The Friday before 14 August 2023" (August 11 is a Friday ✓)
78+
"2023-07-17" = "The weekend before 17 July 2023" (July 17 is itself a Monday — weekend is July 15/16; mark accordingly)
7879
A model that gives the computed absolute date when the expected uses relative phrasing is CORRECT, not wrong.
79-
- Extra correct details do not make an answer wrong.
80+
- If the MODEL ANSWER is short (under 25 words) — a bare name, date, quantity, or short phrase — do NOT invent reasons it is wrong. Only ask: does this core fact match the expected answer? If yes → CORRECT. Do not fabricate attribution errors, entity confusions, or "context doesn't mention this" claims for a short answer. A short answer that matches the expected answer is CORRECT.
81+
- If the EXPECTED ANSWER is short (5 words or fewer), check whether the model answer CONTAINS that core fact, regardless of surrounding phrasing. "Caroline is single, having experienced a tough breakup" is CORRECT for expected="Single". "John explored the Pacific Northwest on a road trip" is CORRECT for expected="Pacific Northwest". Extra phrasing around the core fact does NOT make the answer PARTIALLY_CORRECT.
82+
- Extra correct details do not make an answer wrong or partially correct.
8083
8184
JSON schema:
8285
{{"verdict": "CORRECT|PARTIALLY_CORRECT|WRONG|ABSTAINED", "context_has_answer": true|false, "failure_mode": null|"QA_FAILURE"|"RETRIEVAL_FAILURE", "explanation": "one sentence"}}"""
@@ -90,6 +93,7 @@ class JudgeConfig:
9093
seed: int = 42
9194
output_dir: str = "benchmark_results"
9295
qids: list[str] = field(default_factory=list)
96+
concurrency: int = 5
9397

9498

9599
@dataclass
@@ -191,12 +195,11 @@ def load_entries(full_results_path: Path, qids: list[str], n: int, seed: int) ->
191195
return rng.sample(entries, n)
192196

193197

194-
async def evaluate_entry(entry: dict[str, Any], judge_model: str) -> dict[str, Any]:
195-
llm = create_llm(judge_model)
198+
async def evaluate_entry(entry: dict[str, Any], llm: Any) -> dict[str, Any]:
196199
prompt = build_prompt(entry)
197200
t0 = time.perf_counter()
198201
try:
199-
raw = await llm.generate(prompt, max_tokens=350)
202+
raw = await llm.generate(prompt, max_tokens=400)
200203
latency_ms = (time.perf_counter() - t0) * 1000
201204
verdict = parse_verdict(raw, latency_ms)
202205
except Exception as exc:
@@ -251,15 +254,20 @@ def summarize(results: list[dict[str, Any]]) -> dict[str, Any]:
251254
if result["context_has_answer"]:
252255
by_type[qtype]["ctx_ok"] += 1
253256

257+
answered = total - abstained
254258
return {
255259
"total": total,
256260
"correct": correct,
257261
"partially_correct": partial,
258262
"wrong": wrong,
259263
"abstained": abstained,
264+
"answered": answered,
260265
"correct_rate": round(correct / total, 4) if total else 0.0,
261266
"combined_rate": round((correct + partial) / total, 4) if total else 0.0,
267+
"answer_precision": round(correct / answered, 4) if answered else 0.0,
268+
"answer_recall": round(correct / total, 4) if total else 0.0,
262269
"context_has_answer": context_has_answer,
270+
"retrieval_recall": round(context_has_answer / total, 4) if total else 0.0,
263271
"context_has_answer_rate": round(context_has_answer / total, 4) if total else 0.0,
264272
"qa_failure": qa_failure,
265273
"retrieval_failure": retrieval_failure,
@@ -270,17 +278,25 @@ def summarize(results: list[dict[str, Any]]) -> dict[str, Any]:
270278

271279
async def run(config: JudgeConfig) -> dict[str, Any]:
272280
entries = load_entries(Path(config.full_results), config.qids, config.n, config.seed)
273-
print(f"Judging {len(entries)} LoCoMo answers with {config.judge_model}")
281+
total = len(entries)
282+
print(f"Judging {total} LoCoMo answers with {config.judge_model} (concurrency={config.concurrency})")
274283

275-
results: list[dict[str, Any]] = []
276-
for idx, entry in enumerate(entries, 1):
277-
result = await evaluate_entry(entry, config.judge_model)
278-
results.append(result)
284+
llm = create_llm(config.judge_model)
285+
sem = asyncio.Semaphore(config.concurrency)
286+
counter = {"n": 0}
287+
288+
async def judge_one(entry: dict[str, Any]) -> dict[str, Any]:
289+
async with sem:
290+
result = await evaluate_entry(entry, llm)
291+
counter["n"] += 1
279292
print(
280-
f"[{idx}/{len(entries)}] {result.get('question_id')} "
293+
f"[{counter['n']}/{total}] {result.get('question_id')} "
281294
f"{result['verdict']} ctx={result['context_has_answer']} "
282295
f"failure={result['failure_mode']}"
283296
)
297+
return result
298+
299+
results: list[dict[str, Any]] = await asyncio.gather(*[judge_one(e) for e in entries])
284300

285301
summary = summarize(results)
286302
output = {"config": asdict(config), "summary": summary, "results": results}
@@ -317,6 +333,8 @@ def main() -> None:
317333
parser.add_argument("--output-dir", default="benchmark_results")
318334
parser.add_argument("--qids", default="", help="Comma-separated question IDs")
319335
parser.add_argument("--qids-file", default="", help="One question ID per line")
336+
parser.add_argument("--concurrency", type=int, default=5,
337+
help="Max concurrent judge requests (default 5; lower if hitting RPM limits)")
320338
args = parser.parse_args()
321339

322340
try:
@@ -331,6 +349,7 @@ def main() -> None:
331349
seed=args.seed,
332350
output_dir=args.output_dir,
333351
qids=qids,
352+
concurrency=args.concurrency,
334353
)
335354
asyncio.run(run(config))
336355

benchmarks/locomo/locomo_runner.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ class LoCoMoConfig:
7474

7575
# Retrieval
7676
retrieval_depth: str = "l1"
77-
top_k: int = 10
77+
top_k: int = 15
7878
context_window: int = 3
7979
enable_retrieval_gate: bool = False
8080

@@ -549,10 +549,18 @@ def _avg(vals: list[float]) -> float | None:
549549
qa_vals = _collect("qa_ms")
550550
total_vals = _collect("total_question_ms")
551551

552+
def _p95(vals: list[float]) -> float | None:
553+
if not vals:
554+
return None
555+
return round(sorted(vals)[int(len(vals) * 0.95)], 1)
556+
552557
metrics["latency_ms"] = {
553558
"retrieval_avg": _avg(retrieval_vals),
559+
"retrieval_p95": _p95(retrieval_vals),
554560
"qa_avg": _avg(qa_vals),
561+
"qa_p95": _p95(qa_vals),
555562
"total_question_avg": _avg(total_vals),
563+
"total_question_p95": _p95(total_vals),
556564
}
557565

558566
return metrics
@@ -897,7 +905,15 @@ def _relative_time_note(text: str, timestamp: Any) -> str:
897905
if phrase not in lower:
898906
continue
899907
if kind == "anchor":
900-
notes.append(f'"{phrase}" anchored to session date {reference_date.isoformat()}')
908+
if phrase in ("recently", "lately", "earlier this week", "later this week"):
909+
prior_start = (reference_dt - timedelta(days=7)).date().isoformat()
910+
prior_end = (reference_dt - timedelta(days=1)).date().isoformat()
911+
notes.append(
912+
f'"{phrase}" → event occurred approximately {prior_start} to {prior_end} '
913+
f'(days/weeks BEFORE session date {reference_date.isoformat()}, not on it)'
914+
)
915+
else:
916+
notes.append(f'"{phrase}" anchored to session date {reference_date.isoformat()}')
901917
elif kind == "year":
902918
delta_y = -1 if phrase.startswith("last") else 1
903919
resolved_year = reference_dt.replace(year=reference_dt.year + delta_y).year
@@ -922,13 +938,24 @@ def _format_retrieved_context(search_results: Any) -> str:
922938

923939
lines: list[str] = []
924940

941+
episodes = search_results.get("episodes") or []
942+
if episodes:
943+
lines.append("## Episodes")
944+
for i, ep in enumerate(episodes, 1):
945+
timestamp = _timestamp_for_context(ep)
946+
date = _date_prefix(timestamp)
947+
date_prefix = f"[{date}] " if date else ""
948+
text = str(ep.get("text", str(ep))).strip()
949+
text = f"{text}{_relative_time_note(text, _event_time_only(ep))}"
950+
lines.append(f"{i}. {date_prefix}{text}")
951+
925952
facts = search_results.get("facts") or []
926953
if facts:
927954
facts = sorted(
928955
facts,
929956
key=_fact_context_sort_key,
930957
)
931-
lines.append("## Facts (ranked by relevance and specificity)")
958+
lines.append("\n## Facts (ranked by relevance and specificity)")
932959
for i, fact in enumerate(facts, 1):
933960
timestamp = _timestamp_for_context(fact)
934961
date = _date_prefix(timestamp)
@@ -937,17 +964,6 @@ def _format_retrieved_context(search_results: Any) -> str:
937964
text = f"{text}{_relative_time_note(text, _event_time_only(fact))}"
938965
lines.append(f"{i}. {date_prefix}{text}")
939966

940-
episodes = search_results.get("episodes") or []
941-
if episodes:
942-
lines.append("\n## Episodes")
943-
for i, ep in enumerate(episodes, 1):
944-
timestamp = _timestamp_for_context(ep)
945-
date = _date_prefix(timestamp)
946-
date_prefix = f"[{date}] " if date else ""
947-
text = str(ep.get("text", str(ep))).strip()
948-
text = f"{text}{_relative_time_note(text, _event_time_only(ep))}"
949-
lines.append(f"{i}. {date_prefix}{text}")
950-
951967
syntheses = search_results.get("syntheses") or []
952968
if syntheses:
953969
lines.append("\n## Syntheses")

scripts/run_and_judge.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@ def _parse_args() -> argparse.Namespace:
7272
p.add_argument("--qa-thinking-level", default=None,
7373
help="Thinking level for QA eval model: high/medium/low/minimal")
7474
# Judge args
75-
p.add_argument("--judge-model", default="gemini:gemini-2.5-flash-lite")
75+
p.add_argument("--judge-model", default="gemini:gemini-3-flash-preview")
7676
p.add_argument("--judge-n", type=int, default=0,
7777
help="How many answers to judge (0 = all, default)")
78+
p.add_argument("--judge-concurrency", type=int, default=5,
79+
help="Max concurrent judge requests (default 5)")
7880
return p.parse_args()
7981

8082

@@ -127,21 +129,24 @@ async def _run_judge(
127129
run_output_dir: str,
128130
judge_model: str,
129131
judge_n: int,
132+
judge_concurrency: int = 5,
130133
) -> dict:
131134
from benchmarks.locomo.locomo_judge import JudgeConfig, run as judge_run
132135

133136
print(f"\n{'='*60}")
134137
print(f"STEP 2 — JUDGE")
135-
print(f" input : {full_results}")
136-
print(f" model : {judge_model}")
137-
print(f" sample : {'ALL' if judge_n <= 0 else judge_n}")
138+
print(f" input : {full_results}")
139+
print(f" model : {judge_model}")
140+
print(f" sample : {'ALL' if judge_n <= 0 else judge_n}")
141+
print(f" concurrency : {judge_concurrency}")
138142
print(f"{'='*60}\n")
139143

140144
config = JudgeConfig(
141145
full_results=str(full_results),
142146
judge_model=judge_model,
143147
n=judge_n if judge_n > 0 else 0,
144148
output_dir=run_output_dir,
149+
concurrency=judge_concurrency,
145150
)
146151

147152
return await judge_run(config)
@@ -160,9 +165,13 @@ def _print_scorecard(judge_output: dict, run_name: str, ppr_on: bool) -> None:
160165

161166
correct_pct = 100 * s.get("correct_rate", 0)
162167
combined_pct = 100 * s.get("combined_rate", 0)
168+
precision_pct = 100 * s.get("answer_precision", 0)
169+
recall_pct = 100 * s.get("answer_recall", 0)
170+
ret_recall_pct = 100 * s.get("retrieval_recall", 0)
163171
ctx_pct = 100 * s.get("context_has_answer_rate", 0)
164172
qa_fail = s.get("qa_failure", 0)
165173
ret_fail = s.get("retrieval_failure", 0)
174+
answered = s.get("answered", total - abstained)
166175

167176
print(f"\n{'='*60}")
168177
print(f"FINAL SCORECARD — {run_name}")
@@ -174,6 +183,10 @@ def _print_scorecard(judge_output: dict, run_name: str, ppr_on: bool) -> None:
174183
print(f" Combined C+PC : {combined:>4} ({combined_pct:.1f}%)")
175184
print(f" Wrong : {wrong:>4}")
176185
print(f" Abstained : {abstained:>4}")
186+
print(f" Answered : {answered:>4}")
187+
print(f" Ans Precision : {precision_pct:.1f}% (correct / answered)")
188+
print(f" Ans Recall : {recall_pct:.1f}% (correct / total)")
189+
print(f" Ret Recall : {ret_recall_pct:.1f}% (ctx has answer / total)")
177190
print(f" Context OK : {s.get('context_has_answer'):>4} ({ctx_pct:.1f}%)")
178191
print(f" QA failures : {qa_fail}")
179192
print(f" Retrieval fail: {ret_fail}")
@@ -207,6 +220,7 @@ async def main() -> None:
207220
run_output_dir,
208221
args.judge_model,
209222
args.judge_n,
223+
args.judge_concurrency,
210224
)
211225
_print_scorecard(judge_output, run_name, ppr_on=not args.no_ppr)
212226

vektori/ingestion/extractor.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,11 @@
329329
- source_quotes: verbatim text from the ASSISTANT turn
330330
331331
General:
332+
- Proper nouns, brand names, place names, and specific quantities MUST appear verbatim in the fact text. Never substitute a category or synonym for a specific name:
333+
BAD: "Calvin purchased a luxury car." GOOD: "Calvin purchased a Ferrari 488 GTB."
334+
BAD: "John's old area was flooded." GOOD: "John's area West County was flooded."
335+
BAD: "Tim visited a Harry Potter shop." GOOD: "Tim visited House of MinaLima."
336+
The 20-word guide does NOT override this — preserve the specific name even at 22–25 words.
332337
- One fact per statement. Short and crisp.
333338
- subject: 'user' when about the person speaking; a named entity when about someone/something they mention; 'assistant' for assistant facts
334339
- Extract at most {max_facts} facts total — prioritize high-confidence, significant ones

0 commit comments

Comments
 (0)