@claude-flow/cli 3.10.24 → 3.10.25
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/dist/src/memory/bge-embedder.d.ts +19 -0
- package/dist/src/memory/bge-embedder.d.ts.map +1 -0
- package/dist/src/memory/bge-embedder.js +107 -0
- package/dist/src/memory/bge-embedder.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/scripts/run-beir-bge.mjs +292 -0
- package/scripts/run-beir-nfcorpus.mjs +231 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@claude-flow/cli",
|
|
3
|
-
"version": "3.10.
|
|
3
|
+
"version": "3.10.25",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Ruflo CLI - Enterprise AI agent orchestration with 60+ specialized agents, swarm coordination, MCP server, self-learning hooks, and vector memory for Claude Code",
|
|
6
6
|
"main": "dist/src/index.js",
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// run-beir-bge.mjs — NFCorpus retrieval using real BGE-base-en-v1.5
|
|
3
|
+
// embeddings (bypasses neural-tools' broken agentic-flow path that hash-fell-
|
|
4
|
+
// back on darwin-arm64 without sharp/libvips).
|
|
5
|
+
//
|
|
6
|
+
// Stores doc embeddings in a single .f32 binary file (3633 * 768 * 4 = ~11MB)
|
|
7
|
+
// for fast bench iteration without re-embedding.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// cd /tmp/beir-nfcorpus
|
|
11
|
+
// node /path/to/scripts/run-beir-bge.mjs # full ingest + bench
|
|
12
|
+
// SKIP_INGEST=1 node /path/to/scripts/run-beir-bge.mjs # reuse cached embeds
|
|
13
|
+
// BGE_MODEL=Xenova/bge-small-en-v1.5 node ... # faster, lower-quality
|
|
14
|
+
// BGE_MODEL=Xenova/bge-large-en-v1.5 node ... # slower, higher-quality
|
|
15
|
+
//
|
|
16
|
+
// ADR-086.
|
|
17
|
+
|
|
18
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { performance } from 'node:perf_hooks';
|
|
22
|
+
|
|
23
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const CLI_ROOT = resolve(SCRIPT_DIR, '..');
|
|
25
|
+
const RUFLO_ROOT = resolve(SCRIPT_DIR, '../../../..');
|
|
26
|
+
const RUNS_DIR = join(RUFLO_ROOT, 'docs', 'benchmarks', 'runs');
|
|
27
|
+
|
|
28
|
+
const DATA_DIR = process.env.BEIR_DATA_DIR || '/tmp/beir-nfcorpus/nfcorpus';
|
|
29
|
+
const CACHE_DIR = join('/tmp/beir-nfcorpus', 'bge-cache');
|
|
30
|
+
const BGE_MODEL = process.env.BGE_MODEL || 'Xenova/bge-base-en-v1.5';
|
|
31
|
+
const SKIP_INGEST = process.env.SKIP_INGEST === '1';
|
|
32
|
+
const MAX_QUERIES = Number(process.env.MAX_QUERIES) || 0;
|
|
33
|
+
|
|
34
|
+
// Published BEIR baselines per dataset (nDCG@10).
|
|
35
|
+
// Sources: Thakur et al. 2021 (BEIR paper), BGE paper (BAAI 2024),
|
|
36
|
+
// SPLADE++ paper, papers-with-code BEIR leaderboards.
|
|
37
|
+
const BASELINES_BY_DATASET = {
|
|
38
|
+
nfcorpus: {
|
|
39
|
+
'BM25 (Lucene)': 0.325,
|
|
40
|
+
'DocT5query': 0.328,
|
|
41
|
+
'TAS-B': 0.319,
|
|
42
|
+
'GenQ': 0.319,
|
|
43
|
+
'ColBERT': 0.305,
|
|
44
|
+
'Contriever': 0.328,
|
|
45
|
+
'GTR-XL': 0.343,
|
|
46
|
+
'SPLADE++': 0.347,
|
|
47
|
+
'BGE-large-v1.5 (pub)': 0.380,
|
|
48
|
+
'SBERT msmarco': 0.272,
|
|
49
|
+
},
|
|
50
|
+
scifact: {
|
|
51
|
+
'BM25 (Lucene)': 0.679,
|
|
52
|
+
'DocT5query': 0.675,
|
|
53
|
+
'TAS-B': 0.643,
|
|
54
|
+
'GenQ': 0.644,
|
|
55
|
+
'ColBERT': 0.671,
|
|
56
|
+
'Contriever': 0.677,
|
|
57
|
+
'GTR-XL': 0.662,
|
|
58
|
+
'SPLADE++': 0.704,
|
|
59
|
+
'BGE-large-v1.5 (pub)': 0.722,
|
|
60
|
+
'SBERT msmarco': 0.555,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Auto-detect dataset from DATA_DIR path; default to nfcorpus baselines.
|
|
65
|
+
function detectDataset(path) {
|
|
66
|
+
const p = path.toLowerCase();
|
|
67
|
+
for (const ds of Object.keys(BASELINES_BY_DATASET)) {
|
|
68
|
+
if (p.includes(ds)) return ds;
|
|
69
|
+
}
|
|
70
|
+
return 'nfcorpus';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// nDCG (graded)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
function dcg(rels, k) {
|
|
78
|
+
let s = 0;
|
|
79
|
+
for (let i = 0; i < Math.min(rels.length, k); i++) {
|
|
80
|
+
s += (Math.pow(2, rels[i]) - 1) / Math.log2(i + 2);
|
|
81
|
+
}
|
|
82
|
+
return s;
|
|
83
|
+
}
|
|
84
|
+
function ndcg(retrieved, qrels, k) {
|
|
85
|
+
const rels = retrieved.slice(0, k).map((id) => qrels.get(id) ?? 0);
|
|
86
|
+
const ideal = [...qrels.values()].sort((a, b) => b - a).slice(0, k);
|
|
87
|
+
const idcg = dcg(ideal, k);
|
|
88
|
+
return idcg > 0 ? dcg(rels, k) / idcg : 0;
|
|
89
|
+
}
|
|
90
|
+
function mrr(retrieved, qrels, k) {
|
|
91
|
+
for (let i = 0; i < Math.min(retrieved.length, k); i++) {
|
|
92
|
+
if ((qrels.get(retrieved[i]) ?? 0) > 0) return 1 / (i + 1);
|
|
93
|
+
}
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
function recall(retrieved, qrels, k) {
|
|
97
|
+
const tot = [...qrels.values()].filter((v) => v > 0).length;
|
|
98
|
+
if (tot === 0) return 0;
|
|
99
|
+
let h = 0;
|
|
100
|
+
for (let i = 0; i < Math.min(retrieved.length, k); i++) {
|
|
101
|
+
if ((qrels.get(retrieved[i]) ?? 0) > 0) h++;
|
|
102
|
+
}
|
|
103
|
+
return h / tot;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function cosine(a, b) {
|
|
107
|
+
let s = 0;
|
|
108
|
+
for (let i = 0; i < a.length; i++) s += a[i] * b[i];
|
|
109
|
+
return s; // already L2-normalised in BGE
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Loaders
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function loadJsonl(path) {
|
|
117
|
+
return readFileSync(path, 'utf-8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
118
|
+
}
|
|
119
|
+
function loadQrels(path) {
|
|
120
|
+
const qrels = new Map();
|
|
121
|
+
const lines = readFileSync(path, 'utf-8').split('\n');
|
|
122
|
+
for (let i = 1; i < lines.length; i++) {
|
|
123
|
+
if (!lines[i].trim()) continue;
|
|
124
|
+
const [qid, did, score] = lines[i].split('\t');
|
|
125
|
+
if (!qrels.has(qid)) qrels.set(qid, new Map());
|
|
126
|
+
qrels.get(qid).set(did, Number(score));
|
|
127
|
+
}
|
|
128
|
+
return qrels;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Embedding cache I/O — single .f32 file + .ids file
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
function cachePath(suffix) {
|
|
136
|
+
const safe = BGE_MODEL.replace(/\//g, '_');
|
|
137
|
+
return join(CACHE_DIR, `${safe}.${suffix}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function saveEmbeddings(ids, embeddings, dim) {
|
|
141
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
142
|
+
// ids: one per line
|
|
143
|
+
writeFileSync(cachePath('ids'), ids.join('\n') + '\n');
|
|
144
|
+
// embeddings: concatenated Float32Array
|
|
145
|
+
const buf = new Float32Array(ids.length * dim);
|
|
146
|
+
for (let i = 0; i < ids.length; i++) buf.set(embeddings[i], i * dim);
|
|
147
|
+
writeFileSync(cachePath('f32'), Buffer.from(buf.buffer));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function loadEmbeddings(dim) {
|
|
151
|
+
if (!existsSync(cachePath('ids')) || !existsSync(cachePath('f32'))) return null;
|
|
152
|
+
const ids = readFileSync(cachePath('ids'), 'utf-8').split('\n').filter(Boolean);
|
|
153
|
+
const raw = readFileSync(cachePath('f32'));
|
|
154
|
+
const buf = new Float32Array(raw.buffer, raw.byteOffset, raw.length / 4);
|
|
155
|
+
const embeds = [];
|
|
156
|
+
for (let i = 0; i < ids.length; i++) embeds.push(buf.slice(i * dim, (i + 1) * dim));
|
|
157
|
+
return { ids, embeds };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Main
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
async function main() {
|
|
165
|
+
const dataset = detectDataset(DATA_DIR);
|
|
166
|
+
const BASELINES_NDCG10 = BASELINES_BY_DATASET[dataset];
|
|
167
|
+
console.log(`# BEIR ${dataset} — BGE embedder (ADR-085/086)`);
|
|
168
|
+
console.log(`Model: ${BGE_MODEL}`);
|
|
169
|
+
console.log(`Data: ${DATA_DIR}`);
|
|
170
|
+
|
|
171
|
+
const corpus = loadJsonl(join(DATA_DIR, 'corpus.jsonl'));
|
|
172
|
+
const queries = loadJsonl(join(DATA_DIR, 'queries.jsonl'));
|
|
173
|
+
const qrels = loadQrels(join(DATA_DIR, 'qrels/test.tsv'));
|
|
174
|
+
console.log(`Corpus: ${corpus.length} docs · Queries: ${queries.length} · Test qrels: ${qrels.size}`);
|
|
175
|
+
|
|
176
|
+
const bge = await import(join(CLI_ROOT, 'dist/src/memory/bge-embedder.js'));
|
|
177
|
+
const emb = await bge.getBgeEmbedder(BGE_MODEL);
|
|
178
|
+
if (!emb) {
|
|
179
|
+
console.error('BGE failed to load:', bge.getBgeStatus());
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
const dim = emb.dim();
|
|
183
|
+
console.log(`BGE loaded (dim=${dim})`);
|
|
184
|
+
|
|
185
|
+
// Ingest or load cached doc embeddings.
|
|
186
|
+
let docIds, docEmbeds;
|
|
187
|
+
const cached = SKIP_INGEST ? loadEmbeddings(dim) : null;
|
|
188
|
+
if (cached) {
|
|
189
|
+
docIds = cached.ids;
|
|
190
|
+
docEmbeds = cached.embeds;
|
|
191
|
+
console.log(`Loaded ${docIds.length} cached doc embeddings from ${cachePath('f32')}`);
|
|
192
|
+
} else {
|
|
193
|
+
console.log(`\nEmbedding ${corpus.length} docs (this is the slow step, ~20 min on M-series CPU)...`);
|
|
194
|
+
docIds = corpus.map((d) => d._id);
|
|
195
|
+
docEmbeds = new Array(corpus.length);
|
|
196
|
+
const tIngest = performance.now();
|
|
197
|
+
for (let i = 0; i < corpus.length; i++) {
|
|
198
|
+
const d = corpus[i];
|
|
199
|
+
const text = `${d.title || ''}\n${d.text || ''}`.slice(0, 4096);
|
|
200
|
+
docEmbeds[i] = await emb.embed(text);
|
|
201
|
+
if ((i + 1) % 100 === 0) {
|
|
202
|
+
const elapsed = (performance.now() - tIngest) / 1000;
|
|
203
|
+
const rate = (i + 1) / elapsed;
|
|
204
|
+
const eta = (corpus.length - i - 1) / rate;
|
|
205
|
+
console.log(` ${i + 1}/${corpus.length} ${rate.toFixed(1)} docs/s ETA ${eta.toFixed(0)}s`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
console.log(`Embedded ${corpus.length} docs in ${((performance.now() - tIngest) / 1000).toFixed(0)}s`);
|
|
209
|
+
saveEmbeddings(docIds, docEmbeds, dim);
|
|
210
|
+
console.log(`Cached to ${cachePath('f32')}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Run retrieval per query.
|
|
214
|
+
const queriesById = new Map(queries.map((q) => [q._id, q.text]));
|
|
215
|
+
const evalQueryIds = MAX_QUERIES
|
|
216
|
+
? [...qrels.keys()].slice(0, MAX_QUERIES)
|
|
217
|
+
: [...qrels.keys()];
|
|
218
|
+
|
|
219
|
+
console.log(`\nRunning ${evalQueryIds.length} queries...`);
|
|
220
|
+
let nSum = 0, mSum = 0, r10Sum = 0, r100Sum = 0, n = 0;
|
|
221
|
+
const tQ = performance.now();
|
|
222
|
+
for (const qid of evalQueryIds) {
|
|
223
|
+
const qtext = queriesById.get(qid);
|
|
224
|
+
if (!qtext) continue;
|
|
225
|
+
// BGE recommends prepending "Represent this sentence for searching relevant passages:" to queries
|
|
226
|
+
// but only for v1.5 *en-icl* variants. v1.5 (non-icl) works without it.
|
|
227
|
+
const qEmb = await emb.embed(qtext);
|
|
228
|
+
// Score every doc, sort.
|
|
229
|
+
const scores = new Array(docEmbeds.length);
|
|
230
|
+
for (let i = 0; i < docEmbeds.length; i++) scores[i] = { id: docIds[i], score: cosine(qEmb, docEmbeds[i]) };
|
|
231
|
+
scores.sort((a, b) => b.score - a.score);
|
|
232
|
+
const top100 = scores.slice(0, 100).map((s) => s.id);
|
|
233
|
+
|
|
234
|
+
const qmap = qrels.get(qid);
|
|
235
|
+
nSum += ndcg(top100, qmap, 10);
|
|
236
|
+
mSum += mrr(top100, qmap, 10);
|
|
237
|
+
r10Sum += recall(top100, qmap, 10);
|
|
238
|
+
r100Sum += recall(top100, qmap, 100);
|
|
239
|
+
n++;
|
|
240
|
+
if (n % 50 === 0) {
|
|
241
|
+
const elapsed = (performance.now() - tQ) / 1000;
|
|
242
|
+
console.log(` ${n}/${evalQueryIds.length} in ${elapsed.toFixed(0)}s running nDCG@10=${(nSum / n).toFixed(4)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const queryMs = performance.now() - tQ;
|
|
246
|
+
|
|
247
|
+
const ndcg10 = nSum / n;
|
|
248
|
+
const mrr10 = mSum / n;
|
|
249
|
+
const recall10 = r10Sum / n;
|
|
250
|
+
const recall100 = r100Sum / n;
|
|
251
|
+
|
|
252
|
+
console.log(`\n=== Results (N=${n}, BGE-base-en-v1.5) ===`);
|
|
253
|
+
console.log(` nDCG@10: ${ndcg10.toFixed(4)}`);
|
|
254
|
+
console.log(` MRR@10: ${mrr10.toFixed(4)}`);
|
|
255
|
+
console.log(` Recall@10: ${recall10.toFixed(4)}`);
|
|
256
|
+
console.log(` Recall@100: ${recall100.toFixed(4)}`);
|
|
257
|
+
console.log(` Avg query latency: ${(queryMs / n).toFixed(0)}ms`);
|
|
258
|
+
|
|
259
|
+
console.log(`\n=== vs published NFCorpus baselines (nDCG@10) ===`);
|
|
260
|
+
const ourLabel = `ruflo + ${BGE_MODEL.replace('Xenova/', '')}`;
|
|
261
|
+
const ranking = [
|
|
262
|
+
...Object.entries(BASELINES_NDCG10).map(([name, score]) => ({ name, score, ours: false })),
|
|
263
|
+
{ name: ourLabel, score: ndcg10, ours: true },
|
|
264
|
+
].sort((a, b) => b.score - a.score);
|
|
265
|
+
for (const r of ranking) {
|
|
266
|
+
console.log(` ${r.score.toFixed(3)} ${r.name}${r.ours ? ' ← us' : ''}`);
|
|
267
|
+
}
|
|
268
|
+
const ourRank = ranking.findIndex((r) => r.ours) + 1;
|
|
269
|
+
console.log(`\n Our rank: ${ourRank} / ${ranking.length}`);
|
|
270
|
+
|
|
271
|
+
const summary = {
|
|
272
|
+
runAt: new Date().toISOString(),
|
|
273
|
+
benchmark: `beir-${dataset}-bge`,
|
|
274
|
+
dataset,
|
|
275
|
+
model: BGE_MODEL,
|
|
276
|
+
queries: n,
|
|
277
|
+
corpusSize: corpus.length,
|
|
278
|
+
metrics: { ndcg10, mrr10, recall10, recall100, avgQueryLatencyMs: queryMs / n },
|
|
279
|
+
baselines: BASELINES_NDCG10,
|
|
280
|
+
ourRank,
|
|
281
|
+
leaderboardLength: ranking.length,
|
|
282
|
+
};
|
|
283
|
+
mkdirSync(RUNS_DIR, { recursive: true });
|
|
284
|
+
const stamp = summary.runAt.replace(/[:.]/g, '-');
|
|
285
|
+
const safe = BGE_MODEL.replace(/\//g, '_');
|
|
286
|
+
writeFileSync(join(RUNS_DIR, `beir-${dataset}-bge-${safe}-${stamp}.json`), JSON.stringify(summary, null, 2));
|
|
287
|
+
writeFileSync(join(RUNS_DIR, `beir-${dataset}-bge-latest.json`), JSON.stringify(summary, null, 2));
|
|
288
|
+
console.log(`\nWrote ${join(RUNS_DIR, `beir-${dataset}-bge-${safe}-${stamp}.json`)}`);
|
|
289
|
+
process.exit(0);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// run-beir-nfcorpus.mjs — public benchmark harness for BEIR NFCorpus.
|
|
3
|
+
//
|
|
4
|
+
// Why NFCorpus: smallest BEIR dataset (3.6k docs, 323 test queries) with a
|
|
5
|
+
// well-published BM25 baseline and dense-retriever ceilings — runs end-to-end
|
|
6
|
+
// in <15 min on this hardware.
|
|
7
|
+
//
|
|
8
|
+
// Setup:
|
|
9
|
+
// mkdir -p /tmp/beir-nfcorpus && cd /tmp/beir-nfcorpus
|
|
10
|
+
// curl -sL -o nfcorpus.zip 'https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/nfcorpus.zip'
|
|
11
|
+
// unzip -q nfcorpus.zip
|
|
12
|
+
//
|
|
13
|
+
// Run:
|
|
14
|
+
// cd /tmp/beir-nfcorpus && rm -rf .claude-flow
|
|
15
|
+
// node /path/to/scripts/run-beir-nfcorpus.mjs
|
|
16
|
+
//
|
|
17
|
+
// Reports:
|
|
18
|
+
// - nDCG@10 (graded, BEIR standard)
|
|
19
|
+
// - MRR@10
|
|
20
|
+
// - Recall@10, Recall@100
|
|
21
|
+
// - Published comparisons (BM25, ColBERT, SPLADE, etc.)
|
|
22
|
+
|
|
23
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import { dirname, join, resolve } from 'node:path';
|
|
26
|
+
import { performance } from 'node:perf_hooks';
|
|
27
|
+
|
|
28
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const CLI_ROOT = resolve(SCRIPT_DIR, '..');
|
|
30
|
+
const RUFLO_ROOT = resolve(SCRIPT_DIR, '../../../..');
|
|
31
|
+
const RUNS_DIR = join(RUFLO_ROOT, 'docs', 'benchmarks', 'runs');
|
|
32
|
+
|
|
33
|
+
const DATA_DIR = process.env.BEIR_DATA_DIR || '/tmp/beir-nfcorpus/nfcorpus';
|
|
34
|
+
const TOP_K = Number(process.env.TOP_K) || 100; // top-100 for recall@100
|
|
35
|
+
const NDCG_K = 10;
|
|
36
|
+
const MAX_QUERIES = Number(process.env.MAX_QUERIES) || 0; // 0 = all
|
|
37
|
+
const SKIP_INGEST = process.env.SKIP_INGEST === '1'; // reuse existing store
|
|
38
|
+
|
|
39
|
+
// Published BEIR NFCorpus baselines (from Thakur et al. 2021 + leaderboard tracker)
|
|
40
|
+
const BASELINES_NDCG10 = {
|
|
41
|
+
'BM25': 0.325,
|
|
42
|
+
'DocT5query': 0.328,
|
|
43
|
+
'SBERT (msmarco)': 0.272, // dense bi-encoder on MS MARCO
|
|
44
|
+
'TAS-B': 0.319,
|
|
45
|
+
'GenQ': 0.319,
|
|
46
|
+
'ColBERT': 0.305,
|
|
47
|
+
'SPLADE++': 0.347, // top dense at time of paper
|
|
48
|
+
'Contriever': 0.328,
|
|
49
|
+
'GTR-XL': 0.343,
|
|
50
|
+
'BGE-large-v1.5': 0.380, // current ~top-of-class
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// nDCG with graded relevance (BEIR standard)
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
function dcg(rels, k) {
|
|
58
|
+
let s = 0;
|
|
59
|
+
for (let i = 0; i < Math.min(rels.length, k); i++) {
|
|
60
|
+
s += (Math.pow(2, rels[i]) - 1) / Math.log2(i + 2);
|
|
61
|
+
}
|
|
62
|
+
return s;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function ndcg(retrievedDocIds, qrelsMap, k) {
|
|
66
|
+
const rels = retrievedDocIds.slice(0, k).map((id) => qrelsMap.get(id) ?? 0);
|
|
67
|
+
const ideal = [...qrelsMap.values()].sort((a, b) => b - a).slice(0, k);
|
|
68
|
+
const idcg = dcg(ideal, k);
|
|
69
|
+
return idcg > 0 ? dcg(rels, k) / idcg : 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function mrr(retrievedDocIds, qrelsMap, k) {
|
|
73
|
+
for (let i = 0; i < Math.min(retrievedDocIds.length, k); i++) {
|
|
74
|
+
if ((qrelsMap.get(retrievedDocIds[i]) ?? 0) > 0) return 1 / (i + 1);
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function recall(retrievedDocIds, qrelsMap, k) {
|
|
80
|
+
const totalRelevant = [...qrelsMap.values()].filter((v) => v > 0).length;
|
|
81
|
+
if (totalRelevant === 0) return 0;
|
|
82
|
+
let hit = 0;
|
|
83
|
+
for (let i = 0; i < Math.min(retrievedDocIds.length, k); i++) {
|
|
84
|
+
if ((qrelsMap.get(retrievedDocIds[i]) ?? 0) > 0) hit++;
|
|
85
|
+
}
|
|
86
|
+
return hit / totalRelevant;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Loaders
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
function loadJsonl(path) {
|
|
94
|
+
const items = [];
|
|
95
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
96
|
+
if (!line.trim()) continue;
|
|
97
|
+
items.push(JSON.parse(line));
|
|
98
|
+
}
|
|
99
|
+
return items;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function loadQrels(path) {
|
|
103
|
+
// Map<queryId, Map<docId, grade>>
|
|
104
|
+
const qrels = new Map();
|
|
105
|
+
const lines = readFileSync(path, 'utf-8').split('\n');
|
|
106
|
+
for (let i = 1; i < lines.length; i++) {
|
|
107
|
+
if (!lines[i].trim()) continue;
|
|
108
|
+
const [qid, did, score] = lines[i].split('\t');
|
|
109
|
+
if (!qrels.has(qid)) qrels.set(qid, new Map());
|
|
110
|
+
qrels.get(qid).set(did, Number(score));
|
|
111
|
+
}
|
|
112
|
+
return qrels;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Main
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
async function main() {
|
|
120
|
+
console.log(`# BEIR NFCorpus benchmark (ADR-085)`);
|
|
121
|
+
console.log(`Data: ${DATA_DIR}`);
|
|
122
|
+
console.log(`Cwd: ${process.cwd()}`);
|
|
123
|
+
|
|
124
|
+
const corpus = loadJsonl(join(DATA_DIR, 'corpus.jsonl'));
|
|
125
|
+
const queries = loadJsonl(join(DATA_DIR, 'queries.jsonl'));
|
|
126
|
+
const qrels = loadQrels(join(DATA_DIR, 'qrels/test.tsv'));
|
|
127
|
+
|
|
128
|
+
console.log(`Corpus: ${corpus.length} docs, ${queries.length} queries (total), ${qrels.size} eval queries (test split)`);
|
|
129
|
+
|
|
130
|
+
const neural = await import(join(CLI_ROOT, 'dist/src/mcp-tools/neural-tools.js'));
|
|
131
|
+
const tool = neural.neuralTools.find((t) => t.name === 'neural_patterns');
|
|
132
|
+
|
|
133
|
+
// §1 — Ingest corpus (id-stable pattern name = doc _id; content = title + text).
|
|
134
|
+
// Skip if SKIP_INGEST=1 and store already populated.
|
|
135
|
+
if (!SKIP_INGEST) {
|
|
136
|
+
console.log(`\nIngesting ${corpus.length} docs...`);
|
|
137
|
+
const t0 = performance.now();
|
|
138
|
+
// Batch in chunks to avoid memory pressure.
|
|
139
|
+
const CHUNK = 200;
|
|
140
|
+
for (let i = 0; i < corpus.length; i += CHUNK) {
|
|
141
|
+
const batch = corpus.slice(i, i + CHUNK);
|
|
142
|
+
await neural.storeNeuralPatterns(batch.map((d) => ({
|
|
143
|
+
name: d._id, // use _id as the stable identifier
|
|
144
|
+
type: 'beir-nfcorpus',
|
|
145
|
+
content: `${d.title || ''}\n${d.text || ''}`.slice(0, 4096),
|
|
146
|
+
})));
|
|
147
|
+
if ((i + CHUNK) % 1000 === 0 || i + CHUNK >= corpus.length) {
|
|
148
|
+
const pct = Math.min(100, ((i + CHUNK) / corpus.length * 100)).toFixed(0);
|
|
149
|
+
const elapsed = (performance.now() - t0) / 1000;
|
|
150
|
+
console.log(` ${Math.min(i + CHUNK, corpus.length)}/${corpus.length} (${pct}%) in ${elapsed.toFixed(0)}s`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
console.log(`Ingest done in ${((performance.now() - t0) / 1000).toFixed(0)}s`);
|
|
154
|
+
} else {
|
|
155
|
+
console.log('SKIP_INGEST=1 → reusing existing store');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// §2 — Build query lookup (eval queries only).
|
|
159
|
+
const queriesById = new Map(queries.map((q) => [q._id, q.text]));
|
|
160
|
+
const evalQueryIds = MAX_QUERIES
|
|
161
|
+
? [...qrels.keys()].slice(0, MAX_QUERIES)
|
|
162
|
+
: [...qrels.keys()];
|
|
163
|
+
|
|
164
|
+
// §3 — Run retrieval for each query, compute metrics.
|
|
165
|
+
console.log(`\nRunning ${evalQueryIds.length} queries...`);
|
|
166
|
+
let ndcgSum = 0, mrrSum = 0, recall10Sum = 0, recall100Sum = 0;
|
|
167
|
+
const tQ = performance.now();
|
|
168
|
+
let queryIdx = 0;
|
|
169
|
+
for (const qid of evalQueryIds) {
|
|
170
|
+
const qtext = queriesById.get(qid);
|
|
171
|
+
if (!qtext) { queryIdx++; continue; }
|
|
172
|
+
const r = await tool.handler({ action: 'search', query: qtext, mode: 'hybrid', limit: TOP_K, rerank: false });
|
|
173
|
+
const retrievedIds = (r.results || []).map((m) => m.name); // we set name = _id
|
|
174
|
+
const qrelsMap = qrels.get(qid) || new Map();
|
|
175
|
+
ndcgSum += ndcg(retrievedIds, qrelsMap, NDCG_K);
|
|
176
|
+
mrrSum += mrr(retrievedIds, qrelsMap, NDCG_K);
|
|
177
|
+
recall10Sum += recall(retrievedIds, qrelsMap, 10);
|
|
178
|
+
recall100Sum += recall(retrievedIds, qrelsMap, 100);
|
|
179
|
+
queryIdx++;
|
|
180
|
+
if (queryIdx % 50 === 0) {
|
|
181
|
+
const elapsed = (performance.now() - tQ) / 1000;
|
|
182
|
+
console.log(` ${queryIdx}/${evalQueryIds.length} in ${elapsed.toFixed(0)}s — running nDCG@10=${(ndcgSum / queryIdx).toFixed(4)}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const queryMs = performance.now() - tQ;
|
|
186
|
+
const N = evalQueryIds.length;
|
|
187
|
+
const ndcg10 = ndcgSum / N;
|
|
188
|
+
const mrr10 = mrrSum / N;
|
|
189
|
+
const recall10 = recall10Sum / N;
|
|
190
|
+
const recall100 = recall100Sum / N;
|
|
191
|
+
|
|
192
|
+
console.log(`\n=== Results (N=${N} queries) ===`);
|
|
193
|
+
console.log(` nDCG@10: ${ndcg10.toFixed(4)}`);
|
|
194
|
+
console.log(` MRR@10: ${mrr10.toFixed(4)}`);
|
|
195
|
+
console.log(` Recall@10: ${recall10.toFixed(4)}`);
|
|
196
|
+
console.log(` Recall@100: ${recall100.toFixed(4)}`);
|
|
197
|
+
console.log(` Avg query latency: ${(queryMs / N).toFixed(0)}ms`);
|
|
198
|
+
|
|
199
|
+
console.log(`\n=== vs published NFCorpus nDCG@10 baselines ===`);
|
|
200
|
+
const ourEntry = { name: 'ruflo hybrid (3.10.25)', score: ndcg10, ours: true };
|
|
201
|
+
const ranking = [
|
|
202
|
+
...Object.entries(BASELINES_NDCG10).map(([name, score]) => ({ name, score, ours: false })),
|
|
203
|
+
ourEntry,
|
|
204
|
+
].sort((a, b) => b.score - a.score);
|
|
205
|
+
for (const r of ranking) {
|
|
206
|
+
const marker = r.ours ? ' ← us' : '';
|
|
207
|
+
console.log(` ${r.score.toFixed(3)} ${r.name}${marker}`);
|
|
208
|
+
}
|
|
209
|
+
const ourRank = ranking.findIndex((r) => r.ours) + 1;
|
|
210
|
+
console.log(`\n Our rank: ${ourRank} / ${ranking.length}`);
|
|
211
|
+
|
|
212
|
+
const summary = {
|
|
213
|
+
runAt: new Date().toISOString(),
|
|
214
|
+
benchmark: 'beir-nfcorpus',
|
|
215
|
+
queries: N,
|
|
216
|
+
corpusSize: corpus.length,
|
|
217
|
+
metrics: { ndcg10, mrr10, recall10, recall100, avgQueryLatencyMs: queryMs / N },
|
|
218
|
+
baselines: BASELINES_NDCG10,
|
|
219
|
+
ourRank,
|
|
220
|
+
leaderboardLength: ranking.length,
|
|
221
|
+
};
|
|
222
|
+
mkdirSync(RUNS_DIR, { recursive: true });
|
|
223
|
+
const stamp = summary.runAt.replace(/[:.]/g, '-');
|
|
224
|
+
writeFileSync(join(RUNS_DIR, `beir-nfcorpus-${stamp}.json`), JSON.stringify(summary, null, 2));
|
|
225
|
+
writeFileSync(join(RUNS_DIR, 'beir-nfcorpus-latest.json'), JSON.stringify(summary, null, 2));
|
|
226
|
+
console.log(`\nWrote ${join(RUNS_DIR, `beir-nfcorpus-${stamp}.json`)}`);
|
|
227
|
+
|
|
228
|
+
process.exit(0);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|