voyageai-cli 1.12.1 → 1.15.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.
Files changed (44) hide show
  1. package/README.md +3 -3
  2. package/demo-readme.gif +0 -0
  3. package/package.json +1 -1
  4. package/src/cli.js +2 -0
  5. package/src/commands/benchmark.js +164 -0
  6. package/src/commands/completions.js +18 -1
  7. package/src/commands/estimate.js +209 -0
  8. package/src/commands/models.js +32 -4
  9. package/src/lib/catalog.js +42 -18
  10. package/src/lib/explanations.js +183 -0
  11. package/.github/workflows/ci.yml +0 -22
  12. package/CONTRIBUTING.md +0 -81
  13. package/demo.gif +0 -0
  14. package/demo.tape +0 -39
  15. package/scripts/record-demo.sh +0 -63
  16. package/test/commands/about.test.js +0 -23
  17. package/test/commands/benchmark.test.js +0 -319
  18. package/test/commands/completions.test.js +0 -166
  19. package/test/commands/config.test.js +0 -35
  20. package/test/commands/demo.test.js +0 -46
  21. package/test/commands/embed.test.js +0 -42
  22. package/test/commands/explain.test.js +0 -207
  23. package/test/commands/ingest.test.js +0 -261
  24. package/test/commands/models.test.js +0 -132
  25. package/test/commands/ping.test.js +0 -172
  26. package/test/commands/playground.test.js +0 -137
  27. package/test/commands/rerank.test.js +0 -32
  28. package/test/commands/similarity.test.js +0 -79
  29. package/test/commands/store.test.js +0 -26
  30. package/test/fixtures/sample.csv +0 -6
  31. package/test/fixtures/sample.json +0 -7
  32. package/test/fixtures/sample.jsonl +0 -5
  33. package/test/fixtures/sample.txt +0 -5
  34. package/test/lib/api.test.js +0 -133
  35. package/test/lib/banner.test.js +0 -44
  36. package/test/lib/catalog.test.js +0 -99
  37. package/test/lib/config.test.js +0 -124
  38. package/test/lib/explanations.test.js +0 -141
  39. package/test/lib/format.test.js +0 -75
  40. package/test/lib/input.test.js +0 -48
  41. package/test/lib/math.test.js +0 -43
  42. package/test/lib/ui.test.js +0 -79
  43. package/voyageai-cli-playground.png +0 -0
  44. package/voyageai-cli.png +0 -0
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # voyageai-cli
2
2
 
3
3
  <p align="center">
4
- <img src="https://raw.githubusercontent.com/mrlynn/voyageai-cli/main/voyageai-cli.png" alt="voyageai-cli" width="600" />
4
+ <img src="https://raw.githubusercontent.com/mrlynn/voyageai-cli/main/demo-readme.gif" alt="voyageai-cli demo" width="800" />
5
5
  </p>
6
6
 
