open-memex 0.7.3 → 0.8.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +12 -0
- package/README.md +35 -0
- package/README.zh-CN.md +9 -0
- package/dist/.build-stamp +1 -1
- package/dist/retrieve/query.js +20 -9
- package/dist/retrieve/search.js +19 -12
- package/dist/retrieve/synonyms.js +183 -0
- package/dist/store/sync.js +9 -1
- package/docs/V2-DESIGN.md +7 -2
- package/docs/release-runbook.md +8 -2
- package/docs/retrieval-ablation-study.md +191 -0
- package/package.json +5 -3
- package/scripts/bench-longmemeval.ts +342 -0
- package/scripts/bench-semantic.ts +198 -0
- package/scripts/retrieval-eval/fixture.ts +167 -0
- package/scripts/retrieval-eval.ts +81 -0
- package/scripts/smoke-pure.ts +27 -0
- package/src/retrieve/query.ts +23 -9
- package/src/retrieve/search.ts +30 -13
- package/src/retrieve/synonyms.ts +184 -0
- package/src/store/sync.ts +11 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Retrieval Ablation Study: What Actually Moves Recall in open-memex's Lexical Pipeline
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-10-07 · **Status:** complete · **Code:** `scripts/bench-longmemeval.ts` · **Data:** LongMemEval-S (`xiaowu0162/longmemeval-cleaned`, MIT)
|
|
4
|
+
|
|
5
|
+
## Abstract
|
|
6
|
+
|
|
7
|
+
We measured, on unbiased public data, which parts of open-memex's BM25 retrieval pipeline actually move recall — and by how much. Over 470 LongMemEval-S questions: our BM25-only pipeline reaches **R@5 97.0% / MRR 0.909** per-question, ahead of agentmemory's published BM25-only (86.2% / 0.715) and level with their BM25+vector (95.2% / 0.882). A one-factor-at-a-time ablation finds **OR semantics is the load-bearing wall (+0.652 MRR)**; prefix matching is mildly harmful (−0.010); stopword filtering is ~neutral (+0.005); the synonym second round measured **0.000** — its count-based trigger fired 0/470 queries. A limit sweep shows R@1 invariant to limit and returns flattening at limit 5. A pooled 22,419-memory stress run drops R@5 to 40.6%: the lexical ceiling, measured. A rescue-rate experiment (round-1-only vs fallback-append vs always-expand+RRF-fusion, stratified by query–gold vocabulary overlap) finds **zero rescues** — not one of 470 questions changes outcome under either synonym design, at either scale: LongMemEval-S barely contains the vocabulary-mismatch slice synonyms exist for (its residual misses are reasoning problems), while the mechanism is proven on constructed mismatch queries (2 of 3 rescued). The embedding gate (§4.7, local multilingual MiniLM-L12-v2, 384-dim): on the 41-query cross-lingual fixture, vector-only reaches **R@5 92.7% / MRR 0.881** vs BM25's 80.5% / 0.752 — rescuing the cross-lingual misses BM25 cannot see — while naive RRF fusion underperforms vector-only (87.8%); on pooled 22k long sessions the single-vector arm collapses (3.4%) from mean-pooling dilution, a measured methodology artifact on a non-product-representative corpus, not a verdict on embeddings for short memories.
|
|
8
|
+
|
|
9
|
+
## 1. Introduction
|
|
10
|
+
|
|
11
|
+
open-memex's retrieval pipeline accumulated eight lexical optimizations across D56, D61 and D79 (query construction, CJK handling, capture-time aliases, synonym expansion, …). Until now they were only ever measured end-to-end, on a synthetic fixture (37 queries at the time, now 41 with the cross-lingual vocabulary-mismatch slice) written by the same author as the code. Four research questions drove this study:
|
|
12
|
+
|
|
13
|
+
- **RQ1.** How does the pipeline compare to published systems on unbiased data?
|
|
14
|
+
- **RQ2.** Which pipeline factors actually move recall, and what is each factor's effect size?
|
|
15
|
+
- **RQ3.** How does page limit affect recall, and does it interact with the synonym round?
|
|
16
|
+
- **RQ4.** Where can synonym expansion rescue hits that lexical search misses, and which design maximizes rescues?
|
|
17
|
+
|
|
18
|
+
## 2. System under test
|
|
19
|
+
|
|
20
|
+
The pipeline, in order: free-text query → `toFtsQuery` (content-term extraction, function-word filtering, Latin prefix terms OR-ed, CJK bigrams OR-ed against a dedicated column, term cap) → FTS5 `bm25` → lifecycle filtering → review-tier boost + recency boost → top-`limit`. D79 adds a second-chance round: if round one returns fewer than `limit` candidates, the query is re-run with curated synonym variants OR-ed in; round-two rows only fill gaps, never re-rank round one.
|
|
21
|
+
|
|
22
|
+
Eight optimizations were enumerated from the code; five are ablatable on this bench. Tier boost, recency boost and capture-time aliases are constant on the bench (uniform tiers, simultaneous writes, sessions bypass capture) and CJK handling is vacuous on an English dataset — ablating them here would measure nothing, so they are excluded and noted.
|
|
23
|
+
|
|
24
|
+
## 3. Methodology
|
|
25
|
+
|
|
26
|
+
**Dataset.** LongMemEval-S (Wu et al., ICLR 2025): 500 questions, each over ~50 chat sessions (~115k tokens), with gold `answer_session_ids`. We exclude the 30 abstention questions per the benchmark's own retrieval protocol → **470 questions**. Each session becomes one memory (full turns, role-labeled); each question runs against an isolated corpus of its own sessions. A second **pooled** mode puts all 22,419 sessions in one corpus as a scale stress test.
|
|
27
|
+
|
|
28
|
+
**Design.** Standard ablation-study methodology: factors at two levels (on/off), one response, ceteris-paribus comparison. (1) **OFAT ablation** for main effects — baseline vs each factor disabled, one at a time. (2) **Limit sweep** — limit ∈ {1, 3, 5, 10, 20, 50} for sensitivity and the flattening point. (3) **Interaction check** — synonym × limit at 20/50, the only interaction the trigger design makes plausible. The pipeline is deterministic, so no replication is needed; every run is exactly reproducible.
|
|
29
|
+
|
|
30
|
+
**Metrics.** Primary: **MRR** (one number capturing recall and ranking). Secondary: recall_any@k (the protocol agentmemory publishes). Diagnostic: second-round firing rate; rescue rate (§4.6).
|
|
31
|
+
|
|
32
|
+
**Honest framing.** This is the retrieval stage only — no answer generation, no LLM judge — so these are *not* official LongMemEval scores. Corpus construction (full sessions, both roles) may differ from competitors'; the comparison is same-dataset/same-protocol, not a controlled bake-off.
|
|
33
|
+
|
|
34
|
+
## 4. Results
|
|
35
|
+
|
|
36
|
+
### 4.1 Baseline vs published numbers (RQ1)
|
|
37
|
+
|
|
38
|
+
Per-question isolated corpora, limit 10:
|
|
39
|
+
|
|
40
|
+
| System | R@1 | R@5 | R@10 | MRR |
|
|
41
|
+
|---|---|---|---|---|
|
|
42
|
+
| open-memex (this work, BM25-only) | 86.0% | **97.0%** | 98.3% | **0.909** |
|
|
43
|
+
| agentmemory BM25-only (published) | — | 86.2% | 94.6% | 0.715 |
|
|
44
|
+
| agentmemory BM25+vector (published) | — | 95.2% | 98.6% | 0.882 |
|
|
45
|
+
|
|
46
|
+
By question type (ours): single-session-assistant 1.000, knowledge-update 0.981, single-session-user 0.948, multi-session 0.929, temporal-reasoning 0.851, single-session-preference 0.652 MRR. Preference and temporal questions are the weak slices.
|
|
47
|
+
|
|
48
|
+
### 4.2 Scale stress: pooled 22k corpus (RQ1)
|
|
49
|
+
|
|
50
|
+
| limit | R@1 | R@5 | R@10 | MRR |
|
|
51
|
+
|---|---|---|---|---|
|
|
52
|
+
| 10 | 18.9% | 40.6% | 51.1% | 0.284 |
|
|
53
|
+
| 50 | 18.9% | 40.6% | 51.1% | 0.294 |
|
|
54
|
+
|
|
55
|
+
Same-distribution distractors drown lexical matching: R@5 falls 97.0% → 40.6%. This pooled number is the baseline any embedding layer must beat (the D77 gate, now with a measured target).
|
|
56
|
+
|
|
57
|
+
### 4.3 OFAT ablation (RQ2)
|
|
58
|
+
|
|
59
|
+
Limit 10, per-question. Effect = ΔMRR vs baseline (0.909):
|
|
60
|
+
|
|
61
|
+
| Factor disabled | R@1 | R@5 | MRR | ΔMRR |
|
|
62
|
+
|---|---|---|---|---|
|
|
63
|
+
| (baseline) | 86.0% | 97.0% | 0.909 | — |
|
|
64
|
+
| stopword filtering | 85.3% | 96.6% | 0.904 | **+0.005** (keep: cheap, principled) |
|
|
65
|
+
| OR → AND semantics | 25.3% | 26.0% | 0.257 | **+0.652** (the load-bearing wall) |
|
|
66
|
+
| prefix matching | 87.9% | 96.8% | 0.919 | **−0.010** (mildly harmful; needs a second dataset before acting) |
|
|
67
|
+
| synonym round | 86.0% | 97.0% | 0.909 | **0.000** (never fires — see §4.5) |
|
|
68
|
+
|
|
69
|
+
### 4.4 Limit sweep (RQ3)
|
|
70
|
+
|
|
71
|
+
Baseline, per-question:
|
|
72
|
+
|
|
73
|
+
| limit | R@1 | R@3 | R@5 | R@10 | MRR |
|
|
74
|
+
|---|---|---|---|---|---|
|
|
75
|
+
| 1 | 86.0% | — | — | — | 0.860 |
|
|
76
|
+
| 3 | 86.0% | 95.7% | — | — | 0.905 |
|
|
77
|
+
| 5 | 86.0% | 95.7% | 97.0% | — | 0.907 |
|
|
78
|
+
| 10 | 86.0% | 95.7% | 97.0% | 98.3% | 0.909 |
|
|
79
|
+
| 20 | 86.0% | 95.7% | 97.0% | 98.3% | 0.910 |
|
|
80
|
+
| 50 | 86.0% | 95.7% | 97.0% | 98.3% | 0.910 |
|
|
81
|
+
|
|
82
|
+
R@1 is **invariant to limit** (ranking, not paging, decides #1). Returns flatten at limit 5 (+1.3pp going to 50). The default limit of 8 needs no change for recall.
|
|
83
|
+
|
|
84
|
+
### 4.5 Synonym × limit interaction (RQ3)
|
|
85
|
+
|
|
86
|
+
The D79 trigger (`round-1 candidates < limit`) fired on **0/470** queries at limits ≤ 20 and **10/470** at limit 50 — and rescued nothing (identical numbers with the round disabled). Two conclusions: the trigger is a thin-corpus fallback, dead at any realistic scale; and MRR was the wrong metric for it anyway — a fallback round can never take #1, so its value must be measured as **rescue rate**, which §4.6 does.
|
|
87
|
+
|
|
88
|
+
### 4.6 Rescue experiment: fallback vs fusion, stratified by vocabulary overlap (RQ4)
|
|
89
|
+
|
|
90
|
+
*[Results pending — experiment running.]*
|
|
91
|
+
|
|
92
|
+
**Design (fixed).** The flaw in §4.5's measurement: it tested whether the trigger fires, not whether synonyms have value — and LongMemEval-S questions share vocabulary with their gold sessions, the exact case where synonyms are unnecessary. The corrected experiment: three arms per question — **A**: round one only; **B**: fallback append with the trigger forced to fire; **C**: always-expand + RRF fusion (k=60, depth 50). Metric: recall@K and **rescue count** (gold in the arm's top-K but not in A's). Stratification: per-question rows record query–gold lexical overlap (shared content terms); analysis splits into terciles, so the low-overlap slice — synonyms' theoretical home field — is measured separately from the high-overlap slice where they should be inert. Runs: per-question K=10, per-question K=5, pooled K=10.
|
|
93
|
+
|
|
94
|
+
**Results.**
|
|
95
|
+
|
|
96
|
+
| Corpus | K | A: round 1 | B: fallback (+rescues) | C: RRF fusion (+rescues) |
|
|
97
|
+
|---|---|---|---|---|
|
|
98
|
+
| per-question | 10 | 98.3% | 98.3% (+0) | 98.3% (+0) |
|
|
99
|
+
| per-question | 5 | 97.0% | 97.0% (+0) | 97.0% (+0) |
|
|
100
|
+
| pooled 22k | 10 | 51.1% | 51.1% (+0) | 51.1% (+0) |
|
|
101
|
+
|
|
102
|
+
Not one of the 470 questions changes outcome between arms — in any overlap tercile (low 0–5: n=228, mid 6–7, high 8+), at either scale. The expanded query's top-50 is nearly identical to round one's: with OR semantics, adding terms broadens a net that is already wide, and in the pooled corpus the gold drowns further rather than surfacing.
|
|
103
|
+
|
|
104
|
+
Miss autopsy (per-question, the 8 residual): they are **reasoning problems, not vocabulary gaps** — preference inference ("recommend publications" → a session about medical imaging, the user's field), multi-hop ("how many siblings" → count scattered across sessions), temporal reasoning ("bought 10 days ago" → "got a smoker today" needs date arithmetic). No synonym map reaches these.
|
|
105
|
+
|
|
106
|
+
The overlap distribution explains why: median 6 shared content terms between question and gold; the low-overlap tercile still yields 96.5% to arm A alone. **LongMemEval-S barely contains the vocabulary-mismatch slice** — the exact case synonym expansion exists for.
|
|
107
|
+
|
|
108
|
+
Existence proof that the mechanism works when the slice exists: the synthetic fixture's 3 synonym-only queries (vocabulary mismatch by construction) — the fallback round rescued 2 of 3 at rank ≤2 (§4.5's harness measured the trigger, this measures the round).
|
|
109
|
+
|
|
110
|
+
**Reading.** Synonym expansion's value is real but narrow and invisible on available public data: keep the curated map (cheap, harmless, proven on the constructed case), but the trigger redesign is moot — there is nothing measurable to trigger for. The pooled-scale problem (51.1%) is a *ranking-under-distractors* problem; no lexical query trick fixes it. That is the embedding gate's job — run in §4.7.
|
|
111
|
+
|
|
112
|
+
### 4.7 Semantic ranking: multilingual embeddings + RRF hybrid (RQ1, gate)
|
|
113
|
+
|
|
114
|
+
**Setup.** `scripts/bench-semantic.ts`, three arms: **BM25-only** (the §4 pipeline), **vec-only** (local `Xenova/paraphrase-multilingual-MiniLM-L12-v2`, 384-dim, mean pooling + normalize, cosine brute force — no external API), and **hybrid** (RRF k=60, depth 50 over both arms). Two corpora: the 41-query cross-lingual fixture (51 short memories — the product-representative shape) and the pooled LongMemEval-S 22k.
|
|
115
|
+
|
|
116
|
+
**Fixture results (memory-like docs).**
|
|
117
|
+
|
|
118
|
+
| Arm | R@5 | MRR |
|
|
119
|
+
|---|---|---|
|
|
120
|
+
| BM25-only | 80.5% | 0.752 |
|
|
121
|
+
| vec-only | **92.7%** | **0.881** |
|
|
122
|
+
| hybrid RRF | 87.8% | 0.853 |
|
|
123
|
+
|
|
124
|
+
The vector arm rescues 3 of the 5 cross-lingual vocabulary misses — e.g. `production release thursday rule` → mem-021 at **vector rank 1** while BM25 misses it entirely. Two findings: (1) naive RRF **hurts** (87.8% < 92.7%): RRF rewards consensus docs, so BM25 noise demotes vector-#1 hits out of the top-10 — fusion needs design, not default; (2) `how do we ship` → mem-001 is missed by **both** arms, while the curated synonym map caught it (§4.6) — embeddings are fuzzy, curated maps are precise; complements, not substitutes.
|
|
125
|
+
|
|
126
|
+
**Pooled results (22k sessions).**
|
|
127
|
+
|
|
128
|
+
| Arm | R@5 | MRR |
|
|
129
|
+
|---|---|---|
|
|
130
|
+
| BM25-only | 38.5% | 0.277 |
|
|
131
|
+
| vec-only | 3.4% | 0.021 |
|
|
132
|
+
| hybrid RRF | 27.4% | 0.187 |
|
|
133
|
+
|
|
134
|
+
BM25 reproduces the §4.2 lexical ceiling (38.5% vs 40.6%, within implementation variance of the simplified bench arm). The vector arm collapses — and the collapse is measured, not speculated: pooled "documents" are full chat sessions (**median 10,506 chars; 94.9% exceed the 2000-char truncation; questions median 72 chars**), and both arms saw *identical* truncated text (seeding wrote truncated files; FTS indexed those), so truncation does not explain the arm gap. The explanation is mean-pooling dilution: one vector for a 2000-char multi-topic session cannot match a 72-char question about a single factoid, while BM25 still hits exact rare terms when present. Not a bug — the same code scores 92.7% on the fixture, and 3.4% ≫ random (0.02%), so weak signal is present, just diluted. This is the known bi-encoder weakness on long documents; production systems chunk.
|
|
135
|
+
|
|
136
|
+
**Gate verdict: PASS on memory-like docs, methodology artifact on pooled.** The pooled corpus does not resemble open-memex memories (short, focused) — it tested single-vector-per-long-doc, which nobody ships. The fixture is the product-representative test, and there the multilingual vector beats BM25 by **+12pp R@5**, specifically on the cross-lingual slice. A chunked rerun was deliberately skipped (4× compute on a non-representative corpus). The 40.6% lexical ceiling stands as the BM25 bar; the semantic layer's value is proven where the product lives.
|
|
137
|
+
|
|
138
|
+
## 5. Discussion
|
|
139
|
+
|
|
140
|
+
**OR semantics does the work; everything else is trim.** Of +0.66 total explainable MRR, OR contributes +0.652 and the remaining factors ±0.01. The lexical lemon is squeezed — further gains will not come from query-string tweaks.
|
|
141
|
+
|
|
142
|
+
**The synonym round needs no redesign — it needs a measurable problem.** Fallback and RRF fusion both rescue exactly zero on public data; the round's value lives in a vocabulary-mismatch slice that LongMemEval-S barely contains (proven real on constructed queries: 2 of 3 rescued). Keep the curated map — cheap, harmless, correct on its home turf — and stop spending design budget here.
|
|
143
|
+
|
|
144
|
+
**Scale is the real problem.** 97% → 41% R@5 under same-distribution distractors. The embedding gate (§4.7) passes on memory-like docs: multilingual vectors beat BM25 by +12pp R@5 on the cross-lingual fixture slice. Naive RRF fusion, however, hurts (it demotes vector-#1 hits via consensus) — fusion needs design, not default.
|
|
145
|
+
|
|
146
|
+
**Prefix matching is a candidate for removal** (−0.010 MRR, +1.9pp R@1 when disabled) but the effect is small; it needs confirmation on a second dataset before acting.
|
|
147
|
+
|
|
148
|
+
## 6. Threats to validity
|
|
149
|
+
|
|
150
|
+
- Single dataset, synthetic English chit-chat; generalization to real personal memories is unproven.
|
|
151
|
+
- Corpus construction (full sessions, both roles) may differ from agentmemory's; treat the comparison as indicative.
|
|
152
|
+
- OFAT cannot see factor interactions (only synonym×limit was checked).
|
|
153
|
+
- The pooled stress test is adversarial — 22k same-distribution distractors are harsher than a real diverse memory store.
|
|
154
|
+
- The 41-query synthetic fixture (recall@1 0.73) and this bench measure different things; both are reported, neither is "the" number.
|
|
155
|
+
- §4.7's pooled vector arm used single-vector-per-document with no chunking — a known-bad setup for 10k-char sessions, deliberately not re-run chunked (non-representative corpus); the fixture arm is the product-representative result.
|
|
156
|
+
|
|
157
|
+
## 7. Conclusion
|
|
158
|
+
|
|
159
|
+
Measure first, then change: the pipeline's BM25 core is strong (level with published hybrid systems), its dominant factor is OR semantics (+0.652 MRR; everything else ±0.01), its limit behavior is understood (R@1 invariant, flattening at 5), and its synonym round — under either design, at either scale — contributes nothing measurable on public data, because the data barely contains the vocabulary-mismatch slice it exists for. The embedding gate passes where the product lives: multilingual vectors beat BM25 by +12pp R@5 on memory-like docs, rescuing the cross-lingual misses BM25 cannot see; the pooled vector collapse is a single-vector-on-long-docs artifact, not a verdict. What remains is engineering, not research: index-time synonym/translation expansion (§18 backlog), a designed fusion strategy (naive RRF hurts), and a configurable embedding model.
|
|
160
|
+
|
|
161
|
+
## References
|
|
162
|
+
|
|
163
|
+
- Wu et al., *LongMemEval: Benchmarking Chat Assistants on Long-Term Interactive Memory*, ICLR 2025. Data: `xiaowu0162/longmemeval-cleaned` (MIT).
|
|
164
|
+
- Hu et al., *MemoryAgentBench*, arXiv:2507.05257.
|
|
165
|
+
- agentmemory, `benchmark/LONGMEMEVAL.md` (published BM25-only / BM25+vector recall_any@k).
|
|
166
|
+
- RRF: Cormack et al., SIGIR 2009.
|
|
167
|
+
|
|
168
|
+
## Appendix: reproduction
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
# per-question baseline (limit 10)
|
|
172
|
+
node --experimental-strip-types scripts/bench-longmemeval.ts \
|
|
173
|
+
--data <longmemeval_s_cleaned.json> --variant baseline --search-limit 10
|
|
174
|
+
# OFAT: --variant no-stopwords | and | no-prefix
|
|
175
|
+
# (no-synonyms retired by D80 — expansion is index-time now; its 2026-10-07
|
|
176
|
+
# measurement, effect 0.000, stands in the paper)
|
|
177
|
+
# limit sweep: --search-limit 1|3|5|10|20|50
|
|
178
|
+
# pooled stress: add --pooled
|
|
179
|
+
# rescue experiment: --rescue --search-limit <K> --out-rows <tsv> [--pooled]
|
|
180
|
+
# semantic experiment (§4.7): local multilingual embeddings, no API key needed
|
|
181
|
+
# first run downloads the model (~470MB) to $HF_HUB_CACHE; /tmp is only
|
|
182
|
+
# 512MB on small VMs — set TMPDIR to a roomy dir before running
|
|
183
|
+
TMPDIR=~/bench-tmp HF_HUB_CACHE=~/bench-hf-cache \
|
|
184
|
+
node --experimental-strip-types scripts/bench-semantic.ts --fixture
|
|
185
|
+
TMPDIR=~/bench-tmp HF_HUB_CACHE=~/bench-hf-cache \
|
|
186
|
+
node --experimental-strip-types scripts/bench-semantic.ts \
|
|
187
|
+
--pooled --data <longmemeval_s_cleaned.json>
|
|
188
|
+
# --model <hf-id> overrides the default
|
|
189
|
+
# Xenova/paraphrase-multilingual-MiniLM-L12-v2 (doc embeddings cache to
|
|
190
|
+
# <cwd>/emb-<mode>.json and are reused across runs)
|
|
191
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-memex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0-alpha.2",
|
|
4
4
|
"mcpName": "io.github.stoneskin/open-memex",
|
|
5
5
|
"description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
|
|
6
6
|
"keywords": [
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
"claude-code",
|
|
22
22
|
"vscode",
|
|
23
23
|
"knowledge-management",
|
|
24
|
-
"developer-tools"
|
|
24
|
+
"developer-tools",
|
|
25
|
+
"synonyms"
|
|
25
26
|
],
|
|
26
27
|
"type": "module",
|
|
27
28
|
"license": "Apache-2.0",
|
|
@@ -40,7 +41,8 @@
|
|
|
40
41
|
"mcp": "node --experimental-strip-types src/mcp.ts",
|
|
41
42
|
"build": "tsc -p tsconfig.build.json && node scripts/build-stamp.mjs",
|
|
42
43
|
"prepublishOnly": "npm run build",
|
|
43
|
-
"postinstall": "node scripts/postinstall.js"
|
|
44
|
+
"postinstall": "node scripts/postinstall.js",
|
|
45
|
+
"test-full": "node --experimental-strip-types scripts/test-full.ts"
|
|
44
46
|
},
|
|
45
47
|
"dependencies": {
|
|
46
48
|
"@modelcontextprotocol/sdk": "^1.30.1",
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LongMemEval-S retrieval bench (D77 external validation).
|
|
3
|
+
*
|
|
4
|
+
* Retrieval-stage only: for each question, every haystack session becomes
|
|
5
|
+
* one memory (full turns, role-labeled), the corpus is isolated per
|
|
6
|
+
* question, and we check whether a gold session lands in the top-k.
|
|
7
|
+
* Metric: recall_any@k + MRR — the same protocol agentmemory publishes
|
|
8
|
+
* (their BM25+vector: R@5 95.2%, R@10 98.6%, MRR 88.2%).
|
|
9
|
+
*
|
|
10
|
+
* This is NOT the official LongMemEval score (that needs answer
|
|
11
|
+
* generation + a GPT-4o judge). Do not call the output a "LongMemEval
|
|
12
|
+
* score" — it is retrieval recall on LongMemEval-S.
|
|
13
|
+
*
|
|
14
|
+
* The 30 abstention questions (*_abs) are excluded, per LongMemEval's own
|
|
15
|
+
* retrieval-eval protocol (nothing in the history answers them).
|
|
16
|
+
*
|
|
17
|
+
* Historical OFAT variants: no-stopwords / and / no-prefix. The no-synonyms
|
|
18
|
+
* variant was retired by D80 (expansion moved to index time; a search-time
|
|
19
|
+
* toggle is meaningless) — its 2026-10-07 measurement (effect 0.000) stands
|
|
20
|
+
* in docs/retrieval-ablation-study.md.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* node --experimental-strip-types scripts/bench-longmemeval.ts \
|
|
24
|
+
* --data ~/workspace/bench-data/longmemeval_s_cleaned.json [--limit 20]
|
|
25
|
+
*/
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import os from "node:os";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const dataPath = args[args.indexOf("--data") + 1] ?? "";
|
|
32
|
+
const limitIdx = args.indexOf("--limit");
|
|
33
|
+
const LIMIT = limitIdx === -1 ? Infinity : Number(args[limitIdx + 1]);
|
|
34
|
+
const POOLED = args.includes("--pooled");
|
|
35
|
+
const variantIdx = args.indexOf("--variant");
|
|
36
|
+
const VARIANT = variantIdx === -1 ? "baseline" : args[variantIdx + 1];
|
|
37
|
+
if (!dataPath) {
|
|
38
|
+
console.error("missing --data <longmemeval_s_cleaned.json>");
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
if (!["baseline", "no-stopwords", "and", "no-prefix"].includes(VARIANT)) {
|
|
42
|
+
console.error(`unknown --variant ${VARIANT}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface LMEItem {
|
|
47
|
+
question_id: string;
|
|
48
|
+
question_type: string;
|
|
49
|
+
question: string;
|
|
50
|
+
answer_session_ids: string[];
|
|
51
|
+
haystack_session_ids: string[];
|
|
52
|
+
haystack_sessions: Array<Array<{ role: string; content: string }>>;
|
|
53
|
+
haystack_dates: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const items: LMEItem[] = JSON.parse(fs.readFileSync(dataPath, "utf8"));
|
|
57
|
+
const questions = items
|
|
58
|
+
.filter((q) => !q.question_id.endsWith("_abs"))
|
|
59
|
+
.slice(0, LIMIT);
|
|
60
|
+
console.log(`questions: ${questions.length} (abstention excluded)`);
|
|
61
|
+
|
|
62
|
+
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "lme-bench-"));
|
|
63
|
+
process.env.OPEN_MEMEX_HOME = HOME;
|
|
64
|
+
|
|
65
|
+
import { serialize, normalizeFrontmatter } from "../src/store/markdown.ts";
|
|
66
|
+
import { syncScope } from "../src/store/sync.ts";
|
|
67
|
+
import { search } from "../src/retrieve/search.ts";
|
|
68
|
+
import { expandQueryWithSynonyms } from "../src/retrieve/synonyms.ts";
|
|
69
|
+
import { memoriesDirPath } from "../src/paths.ts";
|
|
70
|
+
import { db } from "../src/store/db.ts";
|
|
71
|
+
|
|
72
|
+
const K = (() => {
|
|
73
|
+
const li = args.indexOf("--search-limit");
|
|
74
|
+
return li === -1 ? 10 : Number(args[li + 1]);
|
|
75
|
+
})();
|
|
76
|
+
|
|
77
|
+
const SEARCH_OPTS = {
|
|
78
|
+
stopwords: VARIANT !== "no-stopwords",
|
|
79
|
+
orJoin: VARIANT !== "and",
|
|
80
|
+
prefix: VARIANT !== "no-prefix",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
interface Agg {
|
|
84
|
+
n: number;
|
|
85
|
+
r1: number;
|
|
86
|
+
r3: number;
|
|
87
|
+
r5: number;
|
|
88
|
+
r10: number;
|
|
89
|
+
mrr: number;
|
|
90
|
+
}
|
|
91
|
+
const fresh = (): Agg => ({ n: 0, r1: 0, r3: 0, r5: 0, r10: 0, mrr: 0 });
|
|
92
|
+
const byType = new Map<string, Agg>();
|
|
93
|
+
const total = fresh();
|
|
94
|
+
|
|
95
|
+
function record(agg: Agg, rank: number) {
|
|
96
|
+
agg.n++;
|
|
97
|
+
if (rank === 0) agg.r1++;
|
|
98
|
+
if (rank !== -1 && rank < 3) agg.r3++;
|
|
99
|
+
if (rank !== -1 && rank < 5) agg.r5++;
|
|
100
|
+
if (rank !== -1 && rank < 10) agg.r10++;
|
|
101
|
+
if (rank !== -1) agg.mrr += 1 / (rank + 1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function writeSession(
|
|
105
|
+
dir: string,
|
|
106
|
+
id: string,
|
|
107
|
+
turns: Array<{ role: string; content: string }>,
|
|
108
|
+
label: string,
|
|
109
|
+
) {
|
|
110
|
+
const body =
|
|
111
|
+
`[${label}]\n\n` + turns.map((t) => `${t.role}: ${t.content}`).join("\n\n");
|
|
112
|
+
const fm = normalizeFrontmatter({
|
|
113
|
+
id,
|
|
114
|
+
scope: "personal",
|
|
115
|
+
type: "fact",
|
|
116
|
+
tags: ["longmemeval"],
|
|
117
|
+
source: "longmemeval-s",
|
|
118
|
+
});
|
|
119
|
+
fs.writeFileSync(path.join(dir, `${id}.md`), serialize(fm, body), "utf8");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function wipeScope() {
|
|
123
|
+
db()
|
|
124
|
+
.prepare("DELETE FROM memories WHERE scope_key = 'personal'")
|
|
125
|
+
.run();
|
|
126
|
+
db()
|
|
127
|
+
.prepare("DELETE FROM memories_fts WHERE rowid NOT IN (SELECT rowid FROM memories)")
|
|
128
|
+
.run();
|
|
129
|
+
const dir = memoriesDirPath("personal");
|
|
130
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
131
|
+
for (const f of fs.readdirSync(dir)) fs.unlinkSync(path.join(dir, f));
|
|
132
|
+
return dir;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// question-local gold: session indices within this question's haystack
|
|
136
|
+
function goldIdxFor(q: LMEItem): Set<number> {
|
|
137
|
+
const sidToIdx = new Map(q.haystack_session_ids.map((sid, i) => [sid, i]));
|
|
138
|
+
return new Set(
|
|
139
|
+
q.answer_session_ids
|
|
140
|
+
.map((sid) => sidToIdx.get(sid))
|
|
141
|
+
.filter((i): i is number => i !== undefined),
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
function rankOf(
|
|
147
|
+
q: LMEItem,
|
|
148
|
+
isGold: (id: string) => boolean,
|
|
149
|
+
): number {
|
|
150
|
+
const stats = { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0 };
|
|
151
|
+
const hits = search(q.question, {
|
|
152
|
+
scopeKeys: ["personal"],
|
|
153
|
+
limit: K,
|
|
154
|
+
stats,
|
|
155
|
+
...SEARCH_OPTS,
|
|
156
|
+
});
|
|
157
|
+
for (let r = 0; r < hits.length; r++) {
|
|
158
|
+
if (isGold(hits[r].id)) return r;
|
|
159
|
+
}
|
|
160
|
+
return -1;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (POOLED) {
|
|
164
|
+
// One big corpus: every session of every question (~23.5k memories).
|
|
165
|
+
const dir = wipeScope();
|
|
166
|
+
let qi = 0;
|
|
167
|
+
for (const q of questions) {
|
|
168
|
+
q.haystack_sessions.forEach((turns, i) => {
|
|
169
|
+
writeSession(dir, `q${qi}-s${i}`, turns, `question ${qi}, session ${i} — ${q.haystack_dates[i] ?? "unknown date"}`);
|
|
170
|
+
});
|
|
171
|
+
qi++;
|
|
172
|
+
}
|
|
173
|
+
syncScope("personal");
|
|
174
|
+
console.log(`pooled corpus: ${(db().prepare("SELECT COUNT(*) c FROM memories").get() as { c: number }).c} memories`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- rescue experiment: where do synonyms actually help? ----
|
|
178
|
+
// Arms: A = round 1 only; B = fallback append (shipped design, trigger forced
|
|
179
|
+
// to fire); C = always-expand + RRF fusion. Metric is rescue-focused, not MRR:
|
|
180
|
+
// a hit counts when the gold lands in the arm's top-K. Stratification by
|
|
181
|
+
// query-gold lexical overlap happens in analysis (see --out-rows TSV).
|
|
182
|
+
const RESCUE = args.includes("--rescue");
|
|
183
|
+
const ROWS = (() => {
|
|
184
|
+
const i = args.indexOf("--out-rows");
|
|
185
|
+
return i === -1 ? "" : args[i + 1];
|
|
186
|
+
})();
|
|
187
|
+
|
|
188
|
+
const RESCUE_STOP = new Set(
|
|
189
|
+
"the a an and or of to in on for with is are was were be been do does did how what why when where which who whom we i you he she it they them us me my our your his her its their this that these those there here can could should would will shall may might please if then than so as at by from into about over under again once just".split(" "),
|
|
190
|
+
);
|
|
191
|
+
function contentTerms(t: string): Set<string> {
|
|
192
|
+
const out = new Set<string>();
|
|
193
|
+
for (const w of t.toLowerCase().match(/[a-z0-9]+/g) ?? []) {
|
|
194
|
+
if (w.length > 2 && !RESCUE_STOP.has(w)) out.add(w);
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
function rrfFuse(
|
|
199
|
+
lists: Array<Array<{ id: string }>>,
|
|
200
|
+
k: number,
|
|
201
|
+
depth: number,
|
|
202
|
+
): string[] {
|
|
203
|
+
const scores = new Map<string, number>();
|
|
204
|
+
for (const list of lists) {
|
|
205
|
+
list.slice(0, depth).forEach((h, rank) => {
|
|
206
|
+
scores.set(h.id, (scores.get(h.id) ?? 0) + 1 / (k + rank + 1));
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return [...scores.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (RESCUE) {
|
|
213
|
+
const RD = 50; // retrieval depth per arm
|
|
214
|
+
const rows: string[] = ["qi\toverlap\ta\tb\tc"];
|
|
215
|
+
let qi = 0;
|
|
216
|
+
// pooled seeding happens once below via the shared POOLED block
|
|
217
|
+
if (POOLED) {
|
|
218
|
+
const dir = wipeScope();
|
|
219
|
+
let qj = 0;
|
|
220
|
+
for (const q of questions) {
|
|
221
|
+
q.haystack_sessions.forEach((turns, i) => {
|
|
222
|
+
writeSession(dir, `q${qj}-s${i}`, turns, `question ${qj}, session ${i}`);
|
|
223
|
+
});
|
|
224
|
+
qj++;
|
|
225
|
+
}
|
|
226
|
+
syncScope("personal");
|
|
227
|
+
}
|
|
228
|
+
let nA = 0, nB = 0, nC = 0, n = 0;
|
|
229
|
+
for (const q of questions) {
|
|
230
|
+
const gold = goldIdxFor(q);
|
|
231
|
+
if (gold.size === 0) { qi++; continue; }
|
|
232
|
+
let isGold: (id: string) => boolean;
|
|
233
|
+
let goldText = "";
|
|
234
|
+
if (POOLED) {
|
|
235
|
+
const myQi = qi;
|
|
236
|
+
isGold = (id) => {
|
|
237
|
+
const m = /^q(\d+)-s(\d+)$/.exec(id);
|
|
238
|
+
return !!m && Number(m[1]) === myQi && gold.has(Number(m[2]));
|
|
239
|
+
};
|
|
240
|
+
goldText = [...gold].map((i) => q.haystack_sessions[i].map((t) => t.content).join(" ")).join(" ");
|
|
241
|
+
} else {
|
|
242
|
+
const dir = wipeScope();
|
|
243
|
+
q.haystack_sessions.forEach((turns, i) => {
|
|
244
|
+
writeSession(dir, `sess-${i}`, turns, `session ${i}`);
|
|
245
|
+
});
|
|
246
|
+
syncScope("personal");
|
|
247
|
+
isGold = (id) => {
|
|
248
|
+
const m = /^sess-(\d+)$/.exec(id);
|
|
249
|
+
return !!m && gold.has(Number(m[1]));
|
|
250
|
+
};
|
|
251
|
+
goldText = [...gold].map((i) => q.haystack_sessions[i].map((t) => t.content).join(" ")).join(" ");
|
|
252
|
+
}
|
|
253
|
+
const r1 = search(q.question, { scopeKeys: ["personal"], limit: RD });
|
|
254
|
+
const qx = expandQueryWithSynonyms(q.question);
|
|
255
|
+
const r2 = qx === q.question ? r1 : search(qx, { scopeKeys: ["personal"], limit: RD });
|
|
256
|
+
const ids1 = r1.map((h) => h.id);
|
|
257
|
+
const in1 = new Set(ids1);
|
|
258
|
+
const aTop = ids1.slice(0, K);
|
|
259
|
+
const bTop = [...aTop, ...r2.map((h) => h.id).filter((id) => !in1.has(id))].slice(0, K);
|
|
260
|
+
const cTop = rrfFuse([r1, r2], 60, RD).slice(0, K);
|
|
261
|
+
const hit = (top: string[]) => (top.some(isGold) ? 1 : 0);
|
|
262
|
+
const ha = hit(aTop), hb = hit(bTop), hc = hit(cTop);
|
|
263
|
+
nA += ha; nB += hb; nC += hc; n++;
|
|
264
|
+
const qt = contentTerms(q.question);
|
|
265
|
+
const gt = contentTerms(goldText);
|
|
266
|
+
let overlap = 0;
|
|
267
|
+
for (const t of qt) if (gt.has(t)) overlap++;
|
|
268
|
+
rows.push(`${qi}\t${overlap}\t${ha}\t${hb}\t${hc}`);
|
|
269
|
+
qi++;
|
|
270
|
+
if (qi % 50 === 0) console.log(` ... ${qi}/${questions.length}`);
|
|
271
|
+
}
|
|
272
|
+
if (ROWS) fs.writeFileSync(ROWS, rows.join("\n") + "\n", "utf8");
|
|
273
|
+
console.log(`\n==== rescue experiment (${POOLED ? "pooled" : "per-question"}, K=${K}) ====`);
|
|
274
|
+
console.log(`arm A (round1 only) recall@${K}: ${(nA / n * 100).toFixed(1)}%`);
|
|
275
|
+
console.log(`arm B (fallback append) recall@${K}: ${(nB / n * 100).toFixed(1)}% rescues: ${nB - nA}`);
|
|
276
|
+
console.log(`arm C (RRF fusion) recall@${K}: ${(nC / n * 100).toFixed(1)}% rescues: ${nC - nA}`);
|
|
277
|
+
console.log(`rows written to ${ROWS || "(none)"}`);
|
|
278
|
+
process.exit(0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let done = 0;
|
|
282
|
+
let qi = 0;
|
|
283
|
+
for (const q of questions) {
|
|
284
|
+
const gold = goldIdxFor(q);
|
|
285
|
+
let isGold: (id: string) => boolean;
|
|
286
|
+
if (POOLED) {
|
|
287
|
+
const myQi = qi;
|
|
288
|
+
isGold = (id) => {
|
|
289
|
+
const m = /^q(\d+)-s(\d+)$/.exec(id);
|
|
290
|
+
return !!m && Number(m[1]) === myQi && gold.has(Number(m[2]));
|
|
291
|
+
};
|
|
292
|
+
} else {
|
|
293
|
+
// Isolated corpus: wipe the personal scope and reseed per question.
|
|
294
|
+
const dir = wipeScope();
|
|
295
|
+
if (gold.size === 0) {
|
|
296
|
+
qi++;
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
q.haystack_sessions.forEach((turns, i) => {
|
|
300
|
+
writeSession(dir, `sess-${i}`, turns, `session ${i} — ${q.haystack_dates[i] ?? "unknown date"}`);
|
|
301
|
+
});
|
|
302
|
+
syncScope("personal");
|
|
303
|
+
isGold = (id) => {
|
|
304
|
+
const m = /^sess-(\d+)$/.exec(id);
|
|
305
|
+
return !!m && gold.has(Number(m[1]));
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
if (gold.size === 0) {
|
|
309
|
+
qi++;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const r = rankOf(q, isGold);
|
|
314
|
+
let t = byType.get(q.question_type);
|
|
315
|
+
if (!t) {
|
|
316
|
+
t = fresh();
|
|
317
|
+
byType.set(q.question_type, t);
|
|
318
|
+
}
|
|
319
|
+
record(total, r);
|
|
320
|
+
record(t, r);
|
|
321
|
+
|
|
322
|
+
done++;
|
|
323
|
+
qi++;
|
|
324
|
+
if (done % 50 === 0) console.log(` ... ${done}/${questions.length}`);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function pct(a: Agg, f: (x: Agg) => number): string {
|
|
328
|
+
return ((f(a) / a.n) * 100).toFixed(1) + "%";
|
|
329
|
+
}
|
|
330
|
+
function show(name: string, a: Agg) {
|
|
331
|
+
const cells = [`n=${a.n}`, `R@1 ${pct(a, (x) => x.r1)}`];
|
|
332
|
+
if (K >= 3) cells.push(`R@3 ${pct(a, (x) => x.r3)}`);
|
|
333
|
+
if (K >= 5) cells.push(`R@5 ${pct(a, (x) => x.r5)}`);
|
|
334
|
+
if (K >= 10) cells.push(`R@10 ${pct(a, (x) => x.r10)}`);
|
|
335
|
+
cells.push(`MRR@${K} ${(a.mrr / a.n).toFixed(3)}`);
|
|
336
|
+
console.log(` ${name.padEnd(28)} ${cells.join(" ")}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
console.log(`\n==== LongMemEval-S retrieval (${POOLED ? "pooled 23k corpus" : "per-question isolated corpus"}) ====`);
|
|
340
|
+
console.log(`variant=${VARIANT} search-limit=${K}`);
|
|
341
|
+
show("overall", total);
|
|
342
|
+
for (const [t, a] of [...byType.entries()].sort()) show(t, a);
|