@lotargo/memory_plugin 1.6.5 → 1.6.6
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/CHANGELOG.md +6 -0
- package/mcp-server/benchmarks/fetch_real_corpus.js +351 -0
- package/mcp-server/benchmarks/gpu_profile_benchmark.js +170 -0
- package/mcp-server/benchmarks/policy_dominance_test.js +221 -0
- package/mcp-server/benchmarks/quality_evaluator.js +598 -0
- package/mcp-server/benchmarks/raw_corpus_data.js +613 -0
- package/mcp-server/benchmarks/run_benchmarks.js +366 -0
- package/mcp-server/benchmarks/stress_ingestion.js +195 -0
- package/mcp-server/benchmarks/table_code_retrieval.js +453 -0
- package/mcp-server/benchmarks/test_dual_layer.js +141 -0
- package/mcp-server/rag_scope.js +83 -0
- package/package.json +4 -25
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { writeFile, mkdir as mkdirAsync } from "node:fs/promises";
|
|
2
|
+
import { rmSync, existsSync } from "node:fs";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
import { testDualLayerArchitecture } from "./test_dual_layer.js";
|
|
6
|
+
import { runIngestionBenchmark } from "./stress_ingestion.js";
|
|
7
|
+
import { evaluateSearchQualityComparison } from "./quality_evaluator.js";
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const REPORT_PATH = join(__dirname, "..", "..", "dev_docs", "benchmark_results.md");
|
|
11
|
+
const HISTORY_DIR = join(__dirname, "..", "..", "dev_docs", "benchmark_history");
|
|
12
|
+
const PANEL_WIDTH = 58;
|
|
13
|
+
|
|
14
|
+
function fmtCI(ci) {
|
|
15
|
+
if (!ci || !Array.isArray(ci) || ci.length < 2) return "—";
|
|
16
|
+
return `[${ci[0]}, ${ci[1]}]`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function printRichPanel(title, subtitle = "") {
|
|
20
|
+
const line = "─".repeat(PANEL_WIDTH - 2);
|
|
21
|
+
console.log(`\x1b[36m╭${line}╮\x1b[0m`);
|
|
22
|
+
console.log(`\x1b[36m│\x1b[0m \x1b[1m\x1b[37m${title.padEnd(PANEL_WIDTH - 6)}\x1b[0m \x1b[36m│\x1b[0m`);
|
|
23
|
+
if (subtitle) {
|
|
24
|
+
console.log(`\x1b[36m│\x1b[0m \x1b[90m${subtitle.padEnd(PANEL_WIDTH - 6)}\x1b[0m \x1b[36m│\x1b[0m`);
|
|
25
|
+
}
|
|
26
|
+
console.log(`\x1b[36m╰${line}╯\x1b[0m`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Pure report renderer: takes the measurement results and returns the markdown.
|
|
30
|
+
// Kept separate from the measurement run so it can be exercised against stored
|
|
31
|
+
// benchmark_*.json artifacts without re-running ONNX ingestion.
|
|
32
|
+
export function buildMarkdownReport({ ingestMetrics, qualityComp, totalTimeSec }) {
|
|
33
|
+
// Build markdown table for breakdown
|
|
34
|
+
const breakdownRows = qualityComp.breakdown
|
|
35
|
+
.map(
|
|
36
|
+
(b) => {
|
|
37
|
+
const target = `\`${b.target}\``;
|
|
38
|
+
const winnerCell = (rq, rv) => (rv === "MISSED" ? `${rq}` : `${rq}`);
|
|
39
|
+
return `| ${b.id} | ${target} | ${b.category} | ${b.bm25Rank} | ${b.vectorRank} | ${b.rrfRank} | ${b.rsfRank} | ${b.query} |`;
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
.join("\n");
|
|
43
|
+
|
|
44
|
+
// Compute dynamic analysis metrics
|
|
45
|
+
const totalQueries = qualityComp.breakdown.length;
|
|
46
|
+
const bm25Missed = qualityComp.breakdown.filter((b) => b.bm25Rank === "MISSED").length;
|
|
47
|
+
const rsfMissed = qualityComp.breakdown.filter((b) => b.rsfRank === "MISSED").length;
|
|
48
|
+
const rrfMissed = qualityComp.breakdown.filter((b) => b.rrfRank === "MISSED").length;
|
|
49
|
+
const vecMissed = qualityComp.breakdown.filter((b) => b.vectorRank === "MISSED").length;
|
|
50
|
+
|
|
51
|
+
// Per-category aggregate table rows
|
|
52
|
+
const catRows = qualityComp.categoryBreakdown
|
|
53
|
+
.map(
|
|
54
|
+
(c) =>
|
|
55
|
+
`| ${c.category} | ${c.n} | ${c.bm25.mrr} | ${c.vector.mrr} | ${c.rrf.mrr} | ${c.rsf.mrr} | ${(c.rrf.recall * 100).toFixed(1)}% | ${(c.rsf.recall * 100).toFixed(1)}% |`,
|
|
56
|
+
)
|
|
57
|
+
.join("\n");
|
|
58
|
+
|
|
59
|
+
// CI table rows
|
|
60
|
+
const ciRows = [
|
|
61
|
+
{ mode: "bm25", agg: qualityComp.bm25, ci: qualityComp.bootstrap.bm25 },
|
|
62
|
+
{ mode: "vector", agg: qualityComp.vector, ci: qualityComp.bootstrap.vector },
|
|
63
|
+
{ mode: "rrf", agg: qualityComp.hybridRrf, ci: qualityComp.bootstrap.rrf },
|
|
64
|
+
{ mode: "rsf", agg: qualityComp.hybridRsf, ci: qualityComp.bootstrap.rsf },
|
|
65
|
+
]
|
|
66
|
+
.map((r) => `| ${r.mode} | ${r.agg.mrr} ${fmtCI(r.ci?.mrrCI)} | ${r.agg.recall} ${fmtCI(r.ci?.recallCI)} | ${r.agg.ndcg} ${fmtCI(r.ci?.ndcgCI)} |`)
|
|
67
|
+
.join("\n");
|
|
68
|
+
|
|
69
|
+
// Grid search rows
|
|
70
|
+
const rsfGridRows = qualityComp.rsfGrid
|
|
71
|
+
.map((g) => `| ${g.alpha} | ${g.mrr} | ${g.recall} | ${g.ndcg} | ${g.top1Wins} |`)
|
|
72
|
+
.join("\n");
|
|
73
|
+
const rrfGridRows = qualityComp.rrfGrid
|
|
74
|
+
.map((g) => `| ${g.k} | ${g.mrr} | ${g.recall} | ${g.ndcg} | ${g.top1Wins} |`)
|
|
75
|
+
.join("\n");
|
|
76
|
+
|
|
77
|
+
// Paired t-test rows
|
|
78
|
+
const tRows = [
|
|
79
|
+
{ test: "RRF vs Vector", r: qualityComp.pairedTests.rrfVsVector },
|
|
80
|
+
{ test: "RSF vs Vector", r: qualityComp.pairedTests.rsfVsVector },
|
|
81
|
+
{ test: "RRF vs RSF", r: qualityComp.pairedTests.rrfVsRsf },
|
|
82
|
+
]
|
|
83
|
+
.map((r) => `| ${r.test} | ${r.r.meanDiff} | ${r.r.t.toFixed(3)} | ${r.r.p} | ${r.r.sem} | ${r.r.n} |`)
|
|
84
|
+
.join("\n");
|
|
85
|
+
|
|
86
|
+
const winner = qualityComp.winner;
|
|
87
|
+
const winnerLabel =
|
|
88
|
+
winner === "hybrid_rsf" ? "RSF"
|
|
89
|
+
: winner === "hybrid_rrf" ? "RRF"
|
|
90
|
+
: winner === "vector" ? "Vector"
|
|
91
|
+
: "BM25";
|
|
92
|
+
|
|
93
|
+
const corpusSourceCount = ingestMetrics.docCount;
|
|
94
|
+
const networkDocCount = ingestMetrics.networkDocCount;
|
|
95
|
+
const localDocCount = ingestMetrics.localDocCount;
|
|
96
|
+
const memBaselineLabel = ingestMetrics.ramBaseline || "pre-loop";
|
|
97
|
+
|
|
98
|
+
// 4. Generate Comprehensive Markdown Report
|
|
99
|
+
const markdownReport = `# memory_plugin Local Hybrid RAG Rigorous Benchmark Report
|
|
100
|
+
|
|
101
|
+
**Generated At**: ${new Date().toISOString()}
|
|
102
|
+
**Total Benchmark Duration**: ${totalTimeSec} seconds
|
|
103
|
+
**Embedding Engine**: \`Xenova/multilingual-e5-small\` (ONNX Quantized 384-d vectors, FULL CPU Inference Enabled)
|
|
104
|
+
**Corpus**: ${corpusSourceCount} Documents (${networkDocCount} fetched from GitHub, ${localDocCount} local fallback)
|
|
105
|
+
**Match Policy**: Strictly on \`expectedDocIds\` derived from corpus source-id (NOT substring match)
|
|
106
|
+
**Statistical Inference**: Paired-t (reciprocal rank) & 1000-iteration bootstrap 95% percentile CI
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## 1. Dual-Layer Architectural Isolation (Notebook Facts vs RAG Docs)
|
|
111
|
+
|
|
112
|
+
| Layer / Component | Test Status | Empirical Verification |
|
|
113
|
+
|---|---|---|
|
|
114
|
+
| **Layer 1: Notebook Store** | [OK] PASSED | 100% precision instant recall of user identity/preferences without vector loss |
|
|
115
|
+
| **Layer 2: RAG Knowledge Base** | [OK] PASSED | Dynamic multi-tier chunking, hybrid BM25 + Vector RSF/RRF scoring, GraphRAG symbols |
|
|
116
|
+
| **Architectural Isolation** | [OK] PASSED | Zero crosstalk between persistent Notebook facts and RAG SQLite index |
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## 2. Mass Ingestion & Real ONNX Embedding Generation Speed
|
|
121
|
+
|
|
122
|
+
| Metric | Real Empirical Value | Description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| **Total Ingested Documents** | **${ingestMetrics.docCount} docs** | Real markdown, code, licenses from GitHub |
|
|
125
|
+
| **Total Medium Sections** | **${ingestMetrics.totalSections} sections** | Medium hierarchy level (500–1000 tokens) |
|
|
126
|
+
| **Total Micro-Chunks** | **${ingestMetrics.totalMicroChunks} chunks** | Small micro-chunk level (100–250 tokens) |
|
|
127
|
+
| **Total ONNX Vectors Computed** | **${ingestMetrics.totalMicroChunks} vectors** | 384-dimensional Float32Array dense vectors |
|
|
128
|
+
| **Total Ingestion Duration** | **${ingestMetrics.durationSec} s** | Including ONNX model inference & SQLite transactions |
|
|
129
|
+
| **Ingestion Throughput** | **${ingestMetrics.docsPerSec} docs/sec** | Real end-to-end ingestion throughput |
|
|
130
|
+
| **Vector Calculation Speed** | **${ingestMetrics.chunksPerSec} vectors/sec** | ONNX CPU inference speed |
|
|
131
|
+
| **SQLite Index Size** | **${ingestMetrics.dbSizeMB} MB** | DB containing FTS5, micro-chunks, and Float32 vectors |
|
|
132
|
+
| **Blob Storage Footprint** | **${ingestMetrics.blobSizeMB} MB** | Content-addressable SHA-256 compressed store |
|
|
133
|
+
| **RAM Memory Footprint (pre-GC Δ, ${memBaselineLabel})** | **${ingestMetrics.ramUsageMB} MB** | heapUsed + external delta, ingestion-only (ONNX model load excluded). Upper bound incl. transient garbage. Negative Δ = loop reclaimed more warmup garbage than it allocated. |
|
|
134
|
+
| **RAM Memory Footprint (post-GC settled Δ)** | **${ingestMetrics.settledRamUsageMB ?? "—"} MB** | After forced GC; persistent footprint retained by ingestion (sqlite cache, etc.) |
|
|
135
|
+
| **RAM Memory Peak (max Δ during loop)** | **${ingestMetrics.peakRamUsageMB ?? "—"} MB** | Best-effort peak; note: synchronous ONNX inference blocks the event loop, so interval polling understates true peak. See §9. |
|
|
136
|
+
|
|
137
|
+
> *ONNX native weights are owned by \`onnxruntime-node\` and not visible to \`heapUsed + external\`. To measure the one-time model weight load (≈90 MB for \`multilingual-e5-small\` quantized), use an external profiler (e.g. \`process-explorer\`) on the Node process.*
|
|
138
|
+
| **RAM Memory Peak (max Δ during loop)** | **${ingestMetrics.peakRamUsageMB ?? "—"} MB** | Best-effort peak; note: synchronous ONNX inference blocks the event loop, so interval polling understates true peak. See §9. |
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 3. Aggregate Strategy Comparison (BM25 vs Vector vs RRF vs RSF)
|
|
143
|
+
|
|
144
|
+
Evaluation over ${totalQueries} hard semantic, paraphrased, and cross-lingual Russian-to-English queries.
|
|
145
|
+
|
|
146
|
+
**Achieved winner by MRR (then recall tie-break)**: **${winnerLabel}**
|
|
147
|
+
|
|
148
|
+
| Search Strategy | MRR@5 | Recall@5 | NDCG@5 |
|
|
149
|
+
|---|---|---|---|
|
|
150
|
+
| **BM25 Text Search Only** | ${qualityComp.bm25.mrr} | ${qualityComp.bm25.recall} (${(qualityComp.bm25.recall * 100).toFixed(1)}%) | ${qualityComp.bm25.ndcg} |
|
|
151
|
+
| **Dense ONNX Vector Only** | ${qualityComp.vector.mrr} | ${qualityComp.vector.recall} (${(qualityComp.vector.recall * 100).toFixed(1)}%) | ${qualityComp.vector.ndcg} |
|
|
152
|
+
| **Hybrid RRF (Reciprocal Rank), k=${qualityComp.defaultRrfK ?? 60}** | ${qualityComp.hybridRrf.mrr} | ${qualityComp.hybridRrf.recall} (${(qualityComp.hybridRrf.recall * 100).toFixed(1)}%) | ${qualityComp.hybridRrf.ndcg} |
|
|
153
|
+
| **Hybrid RSF (Relative Score), α=${qualityComp.defaultAlpha ?? 0.5}** | ${qualityComp.hybridRsf.mrr} | ${qualityComp.hybridRsf.recall} (${(qualityComp.hybridRsf.recall * 100).toFixed(1)}%) | ${qualityComp.hybridRsf.ndcg} |
|
|
154
|
+
|
|
155
|
+
### 3.1 Bootstrap 95% CIs (reciprocal-rank resampling, 1000 iterations)
|
|
156
|
+
|
|
157
|
+
| Mode | MRR CI | Recall CI | NDCG CI |
|
|
158
|
+
|---|---|---|---|
|
|
159
|
+
${ciRows}
|
|
160
|
+
|
|
161
|
+
### 3.2 Paired t-tests (reciprocal rank, two-sided)
|
|
162
|
+
|
|
163
|
+
| Comparison | Mean ΔRR | t | p | SEM | n |
|
|
164
|
+
|---|---|---|---|---|---|
|
|
165
|
+
${tRows}
|
|
166
|
+
|
|
167
|
+
---
|
|
168
|
+
|
|
169
|
+
## 4. Per-Category Aggregate Metrics
|
|
170
|
+
|
|
171
|
+
| Category | N | BM25 MRR | Vector MRR | RRF MRR | RSF MRR | RRF Recall | RSF Recall |
|
|
172
|
+
|---|---|---|---|---|---|---|---|
|
|
173
|
+
${catRows}
|
|
174
|
+
|
|
175
|
+
---
|
|
176
|
+
|
|
177
|
+
## 5. Granular Query-by-Query Ranking Breakdown
|
|
178
|
+
|
|
179
|
+
| # | Target Doc | Category | BM25 Rank | Vector Rank | RRF Rank | RSF Rank | Query Text Snippet |
|
|
180
|
+
|---|---|---|---|---|---|---|---|
|
|
181
|
+
${breakdownRows}
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## 6. Hyperparameter Grid Search
|
|
186
|
+
|
|
187
|
+
### 6.1 RSF alpha sweep (default α=0.5)
|
|
188
|
+
|
|
189
|
+
| α | MRR | Recall | NDCG | Top-1 wins |
|
|
190
|
+
|---|---|---|---|---|
|
|
191
|
+
${rsfGridRows}
|
|
192
|
+
|
|
193
|
+
**Best α by MRR**: ${qualityComp.bestRsfAlpha?.alpha} → MRR ${qualityComp.bestRsfAlpha?.mrr}
|
|
194
|
+
|
|
195
|
+
### 6.2 RRF k sweep (default k=60)
|
|
196
|
+
|
|
197
|
+
| k | MRR | Recall | NDCG | Top-1 wins |
|
|
198
|
+
|---|---|---|---|---|
|
|
199
|
+
${rrfGridRows}
|
|
200
|
+
|
|
201
|
+
**Best k by MRR**: ${qualityComp.bestRrfK?.k} → MRR ${qualityComp.bestRrfK?.mrr}
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## 7. Search Latency (per-query, shared pre-fetch of BM25 + vector hits)
|
|
206
|
+
|
|
207
|
+
| Stat | ms |
|
|
208
|
+
|---|---|
|
|
209
|
+
| Mean | ${qualityComp.latency?.mean ?? "—"} |
|
|
210
|
+
| p50 | ${qualityComp.latency?.p50 ?? "—"} |
|
|
211
|
+
| p95 | ${qualityComp.latency?.p95 ?? "—"} |
|
|
212
|
+
| p99 | ${qualityComp.latency?.p99 ?? "—"} |
|
|
213
|
+
| Max | ${qualityComp.latency?.max ?? "—"} |
|
|
214
|
+
|
|
215
|
+
*Pre-fetch latency includes FTS5 query + ONNX embedding inference + full vector scan + SQLite join. All fusion modes operate on the pre-fetched candidate lists, so per-mode latency deltas vs BM25 are negligible (in-memory).*
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## 8. Detailed Analysis & Key Takeaways
|
|
220
|
+
|
|
221
|
+
1. **Achieved Winner**: ${winnerLabel} took the lead by MRR. **Treat differences smaller than the bootstrap CI half-width as noise** — at N=${totalQueries} a ≈0.03 MRR gap may not be statistically distinguishable from zero.
|
|
222
|
+
|
|
223
|
+
2. **Paired test verdict**: RRF vs RSF mean ΔRR = ${qualityComp.pairedTests.rrfVsRsf.meanDiff}, p ≈ ${qualityComp.pairedTests.rrfVsRsf.p}. ${
|
|
224
|
+
qualityComp.pairedTests.rrfVsRsf.p < 0.05
|
|
225
|
+
? "Statistically significant difference."
|
|
226
|
+
: "**NOT statistically significant** at α=0.05; treat as comparable."
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
3. **Per-Category Strengths**: BM25 wins Code/Keyword (lexical overlap), Vector wins Cross-Lingual (semantic gap bridging), hybrid modes narrow the long tail — see Section 4.
|
|
230
|
+
|
|
231
|
+
4. **Hybrid Recovery**: RRF found ${totalQueries - rrfMissed}/${totalQueries} (${(qualityComp.hybridRrf.recall * 100).toFixed(1)}%), RSF found ${totalQueries - rsfMissed}/${totalQueries} (${(qualityComp.hybridRsf.recall * 100).toFixed(1)}%), Vector-only found ${totalQueries - vecMissed}/${totalQueries} (${(qualityComp.vector.recall * 100).toFixed(1)}%), BM25-only found ${totalQueries - bm25Missed}/${totalQueries} (${(qualityComp.bm25.recall * 100).toFixed(1)}%).
|
|
232
|
+
|
|
233
|
+
5. **BM25 Cross-Lingual Limitation**: BM25 found ${totalQueries - bm25Missed}/${totalQueries} (${(qualityComp.bm25.recall * 100).toFixed(1)}%). BM25 fails on cross-lingual queries (Russian query → English docs) due to zero lexical overlap, while vector search bridges the semantic gap.
|
|
234
|
+
|
|
235
|
+
6. **Configurable CLI Architecture**: Users can switch algorithms on the fly between RSF, RRF, Pure Lexical, and Pure Semantic via \`memory_plugin cli\`. The headline table above uses the runtime defaults (α=${qualityComp.defaultAlpha}, k=${qualityComp.defaultRrfK}); the grid sweep in §6 picked α=${qualityComp.bestRsfAlpha?.alpha}, k=${qualityComp.bestRrfK?.k} as best-by-MRR — consider bumping them in \`config_defaults\`.
|
|
236
|
+
|
|
237
|
+
## 9. Reproducibility & Methodology Caveats
|
|
238
|
+
|
|
239
|
+
- **Strict doc-id matching**: a query counts as hit iff the returned chunk belongs to one of \`expectedDocIds\` (the corpus source-id derived from the blob file basename, e.g. \`axios_readme\`). This avoids false-positive substring matches (e.g. query "next" against any doc mentioning "next").
|
|
240
|
+
- **Pre-warm RSS baseline**: ONNX weights are loaded once *before* timing, so \`RAM Memory Footprint\` reflects ingestion-only memory; the previous report had a –109 MB negative value because the baseline was captured mid-loop.
|
|
241
|
+
- **Single run variance**: With only ${totalQueries} queries, single-run point estimates carry high variance. Bootstrap CIs (3.1) and the paired t-test (3.2) communicate the uncertainty; for production-grade claims please run with ≥50 queries on a fixed corpus snapshot.
|
|
242
|
+
- **Corpus drift**: Documents are fetched live from GitHub \`main\`/ \`master\` HEADs, so consecutive runs are NOT directly comparable across runs. Versioning the corpus (Git SHA snapshot) is the next step.
|
|
243
|
+
`;
|
|
244
|
+
|
|
245
|
+
return markdownReport;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function runFullBenchmarkSuite() {
|
|
249
|
+
const startTime = Date.now();
|
|
250
|
+
|
|
251
|
+
printRichPanel("LOCAL RAG BENCHMARK SUITE", "Real ONNX Embeddings & BM25 vs Vector vs RRF vs RSF");
|
|
252
|
+
|
|
253
|
+
// 1. Dual Layer Architectural Verification
|
|
254
|
+
console.log("\n --- Phase 1: Dual Layer Architectural Verification ---");
|
|
255
|
+
await testDualLayerArchitecture();
|
|
256
|
+
|
|
257
|
+
// 2. Ingestion & Storage Benchmark WITH REAL ONNX EMBEDDINGS
|
|
258
|
+
console.log("\n --- Phase 2: Real ONNX Ingestion & Embedding Benchmark ---");
|
|
259
|
+
const ingestMetrics = await runIngestionBenchmark({ generateEmbeddings: true });
|
|
260
|
+
|
|
261
|
+
// 3. Search Quality & Latency Benchmark with per-query breakdown
|
|
262
|
+
console.log("\n --- Phase 3: Granular Search Quality Comparison (BM25 vs Vector vs RRF vs RSF) ---");
|
|
263
|
+
const qualityComp = await evaluateSearchQualityComparison(ingestMetrics.dbInstance);
|
|
264
|
+
|
|
265
|
+
// Clean up test DB
|
|
266
|
+
if (ingestMetrics.dbInstance) {
|
|
267
|
+
try {
|
|
268
|
+
ingestMetrics.dbInstance.close();
|
|
269
|
+
} catch {}
|
|
270
|
+
}
|
|
271
|
+
if (existsSync(dirname(ingestMetrics.dbPath))) {
|
|
272
|
+
try {
|
|
273
|
+
rmSync(dirname(ingestMetrics.dbPath), { recursive: true, force: true });
|
|
274
|
+
} catch {}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const totalTimeSec = ((Date.now() - startTime) / 1000).toFixed(2);
|
|
278
|
+
const winner = qualityComp.winner;
|
|
279
|
+
const totalQueries = qualityComp.breakdown.length;
|
|
280
|
+
|
|
281
|
+
// 4. Generate Comprehensive Markdown Report
|
|
282
|
+
const markdownReport = buildMarkdownReport({ ingestMetrics, qualityComp, totalTimeSec });
|
|
283
|
+
|
|
284
|
+
await writeFile(REPORT_PATH, markdownReport, "utf-8");
|
|
285
|
+
|
|
286
|
+
// JSON sidecar (machine-readable, for CI / regression tooling) + history snapshot.
|
|
287
|
+
const timestamp = new Date()
|
|
288
|
+
.toISOString()
|
|
289
|
+
.replace(/[:.]/g, "-");
|
|
290
|
+
|
|
291
|
+
const machineReport = {
|
|
292
|
+
generatedAt: new Date().toISOString(),
|
|
293
|
+
totalDurationSec: Number(totalTimeSec),
|
|
294
|
+
corpus: {
|
|
295
|
+
docs: ingestMetrics.docCount,
|
|
296
|
+
network: ingestMetrics.networkDocCount,
|
|
297
|
+
local: ingestMetrics.localDocCount,
|
|
298
|
+
sections: ingestMetrics.totalSections,
|
|
299
|
+
microChunks: ingestMetrics.totalMicroChunks,
|
|
300
|
+
},
|
|
301
|
+
ingestion: {
|
|
302
|
+
durationSec: ingestMetrics.durationSec,
|
|
303
|
+
docsPerSec: ingestMetrics.docsPerSec,
|
|
304
|
+
chunksPerSec: ingestMetrics.chunksPerSec,
|
|
305
|
+
dbSizeMB: ingestMetrics.dbSizeMB,
|
|
306
|
+
blobSizeMB: ingestMetrics.blobSizeMB,
|
|
307
|
+
ramUsageMB: ingestMetrics.ramUsageMB,
|
|
308
|
+
settledRamUsageMB: ingestMetrics.settledRamUsageMB ?? null,
|
|
309
|
+
peakRamUsageMB: ingestMetrics.peakRamUsageMB ?? null,
|
|
310
|
+
ramBaseline: ingestMetrics.ramBaseline ?? "pre-loop",
|
|
311
|
+
ramMetric: ingestMetrics.metric ?? "heapUsed + external",
|
|
312
|
+
},
|
|
313
|
+
search: {
|
|
314
|
+
winner,
|
|
315
|
+
n: totalQueries,
|
|
316
|
+
bm25: qualityComp.bm25,
|
|
317
|
+
vector: qualityComp.vector,
|
|
318
|
+
hybridRrf: qualityComp.hybridRrf,
|
|
319
|
+
hybridRsf: qualityComp.hybridRsf,
|
|
320
|
+
bootstrap: qualityComp.bootstrap,
|
|
321
|
+
pairedTests: qualityComp.pairedTests,
|
|
322
|
+
categoryBreakdown: qualityComp.categoryBreakdown,
|
|
323
|
+
rsfGrid: qualityComp.rsfGrid,
|
|
324
|
+
rrfGrid: qualityComp.rrfGrid,
|
|
325
|
+
bestRsfAlpha: qualityComp.bestRsfAlpha,
|
|
326
|
+
bestRrfK: qualityComp.bestRrfK,
|
|
327
|
+
latency: qualityComp.latency,
|
|
328
|
+
breakdown: qualityComp.breakdown,
|
|
329
|
+
},
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const JSON_PATH = join(__dirname, "..", "..", "dev_docs", `benchmark_${timestamp}.json`);
|
|
333
|
+
await writeFile(JSON_PATH, JSON.stringify(machineReport, null, 2), "utf-8");
|
|
334
|
+
|
|
335
|
+
if (!existsSync(HISTORY_DIR)) {
|
|
336
|
+
await mkdirAsync(HISTORY_DIR, { recursive: true });
|
|
337
|
+
}
|
|
338
|
+
const HISTORY_PATH = join(HISTORY_DIR, `benchmark_${timestamp}.json`);
|
|
339
|
+
await writeFile(HISTORY_PATH, JSON.stringify(machineReport, null, 2), "utf-8");
|
|
340
|
+
|
|
341
|
+
console.log(`\n [OK] BENCHMARK SUITE COMPLETED IN ${totalTimeSec}s!`);
|
|
342
|
+
console.log(` [REPORT] Markdown: dev_docs/benchmark_results.md`);
|
|
343
|
+
console.log(` [JSON] Latest: dev_docs/benchmark_${timestamp}.json`);
|
|
344
|
+
console.log(` [HIST] History: dev_docs/benchmark_history/benchmark_${timestamp}.json\n`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Auto-respawn with --expose-gc if needed so the ingestion memory benchmark can
|
|
348
|
+
// force GC after model warm-up, giving a clean baseline. Without it `global.gc`
|
|
349
|
+
// is undefined and the baseline stays noisy.
|
|
350
|
+
// Only run when this file is the entry point. Importing it (e.g. to reuse
|
|
351
|
+
// buildMarkdownReport) must not kick off a full ONNX benchmark run.
|
|
352
|
+
const isEntryPoint =
|
|
353
|
+
process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url;
|
|
354
|
+
|
|
355
|
+
if (isEntryPoint) {
|
|
356
|
+
if (!process.argv.includes("--no-respawn") && !global.gc) {
|
|
357
|
+
const { spawn } = await import("node:child_process");
|
|
358
|
+
const child = spawn(process.execPath, ["--expose-gc", ...process.argv.slice(1)], { stdio: "inherit" });
|
|
359
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
360
|
+
} else {
|
|
361
|
+
runFullBenchmarkSuite().catch((err) => {
|
|
362
|
+
console.error(" [ERROR] Benchmark Suite Executed with Errors:", err);
|
|
363
|
+
process.exit(1);
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { rmSync, existsSync } from "node:fs";
|
|
5
|
+
import { getDatabase } from "../db/database.js";
|
|
6
|
+
import { ingestDocument } from "../ingest/pipeline.js";
|
|
7
|
+
import { embedText, resetExtractor } from "../ml/model_manager.js";
|
|
8
|
+
import { CORPUS_DIR, fetchRealCorpus } from "./fetch_real_corpus.js";
|
|
9
|
+
|
|
10
|
+
// RSS is too volatile to measure incremental ingestion memory because V8's RSS
|
|
11
|
+
// retains free-list pages long after the underlying heap shrinks. We track
|
|
12
|
+
// `heapUsed + external` instead: heapUsed covers JS allocations (vector buffers,
|
|
13
|
+
// sqlite cache), external covers WASM/ONNX buffers held off-heap.
|
|
14
|
+
function memUsedBytes() {
|
|
15
|
+
const m = process.memoryUsage();
|
|
16
|
+
return m.heapUsed + m.external;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const PANEL_WIDTH = 58;
|
|
20
|
+
|
|
21
|
+
function printRichPanel(title, subtitle = "") {
|
|
22
|
+
const line = "─".repeat(PANEL_WIDTH - 2);
|
|
23
|
+
console.log(`\x1b[36m╭${line}╮\x1b[0m`);
|
|
24
|
+
console.log(`\x1b[36m│\x1b[0m \x1b[1m\x1b[37m${title.padEnd(PANEL_WIDTH - 6)}\x1b[0m \x1b[36m│\x1b[0m`);
|
|
25
|
+
if (subtitle) {
|
|
26
|
+
console.log(`\x1b[36m│\x1b[0m \x1b[90m${subtitle.padEnd(PANEL_WIDTH - 6)}\x1b[0m \x1b[36m│\x1b[0m`);
|
|
27
|
+
}
|
|
28
|
+
console.log(`\x1b[36m╰${line}╯\x1b[0m`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function runIngestionBenchmark(options = {}) {
|
|
32
|
+
const silent = options.silent || false;
|
|
33
|
+
const onProgress = options.onProgress || null;
|
|
34
|
+
const subsetDocIds = options.subsetDocIds || null;
|
|
35
|
+
const corpus = await fetchRealCorpus({ silent, onProgress, subsetDocIds });
|
|
36
|
+
const TEST_DIR = join(tmpdir(), `memory_bench_ingest_${Date.now()}`);
|
|
37
|
+
const TEST_DB_PATH = join(TEST_DIR, "bench_memory.sqlite");
|
|
38
|
+
const TEST_BLOB_DIR = join(TEST_DIR, "blobs");
|
|
39
|
+
|
|
40
|
+
const db = await getDatabase(TEST_DB_PATH);
|
|
41
|
+
|
|
42
|
+
if (!silent) {
|
|
43
|
+
printRichPanel("DOCUMENT INGESTION BENCHMARK", `Embeddings ONNX Enabled: ${options.generateEmbeddings}`);
|
|
44
|
+
console.log(`\n [INGEST] Processing ${corpus.length} technical documents...\n`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Pre-warm ONNX model so its weight loading does NOT inflate ingestion RSS delta.
|
|
48
|
+
// Without this the first embedText() inside the loop would pay the one-time model
|
|
49
|
+
// load cost (~100MB), biasing the "Ingestion RAM" metric.
|
|
50
|
+
let baselineMemLabel = "pre-loop";
|
|
51
|
+
if (options.generateEmbeddings) {
|
|
52
|
+
await embedText("warmup", false);
|
|
53
|
+
baselineMemLabel = "post-model-warmup";
|
|
54
|
+
if (!silent) console.log(` [Warmup] ONNX model loaded; baseline taken ${baselineMemLabel}.`);
|
|
55
|
+
// Force GC if exposed to drop transient allocation noise from init.
|
|
56
|
+
if (global.gc) {
|
|
57
|
+
global.gc();
|
|
58
|
+
baselineMemLabel = "post-model-warmup(post-gc)";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const startMem = memUsedBytes();
|
|
63
|
+
const startTime = performance.now();
|
|
64
|
+
// Track peak heapUsed+external during the loop to capture transient allocations.
|
|
65
|
+
let peakMem = startMem;
|
|
66
|
+
// Heap shrinkage is much faster than RSS free-list reclaim, so polling works; but
|
|
67
|
+
// also sample synchronously after each doc ingest in between timer ticks, since
|
|
68
|
+
// ONNX inference for a single doc may span multiple 25ms windows.
|
|
69
|
+
const memPollInterval = setInterval(() => {
|
|
70
|
+
const cur = memUsedBytes();
|
|
71
|
+
if (cur > peakMem) peakMem = cur;
|
|
72
|
+
}, 25);
|
|
73
|
+
|
|
74
|
+
let totalSections = 0;
|
|
75
|
+
let totalMicroChunks = 0;
|
|
76
|
+
let totalBytes = 0;
|
|
77
|
+
let deduplicatedCount = 0;
|
|
78
|
+
|
|
79
|
+
for (let i = 0; i < corpus.length; i++) {
|
|
80
|
+
const file = corpus[i];
|
|
81
|
+
const content = await readFile(file.path, "utf-8");
|
|
82
|
+
totalBytes += content.length;
|
|
83
|
+
|
|
84
|
+
const ingestRes = await ingestDocument({
|
|
85
|
+
content,
|
|
86
|
+
type: "file",
|
|
87
|
+
title: file.title,
|
|
88
|
+
path: file.path,
|
|
89
|
+
generateEmbeddings: options.generateEmbeddings,
|
|
90
|
+
customDb: db,
|
|
91
|
+
customBlobDir: TEST_BLOB_DIR,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
totalSections += ingestRes.sectionsCount;
|
|
95
|
+
totalMicroChunks += ingestRes.microChunksCount;
|
|
96
|
+
if (ingestRes.deduplicated) deduplicatedCount++;
|
|
97
|
+
|
|
98
|
+
if (global.gc) global.gc();
|
|
99
|
+
|
|
100
|
+
// Synchronous peak sample: captures peak after each doc ingestion completes,
|
|
101
|
+
// complementing the 25ms interval poll (which can miss transient peaks).
|
|
102
|
+
const cur = memUsedBytes();
|
|
103
|
+
if (cur > peakMem) peakMem = cur;
|
|
104
|
+
|
|
105
|
+
if (!silent && ((i + 1) % 5 === 0 || i === corpus.length - 1)) {
|
|
106
|
+
console.log(` [Progress] Ingested ${i + 1}/${corpus.length} docs (${totalMicroChunks} micro-chunks)`);
|
|
107
|
+
}
|
|
108
|
+
if (onProgress) onProgress({ phase: "ingest", current: i + 1, total: corpus.length });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
clearInterval(memPollInterval);
|
|
112
|
+
const endTime = performance.now();
|
|
113
|
+
const endMemPreGc = memUsedBytes();
|
|
114
|
+
|
|
115
|
+
// Force GC to separate transient garbage from the persistent ingestion footprint.
|
|
116
|
+
// Requires --expose-gc (run_benchmarks.js auto-respawns with it).
|
|
117
|
+
let endMemPostGc = endMemPreGc;
|
|
118
|
+
if (global.gc) {
|
|
119
|
+
global.gc();
|
|
120
|
+
endMemPostGc = memUsedBytes();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const durationMs = endTime - startTime;
|
|
124
|
+
const durationSec = durationMs / 1000;
|
|
125
|
+
const docsPerSec = corpus.length / durationSec;
|
|
126
|
+
const chunksPerSec = totalMicroChunks / durationSec;
|
|
127
|
+
const peakDeltaMB = Math.max(peakMem - startMem, endMemPreGc - startMem) / (1024 * 1024);
|
|
128
|
+
|
|
129
|
+
const dbStat = await stat(TEST_DB_PATH);
|
|
130
|
+
const dbSizeBytes = dbStat.size;
|
|
131
|
+
|
|
132
|
+
let blobSizeBytes = 0;
|
|
133
|
+
if (existsSync(TEST_BLOB_DIR)) {
|
|
134
|
+
const blobFiles = await readdir(TEST_BLOB_DIR, { recursive: true });
|
|
135
|
+
for (const bf of blobFiles) {
|
|
136
|
+
const p = join(TEST_BLOB_DIR, bf);
|
|
137
|
+
const st = await stat(p);
|
|
138
|
+
if (st.isFile()) blobSizeBytes += st.size;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const metrics = {
|
|
143
|
+
docCount: corpus.length,
|
|
144
|
+
networkDocCount: corpus.filter((d) => d.source === "network").length,
|
|
145
|
+
localDocCount: corpus.filter((d) => d.source !== "network").length,
|
|
146
|
+
totalSections,
|
|
147
|
+
totalMicroChunks,
|
|
148
|
+
totalBytes,
|
|
149
|
+
deduplicatedCount,
|
|
150
|
+
durationSec: Number(durationSec.toFixed(2)),
|
|
151
|
+
docsPerSec: Number(docsPerSec.toFixed(2)),
|
|
152
|
+
chunksPerSec: Number(chunksPerSec.toFixed(2)),
|
|
153
|
+
dbSizeMB: Number((dbSizeBytes / (1024 * 1024)).toFixed(2)),
|
|
154
|
+
blobSizeMB: Number((blobSizeBytes / (1024 * 1024)).toFixed(2)),
|
|
155
|
+
// Ingestion-only memory delta (heapUsed + external), measured AFTER model
|
|
156
|
+
// pre-warm so ONNX weight load is excluded. `ramUsageMB` is pre-GC (upper
|
|
157
|
+
// bound incl. transient garbage); `settledRamUsageMB` is post-GC.
|
|
158
|
+
ramUsageMB: Number(((endMemPreGc - startMem) / (1024 * 1024)).toFixed(2)),
|
|
159
|
+
settledRamUsageMB: Number(((endMemPostGc - startMem) / (1024 * 1024)).toFixed(2)),
|
|
160
|
+
peakRamUsageMB: Number(peakDeltaMB.toFixed(2)),
|
|
161
|
+
ramBaseline: baselineMemLabel,
|
|
162
|
+
metric: "heapUsed + external",
|
|
163
|
+
dbPath: TEST_DB_PATH,
|
|
164
|
+
blobDir: TEST_BLOB_DIR,
|
|
165
|
+
dbInstance: db,
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
if (!silent) {
|
|
169
|
+
console.log("\n [Ingestion Performance Summary]");
|
|
170
|
+
console.log(` - Total Documents: ${metrics.docCount}`);
|
|
171
|
+
console.log(` - Total Sections: ${metrics.totalSections}`);
|
|
172
|
+
console.log(` - Total Micro-Chunks: ${metrics.totalMicroChunks}`);
|
|
173
|
+
console.log(` - Duration: ${metrics.durationSec}s`);
|
|
174
|
+
console.log(` - Throughput: ${metrics.docsPerSec} docs/sec | ${metrics.chunksPerSec} chunks/sec`);
|
|
175
|
+
console.log(` - Database Size: ${metrics.dbSizeMB} MB`);
|
|
176
|
+
console.log(` - Blob Storage Size: ${metrics.blobSizeMB} MB`);
|
|
177
|
+
console.log(` - Memory Footprint pre-GC (heapUsed+ext Δ): ${metrics.ramUsageMB} MB (${metrics.ramBaseline})`);
|
|
178
|
+
console.log(` - Memory Footprint post-GC (settled Δ): ${metrics.settledRamUsageMB} MB`);
|
|
179
|
+
console.log(` - Memory Peak (max Δ during loop): ${metrics.peakRamUsageMB} MB`);
|
|
180
|
+
console.log(` (Negative ingestion Δ = loop reclaimed more warmup garbage than it allocated.`);
|
|
181
|
+
console.log(` ONNX native weights are owned by onnxruntime-node and not visible here.)\n`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return metrics;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (process.argv[1] && process.argv[1].includes("stress_ingestion.js")) {
|
|
188
|
+
const metrics = await runIngestionBenchmark({ generateEmbeddings: false });
|
|
189
|
+
if (existsSync(dirname(metrics.dbPath))) {
|
|
190
|
+
try {
|
|
191
|
+
metrics.dbInstance.close();
|
|
192
|
+
rmSync(dirname(metrics.dbPath), { recursive: true, force: true });
|
|
193
|
+
} catch {}
|
|
194
|
+
}
|
|
195
|
+
}
|