voyageai-cli 1.6.1 → 1.8.0
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/README.md +4 -3
- package/package.json +2 -3
- package/src/cli.js +4 -0
- package/src/commands/benchmark.js +799 -0
- package/src/commands/playground.js +236 -0
- package/src/lib/explanations.js +47 -0
- package/src/lib/ui.js +53 -4
- package/src/playground/index.html +1111 -0
- package/test/commands/benchmark.test.js +252 -0
- package/test/commands/playground.test.js +137 -0
- package/test/lib/explanations.test.js +1 -0
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const { generateEmbeddings, apiRequest } = require('../lib/api');
|
|
5
|
+
const { cosineSimilarity } = require('../lib/math');
|
|
6
|
+
const { MODEL_CATALOG, getDefaultModel, DEFAULT_RERANK_MODEL } = require('../lib/catalog');
|
|
7
|
+
const ui = require('../lib/ui');
|
|
8
|
+
|
|
9
|
+
// ── Built-in sample corpus for zero-config benchmarks ──
|
|
10
|
+
|
|
11
|
+
const SAMPLE_TEXTS = [
|
|
12
|
+
'MongoDB Atlas provides a fully managed cloud database service with built-in vector search capabilities.',
|
|
13
|
+
'Kubernetes orchestrates containerized applications across clusters of machines for high availability.',
|
|
14
|
+
'Machine learning models transform raw data into embeddings that capture semantic meaning.',
|
|
15
|
+
'RESTful APIs use HTTP methods like GET, POST, PUT, and DELETE to manage resources.',
|
|
16
|
+
'Natural language processing enables computers to understand and generate human language.',
|
|
17
|
+
'Vector databases store high-dimensional embeddings and support fast nearest-neighbor search.',
|
|
18
|
+
'Microservices architecture breaks applications into small, independently deployable services.',
|
|
19
|
+
'Retrieval-augmented generation combines search with language models for grounded answers.',
|
|
20
|
+
'TLS encryption protects data in transit between clients and servers using certificate-based authentication.',
|
|
21
|
+
'GraphQL provides a flexible query language that lets clients request exactly the data they need.',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const SAMPLE_QUERY = 'How do I search for similar documents using embeddings?';
|
|
25
|
+
|
|
26
|
+
const SAMPLE_RERANK_DOCS = [
|
|
27
|
+
'Vector search finds documents by computing similarity between embedding vectors in high-dimensional space.',
|
|
28
|
+
'MongoDB Atlas Vector Search lets you index and query vector embeddings alongside your operational data.',
|
|
29
|
+
'Traditional full-text search uses inverted indexes to match keyword terms in documents.',
|
|
30
|
+
'Cosine similarity measures the angle between two vectors, commonly used for semantic search.',
|
|
31
|
+
'Database sharding distributes data across multiple servers for horizontal scalability.',
|
|
32
|
+
'Embedding models convert text into dense numerical vectors that capture meaning.',
|
|
33
|
+
'SQL JOIN operations combine rows from two or more tables based on related columns.',
|
|
34
|
+
'Approximate nearest neighbor algorithms like HNSW enable fast similarity search at scale.',
|
|
35
|
+
'Load balancers distribute network traffic across multiple servers to ensure reliability.',
|
|
36
|
+
'Reranking models rescore initial search results to improve relevance ordering.',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// ── Helpers ──
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a comma-separated list of model names.
|
|
43
|
+
*/
|
|
44
|
+
function parseModels(val) {
|
|
45
|
+
return val.split(',').map(m => m.trim()).filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Compute percentile from sorted array.
|
|
50
|
+
*/
|
|
51
|
+
function percentile(sorted, p) {
|
|
52
|
+
const idx = Math.ceil((p / 100) * sorted.length) - 1;
|
|
53
|
+
return sorted[Math.max(0, idx)];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Strip ANSI escape codes from a string for width calculations.
|
|
58
|
+
*/
|
|
59
|
+
// eslint-disable-next-line no-control-regex
|
|
60
|
+
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
61
|
+
function stripAnsi(str) {
|
|
62
|
+
return String(str).replace(ANSI_RE, '');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Format milliseconds with color.
|
|
67
|
+
*/
|
|
68
|
+
function fmtMs(ms) {
|
|
69
|
+
const s = ms.toFixed(0) + 'ms';
|
|
70
|
+
if (ms < 200) return ui.green(s);
|
|
71
|
+
if (ms < 500) return ui.yellow(s);
|
|
72
|
+
return ui.red(s);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Right-pad a (possibly ANSI-colored) string to a visible width.
|
|
77
|
+
*/
|
|
78
|
+
function rpad(str, width) {
|
|
79
|
+
const s = String(str);
|
|
80
|
+
const visible = stripAnsi(s).length;
|
|
81
|
+
return s + ' '.repeat(Math.max(0, width - visible));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Left-pad a (possibly ANSI-colored) string to a visible width.
|
|
86
|
+
*/
|
|
87
|
+
function lpad(str, width) {
|
|
88
|
+
const s = String(str);
|
|
89
|
+
const visible = stripAnsi(s).length;
|
|
90
|
+
return ' '.repeat(Math.max(0, width - visible)) + s;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Load texts from a file (JSON array or newline-delimited).
|
|
95
|
+
*/
|
|
96
|
+
function loadTexts(filePath) {
|
|
97
|
+
const content = fs.readFileSync(filePath, 'utf-8').trim();
|
|
98
|
+
try {
|
|
99
|
+
const parsed = JSON.parse(content);
|
|
100
|
+
if (Array.isArray(parsed)) {
|
|
101
|
+
return parsed.map(item => (typeof item === 'string') ? item : (item.text || JSON.stringify(item)));
|
|
102
|
+
}
|
|
103
|
+
} catch { /* not JSON */ }
|
|
104
|
+
return content.split('\n').filter(line => line.trim());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run a single timed embedding request.
|
|
109
|
+
*/
|
|
110
|
+
async function timedEmbed(texts, model, inputType, dimensions) {
|
|
111
|
+
const opts = { model };
|
|
112
|
+
if (inputType) opts.inputType = inputType;
|
|
113
|
+
if (dimensions) opts.dimensions = dimensions;
|
|
114
|
+
|
|
115
|
+
const start = performance.now();
|
|
116
|
+
const result = await generateEmbeddings(texts, opts);
|
|
117
|
+
const elapsed = performance.now() - start;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
elapsed,
|
|
121
|
+
tokens: result.usage?.total_tokens || 0,
|
|
122
|
+
dimensions: result.data?.[0]?.embedding?.length || 0,
|
|
123
|
+
embeddings: result.data?.map(d => d.embedding),
|
|
124
|
+
model: result.model,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Run a single timed rerank request.
|
|
130
|
+
*/
|
|
131
|
+
async function timedRerank(query, documents, model, topK) {
|
|
132
|
+
const body = { query, documents, model };
|
|
133
|
+
if (topK) body.top_k = topK;
|
|
134
|
+
|
|
135
|
+
const start = performance.now();
|
|
136
|
+
const result = await apiRequest('/rerank', body);
|
|
137
|
+
const elapsed = performance.now() - start;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
elapsed,
|
|
141
|
+
tokens: result.usage?.total_tokens || 0,
|
|
142
|
+
results: result.data || [],
|
|
143
|
+
model: result.model,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get price per 1M tokens for a model from catalog.
|
|
149
|
+
*/
|
|
150
|
+
function getPrice(modelName) {
|
|
151
|
+
const entry = MODEL_CATALOG.find(m => m.name === modelName);
|
|
152
|
+
if (!entry) return null;
|
|
153
|
+
const match = entry.price.match(/\$([0-9.]+)\/1M/);
|
|
154
|
+
return match ? parseFloat(match[1]) : null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Subcommands ──
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* benchmark embed — Latency & throughput comparison across embedding models.
|
|
161
|
+
*/
|
|
162
|
+
async function benchmarkEmbed(opts) {
|
|
163
|
+
const models = opts.models ? parseModels(opts.models) : ['voyage-4-large', 'voyage-4', 'voyage-4-lite'];
|
|
164
|
+
const rounds = parseInt(opts.rounds, 10) || 5;
|
|
165
|
+
const inputType = opts.inputType || 'document';
|
|
166
|
+
const dimensions = opts.dimensions ? parseInt(opts.dimensions, 10) : undefined;
|
|
167
|
+
|
|
168
|
+
let texts;
|
|
169
|
+
if (opts.input) {
|
|
170
|
+
texts = [opts.input];
|
|
171
|
+
} else if (opts.file) {
|
|
172
|
+
texts = loadTexts(opts.file);
|
|
173
|
+
} else {
|
|
174
|
+
texts = SAMPLE_TEXTS;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (!opts.json && !opts.quiet) {
|
|
178
|
+
console.log('');
|
|
179
|
+
console.log(ui.bold(' Embedding Benchmark'));
|
|
180
|
+
console.log(ui.dim(` ${texts.length} text(s) × ${rounds} rounds × ${models.length} model(s)`));
|
|
181
|
+
if (dimensions) console.log(ui.dim(` Output dimensions: ${dimensions}`));
|
|
182
|
+
console.log('');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const results = [];
|
|
186
|
+
|
|
187
|
+
for (const model of models) {
|
|
188
|
+
const latencies = [];
|
|
189
|
+
let totalTokens = 0;
|
|
190
|
+
let dims = 0;
|
|
191
|
+
|
|
192
|
+
const spin = (!opts.json && !opts.quiet) ? ui.spinner(` Benchmarking ${model}...`) : null;
|
|
193
|
+
if (spin) spin.start();
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < rounds; i++) {
|
|
196
|
+
try {
|
|
197
|
+
const r = await timedEmbed(texts, model, inputType, dimensions);
|
|
198
|
+
latencies.push(r.elapsed);
|
|
199
|
+
totalTokens = r.tokens; // same per call for same input
|
|
200
|
+
dims = r.dimensions;
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (spin) spin.stop();
|
|
203
|
+
console.error(ui.warn(` ${model}: ${err.message} — skipping`));
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (spin) spin.stop();
|
|
209
|
+
|
|
210
|
+
if (latencies.length === 0) continue;
|
|
211
|
+
|
|
212
|
+
const sorted = [...latencies].sort((a, b) => a - b);
|
|
213
|
+
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
|
214
|
+
const price = getPrice(model);
|
|
215
|
+
|
|
216
|
+
results.push({
|
|
217
|
+
model,
|
|
218
|
+
rounds: latencies.length,
|
|
219
|
+
avg,
|
|
220
|
+
min: sorted[0],
|
|
221
|
+
max: sorted[sorted.length - 1],
|
|
222
|
+
p50: percentile(sorted, 50),
|
|
223
|
+
p95: percentile(sorted, 95),
|
|
224
|
+
tokens: totalTokens,
|
|
225
|
+
dimensions: dims,
|
|
226
|
+
pricePerMillion: price,
|
|
227
|
+
costEstimate: price ? (totalTokens / 1_000_000) * price : null,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (opts.json) {
|
|
232
|
+
console.log(JSON.stringify({ benchmark: 'embed', texts: texts.length, rounds, results }, null, 2));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (results.length === 0) {
|
|
237
|
+
console.error(ui.error('No models completed successfully.'));
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Display table
|
|
242
|
+
const header = ` ${rpad('Model', 24)} ${lpad('Avg', 8)} ${lpad('p50', 8)} ${lpad('p95', 8)} ${lpad('Min', 8)} ${lpad('Max', 8)} ${lpad('Dims', 6)} ${lpad('Tokens', 7)} ${lpad('$/1M tok', 9)}`;
|
|
243
|
+
console.log(ui.dim(header));
|
|
244
|
+
console.log(ui.dim(' ' + '─'.repeat(header.length - 2)));
|
|
245
|
+
|
|
246
|
+
// Sort by avg latency
|
|
247
|
+
results.sort((a, b) => a.avg - b.avg);
|
|
248
|
+
const fastest = results[0].avg;
|
|
249
|
+
|
|
250
|
+
for (const r of results) {
|
|
251
|
+
const badge = r.avg === fastest ? ui.green(' ⚡') : ' ';
|
|
252
|
+
const priceStr = r.pricePerMillion != null ? `$${r.pricePerMillion.toFixed(2)}` : 'N/A';
|
|
253
|
+
console.log(
|
|
254
|
+
` ${rpad(r.model, 24)} ${lpad(fmtMs(r.avg), 8)} ${lpad(fmtMs(r.p50), 8)} ${lpad(fmtMs(r.p95), 8)} ${lpad(fmtMs(r.min), 8)} ${lpad(fmtMs(r.max), 8)} ${lpad(String(r.dimensions), 6)} ${lpad(String(r.tokens), 7)} ${lpad(priceStr, 9)}${badge}`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
console.log('');
|
|
259
|
+
|
|
260
|
+
// Verdict
|
|
261
|
+
const cheapest = [...results].sort((a, b) => (a.pricePerMillion || 999) - (b.pricePerMillion || 999))[0];
|
|
262
|
+
if (results.length > 1) {
|
|
263
|
+
console.log(ui.success(`Fastest: ${ui.bold(results[0].model)} (${results[0].avg.toFixed(0)}ms avg)`));
|
|
264
|
+
if (cheapest.model !== results[0].model) {
|
|
265
|
+
console.log(ui.info(`Cheapest: ${ui.bold(cheapest.model)} ($${cheapest.pricePerMillion?.toFixed(2)}/1M tokens)`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
console.log('');
|
|
269
|
+
|
|
270
|
+
// Save results
|
|
271
|
+
if (opts.save) {
|
|
272
|
+
const outData = { benchmark: 'embed', timestamp: new Date().toISOString(), texts: texts.length, rounds, results };
|
|
273
|
+
const outPath = typeof opts.save === 'string' ? opts.save : `benchmark-embed-${Date.now()}.json`;
|
|
274
|
+
fs.writeFileSync(outPath, JSON.stringify(outData, null, 2));
|
|
275
|
+
console.log(ui.info(`Results saved to ${outPath}`));
|
|
276
|
+
console.log('');
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* benchmark rerank — Compare reranking models.
|
|
282
|
+
*/
|
|
283
|
+
async function benchmarkRerank(opts) {
|
|
284
|
+
const models = opts.models ? parseModels(opts.models) : ['rerank-2.5', 'rerank-2.5-lite'];
|
|
285
|
+
const rounds = parseInt(opts.rounds, 10) || 5;
|
|
286
|
+
const query = opts.query || SAMPLE_QUERY;
|
|
287
|
+
const topK = opts.topK ? parseInt(opts.topK, 10) : undefined;
|
|
288
|
+
|
|
289
|
+
let documents;
|
|
290
|
+
if (opts.documentsFile) {
|
|
291
|
+
documents = loadTexts(opts.documentsFile);
|
|
292
|
+
} else {
|
|
293
|
+
documents = SAMPLE_RERANK_DOCS;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!opts.json && !opts.quiet) {
|
|
297
|
+
console.log('');
|
|
298
|
+
console.log(ui.bold(' Rerank Benchmark'));
|
|
299
|
+
console.log(ui.dim(` ${documents.length} docs × ${rounds} rounds × ${models.length} model(s)`));
|
|
300
|
+
console.log(ui.dim(` Query: "${query.substring(0, 60)}${query.length > 60 ? '...' : ''}"`));
|
|
301
|
+
if (topK) console.log(ui.dim(` Top-K: ${topK}`));
|
|
302
|
+
console.log('');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const allResults = [];
|
|
306
|
+
|
|
307
|
+
for (const model of models) {
|
|
308
|
+
const latencies = [];
|
|
309
|
+
let totalTokens = 0;
|
|
310
|
+
let lastRankResults = [];
|
|
311
|
+
|
|
312
|
+
const spin = (!opts.json && !opts.quiet) ? ui.spinner(` Benchmarking ${model}...`) : null;
|
|
313
|
+
if (spin) spin.start();
|
|
314
|
+
|
|
315
|
+
for (let i = 0; i < rounds; i++) {
|
|
316
|
+
try {
|
|
317
|
+
const r = await timedRerank(query, documents, model, topK);
|
|
318
|
+
latencies.push(r.elapsed);
|
|
319
|
+
totalTokens = r.tokens;
|
|
320
|
+
lastRankResults = r.results;
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (spin) spin.stop();
|
|
323
|
+
console.error(ui.warn(` ${model}: ${err.message} — skipping`));
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (spin) spin.stop();
|
|
329
|
+
|
|
330
|
+
if (latencies.length === 0) continue;
|
|
331
|
+
|
|
332
|
+
const sorted = [...latencies].sort((a, b) => a - b);
|
|
333
|
+
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
|
334
|
+
const price = getPrice(model);
|
|
335
|
+
|
|
336
|
+
allResults.push({
|
|
337
|
+
model,
|
|
338
|
+
rounds: latencies.length,
|
|
339
|
+
avg,
|
|
340
|
+
min: sorted[0],
|
|
341
|
+
max: sorted[sorted.length - 1],
|
|
342
|
+
p50: percentile(sorted, 50),
|
|
343
|
+
p95: percentile(sorted, 95),
|
|
344
|
+
tokens: totalTokens,
|
|
345
|
+
pricePerMillion: price,
|
|
346
|
+
topResults: lastRankResults.slice(0, topK || 5),
|
|
347
|
+
rankOrder: lastRankResults.map(r => r.index),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (opts.json) {
|
|
352
|
+
console.log(JSON.stringify({ benchmark: 'rerank', query, documents: documents.length, rounds, results: allResults }, null, 2));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (allResults.length === 0) {
|
|
357
|
+
console.error(ui.error('No models completed successfully.'));
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Latency table
|
|
362
|
+
const header = ` ${rpad('Model', 22)} ${lpad('Avg', 8)} ${lpad('p50', 8)} ${lpad('p95', 8)} ${lpad('Min', 8)} ${lpad('Max', 8)} ${lpad('Tokens', 7)} ${lpad('$/1M tok', 9)}`;
|
|
363
|
+
console.log(ui.dim(header));
|
|
364
|
+
console.log(ui.dim(' ' + '─'.repeat(header.length - 2)));
|
|
365
|
+
|
|
366
|
+
allResults.sort((a, b) => a.avg - b.avg);
|
|
367
|
+
const fastest = allResults[0].avg;
|
|
368
|
+
|
|
369
|
+
for (const r of allResults) {
|
|
370
|
+
const badge = r.avg === fastest ? ui.green(' ⚡') : ' ';
|
|
371
|
+
const priceStr = r.pricePerMillion != null ? `$${r.pricePerMillion.toFixed(2)}` : 'N/A';
|
|
372
|
+
console.log(
|
|
373
|
+
` ${rpad(r.model, 22)} ${lpad(fmtMs(r.avg), 8)} ${lpad(fmtMs(r.p50), 8)} ${lpad(fmtMs(r.p95), 8)} ${lpad(fmtMs(r.min), 8)} ${lpad(fmtMs(r.max), 8)} ${lpad(String(r.tokens), 7)} ${lpad(priceStr, 9)}${badge}`
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
console.log('');
|
|
378
|
+
|
|
379
|
+
// Compare ranking order if multiple models
|
|
380
|
+
if (allResults.length > 1) {
|
|
381
|
+
console.log(ui.bold(' Ranking Comparison (top 5)'));
|
|
382
|
+
console.log('');
|
|
383
|
+
|
|
384
|
+
const showK = Math.min(topK || 5, 5);
|
|
385
|
+
for (let rank = 0; rank < showK; rank++) {
|
|
386
|
+
const parts = allResults.map(r => {
|
|
387
|
+
const item = r.topResults[rank];
|
|
388
|
+
if (!item) return ui.dim('—');
|
|
389
|
+
const docIdx = item.index;
|
|
390
|
+
const score = item.relevance_score;
|
|
391
|
+
const preview = documents[docIdx].substring(0, 40) + (documents[docIdx].length > 40 ? '...' : '');
|
|
392
|
+
return `[${docIdx}] ${score.toFixed(3)} ${ui.dim(preview)}`;
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
console.log(ui.dim(` #${rank + 1}`));
|
|
396
|
+
allResults.forEach((r, i) => {
|
|
397
|
+
console.log(` ${ui.cyan(rpad(r.model, 20))} ${parts[i]}`);
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Check if top-5 ordering agrees
|
|
402
|
+
const orders = allResults.map(r => r.rankOrder.slice(0, 5).join(','));
|
|
403
|
+
const allAgree = orders.every(o => o === orders[0]);
|
|
404
|
+
console.log('');
|
|
405
|
+
if (allAgree) {
|
|
406
|
+
console.log(ui.info('Models agree on top-5 ranking — cheaper model may be sufficient.'));
|
|
407
|
+
} else {
|
|
408
|
+
console.log(ui.warn('Models disagree on ranking — premium model may capture nuances the lite model misses.'));
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
console.log('');
|
|
413
|
+
console.log(ui.success(`Fastest: ${ui.bold(allResults[0].model)} (${allResults[0].avg.toFixed(0)}ms avg)`));
|
|
414
|
+
console.log('');
|
|
415
|
+
|
|
416
|
+
// Save results
|
|
417
|
+
if (opts.save) {
|
|
418
|
+
const outData = { benchmark: 'rerank', timestamp: new Date().toISOString(), query, documents: documents.length, rounds, results: allResults };
|
|
419
|
+
const outPath = typeof opts.save === 'string' ? opts.save : `benchmark-rerank-${Date.now()}.json`;
|
|
420
|
+
fs.writeFileSync(outPath, JSON.stringify(outData, null, 2));
|
|
421
|
+
console.log(ui.info(`Results saved to ${outPath}`));
|
|
422
|
+
console.log('');
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* benchmark similarity — Compare how different models rank the same corpus.
|
|
428
|
+
*/
|
|
429
|
+
async function benchmarkSimilarity(opts) {
|
|
430
|
+
const models = opts.models ? parseModels(opts.models) : ['voyage-4-large', 'voyage-4', 'voyage-4-lite'];
|
|
431
|
+
const query = opts.query || SAMPLE_QUERY;
|
|
432
|
+
const dimensions = opts.dimensions ? parseInt(opts.dimensions, 10) : undefined;
|
|
433
|
+
const showK = opts.topK ? parseInt(opts.topK, 10) : 5;
|
|
434
|
+
|
|
435
|
+
let corpus;
|
|
436
|
+
if (opts.file) {
|
|
437
|
+
corpus = loadTexts(opts.file);
|
|
438
|
+
} else {
|
|
439
|
+
corpus = SAMPLE_RERANK_DOCS;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (!opts.json && !opts.quiet) {
|
|
443
|
+
console.log('');
|
|
444
|
+
console.log(ui.bold(' Similarity Benchmark'));
|
|
445
|
+
console.log(ui.dim(` Query: "${query.substring(0, 60)}${query.length > 60 ? '...' : ''}"`));
|
|
446
|
+
console.log(ui.dim(` ${corpus.length} documents × ${models.length} model(s)`));
|
|
447
|
+
if (dimensions) console.log(ui.dim(` Dimensions: ${dimensions}`));
|
|
448
|
+
console.log('');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const allResults = [];
|
|
452
|
+
|
|
453
|
+
for (const model of models) {
|
|
454
|
+
const spin = (!opts.json && !opts.quiet) ? ui.spinner(` Embedding with ${model}...`) : null;
|
|
455
|
+
if (spin) spin.start();
|
|
456
|
+
|
|
457
|
+
try {
|
|
458
|
+
const allTexts = [query, ...corpus];
|
|
459
|
+
const embedOpts = { model, inputType: 'document' };
|
|
460
|
+
if (dimensions) embedOpts.dimensions = dimensions;
|
|
461
|
+
|
|
462
|
+
const result = await generateEmbeddings(allTexts, embedOpts);
|
|
463
|
+
if (spin) spin.stop();
|
|
464
|
+
|
|
465
|
+
const embeddings = result.data.map(d => d.embedding);
|
|
466
|
+
const queryEmbed = embeddings[0];
|
|
467
|
+
|
|
468
|
+
const ranked = corpus.map((text, i) => ({
|
|
469
|
+
index: i,
|
|
470
|
+
text,
|
|
471
|
+
similarity: cosineSimilarity(queryEmbed, embeddings[i + 1]),
|
|
472
|
+
})).sort((a, b) => b.similarity - a.similarity);
|
|
473
|
+
|
|
474
|
+
allResults.push({
|
|
475
|
+
model: result.model || model,
|
|
476
|
+
tokens: result.usage?.total_tokens || 0,
|
|
477
|
+
ranked,
|
|
478
|
+
});
|
|
479
|
+
} catch (err) {
|
|
480
|
+
if (spin) spin.stop();
|
|
481
|
+
console.error(ui.warn(` ${model}: ${err.message} — skipping`));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (opts.json) {
|
|
486
|
+
console.log(JSON.stringify({ benchmark: 'similarity', query, corpus: corpus.length, results: allResults }, null, 2));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (allResults.length === 0) {
|
|
491
|
+
console.error(ui.error('No models completed successfully.'));
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Show side-by-side rankings
|
|
496
|
+
console.log(ui.bold(` Top ${showK} results by model`));
|
|
497
|
+
console.log('');
|
|
498
|
+
|
|
499
|
+
for (let rank = 0; rank < showK && rank < corpus.length; rank++) {
|
|
500
|
+
console.log(ui.dim(` #${rank + 1}`));
|
|
501
|
+
for (const r of allResults) {
|
|
502
|
+
const item = r.ranked[rank];
|
|
503
|
+
const preview = item.text.substring(0, 50) + (item.text.length > 50 ? '...' : '');
|
|
504
|
+
console.log(` ${ui.cyan(rpad(r.model, 20))} ${ui.score(item.similarity)} [${item.index}] ${ui.dim(preview)}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
console.log('');
|
|
509
|
+
|
|
510
|
+
// Measure agreement
|
|
511
|
+
if (allResults.length > 1) {
|
|
512
|
+
const orders = allResults.map(r => r.ranked.slice(0, showK).map(x => x.index).join(','));
|
|
513
|
+
const allAgree = orders.every(o => o === orders[0]);
|
|
514
|
+
|
|
515
|
+
if (allAgree) {
|
|
516
|
+
console.log(ui.info(`All models agree on top-${showK} ranking — cheaper model is likely sufficient for your data.`));
|
|
517
|
+
} else {
|
|
518
|
+
// Compute overlap
|
|
519
|
+
const sets = allResults.map(r => new Set(r.ranked.slice(0, showK).map(x => x.index)));
|
|
520
|
+
const intersection = [...sets[0]].filter(idx => sets.every(s => s.has(idx)));
|
|
521
|
+
const overlapPct = ((intersection.length / showK) * 100).toFixed(0);
|
|
522
|
+
console.log(ui.warn(`Models share ${overlapPct}% of top-${showK} results — differences may matter for your use case.`));
|
|
523
|
+
}
|
|
524
|
+
console.log('');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* benchmark cost — Project costs at different query volumes.
|
|
530
|
+
*/
|
|
531
|
+
async function benchmarkCost(opts) {
|
|
532
|
+
const models = opts.models
|
|
533
|
+
? parseModels(opts.models)
|
|
534
|
+
: MODEL_CATALOG.filter(m => !m.legacy && m.type === 'embedding').map(m => m.name);
|
|
535
|
+
|
|
536
|
+
let tokensPerQuery;
|
|
537
|
+
if (opts.file) {
|
|
538
|
+
const texts = loadTexts(opts.file);
|
|
539
|
+
// Estimate tokens (rough: 1 token ≈ 4 chars for English)
|
|
540
|
+
const totalChars = texts.reduce((sum, t) => sum + t.length, 0);
|
|
541
|
+
tokensPerQuery = Math.ceil(totalChars / 4);
|
|
542
|
+
if (!opts.json && !opts.quiet) {
|
|
543
|
+
console.log('');
|
|
544
|
+
console.log(ui.dim(` Estimated ~${tokensPerQuery} tokens from ${texts.length} text(s) in file`));
|
|
545
|
+
}
|
|
546
|
+
} else {
|
|
547
|
+
tokensPerQuery = parseInt(opts.tokens, 10) || 500;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const volumes = opts.volumes
|
|
551
|
+
? opts.volumes.split(',').map(v => parseInt(v.trim(), 10))
|
|
552
|
+
: [100, 1000, 10000, 100000];
|
|
553
|
+
|
|
554
|
+
if (!opts.json && !opts.quiet) {
|
|
555
|
+
console.log('');
|
|
556
|
+
console.log(ui.bold(' Cost Projection'));
|
|
557
|
+
console.log(ui.dim(` ~${tokensPerQuery} tokens per query`));
|
|
558
|
+
console.log('');
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const costData = [];
|
|
562
|
+
|
|
563
|
+
for (const model of models) {
|
|
564
|
+
const price = getPrice(model);
|
|
565
|
+
if (price == null) continue;
|
|
566
|
+
|
|
567
|
+
const entry = { model, pricePerMillion: price, volumes: {} };
|
|
568
|
+
for (const vol of volumes) {
|
|
569
|
+
const dailyTokens = tokensPerQuery * vol;
|
|
570
|
+
const dailyCost = (dailyTokens / 1_000_000) * price;
|
|
571
|
+
const monthlyCost = dailyCost * 30;
|
|
572
|
+
entry.volumes[vol] = { dailyCost, monthlyCost };
|
|
573
|
+
}
|
|
574
|
+
costData.push(entry);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (opts.json) {
|
|
578
|
+
console.log(JSON.stringify({ benchmark: 'cost', tokensPerQuery, volumes, models: costData }, null, 2));
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// Build header
|
|
583
|
+
const volHeaders = volumes.map(v => {
|
|
584
|
+
if (v >= 1000) return `${v / 1000}K/day`;
|
|
585
|
+
return `${v}/day`;
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
const headerParts = [` ${rpad('Model', 24)} ${lpad('$/1M', 7)}`];
|
|
589
|
+
for (const vh of volHeaders) {
|
|
590
|
+
headerParts.push(lpad(vh, 12));
|
|
591
|
+
}
|
|
592
|
+
const header = headerParts.join('');
|
|
593
|
+
|
|
594
|
+
console.log(ui.dim(header));
|
|
595
|
+
console.log(ui.dim(' ' + '─'.repeat(header.length - 2)));
|
|
596
|
+
|
|
597
|
+
// Sort by price
|
|
598
|
+
costData.sort((a, b) => a.pricePerMillion - b.pricePerMillion);
|
|
599
|
+
|
|
600
|
+
for (const entry of costData) {
|
|
601
|
+
const parts = [` ${rpad(entry.model, 24)} ${lpad('$' + entry.pricePerMillion.toFixed(2), 7)}`];
|
|
602
|
+
for (const vol of volumes) {
|
|
603
|
+
const monthly = entry.volumes[vol].monthlyCost;
|
|
604
|
+
let costStr;
|
|
605
|
+
if (monthly < 0.01) costStr = '<$0.01';
|
|
606
|
+
else if (monthly < 1) costStr = `$${monthly.toFixed(2)}`;
|
|
607
|
+
else if (monthly < 100) costStr = `$${monthly.toFixed(1)}`;
|
|
608
|
+
else costStr = `$${monthly.toFixed(0)}`;
|
|
609
|
+
parts.push(lpad(costStr + '/mo', 12));
|
|
610
|
+
}
|
|
611
|
+
console.log(parts.join(''));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
console.log('');
|
|
615
|
+
console.log(ui.dim(' Costs are estimates based on published per-token pricing.'));
|
|
616
|
+
console.log('');
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* benchmark batch — Measure throughput at different batch sizes.
|
|
621
|
+
*/
|
|
622
|
+
async function benchmarkBatch(opts) {
|
|
623
|
+
const model = opts.model || getDefaultModel();
|
|
624
|
+
const batchSizes = opts.batchSizes
|
|
625
|
+
? opts.batchSizes.split(',').map(v => parseInt(v.trim(), 10))
|
|
626
|
+
: [1, 5, 10, 25, 50];
|
|
627
|
+
const rounds = parseInt(opts.rounds, 10) || 3;
|
|
628
|
+
|
|
629
|
+
// Build a pool of texts
|
|
630
|
+
let pool;
|
|
631
|
+
if (opts.file) {
|
|
632
|
+
pool = loadTexts(opts.file);
|
|
633
|
+
} else {
|
|
634
|
+
// Repeat sample texts to fill larger batches
|
|
635
|
+
pool = [];
|
|
636
|
+
while (pool.length < Math.max(...batchSizes)) {
|
|
637
|
+
pool.push(...SAMPLE_TEXTS);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (!opts.json && !opts.quiet) {
|
|
642
|
+
console.log('');
|
|
643
|
+
console.log(ui.bold(' Batch Throughput Benchmark'));
|
|
644
|
+
console.log(ui.dim(` Model: ${model}`));
|
|
645
|
+
console.log(ui.dim(` Batch sizes: ${batchSizes.join(', ')} × ${rounds} rounds`));
|
|
646
|
+
console.log('');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const results = [];
|
|
650
|
+
|
|
651
|
+
for (const size of batchSizes) {
|
|
652
|
+
const texts = pool.slice(0, size);
|
|
653
|
+
if (texts.length < size) {
|
|
654
|
+
console.error(ui.warn(` Not enough texts for batch size ${size} (have ${pool.length}) — skipping`));
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const latencies = [];
|
|
659
|
+
let totalTokens = 0;
|
|
660
|
+
|
|
661
|
+
const spin = (!opts.json && !opts.quiet) ? ui.spinner(` Batch size ${size}...`) : null;
|
|
662
|
+
if (spin) spin.start();
|
|
663
|
+
|
|
664
|
+
for (let i = 0; i < rounds; i++) {
|
|
665
|
+
try {
|
|
666
|
+
const r = await timedEmbed(texts, model, 'document');
|
|
667
|
+
latencies.push(r.elapsed);
|
|
668
|
+
totalTokens = r.tokens;
|
|
669
|
+
} catch (err) {
|
|
670
|
+
if (spin) spin.stop();
|
|
671
|
+
console.error(ui.warn(` Batch ${size}: ${err.message} — skipping`));
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (spin) spin.stop();
|
|
677
|
+
if (latencies.length === 0) continue;
|
|
678
|
+
|
|
679
|
+
const avg = latencies.reduce((a, b) => a + b, 0) / latencies.length;
|
|
680
|
+
const textsPerSec = (size / (avg / 1000));
|
|
681
|
+
const tokensPerSec = totalTokens / (avg / 1000);
|
|
682
|
+
|
|
683
|
+
results.push({
|
|
684
|
+
batchSize: size,
|
|
685
|
+
avgLatency: avg,
|
|
686
|
+
textsPerSec,
|
|
687
|
+
tokensPerSec,
|
|
688
|
+
tokens: totalTokens,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (opts.json) {
|
|
693
|
+
console.log(JSON.stringify({ benchmark: 'batch', model, rounds, results }, null, 2));
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (results.length === 0) {
|
|
698
|
+
console.error(ui.error('No batch sizes completed successfully.'));
|
|
699
|
+
process.exit(1);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const header = ` ${rpad('Batch Size', 12)} ${lpad('Avg Latency', 12)} ${lpad('Texts/sec', 12)} ${lpad('Tokens/sec', 12)} ${lpad('Tokens', 8)}`;
|
|
703
|
+
console.log(ui.dim(header));
|
|
704
|
+
console.log(ui.dim(' ' + '─'.repeat(header.length - 2)));
|
|
705
|
+
|
|
706
|
+
for (const r of results) {
|
|
707
|
+
console.log(
|
|
708
|
+
` ${rpad(String(r.batchSize), 12)} ${lpad(fmtMs(r.avgLatency), 12)} ${lpad(r.textsPerSec.toFixed(1), 12)} ${lpad(r.tokensPerSec.toFixed(0), 12)} ${lpad(String(r.tokens), 8)}`
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
console.log('');
|
|
713
|
+
|
|
714
|
+
// Throughput verdict
|
|
715
|
+
const best = [...results].sort((a, b) => b.textsPerSec - a.textsPerSec)[0];
|
|
716
|
+
console.log(ui.success(`Best throughput: batch size ${best.batchSize} (${best.textsPerSec.toFixed(1)} texts/sec)`));
|
|
717
|
+
console.log('');
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// ── Registration ──
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Register the benchmark command tree on a Commander program.
|
|
724
|
+
* @param {import('commander').Command} program
|
|
725
|
+
*/
|
|
726
|
+
function registerBenchmark(program) {
|
|
727
|
+
const bench = program
|
|
728
|
+
.command('benchmark')
|
|
729
|
+
.alias('bench')
|
|
730
|
+
.description('Benchmark models to choose the right one for your use case');
|
|
731
|
+
|
|
732
|
+
// ── benchmark embed ──
|
|
733
|
+
bench
|
|
734
|
+
.command('embed')
|
|
735
|
+
.description('Compare embedding model latency, throughput, and cost')
|
|
736
|
+
.option('--models <models>', 'Comma-separated model names', 'voyage-4-large,voyage-4,voyage-4-lite')
|
|
737
|
+
.option('-r, --rounds <n>', 'Number of rounds per model', '5')
|
|
738
|
+
.option('-i, --input <text>', 'Custom input text')
|
|
739
|
+
.option('-f, --file <path>', 'Load texts from file (JSON array or newline-delimited)')
|
|
740
|
+
.option('-t, --input-type <type>', 'Input type: query or document', 'document')
|
|
741
|
+
.option('-d, --dimensions <n>', 'Output dimensions')
|
|
742
|
+
.option('--json', 'Machine-readable JSON output')
|
|
743
|
+
.option('-q, --quiet', 'Suppress non-essential output')
|
|
744
|
+
.option('-s, --save [path]', 'Save results to JSON file')
|
|
745
|
+
.action(benchmarkEmbed);
|
|
746
|
+
|
|
747
|
+
// ── benchmark rerank ──
|
|
748
|
+
bench
|
|
749
|
+
.command('rerank')
|
|
750
|
+
.description('Compare reranking model latency and ranking quality')
|
|
751
|
+
.option('--models <models>', 'Comma-separated rerank model names', 'rerank-2.5,rerank-2.5-lite')
|
|
752
|
+
.option('-r, --rounds <n>', 'Number of rounds per model', '5')
|
|
753
|
+
.option('--query <text>', 'Search query')
|
|
754
|
+
.option('--documents-file <path>', 'File with documents to rerank')
|
|
755
|
+
.option('-k, --top-k <n>', 'Return top K results')
|
|
756
|
+
.option('--json', 'Machine-readable JSON output')
|
|
757
|
+
.option('-q, --quiet', 'Suppress non-essential output')
|
|
758
|
+
.option('-s, --save [path]', 'Save results to JSON file')
|
|
759
|
+
.action(benchmarkRerank);
|
|
760
|
+
|
|
761
|
+
// ── benchmark similarity ──
|
|
762
|
+
bench
|
|
763
|
+
.command('similarity')
|
|
764
|
+
.description('Compare how different models rank the same corpus')
|
|
765
|
+
.option('--models <models>', 'Comma-separated embedding model names', 'voyage-4-large,voyage-4,voyage-4-lite')
|
|
766
|
+
.option('--query <text>', 'Search query')
|
|
767
|
+
.option('-f, --file <path>', 'Corpus file (JSON array or newline-delimited)')
|
|
768
|
+
.option('-k, --top-k <n>', 'Show top K results', '5')
|
|
769
|
+
.option('-d, --dimensions <n>', 'Output dimensions')
|
|
770
|
+
.option('--json', 'Machine-readable JSON output')
|
|
771
|
+
.option('-q, --quiet', 'Suppress non-essential output')
|
|
772
|
+
.action(benchmarkSimilarity);
|
|
773
|
+
|
|
774
|
+
// ── benchmark cost ──
|
|
775
|
+
bench
|
|
776
|
+
.command('cost')
|
|
777
|
+
.description('Project monthly costs at different query volumes')
|
|
778
|
+
.option('--models <models>', 'Comma-separated model names (default: all current embedding models)')
|
|
779
|
+
.option('--tokens <n>', 'Estimated tokens per query', '500')
|
|
780
|
+
.option('-f, --file <path>', 'Estimate tokens from file contents')
|
|
781
|
+
.option('--volumes <list>', 'Comma-separated daily query volumes', '100,1000,10000,100000')
|
|
782
|
+
.option('--json', 'Machine-readable JSON output')
|
|
783
|
+
.option('-q, --quiet', 'Suppress non-essential output')
|
|
784
|
+
.action(benchmarkCost);
|
|
785
|
+
|
|
786
|
+
// ── benchmark batch ──
|
|
787
|
+
bench
|
|
788
|
+
.command('batch')
|
|
789
|
+
.description('Measure throughput at different batch sizes')
|
|
790
|
+
.option('-m, --model <model>', 'Embedding model to benchmark')
|
|
791
|
+
.option('--batch-sizes <sizes>', 'Comma-separated batch sizes', '1,5,10,25,50')
|
|
792
|
+
.option('-r, --rounds <n>', 'Number of rounds per batch size', '3')
|
|
793
|
+
.option('-f, --file <path>', 'Load texts from file')
|
|
794
|
+
.option('--json', 'Machine-readable JSON output')
|
|
795
|
+
.option('-q, --quiet', 'Suppress non-essential output')
|
|
796
|
+
.action(benchmarkBatch);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
module.exports = { registerBenchmark };
|