search/scripts/benchmark-search.mjs

203 lines
7.0 KiB
JavaScript

#!/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)
})