#!/usr/bin/env node /** * Benchmark de red para el buscador: compara la latencia del patrón de * búsqueda ANTES de las optimizaciones (varias requests secuenciales) contra * el patrón DESPUÉS (una sola request con join), golpeando directamente el * cluster real de Typesense — sin pasar por el navegador ni por Nuxt. * * Sirve como línea base repetible: correr este script antes/después de un * cambio futuro en las queries de búsqueda muestra si mejoró o empeoró la * latencia real contra el servidor, no solo "se siente más rápido". * * Uso: * node --env-file=.env scripts/benchmark-search.mjs * node --env-file=.env scripts/benchmark-search.mjs --iterations 20 --query "amor" * pnpm run benchmark:search -- --iterations 20 * * Requiere NUXT_PUBLIC_TYPESENSE_URL y NUXT_PUBLIC_TYPESENSE_API_KEY en el * entorno (--env-file=.env los carga automáticamente en Node 20.6+). */ const args = process.argv.slice(2) function argValue(name, fallback) { const idx = args.indexOf(`--${name}`) return idx !== -1 && args[idx + 1] ? args[idx + 1] : fallback } const ITERATIONS = Number(argValue('iterations', '15')) const QUERY = argValue('query', 'amor') const LOCALE = argValue('locale', 'es') const TYPESENSE_URL = process.env.NUXT_PUBLIC_TYPESENSE_URL const API_KEY = process.env.NUXT_PUBLIC_TYPESENSE_API_KEY if (!TYPESENSE_URL || !API_KEY) { console.error('Faltan NUXT_PUBLIC_TYPESENSE_URL / NUXT_PUBLIC_TYPESENSE_API_KEY.') console.error('Corré con: node --env-file=.env scripts/benchmark-search.mjs') process.exit(1) } async function multiSearch(searches) { const res = await fetch(`${TYPESENSE_URL}/multi_search`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-TYPESENSE-API-KEY': API_KEY }, body: JSON.stringify({ searches }) }) if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`) return res.json() } async function timeIt(fn) { const start = performance.now() await fn() return performance.now() - start } function stats(samples) { const sorted = [...samples].sort((a, b) => a - b) const sum = sorted.reduce((a, b) => a + b, 0) const p = q => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] return { mean: sum / sorted.length, median: p(0.5), p95: p(0.95), min: sorted[0], max: sorted[sorted.length - 1] } } async function runScenario(name, fn) { const samples = [] // Un warmup fuera de la medición, para no medir handshake TLS/DNS frío. await fn().catch(() => {}) for (let i = 0; i < ITERATIONS; i++) { samples.push(await timeIt(fn)) } return { name, ...stats(samples) } } // ── Escenarios por colección (conferences / activities) ───────────────────── const COLLECTIONS = [ { label: 'conferences', main: 'conferences', paragraphs: 'conferences_paragraphs', groupBy: 'conferences_id' }, { label: 'activities', main: 'activities', paragraphs: 'activities_paragraphs', groupBy: 'activities_id' } ] function oldSearchScenario({ main, paragraphs, groupBy }) { return async () => { // 1) búsqueda de párrafos, SIN join (como antes de la Fase 1.1) const r1 = await multiSearch([{ collection: paragraphs, q: QUERY, query_by: 'text', filter_by: `locale:=${LOCALE}`, per_page: 10, highlight_full_fields: 'text', highlight_fields: 'text', highlight_affix_num_tokens: 30, group_by: groupBy }]) const ids = (r1.results?.[0]?.grouped_hits ?? []) .map(g => g.group_key?.[0]) .filter(Boolean) if (!ids.length) return // 2) segunda request para la metadata (la cascada eliminada en 1.1) await multiSearch([{ collection: main, q: '*', query_by: 'title', filter_by: `id:=[${ids.join(',')}]`, per_page: ids.length, include_fields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft' }]) } } function newSearchScenario({ main, paragraphs, groupBy }) { return async () => { // Una sola request: join a la colección principal + highlight recortado await multiSearch([{ collection: paragraphs, q: QUERY, query_by: 'text', filter_by: `locale:=${LOCALE}`, per_page: 10, highlight_full_fields: 'text', highlight_fields: 'text', highlight_affix_num_tokens: 15, group_by: groupBy, include_fields: `*, $${main}(id,title,date,timestamp,place,city,state,country,type,slug,draft)` }]) } } function oldBrowseScenario({ main, paragraphs, groupBy }) { return async () => { // Explorar sin query contra la colección grande de párrafos (antes de 1.2) await multiSearch([{ collection: paragraphs, q: '*', query_by: 'text', filter_by: `locale:=${LOCALE} && $${main}(locale:=${LOCALE})`, sort_by: `$${main}(timestamp:desc)`, group_by: groupBy, per_page: 10, include_fields: `$${main}(id,title,date,timestamp,place,city,state,country,type,slug,draft)` }]) } } function newBrowseScenario({ main }) { return async () => { // Explorar sin query contra la colección principal, directo (después de 1.2) await multiSearch([{ collection: main, q: '*', query_by: 'title', filter_by: `locale:=${LOCALE}`, sort_by: 'timestamp:desc', per_page: 10, include_fields: 'id,title,date,timestamp,place,city,state,country,type,slug,draft' }]) } } function printTable(rows) { const cols = ['name', 'mean', 'median', 'p95', 'min', 'max'] const widths = cols.map(c => Math.max(c.length, ...rows.map(r => String(typeof r[c] === 'number' ? r[c].toFixed(1) : r[c]).length))) const fmtRow = vals => vals.map((v, i) => String(v).padEnd(widths[i])).join(' ') console.log(fmtRow(cols.map(c => c.toUpperCase()))) console.log(widths.map(w => '-'.repeat(w)).join(' ')) for (const r of rows) { console.log(fmtRow(cols.map(c => (typeof r[c] === 'number' ? r[c].toFixed(1) + 'ms' : r[c])))) } } async function main() { console.log(`Typesense: ${TYPESENSE_URL} | query="${QUERY}" | locale=${LOCALE} | iteraciones=${ITERATIONS}\n`) for (const col of COLLECTIONS) { console.log(`\n=== ${col.label} — búsqueda con texto ===`) const oldR = await runScenario('antes (2 requests)', oldSearchScenario(col)) const newR = await runScenario('después (1 request)', newSearchScenario(col)) printTable([oldR, newR]) const improvement = ((oldR.mean - newR.mean) / oldR.mean * 100).toFixed(1) console.log(`→ ${improvement}% más rápido en promedio`) console.log(`\n=== ${col.label} — explorar sin query ===`) const oldB = await runScenario('antes (colección párrafos)', oldBrowseScenario(col)) const newB = await runScenario('después (colección principal)', newBrowseScenario(col)) printTable([oldB, newB]) const improvementB = ((oldB.mean - newB.mean) / oldB.mean * 100).toFixed(1) console.log(`→ ${improvementB}% más rápido en promedio`) } } main().catch((err) => { console.error('Error corriendo el benchmark:', err) process.exit(1) })