UNPKG

voyageai-cli

Version:

CLI for Voyage AI embeddings, reranking, and MongoDB Atlas Vector Search

326 lines (286 loc) 10 kB
'use strict'; let p; function clack() { if (!p) p = require('@clack/prompts'); return p; } const { loadProject, saveProject } = require('../lib/project'); const { getMongoCollection } = require('../lib/mongo'); const { generateEmbeddings } = require('../lib/api'); const { chunkText } = require('../lib/chunker'); const ui = require('../lib/ui'); /** * Process documents in batches. */ async function processBatch(docs, embedder, options) { const texts = docs.map(d => d.text); const embeddings = await embedder(texts); return docs.map((doc, i) => ({ ...doc, [options.field]: embeddings[i], _model: options.model, _embeddedAt: new Date(), })); } /** * Re-chunk a document's text. */ function rechunkDocument(doc, options) { const text = doc.text || doc.content || ''; if (!text) return [doc]; const chunks = chunkText(text, { strategy: options.strategy || 'recursive', chunkSize: options.chunkSize || 512, overlap: options.overlap || 50, }); return chunks.map((chunk, i) => ({ ...doc, text: chunk.text, _chunkIndex: i, _chunkCount: chunks.length, metadata: { ...doc.metadata, chunkIndex: i, chunkCount: chunks.length, originalId: doc._id?.toString(), }, })); } /** * Execute the refresh command. */ async function refresh(options = {}) { clack(); // lazy-load @clack/prompts const quiet = options.quiet || options.json; // Load project config const project = loadProject(); const db = options.db || project.db || process.env.VAI_DB || 'vai'; const collectionName = options.collection || project.collection || process.env.VAI_COLLECTION || 'embeddings'; const field = options.field || project.field || 'embedding'; const model = options.model || project.model || 'voyage-3.5-lite'; const dimensions = options.dimensions || project.dimensions; const batchSize = options.batchSize || 25; if (!quiet) { p.intro(ui.bold('vai refresh')); } let client; try { // Connect to MongoDB if (!quiet) { p.log.step(`Connecting to database: ${db}`); } const result = await getMongoCollection(db, collectionName); client = result.client; const collection = result.collection; // Build filter let filter = {}; if (options.filter) { try { filter = JSON.parse(options.filter); } catch (err) { throw new Error(`Invalid JSON filter: ${err.message}`); } } // Count documents const totalCount = await collection.countDocuments(filter); if (totalCount === 0) { if (options.json) { console.log(JSON.stringify({ success: true, count: 0, message: 'No documents to refresh' })); } else { p.log.success('No documents to refresh.'); p.outro('Nothing to do.'); } return { success: true, count: 0 }; } // Show plan const rechunkLabel = options.rechunk ? ` (re-chunking with ${options.strategy || 'recursive'})` : ''; const dimLabel = dimensions ? ` @ ${dimensions}d` : ''; if (options.json && options.dryRun) { console.log(JSON.stringify({ dryRun: true, count: totalCount, model, dimensions: dimensions || 'default', rechunk: !!options.rechunk, })); return { success: true, dryRun: true, count: totalCount }; } if (!quiet) { p.log.info(`Found ${totalCount} document${totalCount === 1 ? '' : 's'} to refresh`); p.log.info(`Target model: ${model}${dimLabel}${rechunkLabel}`); } // Dry run - stop here if (options.dryRun) { if (!quiet) { p.log.info('Dry run - no documents modified.'); p.outro(`Would refresh ${totalCount} document${totalCount === 1 ? '' : 's'}.`); } return { success: true, dryRun: true, count: totalCount }; } // Confirm unless --force if (!options.force && !options.json) { const confirmed = await p.confirm({ message: `Re-embed ${totalCount} document${totalCount === 1 ? '' : 's'}? This will update the embeddings in-place.`, initialValue: true, }); if (p.isCancel(confirmed) || !confirmed) { p.log.info('Refresh cancelled.'); p.outro('No documents modified.'); return { success: false, cancelled: true }; } } // Create embedder function const embedder = async (texts) => { const result = await generateEmbeddings(texts, { model, dimensions, inputType: 'document', }); return result.embeddings; }; // Process documents let processed = 0; let errors = 0; const cursor = collection.find(filter); let batch = []; const spinner = !quiet ? p.spinner() : null; if (spinner) spinner.start('Processing documents...'); while (await cursor.hasNext()) { const doc = await cursor.next(); if (options.rechunk) { // Re-chunk the document const chunks = rechunkDocument(doc, options); batch.push(...chunks); } else { batch.push(doc); } // Process when batch is full if (batch.length >= batchSize) { try { const updated = await processBatch(batch, embedder, { field, model }); // Replace documents in database for (const updatedDoc of updated) { if (options.rechunk && updatedDoc.metadata?.originalId) { // For rechunked docs, insert new and delete original later await collection.insertOne(updatedDoc); } else { // Update in place await collection.updateOne( { _id: updatedDoc._id }, { $set: { [field]: updatedDoc[field], _model: model, _embeddedAt: new Date() } } ); } } processed += batch.length; if (spinner) spinner.message(`Processed ${processed}/${totalCount} documents...`); } catch (err) { errors += batch.length; if (!quiet) { p.log.warn(`Batch error: ${err.message}`); } } batch = []; } } // Process remaining batch if (batch.length > 0) { try { const updated = await processBatch(batch, embedder, { field, model }); for (const updatedDoc of updated) { if (options.rechunk && updatedDoc.metadata?.originalId) { await collection.insertOne(updatedDoc); } else { await collection.updateOne( { _id: updatedDoc._id }, { $set: { [field]: updatedDoc[field], _model: model, _embeddedAt: new Date() } } ); } } processed += batch.length; } catch (err) { errors += batch.length; if (!quiet) { p.log.warn(`Batch error: ${err.message}`); } } } // If rechunking, delete original documents if (options.rechunk) { const originalIds = await collection.distinct('metadata.originalId', filter); if (originalIds.length > 0) { // Convert string IDs back to ObjectIds for deletion const { ObjectId } = require('mongodb'); const objectIds = originalIds .filter(id => id) .map(id => { try { return new ObjectId(id); } catch { return null; } }) .filter(id => id); if (objectIds.length > 0) { await collection.deleteMany({ _id: { $in: objectIds } }); } } } if (spinner) spinner.stop('Processing complete.'); // Update project config if model/dimensions changed const configUpdated = (model !== project.model) || (dimensions && dimensions !== project.dimensions); if (configUpdated && !options.json) { try { saveProject({ ...project, model, ...(dimensions && { dimensions }), }); if (!quiet) { p.log.info('Updated .vai.json with new model/dimensions.'); } } catch { // Ignore save errors } } if (options.json) { console.log(JSON.stringify({ success: true, processed, errors })); } else { if (errors > 0) { p.log.warn(`Refreshed ${processed} documents with ${errors} errors.`); } else { p.log.success(`Refreshed ${processed} document${processed === 1 ? '' : 's'}.`); } p.outro('Refresh complete.'); } return { success: true, processed, errors }; } catch (err) { if (options.json) { console.log(JSON.stringify({ error: err.message })); } else { p.log.error(`Refresh failed: ${err.message}`); } return { success: false, error: err.message }; } finally { if (client) { await client.close(); } } } /** * Register the refresh command with Commander. */ function register(program) { program .command('refresh') .description('Re-embed documents with a new model, dimensions, or chunk settings') .option('--db <database>', 'Database name') .option('--collection <name>', 'Collection name') .option('--field <name>', 'Embedding field name') .option('-m, --model <model>', 'New embedding model') .option('-d, --dimensions <n>', 'New dimensions', parseInt) .option('--rechunk', 'Re-chunk text before re-embedding') .option('-s, --strategy <strategy>', 'Chunk strategy (with --rechunk)') .option('-c, --chunk-size <n>', 'Chunk size (with --rechunk)', parseInt) .option('--overlap <n>', 'Chunk overlap (with --rechunk)', parseInt) .option('--batch-size <n>', 'Texts per API call (default: 25)', parseInt) .option('--filter <json>', 'Only refresh matching documents (JSON)') .option('--force', 'Skip confirmation prompt') .option('--dry-run', 'Show plan without executing') .option('--json', 'Machine-readable output') .option('-q, --quiet', 'Suppress non-essential output') .action(refresh); } module.exports = { register, refresh };