7
7
  [![CI](https://github.com/mrlynn/voyageai-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/mrlynn/voyageai-cli/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/voyageai-cli.svg)](https://www.npmjs.com/package/voyageai-cli) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) [![Node.js](https://img.shields.io/node/v/voyageai-cli.svg)](https://nodejs.org)
8
8
 
9
- CLI for [Voyage AI](https://www.mongodb.com/docs/voyageai/) embeddings, reranking, and [MongoDB Atlas Vector Search](https://www.mongodb.com/docs/atlas/atlas-vector-search/). Pure Node.js — no Python required.
9
+ CLI for [Voyage AI](https://www.mongodb.com/docs/voyageai/) embeddings, reranking, and [MongoDB Atlas Vector Search](https://www.mongodb.com/docs/atlas/atlas-vector-search/). Embed text, benchmark models, compare quantization tradeoffs, and search — all from the terminal. Pure Node.js — no Python required.
10
10
 
11
- Generate embeddings, rerank search results, store vectors in Atlas, and run semantic search — all from the command line.
11
+ **16 commands · 201 tests · Interactive playground · Quantization benchmarks**
12
12
 
13
13
  > **⚠️ Disclaimer:** This is an independent, community-built tool. It is **not** an official product of MongoDB, Inc. or Voyage AI. It is not supported, endorsed, or maintained by either company. For official documentation, support, and products, visit:
14
14
  > - **MongoDB:** [mongodb.com](https://www.mongodb.com) | [MongoDB Atlas](https://www.mongodb.com/atlas) | [Support](https://support.mongodb.com)
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "voyageai-cli",
3
- "version": "1.12.1",
3
+ "version": "1.15.0",
4
4
  "description": "CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search",
5
5
  "bin": {
6
6
  "vai": "./src/cli.js"
package/src/cli.js CHANGED
@@ -20,6 +20,7 @@ const { registerIngest } = require('./commands/ingest');
20
20
  const { registerCompletions } = require('./commands/completions');
21
21
  const { registerPlayground } = require('./commands/playground');
22
22
  const { registerBenchmark } = require('./commands/benchmark');
23
+ const { registerEstimate } = require('./commands/estimate');
23
24
  const { registerAbout } = require('./commands/about');
24
25
  const { showBanner, showQuickStart, getVersion } = require('./lib/banner');
25
26
 
@@ -45,6 +46,7 @@ registerIngest(program);
45
46
  registerCompletions(program);
46
47
  registerPlayground(program);
47
48
  registerBenchmark(program);
49
+ registerEstimate(program);
48
50
  registerAbout(program);
49
51
 
50
52
  // Append disclaimer to all help output
@@ -1212,6 +1212,170 @@ function registerBenchmark(program) {
1212
1212
  .option('--json', 'Machine-readable JSON output')
1213
1213
  .option('-q, --quiet', 'Suppress non-essential output')
1214
1214
  .action(benchmarkAsymmetric);
1215
+
1216
+ // ── benchmark space ──
1217
+ bench
1218
+ .command('space')
1219
+ .description('Validate shared embedding space — embed same text with all Voyage 4 models')
1220
+ .option('--text <text>', 'Text to embed across models')
1221
+ .option('--texts <texts>', 'Comma-separated texts to compare')
1222
+ .option('--models <models>', 'Comma-separated models', 'voyage-4-large,voyage-4,voyage-4-lite')
1223
+ .option('-d, --dimensions <n>', 'Output dimensions (must be supported by all models)')
1224
+ .option('--json', 'Machine-readable JSON output')
1225
+ .option('-q, --quiet', 'Suppress non-essential output')
1226
+ .action(benchmarkSpace);
1227
+ }
1228
+
1229
+ /**
1230
+ * benchmark space — Validate shared embedding space across Voyage 4 models.
1231
+ * Embeds the same text(s) with multiple models, then computes pairwise cosine
1232
+ * similarities to prove they produce compatible embeddings.
1233
+ */
1234
+ async function benchmarkSpace(opts) {
1235
+ const models = opts.models
1236
+ ? parseModels(opts.models)
1237
+ : ['voyage-4-large', 'voyage-4', 'voyage-4-lite'];
1238
+
1239
+ const texts = opts.texts
1240
+ ? opts.texts.split(',').map(t => t.trim())
1241
+ : opts.text
1242
+ ? [opts.text]
1243
+ : [
1244
+ 'MongoDB Atlas provides a fully managed cloud database with vector search.',
1245
+ 'Machine learning models transform raw data into semantic embeddings.',
1246
+ 'The quick brown fox jumps over the lazy dog.',
1247
+ ];
1248
+
1249
+ const dimensions = opts.dimensions ? parseInt(opts.dimensions, 10) : undefined;
1250
+
1251
+ if (!opts.json && !opts.quiet) {
1252
+ console.log('');
1253
+ console.log(ui.bold(' 🔮 Shared Embedding Space Validation'));
1254
+ console.log(ui.dim(` Models: ${models.join(', ')}`));
1255
+ console.log(ui.dim(` Texts: ${texts.length}${dimensions ? `, dimensions: ${dimensions}` : ''}`));
1256
+ console.log('');
1257
+ }
1258
+
1259
+ // Embed all texts with all models
1260
+ const embeddings = {}; // { model: [[embedding for text 0], [embedding for text 1], ...] }
1261
+
1262
+ for (const model of models) {
1263
+ const spin = (!opts.json && !opts.quiet) ? ui.spinner(` Embedding with ${model}...`) : null;
1264
+ if (spin) spin.start();
1265
+
1266
+ try {
1267
+ const embedOpts = { model, inputType: 'document' };
1268
+ if (dimensions) embedOpts.dimensions = dimensions;
1269
+ const result = await generateEmbeddings(texts, embedOpts);
1270
+ embeddings[model] = result.data.map(d => d.embedding);
1271
+ if (spin) spin.stop();
1272
+ } catch (err) {
1273
+ if (spin) spin.stop();
1274
+ console.error(ui.warn(` ${model}: ${err.message} — skipping`));
1275
+ }
1276
+ }
1277
+
1278
+ const validModels = Object.keys(embeddings);
1279
+ if (validModels.length < 2) {
1280
+ console.error(ui.error('Need at least 2 models to compare embedding spaces.'));
1281
+ process.exit(1);
1282
+ }
1283
+
1284
+ // Compute pairwise cross-model similarities for each text
1285
+ const results = [];
1286
+
1287
+ for (let t = 0; t < texts.length; t++) {
1288
+ const textResult = {
1289
+ text: texts[t],
1290
+ pairs: [],
1291
+ };
1292
+
1293
+ for (let i = 0; i < validModels.length; i++) {
1294
+ for (let j = i + 1; j < validModels.length; j++) {
1295
+ const modelA = validModels[i];
1296
+ const modelB = validModels[j];
1297
+ const sim = cosineSimilarity(embeddings[modelA][t], embeddings[modelB][t]);
1298
+ textResult.pairs.push({
1299
+ modelA,
1300
+ modelB,
1301
+ similarity: sim,
1302
+ });
1303
+ }
1304
+ }
1305
+
1306
+ results.push(textResult);
1307
+ }
1308
+
1309
+ // Also compute within-model similarity across different texts (baseline)
1310
+ const withinModelSims = [];
1311
+ if (texts.length >= 2) {
1312
+ for (const model of validModels) {
1313
+ const sim = cosineSimilarity(embeddings[model][0], embeddings[model][1]);
1314
+ withinModelSims.push({ model, text0: texts[0], text1: texts[1], similarity: sim });
1315
+ }
1316
+ }
1317
+
1318
+ if (opts.json) {
1319
+ console.log(JSON.stringify({ benchmark: 'space', models: validModels, texts, results, withinModelSims }, null, 2));
1320
+ return;
1321
+ }
1322
+
1323
+ // Display results
1324
+ console.log(ui.bold(' Cross-Model Similarity (same text, different models):'));
1325
+ console.log(ui.dim(' High similarity (>0.95) = shared embedding space confirmed'));
1326
+ console.log('');
1327
+
1328
+ let allHigh = true;
1329
+ for (const r of results) {
1330
+ const preview = r.text.substring(0, 55) + (r.text.length > 55 ? '...' : '');
1331
+ console.log(` ${ui.dim('Text:')} "${preview}"`);
1332
+
1333
+ for (const p of r.pairs) {
1334
+ const simStr = p.similarity.toFixed(4);
1335
+ const quality = p.similarity >= 0.98 ? ui.green('●')
1336
+ : p.similarity >= 0.95 ? ui.cyan('●')
1337
+ : p.similarity >= 0.90 ? ui.yellow('●')
1338
+ : ui.red('●');
1339
+ if (p.similarity < 0.95) allHigh = false;
1340
+ console.log(` ${quality} ${rpad(p.modelA, 18)} ↔ ${rpad(p.modelB, 18)} ${ui.bold(simStr)}`);
1341
+ }
1342
+ console.log('');
1343
+ }
1344
+
1345
+ // Show within-model cross-text similarity for context
1346
+ if (withinModelSims.length > 0) {
1347
+ console.log(ui.bold(' Within-Model Similarity (different texts, same model):'));
1348
+ console.log(ui.dim(' Shows that cross-model same-text similarity is much higher'));
1349
+ console.log('');
1350
+
1351
+ for (const w of withinModelSims) {
1352
+ console.log(` ${ui.dim(rpad(w.model, 18))} text₀ ↔ text₁ ${ui.dim(w.similarity.toFixed(4))}`);
1353
+ }
1354
+ console.log('');
1355
+ }
1356
+
1357
+ // Summary
1358
+ const avgCrossModel = results.flatMap(r => r.pairs).reduce((sum, p) => sum + p.similarity, 0)
1359
+ / results.flatMap(r => r.pairs).length;
1360
+ const avgWithin = withinModelSims.length > 0
1361
+ ? withinModelSims.reduce((sum, w) => sum + w.similarity, 0) / withinModelSims.length
1362
+ : null;
1363
+
1364
+ if (allHigh) {
1365
+ console.log(ui.success(`Shared embedding space confirmed! Avg cross-model similarity: ${avgCrossModel.toFixed(4)}`));
1366
+ } else {
1367
+ console.log(ui.warn(`Cross-model similarity lower than expected. Avg: ${avgCrossModel.toFixed(4)}`));
1368
+ }
1369
+
1370
+ if (avgWithin !== null) {
1371
+ const ratio = (avgCrossModel / avgWithin).toFixed(1);
1372
+ console.log(ui.dim(` Cross-model same-text similarity is ${ratio}× higher than same-model different-text similarity.`));
1373
+ }
1374
+
1375
+ console.log('');
1376
+ console.log(ui.dim(' This means you can embed docs with voyage-4-large and query with voyage-4-lite'));
1377
+ console.log(ui.dim(' — the embeddings live in the same space. See "vai explain shared-space".'));
1378
+ console.log('');
1215
1379
  }
1216
1380
 
1217
1381
  module.exports = { registerBenchmark };
@@ -19,7 +19,7 @@ _vai_completions() {
19
19
  prev="\${COMP_WORDS[COMP_CWORD-1]}"
20
20
 
21
21
  # Top-level commands
22
- commands="embed rerank store search index models ping config demo explain similarity ingest completions help"
22
+ commands="embed rerank store search index models ping config demo explain similarity ingest estimate completions help"
23
23
 
24
24
  # Subcommands
25
25
  local index_subs="create list delete"
@@ -102,6 +102,10 @@ _vai_completions() {
102
102
  COMPREPLY=( \$(compgen -W "--file --db --collection --field --model --input-type --dimensions --batch-size --text-field --text-column --strict --dry-run --json --quiet --help" -- "\$cur") )
103
103
  return 0
104
104
  ;;
105
+ estimate)
106
+ COMPREPLY=( \$(compgen -W "--docs --queries --doc-tokens --query-tokens --doc-model --query-model --months --json --quiet --help" -- "\$cur") )
107
+ return 0
108
+ ;;
105
109
  completions)
106
110
  COMPREPLY=( \$(compgen -W "bash zsh --help" -- "\$cur") )
107
111
  return 0
@@ -172,6 +176,7 @@ _vai() {
172
176
  'explain:Learn about AI and vector search concepts'
173
177
  'similarity:Compute cosine similarity between texts'
174
178
  'ingest:Bulk import documents with progress'
179
+ 'estimate:Estimate embedding costs — symmetric vs asymmetric'
175
180
  'completions:Generate shell completion scripts'
176
181
  'help:Display help for command'
177
182
  )
@@ -375,6 +380,18 @@ _vai() {
375
380
  '--json[Machine-readable JSON output]' \\
376
381
  '(-q --quiet)'{-q,--quiet}'[Suppress non-essential output]'
377
382
  ;;
383
+ estimate)
384
+ _arguments \\
385
+ '--docs[Number of documents]:count:' \\
386
+ '--queries[Queries per month]:count:' \\
387
+ '--doc-tokens[Avg tokens per document]:tokens:' \\
388
+ '--query-tokens[Avg tokens per query]:tokens:' \\
389
+ '--doc-model[Document embedding model]:model:(\$models)' \\
390
+ '--query-model[Query embedding model]:model:(\$models)' \\
391
+ '--months[Months to project]:months:' \\
392
+ '--json[Machine-readable JSON output]' \\
393
+ '(-q --quiet)'{-q,--quiet}'[Suppress non-essential output]'
394
+ ;;
378
395
  completions)
379
396
  _arguments \\
380
397
  '1:shell:(bash zsh)'
@@ -0,0 +1,209 @@
1
+ 'use strict';
2
+
3
+ const { MODEL_CATALOG } = require('../lib/catalog');
4
+ const ui = require('../lib/ui');
5
+
6
+ // Average tokens per document/query (rough industry estimates)
7
+ const DEFAULT_DOC_TOKENS = 500;
8
+ const DEFAULT_QUERY_TOKENS = 30;
9
+
10
+ /**
11
+ * Parse a shorthand number: "1M" → 1000000, "500K" → 500000, "1B" → 1000000000.
12
+ * @param {string} val
13
+ * @returns {number}
14
+ */
15
+ function parseShorthand(val) {
16
+ if (!val) return NaN;
17
+ const str = String(val).trim().toUpperCase();
18
+ const multipliers = { K: 1e3, M: 1e6, B: 1e9, T: 1e12 };
19
+ const match = str.match(/^([\d.]+)\s*([KMBT])?$/);
20
+ if (!match) return parseFloat(str);
21
+ const num = parseFloat(match[1]);
22
+ const suffix = match[2];
23
+ return suffix ? num * multipliers[suffix] : num;
24
+ }
25
+
26
+ /**
27
+ * Format a number with commas: 1234567 → "1,234,567".
28
+ */
29
+ function formatNum(n) {
30
+ return n.toLocaleString('en-US');
31
+ }
32
+
33
+ /**
34
+ * Format dollars: 0.50 → "$0.50", 1234.56 → "$1,234.56".
35
+ */
36
+ function formatDollars(n) {
37
+ if (n < 0.01 && n > 0) return `$${n.toFixed(4)}`;
38
+ if (n < 1) return `$${n.toFixed(2)}`;
39
+ return '$' + n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
40
+ }
41
+
42
+ /**
43
+ * Format a large number in short form: 1000000 → "1M".
44
+ */
45
+ function shortNum(n) {
46
+ if (n >= 1e9) return (n / 1e9).toFixed(n % 1e9 === 0 ? 0 : 1) + 'B';
47
+ if (n >= 1e6) return (n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1) + 'M';
48
+ if (n >= 1e3) return (n / 1e3).toFixed(n % 1e3 === 0 ? 0 : 1) + 'K';
49
+ return String(n);
50
+ }
51
+
52
+ /**
53
+ * Register the estimate command on a Commander program.
54
+ * @param {import('commander').Command} program
55
+ */
56
+ function registerEstimate(program) {
57
+ program
58
+ .command('estimate')
59
+ .description('Estimate embedding costs — symmetric vs asymmetric strategies')
60
+ .option('--docs <n>', 'Number of documents to embed (supports K/M/B shorthand)', '100K')
61
+ .option('--queries <n>', 'Number of queries per month (supports K/M/B shorthand)', '1M')
62
+ .option('--doc-tokens <n>', 'Average tokens per document', String(DEFAULT_DOC_TOKENS))
63
+ .option('--query-tokens <n>', 'Average tokens per query', String(DEFAULT_QUERY_TOKENS))
64
+ .option('--doc-model <model>', 'Model for document embedding (asymmetric)', 'voyage-4-large')
65
+ .option('--query-model <model>', 'Model for query embedding (asymmetric)', 'voyage-4-lite')
66
+ .option('--months <n>', 'Months to project', '12')
67
+ .option('--json', 'Machine-readable JSON output')
68
+ .option('-q, --quiet', 'Suppress non-essential output')
69
+ .action((opts) => {
70
+ const numDocs = parseShorthand(opts.docs);
71
+ const numQueries = parseShorthand(opts.queries);
72
+ const docTokens = parseInt(opts.docTokens, 10) || DEFAULT_DOC_TOKENS;
73
+ const queryTokens = parseInt(opts.queryTokens, 10) || DEFAULT_QUERY_TOKENS;
74
+ const months = parseInt(opts.months, 10) || 12;
75
+
76
+ if (isNaN(numDocs) || isNaN(numQueries)) {
77
+ console.error(ui.error('Invalid --docs or --queries value. Use numbers or shorthand (e.g., 1M, 500K).'));
78
+ process.exit(1);
79
+ }
80
+
81
+ // Get model prices
82
+ const v4Models = MODEL_CATALOG.filter(m => m.sharedSpace === 'voyage-4' && m.pricePerMToken != null);
83
+ const docModel = MODEL_CATALOG.find(m => m.name === opts.docModel);
84
+ const queryModel = MODEL_CATALOG.find(m => m.name === opts.queryModel);
85
+
86
+ if (!docModel || docModel.pricePerMToken == null) {
87
+ console.error(ui.error(`Unknown or unpriced model: ${opts.docModel}`));
88
+ process.exit(1);
89
+ }
90
+ if (!queryModel || queryModel.pricePerMToken == null) {
91
+ console.error(ui.error(`Unknown or unpriced model: ${opts.queryModel}`));
92
+ process.exit(1);
93
+ }
94
+
95
+ const docTotalTokens = numDocs * docTokens;
96
+ const queryTotalTokensPerMonth = numQueries * queryTokens;
97
+
98
+ // Calculate costs for different strategies
99
+ const strategies = [];
100
+
101
+ // Strategy 1: Symmetric with each V4 model
102
+ for (const model of v4Models) {
103
+ if (model.pricePerMToken === 0) continue; // skip free models for symmetric
104
+ const docCost = (docTotalTokens / 1e6) * model.pricePerMToken;
105
+ const queryCostPerMonth = (queryTotalTokensPerMonth / 1e6) * model.pricePerMToken;
106
+ const totalCost = docCost + (queryCostPerMonth * months);
107
+ strategies.push({
108
+ name: `Symmetric: ${model.name}`,
109
+ type: 'symmetric',
110
+ docModel: model.name,
111
+ queryModel: model.name,
112
+ docCost,
113
+ queryCostPerMonth,
114
+ totalCost,
115
+ months,
116
+ });
117
+ }
118
+
119
+ // Strategy 2: Asymmetric — user-specified doc+query combo
120
+ const asymDocCost = (docTotalTokens / 1e6) * docModel.pricePerMToken;
121
+ const asymQueryCostPerMonth = (queryTotalTokensPerMonth / 1e6) * queryModel.pricePerMToken;
122
+ const asymTotalCost = asymDocCost + (asymQueryCostPerMonth * months);
123
+ strategies.push({
124
+ name: `Asymmetric: ${docModel.name} docs + ${queryModel.name} queries`,
125
+ type: 'asymmetric',
126
+ docModel: docModel.name,
127
+ queryModel: queryModel.name,
128
+ docCost: asymDocCost,
129
+ queryCostPerMonth: asymQueryCostPerMonth,
130
+ totalCost: asymTotalCost,
131
+ months,
132
+ recommended: true,
133
+ });
134
+
135
+ // Strategy 3: Asymmetric with nano queries (if doc model isn't nano)
136
+ if (opts.queryModel !== 'voyage-4-nano') {
137
+ const nanoModel = MODEL_CATALOG.find(m => m.name === 'voyage-4-nano');
138
+ if (nanoModel) {
139
+ strategies.push({
140
+ name: `Asymmetric: ${docModel.name} docs + voyage-4-nano queries (local)`,
141
+ type: 'asymmetric-local',
142
+ docModel: docModel.name,
143
+ queryModel: 'voyage-4-nano',
144
+ docCost: asymDocCost,
145
+ queryCostPerMonth: 0,
146
+ totalCost: asymDocCost,
147
+ months,
148
+ });
149
+ }
150
+ }
151
+
152
+ // Sort by total cost
153
+ strategies.sort((a, b) => a.totalCost - b.totalCost);
154
+
155
+ if (opts.json) {
156
+ console.log(JSON.stringify({
157
+ params: { docs: numDocs, queries: numQueries, docTokens, queryTokens, months },
158
+ strategies,
159
+ }, null, 2));
160
+ return;
161
+ }
162
+
163
+ // Find the most expensive for savings comparison
164
+ const maxCost = Math.max(...strategies.map(s => s.totalCost));
165
+
166
+ if (!opts.quiet) {
167
+ console.log(ui.bold('💰 Voyage AI Cost Estimator'));
168
+ console.log('');
169
+ console.log(ui.label('Documents', `${shortNum(numDocs)} × ${formatNum(docTokens)} tokens = ${shortNum(docTotalTokens)} tokens (one-time)`));
170
+ console.log(ui.label('Queries', `${shortNum(numQueries)}/mo × ${formatNum(queryTokens)} tokens = ${shortNum(queryTotalTokensPerMonth)} tokens/mo`));
171
+ console.log(ui.label('Projection', `${months} months`));
172
+ console.log('');
173
+ }
174
+
175
+ console.log(ui.bold('Strategy Comparison:'));
176
+ console.log('');
177
+
178
+ for (const s of strategies) {
179
+ const savings = maxCost > 0 ? ((1 - s.totalCost / maxCost) * 100) : 0;
180
+ const savingsStr = savings > 0 ? ui.green(` (${savings.toFixed(0)}% savings)`) : '';
181
+ const marker = s.recommended ? ui.cyan(' ★ recommended') : '';
182
+ const localNote = s.type === 'asymmetric-local' ? ui.dim(' (query cost = $0, runs locally)') : '';
183
+
184
+ console.log(` ${s.recommended ? ui.cyan('►') : ' '} ${ui.bold(s.name)}${marker}`);
185
+ console.log(` Doc embedding: ${formatDollars(s.docCost)} ${ui.dim('(one-time)')}`);
186
+ console.log(` Query cost: ${formatDollars(s.queryCostPerMonth)}/mo${localNote}`);
187
+ console.log(` ${months}-mo total: ${ui.bold(formatDollars(s.totalCost))}${savingsStr}`);
188
+ console.log('');
189
+ }
190
+
191
+ // Show the asymmetric advantage
192
+ const symmetricLarge = strategies.find(s => s.type === 'symmetric' && s.docModel === 'voyage-4-large');
193
+ const asymmetric = strategies.find(s => s.recommended);
194
+ if (symmetricLarge && asymmetric && symmetricLarge.totalCost > asymmetric.totalCost) {
195
+ const saved = symmetricLarge.totalCost - asymmetric.totalCost;
196
+ const pct = ((saved / symmetricLarge.totalCost) * 100).toFixed(0);
197
+ console.log(ui.success(`Asymmetric retrieval saves ${formatDollars(saved)} (${pct}%) over symmetric voyage-4-large`));
198
+ console.log(ui.dim(' Same document quality — lower query costs. Shared embedding space makes this possible.'));
199
+ console.log('');
200
+ }
201
+
202
+ if (!opts.quiet) {
203
+ console.log(ui.dim('Tip: Use --doc-model and --query-model to compare any combination.'));
204
+ console.log(ui.dim(' Use "vai explain shared-space" to learn about asymmetric retrieval.'));
205
+ }
206
+ });
207
+ }
208
+
209
+ module.exports = { registerEstimate };
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const { MODEL_CATALOG } = require('../lib/catalog');
3
+ const { MODEL_CATALOG, BENCHMARK_SCORES } = require('../lib/catalog');
4
4
  const { getApiBase } = require('../lib/api');
5
5
  const { formatTable } = require('../lib/format');
6
6
  const ui = require('../lib/ui');
@@ -42,6 +42,7 @@ function registerModels(program) {
42
42
  .option('-t, --type <type>', 'Filter by type: embedding, reranking, or all', 'all')
43
43
  .option('-a, --all', 'Show all models including legacy')
44
44
  .option('-w, --wide', 'Wide output (show all columns untruncated)')
45
+ .option('-b, --benchmarks', 'Show RTEB benchmark scores')
45
46
  .option('--json', 'Machine-readable JSON output')
46
47
  .option('-q, --quiet', 'Suppress non-essential output')
47
48
  .action((opts) => {
@@ -86,7 +87,9 @@ function registerModels(program) {
86
87
  const name = ui.cyan(m.name);
87
88
  const type = m.type.startsWith('embedding') ? ui.green(m.type) : ui.yellow(m.type);
88
89
  const price = ui.dim(m.price);
89
- return [name, type, m.context, m.dimensions, price, m.bestFor];
90
+ const arch = m.architecture ? (m.architecture === 'moe' ? ui.cyan('MoE') : m.architecture) : '—';
91
+ const space = m.sharedSpace ? ui.green('✓ ' + m.sharedSpace) : '—';
92
+ return [name, type, m.context, m.dimensions, arch, space, price, m.bestFor];
90
93
  };
91
94
 
92
95
  const formatCompactRow = (m) => {
@@ -98,7 +101,7 @@ function registerModels(program) {
98
101
  };
99
102
 
100
103
  if (opts.wide) {
101
- const headers = ['Model', 'Type', 'Context', 'Dimensions', 'Price', 'Best For'];
104
+ const headers = ['Model', 'Type', 'Context', 'Dimensions', 'Arch', 'Space', 'Price', 'Best For'];
102
105
  const boldHeaders = headers.map(h => ui.bold(h));
103
106
  const rows = displayCurrent.map(formatWideRow);
104
107
  console.log(formatTable(boldHeaders, rows));
@@ -123,6 +126,29 @@ function registerModels(program) {
123
126
  }
124
127
  }
125
128
 
129
+ // Show benchmark scores if requested
130
+ if (opts.benchmarks) {
131
+ console.log('');
132
+ console.log(ui.bold('RTEB Benchmark Scores (NDCG@10, avg 29 datasets)'));
133
+ console.log(ui.dim('Source: Voyage AI, January 2026'));
134
+ console.log('');
135
+
136
+ const maxScore = Math.max(...BENCHMARK_SCORES.map(b => b.score));
137
+ const barWidth = 30;
138
+
139
+ for (const b of BENCHMARK_SCORES) {
140
+ const barLen = Math.round((b.score / maxScore) * barWidth);
141
+ const bar = '█'.repeat(barLen) + '░'.repeat(barWidth - barLen);
142
+ const isVoyage = b.provider === 'Voyage AI';
143
+ const name = isVoyage ? ui.cyan(b.model.padEnd(22)) : ui.dim(b.model.padEnd(22));
144
+ const score = isVoyage ? ui.bold(b.score.toFixed(2)) : b.score.toFixed(2);
145
+ const colorBar = isVoyage ? ui.cyan(bar) : ui.dim(bar);
146
+ console.log(` ${name} ${colorBar} ${score}`);
147
+ }
148
+ console.log('');
149
+ console.log(ui.dim(' Run "vai explain rteb" for details.'));
150
+ }
151
+
126
152
  if (!opts.quiet) {
127
153
  console.log('');
128
154
  if (!opts.wide) {
@@ -130,7 +156,9 @@ function registerModels(program) {
130
156
  }
131
157
  console.log(ui.dim('Free tier: 200M tokens (most models), 50M (domain-specific)'));
132
158
  console.log(ui.dim('All 4-series models share the same embedding space.'));
133
- if (!opts.wide) {
159
+ if (!opts.wide && !opts.benchmarks) {
160
+ console.log(ui.dim('Use --wide for full details, --benchmarks for RTEB scores.'));
161
+ } else if (!opts.wide) {
134
162
  console.log(ui.dim('Use --wide for full details.'));
135
163
  }
136
164
  }
@@ -24,29 +24,51 @@ function getDefaultDimensions() {
24
24
 
25
25
  // The model catalog: like a wine list (I don't drink :-P), except every choice
26
26
  // leads to vectors instead of regret.
27
- /** @type {Array<{name: string, type: string, context: string, dimensions: string, price: string, bestFor: string}>} */
27
+ /** @type {Array<{name: string, type: string, context: string, dimensions: string, price: string, bestFor: string, family?: string, architecture?: string, sharedSpace?: string, huggingface?: string, pricePerMToken?: number, rtebScore?: number}>} */
28
28
  const MODEL_CATALOG = [
29
- { name: 'voyage-4-large', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.12/1M tokens', bestFor: 'Best quality, multilingual', shortFor: 'Best quality' },
30
- { name: 'voyage-4', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.06/1M tokens', bestFor: 'Balanced quality/perf', shortFor: 'Balanced' },
31
- { name: 'voyage-4-lite', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.02/1M tokens', bestFor: 'Lowest cost', shortFor: 'Budget' },
32
- { name: 'voyage-code-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', bestFor: 'Code retrieval', shortFor: 'Code' },
33
- { name: 'voyage-finance-2', type: 'embedding', context: '32K', dimensions: '1024', price: '$0.12/1M tokens', bestFor: 'Finance', shortFor: 'Finance' },
34
- { name: 'voyage-law-2', type: 'embedding', context: '16K', dimensions: '1024', price: '$0.12/1M tokens', bestFor: 'Legal', shortFor: 'Legal' },
35
- { name: 'voyage-context-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', bestFor: 'Contextualized chunks', shortFor: 'Context chunks', unreleased: true },
29
+ { name: 'voyage-4-large', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.12/1M tokens', pricePerMToken: 0.12, bestFor: 'Best quality, multilingual, MoE', shortFor: 'Best quality', family: 'voyage-4', architecture: 'moe', sharedSpace: 'voyage-4', rtebScore: 71.41 },
30
+ { name: 'voyage-4', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.06/1M tokens', pricePerMToken: 0.06, bestFor: 'Balanced quality/perf', shortFor: 'Balanced', family: 'voyage-4', architecture: 'dense', sharedSpace: 'voyage-4', rtebScore: 70.07 },
31
+ { name: 'voyage-4-lite', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.02/1M tokens', pricePerMToken: 0.02, bestFor: 'Lowest cost', shortFor: 'Budget', family: 'voyage-4', architecture: 'dense', sharedSpace: 'voyage-4', rtebScore: 68.10 },
32
+ { name: 'voyage-code-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', pricePerMToken: 0.18, bestFor: 'Code retrieval', shortFor: 'Code' },
33
+ { name: 'voyage-finance-2', type: 'embedding', context: '32K', dimensions: '1024', price: '$0.12/1M tokens', pricePerMToken: 0.12, bestFor: 'Finance', shortFor: 'Finance' },
34
+ { name: 'voyage-law-2', type: 'embedding', context: '16K', dimensions: '1024', price: '$0.12/1M tokens', pricePerMToken: 0.12, bestFor: 'Legal', shortFor: 'Legal' },
35
+ { name: 'voyage-context-3', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', pricePerMToken: 0.18, bestFor: 'Contextualized chunks', shortFor: 'Context chunks', unreleased: true },
36
36
  { name: 'voyage-multimodal-3.5', type: 'embedding-multimodal', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.12/M + $0.60/B px', bestFor: 'Text + images + video', shortFor: 'Multimodal', multimodal: true },
37
- { name: 'rerank-2.5', type: 'reranking', context: '32K', dimensions: '—', price: '$0.05/1M tokens', bestFor: 'Best quality reranking', shortFor: 'Best reranker' },
38
- { name: 'rerank-2.5-lite', type: 'reranking', context: '32K', dimensions: '—', price: '$0.02/1M tokens', bestFor: 'Fast reranking', shortFor: 'Fast reranker' },
39
- { name: 'voyage-4-nano', type: 'embedding', context: '32K', dimensions: '512 (default), 128, 256', price: 'Open-weight', bestFor: 'Open-weight / edge', shortFor: 'Open / edge', local: true },
37
+ { name: 'rerank-2.5', type: 'reranking', context: '32K', dimensions: '—', price: '$0.05/1M tokens', pricePerMToken: 0.05, bestFor: 'Best quality reranking', shortFor: 'Best reranker' },
38
+ { name: 'rerank-2.5-lite', type: 'reranking', context: '32K', dimensions: '—', price: '$0.02/1M tokens', pricePerMToken: 0.02, bestFor: 'Fast reranking', shortFor: 'Fast reranker' },
39
+ { name: 'voyage-4-nano', type: 'embedding', context: '32K', dimensions: '512 (default), 128, 256', price: 'Open-weight (free)', pricePerMToken: 0, bestFor: 'Open-weight / edge / local', shortFor: 'Open / edge', local: true, family: 'voyage-4', architecture: 'dense', sharedSpace: 'voyage-4', huggingface: 'https://huggingface.co/voyageai/voyage-4-nano', rtebScore: null },
40
40
  // Legacy models
41
- { name: 'voyage-3-large', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', bestFor: 'Previous gen quality', shortFor: 'Previous gen quality', legacy: true },
42
- { name: 'voyage-3.5', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.06/1M tokens', bestFor: 'Previous gen balanced', shortFor: 'Previous gen balanced', legacy: true },
43
- { name: 'voyage-3.5-lite', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.02/1M tokens', bestFor: 'Previous gen budget', shortFor: 'Previous gen budget', legacy: true },
44
- { name: 'voyage-code-2', type: 'embedding', context: '16K', dimensions: '1536', price: '$0.12/1M tokens', bestFor: 'Legacy code', shortFor: 'Legacy code', legacy: true },
45
- { name: 'voyage-multimodal-3', type: 'embedding-multimodal', context: '32K', dimensions: '1024', price: '$0.12/1M tokens', bestFor: 'Legacy multimodal', shortFor: 'Legacy multimodal', legacy: true, multimodal: true },
46
- { name: 'rerank-2', type: 'reranking', context: '16K', dimensions: '—', price: '$0.05/1M tokens', bestFor: 'Legacy reranker', shortFor: 'Legacy reranker', legacy: true },
47
- { name: 'rerank-2-lite', type: 'reranking', context: '8K', dimensions: '—', price: '$0.02/1M tokens', bestFor: 'Legacy fast reranker', shortFor: 'Legacy fast reranker', legacy: true },
41
+ { name: 'voyage-3-large', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.18/1M tokens', pricePerMToken: 0.18, bestFor: 'Previous gen quality', shortFor: 'Previous gen quality', legacy: true, rtebScore: null },
42
+ { name: 'voyage-3.5', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.06/1M tokens', pricePerMToken: 0.06, bestFor: 'Previous gen balanced', shortFor: 'Previous gen balanced', legacy: true, rtebScore: null },
43
+ { name: 'voyage-3.5-lite', type: 'embedding', context: '32K', dimensions: '1024 (default), 256, 512, 2048', price: '$0.02/1M tokens', pricePerMToken: 0.02, bestFor: 'Previous gen budget', shortFor: 'Previous gen budget', legacy: true, rtebScore: null },
44
+ { name: 'voyage-code-2', type: 'embedding', context: '16K', dimensions: '1536', price: '$0.12/1M tokens', pricePerMToken: 0.12, bestFor: 'Legacy code', shortFor: 'Legacy code', legacy: true },
45
+ { name: 'voyage-multimodal-3', type: 'embedding-multimodal', context: '32K', dimensions: '1024', price: '$0.12/1M tokens', pricePerMToken: 0.12, bestFor: 'Legacy multimodal', shortFor: 'Legacy multimodal', legacy: true, multimodal: true },
46
+ { name: 'rerank-2', type: 'reranking', context: '16K', dimensions: '—', price: '$0.05/1M tokens', pricePerMToken: 0.05, bestFor: 'Legacy reranker', shortFor: 'Legacy reranker', legacy: true },
47
+ { name: 'rerank-2-lite', type: 'reranking', context: '8K', dimensions: '—', price: '$0.02/1M tokens', pricePerMToken: 0.02, bestFor: 'Legacy fast reranker', shortFor: 'Legacy fast reranker', legacy: true },
48
48
  ];
49
49
 
50
+ /**
51
+ * RTEB benchmark scores for competitive models (NDCG@10 average across 29 datasets).
52
+ * Source: Voyage AI blog, January 15 2026.
53
+ */
54
+ const BENCHMARK_SCORES = [
55
+ { model: 'voyage-4-large', provider: 'Voyage AI', score: 71.41 },
56
+ { model: 'voyage-4', provider: 'Voyage AI', score: 70.07 },
57
+ { model: 'voyage-4-lite', provider: 'Voyage AI', score: 68.10 },
58
+ { model: 'Gemini Embedding 001', provider: 'Google', score: 68.66 },
59
+ { model: 'Cohere Embed v4', provider: 'Cohere', score: 65.75 },
60
+ { model: 'OpenAI v3 Large', provider: 'OpenAI', score: 62.57 },
61
+ ];
62
+
63
+ /**
64
+ * Get models that share an embedding space.
65
+ * @param {string} space - e.g. 'voyage-4'
66
+ * @returns {Array}
67
+ */
68
+ function getSharedSpaceModels(space) {
69
+ return MODEL_CATALOG.filter(m => m.sharedSpace === space);
70
+ }
71
+
50
72
  module.exports = {
51
73
  DEFAULT_EMBED_MODEL,
52
74
  DEFAULT_RERANK_MODEL,
@@ -54,4 +76,6 @@ module.exports = {
54
76
  getDefaultModel,
55
77
  getDefaultDimensions,
56
78
  MODEL_CATALOG,
79
+ BENCHMARK_SCORES,
80
+ getSharedSpaceModels,
57
81
  };