const fs = require('fs'); const path = require('path'); const { Client } = require('../../backend/node_modules/pg'); const { getDbConfig, readCsv, resultsDir, benchmarkDir } = require('./benchmark-lib'); const fmt = (value, suffix = '') => { const num = Number(value); if (!Number.isFinite(num)) return 'No disponible'; return `${num.toLocaleString('es-CO', { maximumFractionDigits: 4 })}${suffix}`; }; const readJsonMaybe = (file) => { if (!fs.existsSync(file)) return null; return JSON.parse(fs.readFileSync(file, 'utf8')); }; const byQuery = (rows, queryId) => rows.find((row) => row.query_id === queryId); const improve = (before, after) => { const b = Number(before); const a = Number(after); if (!Number.isFinite(b) || !Number.isFinite(a)) return { absolute: null, percent: null }; return { absolute: b - a, percent: b ? ((b - a) / b) * 100 : null }; }; const planHighlights = (explain) => { const plan = explain?.explain?.Plan; if (!plan) return {}; const nodes = []; const visit = (node) => { nodes.push(node); for (const child of node.Plans || []) visit(child); }; visit(plan); return { executionMs: explain.explain['Execution Time'], planningMs: explain.explain['Planning Time'], nodeTypes: Array.from(new Set(nodes.map((node) => node['Node Type']))).join(', '), seqScans: nodes.filter((node) => node['Node Type'] === 'Seq Scan').length, bitmapScans: nodes.filter((node) => String(node['Node Type']).includes('Bitmap')).length, sharedReadBlocks: nodes.reduce((acc, node) => acc + Number(node['Shared Read Blocks'] || 0), 0), sharedHitBlocks: nodes.reduce((acc, node) => acc + Number(node['Shared Hit Blocks'] || 0), 0), rowsRemoved: nodes.reduce((acc, node) => acc + Number(node['Rows Removed by Filter'] || 0), 0) }; }; const indexSizes = async () => { const client = new Client(getDbConfig()); await client.connect(); try { const result = await client.query(` SELECT c.relname AS name, pg_size_pretty(pg_relation_size(c.oid)) AS size, pg_relation_size(c.oid) AS bytes, i.indisvalid AS valid, i.indisready AS ready FROM pg_class c JOIN pg_index i ON i.indexrelid = c.oid JOIN pg_class t ON t.oid = i.indrelid WHERE t.relname = 'invima_api_catalog' AND c.relname LIKE 'idx_bench_%' ORDER BY pg_relation_size(c.oid) DESC, c.relname; `); return result.rows; } finally { await client.end(); } }; const rowTable = (rows) => { if (!rows.length) return 'No hay datos disponibles.'; return [ '| Indice | Tamano | Valido | Listo |', '|---|---:|---|---|', ...rows.map((row) => `| \`${row.name}\` | ${row.size} | ${row.valid} | ${row.ready} |`) ].join('\n'); }; const resultLine = (queryId, before, after) => { if (!before || !after) return `- ${queryId}: datos incompletos.`; const avg = improve(before.latency_avg_ms, after.latency_avg_ms); const p95 = improve(before.latency_p95_ms, after.latency_p95_ms); return `- ${queryId}: promedio ${fmt(before.latency_avg_ms, ' ms')} -> ${fmt(after.latency_avg_ms, ' ms')} (mejora ${fmt(avg.absolute, ' ms')}, ${fmt(avg.percent, ' %')}); p95 ${fmt(before.latency_p95_ms, ' ms')} -> ${fmt(after.latency_p95_ms, ' ms')} (mejora ${fmt(p95.absolute, ' ms')}, ${fmt(p95.percent, ' %')}).`; }; const criterion = (queryId, row) => { if (!row) return 'No evaluado'; const avg = Number(row.latency_avg_ms); const p95 = Number(row.latency_p95_ms); if (queryId === 'query_06') { return avg <= 3000 && p95 <= 5000 ? 'Cumple objetivo textual' : 'No cumple objetivo textual'; } return avg <= 5000 && p95 <= 8000 ? 'Cumple objetivo de conteo' : 'No cumple objetivo de conteo'; }; const main = async () => { const baseline = readCsv(path.join(resultsDir, 'baseline_summary.csv')); const optimized = readCsv(path.join(resultsDir, 'optimized_summary.csv')); const validation = readJsonMaybe(path.join(resultsDir, 'validation_search_results.json')); const before06 = planHighlights(readJsonMaybe(path.join(resultsDir, 'explain_before', 'query_06.json'))); const before07 = planHighlights(readJsonMaybe(path.join(resultsDir, 'explain_before', 'query_07.json'))); const after06 = planHighlights(readJsonMaybe(path.join(resultsDir, 'explain_after', 'query_06.json'))); const after07 = planHighlights(readJsonMaybe(path.join(resultsDir, 'explain_after', 'query_07.json'))); const sizes = await indexSizes(); const b6 = byQuery(baseline, 'query_06'); const b7 = byQuery(baseline, 'query_07'); const o6 = byQuery(optimized, 'query_06'); const o7 = byQuery(optimized, 'query_07'); const generatedAt = new Date().toISOString(); const report = `# Informe de optimizacion de busqueda textual Generado: ${generatedAt} ## 1. Problema encontrado Las consultas \`query_06\` y \`query_07\` evaluan la busqueda textual de \`marcapasos\` combinando busqueda FTS sobre \`search_text\` con condiciones \`ILIKE '%marcapasos%'\` sobre \`registro_sanitario\`, \`expediente\`, \`producto\`, \`categoria_canonica\` y \`categoria_linea\`. Antes de optimizar, PostgreSQL eligio \`Parallel Seq Scan\`, por lo que recorrio la tabla completa y aplico el filtro despues de leer cientos de miles de bloques. ## 2. Plan SQL anterior - query_06: ${before06.nodeTypes || 'No disponible'}; Seq Scan=${before06.seqScans ?? 'N/D'}; bloques leidos=${fmt(before06.sharedReadBlocks)}; bloques hit=${fmt(before06.sharedHitBlocks)}; filas removidas=${fmt(before06.rowsRemoved)}; ejecucion=${fmt(before06.executionMs, ' ms')}. - query_07: ${before07.nodeTypes || 'No disponible'}; Seq Scan=${before07.seqScans ?? 'N/D'}; bloques leidos=${fmt(before07.sharedReadBlocks)}; bloques hit=${fmt(before07.sharedHitBlocks)}; filas removidas=${fmt(before07.rowsRemoved)}; ejecucion=${fmt(before07.executionMs, ' ms')}. ## 3. Cambios implementados Se mantuvo intacta la semantica de las consultas. La migracion agrega indices GIN trigram de expresion sobre los mismos \`COALESCE(..., '')\` usados por los predicados \`ILIKE\`, elimina el indice de orden de benchmark si estaba invalido y aumenta estadisticas de columnas de busqueda. \`ANALYZE\` se dejo como accion manual porque en esta base local falla por bytes con codificacion invalida en datos existentes. ## 4. Indices nuevos ${rowTable(sizes)} ## 5. Validacion funcional - Conteo antes: ${validation?.comparison ? fmt(validation.comparison.countBefore) : 'No disponible'}. - Conteo despues: ${validation?.comparison ? fmt(validation.comparison.countAfter) : 'No disponible'}. - Faltantes: ${validation?.comparison ? validation.comparison.missingIds.length : 'No disponible'}. - Adicionales: ${validation?.comparison ? validation.comparison.additionalIds.length : 'No disponible'}. - Duplicados despues: ${validation?.comparison ? validation.comparison.duplicateIdsAfter.length : 'No disponible'}. - Cambio de orden: ${validation?.comparison ? validation.comparison.orderChanged : 'No disponible'}. - Cambio de claves ORDER BY: ${validation?.comparison ? validation.comparison.orderKeysChanged : 'No disponible'}. - Nota de orden: ${validation?.comparison?.note || 'Sin observaciones'} - Resultado: ${validation?.comparison?.valid ? 'VALIDO, sin diferencias funcionales' : 'No validado o con diferencias'}. ## 6. Resultados antes y despues ${resultLine('query_06', b6, o6)} ${resultLine('query_07', b7, o7)} ## 7. Plan SQL posterior - query_06: ${after06.nodeTypes || 'No disponible'}; Seq Scan=${after06.seqScans ?? 'N/D'}; Bitmap=${after06.bitmapScans ?? 'N/D'}; bloques leidos=${fmt(after06.sharedReadBlocks)}; bloques hit=${fmt(after06.sharedHitBlocks)}; ejecucion=${fmt(after06.executionMs, ' ms')}. - query_07: ${after07.nodeTypes || 'No disponible'}; Seq Scan=${after07.seqScans ?? 'N/D'}; Bitmap=${after07.bitmapScans ?? 'N/D'}; bloques leidos=${fmt(after07.sharedReadBlocks)}; bloques hit=${fmt(after07.sharedHitBlocks)}; ejecucion=${fmt(after07.executionMs, ' ms')}. ## 8. Criterios - query_06: ${criterion('query_06', o6)}. Objetivo: promedio <= 3 s, p95 <= 5 s, ideal < 1 s. - query_07: ${criterion('query_07', o7)}. Objetivo: promedio <= 5 s, p95 <= 8 s. ## 9. Riesgos e impacto Los indices GIN adicionales consumen espacio y pueden aumentar el costo de futuras inserciones o actualizaciones en columnas textuales. No modifican datos regulatorios ni cambian resultados. El conteo exacto sigue evaluando todo el conjunto de coincidencias; si no cumple el objetivo, la siguiente alternativa es reescribir la busqueda como \`UNION\`/deduplicacion exacta o separar el conteo exacto como operacion diferida sin cambiar la ruta que lo obtiene. ## 10. Reversion y repeticion Revertir: \`\`\`powershell npm.cmd run benchmark:rollback \`\`\` Repetir pruebas: \`\`\`powershell $env:BENCHMARK_QUERY_IDS="query_06,query_07" $env:BENCHMARK_REPETITIONS="100" $env:BENCHMARK_WARMUPS="5" npm.cmd run benchmark:optimized node benchmarks/scripts/validate-search-results.js after npm.cmd run benchmark:search:report \`\`\` EA, MSE, precision y exhaustividad no calculables por ausencia de un conjunto de referencia validado. `; fs.writeFileSync(path.join(benchmarkDir, 'INFORME_OPTIMIZACION_BUSQUEDA.md'), report, 'utf8'); }; main().catch((error) => { console.error(error); process.exit(1); });