197 lines
9.1 KiB
JavaScript
197 lines
9.1 KiB
JavaScript
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);
|
|
});
|