-- pg_recall_guard 0.1.0 — vigila que un índice vectorial siga devolviendo lo que aprobaste. -- -- El problema: un índice ANN degradado NO falla. Devuelve k vecinos plausibles y -- nunca avisa que cuatro de los diez buenos quedaron afuera. No hay error, no hay -- log, no hay alerta — igual que una regresión de plan, que devuelve las mismas -- filas y sólo deja de usar el índice. -- -- Lo que hace: descubre los índices vectoriales por CATÁLOGO (no por nombre de -- extensión), mide su recall real contra ground truth exacto, guarda una línea base -- y avisa cuando el recall se aleja de ella. -- -- Agnóstico por construcción: funciona sobre cualquier índice cuyo access method -- declare operadores de ordenamiento (pg_amop.amoppurpose='o'), que hoy es hnsw, -- ivfflat, diskann, gist y spgist — y mañana lo que venga, sin tocar este código. \echo Use "CREATE EXTENSION pg_recall_guard" to load this file. \quit CREATE SCHEMA IF NOT EXISTS recall_guard; -- --------------------------------------------------------------------------- -- 1. Descubrimiento -- --------------------------------------------------------------------------- -- Todo índice del cluster que pueda responder un ORDER BY por operador de -- distancia, con los datos que hacen falta para reconstruir esa consulta. CREATE VIEW recall_guard.vector_indexes AS SELECT i.indexrelid AS index_oid, i.indexrelid::regclass::text AS index_name, n.nspname AS schema_name, t.relname AS table_name, a.attname AS column_name, am.amname AS access_method, op.oprname AS operator, -- El operador NO vive en pg_catalog sino donde se instaló la extensión que lo -- trae, así que su esquema se lee del catálogo en vez de suponerse. Asumir -- pg_catalog acá da "operator does not exist: vector pg_catalog.<=> vector". opn.nspname AS operator_schema, oc.opcname AS opclass, pg_relation_size(i.indexrelid) AS index_bytes FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_class t ON t.oid = i.indrelid JOIN pg_namespace n ON n.oid = t.relnamespace JOIN pg_am am ON am.oid = c.relam JOIN pg_opclass oc ON oc.oid = i.indclass[0] JOIN pg_attribute a ON a.attrelid = i.indexrelid AND a.attnum = 1 JOIN pg_amop ao ON ao.amopfamily = oc.opcfamily AND ao.amoppurpose = 'o' JOIN pg_operator op ON op.oid = ao.amopopr JOIN pg_namespace opn ON opn.oid = op.oprnamespace WHERE i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema'); COMMENT ON VIEW recall_guard.vector_indexes IS 'Índices que responden ORDER BY por operador de distancia, detectados por catálogo. ' 'No nombra ninguna extensión: sirve para pgvector, pgvectorscale, VectorChord o lo que venga.'; -- --------------------------------------------------------------------------- -- 2. Estado persistido -- --------------------------------------------------------------------------- CREATE TABLE recall_guard.baselines ( index_name text PRIMARY KEY, k int NOT NULL, sample_size int NOT NULL, recall numeric(5,4) NOT NULL, approved_at timestamptz NOT NULL DEFAULT now(), note text ); COMMENT ON TABLE recall_guard.baselines IS 'El recall que el dueño aceptó como bueno. Sin línea base no hay drift que medir: ' '"0,82" no dice nada, "0,82 donde aprobaste 0,97" lo dice todo.'; CREATE TABLE recall_guard.measurements ( id bigserial PRIMARY KEY, index_name text NOT NULL, k int NOT NULL, sample_size int NOT NULL, recall numeric(5,4) NOT NULL, index_bytes bigint, measured_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX ON recall_guard.measurements (index_name, measured_at DESC); -- --------------------------------------------------------------------------- -- 3. La medición -- --------------------------------------------------------------------------- -- Recall de UNA consulta: cuántos de los k vecinos que devuelve el índice están -- entre los k verdaderos. -- -- La honestidad de esta función depende de dos cosas que se COMPRUEBAN, no se -- asumen: que el lado indexado de verdad usó el índice, y que el lado exacto de -- verdad NO lo usó. Si cualquiera de las dos falla, el número sería un 1.0000 -- inventado — el peor resultado posible, porque es tranquilizador y falso. Por eso -- se verifica el plan con EXPLAIN y se levanta excepción en vez de devolver algo. CREATE FUNCTION recall_guard.evaluate_query( p_index regclass, p_vector text, p_k int DEFAULT 10, p_exclude tid DEFAULT NULL -- el ctid de origen, cuando la consulta sale de la tabla ) RETURNS numeric LANGUAGE plpgsql AS $$ DECLARE v record; q_indexed text; q_exact text; plan_txt text; tids_idx tid[]; tids_exact tid[]; v_limit int; hits int; BEGIN -- Si la consulta sale de una fila de la tabla, esa fila se encuentra a sí misma -- a distancia 0 y regala un acierto en todas las consultas: con k=10 el recall -- nunca puede bajar de 0,1 por más roto que esté el índice. Se pide uno de más -- y se descarta el propio ctid de los dos lados. v_limit := p_k + CASE WHEN p_exclude IS NULL THEN 0 ELSE 1 END; SELECT * INTO v FROM recall_guard.vector_indexes WHERE index_oid = p_index; IF NOT FOUND THEN RAISE EXCEPTION 'pg_recall_guard: % no es un índice con operador de ordenamiento', p_index USING HINT = 'Mira recall_guard.vector_indexes para los que sí lo son.'; END IF; q_indexed := format( 'SELECT array_agg(ctid) FROM (SELECT ctid FROM %I.%I ORDER BY %I OPERATOR(%I.%s) %L::%s LIMIT %s) s', v.schema_name, v.table_name, v.column_name, v.operator_schema, v.operator, p_vector, recall_guard._vector_type(p_index), v_limit); q_exact := q_indexed; -- Lado indexado: forzar el índice y COMPROBAR que se usó. -- -- El plan se pide en JSON y no en texto porque EXPLAIN (FORMAT TEXT) devuelve -- UNA FILA POR LÍNEA, y un INTO se queda sólo con la primera: el "Index Scan" -- que aparece más abajo no se vería nunca y la comprobación sería decorativa. SET LOCAL enable_seqscan = off; EXECUTE 'EXPLAIN (FORMAT JSON) ' || q_indexed INTO plan_txt; IF plan_txt NOT LIKE '%Index Scan%' THEN RAISE EXCEPTION 'pg_recall_guard: el plan no usó el índice %', v.index_name USING DETAIL = plan_txt, HINT = 'Sin index scan la medición compararía el índice contra sí mismo y daría 1.0 siempre.'; END IF; EXECUTE q_indexed INTO tids_idx; RESET enable_seqscan; -- Ground truth: prohibir todo acceso por índice y COMPROBAR que no se usó. SET LOCAL enable_indexscan = off; SET LOCAL enable_bitmapscan = off; SET LOCAL enable_indexonlyscan = off; EXECUTE 'EXPLAIN (FORMAT JSON) ' || q_exact INTO plan_txt; IF plan_txt LIKE '%Index Scan%' THEN RAISE EXCEPTION 'pg_recall_guard: no se pudo obtener ground truth exacto para %', v.index_name USING DETAIL = plan_txt, HINT = 'El planner insistió con el índice pese a los enable_*=off.'; END IF; EXECUTE q_exact INTO tids_exact; RESET enable_indexscan; RESET enable_bitmapscan; RESET enable_indexonlyscan; -- Fuera el self-match de ambos lados, y recién ahí recortar a k. Si se -- recortara antes, el hueco que deja el descarte se llenaría con el vecino -- k+1 y volveríamos a contar de más. IF p_exclude IS NOT NULL THEN SELECT array_agg(x) INTO tids_idx FROM (SELECT x FROM unnest(tids_idx) x WHERE x <> p_exclude LIMIT p_k) s; SELECT array_agg(x) INTO tids_exact FROM (SELECT x FROM unnest(tids_exact) x WHERE x <> p_exclude LIMIT p_k) s; END IF; IF tids_exact IS NULL OR array_length(tids_exact, 1) IS NULL THEN RAISE EXCEPTION 'pg_recall_guard: el ground truth salió vacío para %', v.index_name; END IF; SELECT count(*) INTO hits FROM unnest(tids_idx) x WHERE x = ANY (tids_exact); RETURN round(hits::numeric / array_length(tids_exact, 1), 4); END; $$; -- El tipo de la columna indexada, para castear el literal del vector sin asumir -- que siempre es `vector`: puede ser halfvec, sparsevec o lo que traiga la -- extensión de turno. CREATE FUNCTION recall_guard._vector_type(p_index regclass) RETURNS text LANGUAGE sql STABLE AS $$ SELECT format_type(a.atttypid, a.atttypmod) FROM pg_index i JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = i.indkey[0] WHERE i.indexrelid = p_index; $$; -- --------------------------------------------------------------------------- -- 4. Muestreo y medición agregada -- --------------------------------------------------------------------------- -- Recall promedio del índice sobre una muestra de consultas. -- -- Las consultas salen de vectores de la propia tabla, y eso tiene una trampa que -- hay que desactivar: un vector de la tabla SIEMPRE se encuentra a sí mismo a -- distancia 0, y ese acierto regalado infla el recall — con k=10 son 10 puntos -- gratis en cada consulta. Por eso se piden k+1 vecinos y se descarta el propio -- ctid de los dos lados. Es la diferencia entre medir el índice y medir que un -- vector es igual a sí mismo. CREATE FUNCTION recall_guard.measure( p_index regclass, p_k int DEFAULT 10, p_sample_size int DEFAULT 30 ) RETURNS numeric LANGUAGE plpgsql AS $$ DECLARE v record; muestra record; total numeric := 0; n int := 0; r numeric; BEGIN SELECT * INTO v FROM recall_guard.vector_indexes WHERE index_oid = p_index; IF NOT FOUND THEN RAISE EXCEPTION 'pg_recall_guard: % no es un índice vectorial', p_index; END IF; -- Se trae el ctid junto al vector: sin él no se puede descartar el self-match -- y el recall queda con un piso artificial de 1/k. FOR muestra IN EXECUTE format( 'SELECT ctid, %I::text AS vec FROM %I.%I TABLESAMPLE SYSTEM_ROWS(%s) WHERE %I IS NOT NULL', v.column_name, v.schema_name, v.table_name, p_sample_size, v.column_name) LOOP r := recall_guard.evaluate_query(p_index, muestra.vec, p_k, muestra.ctid); total := total + r; n := n + 1; END LOOP; IF n = 0 THEN RAISE EXCEPTION 'pg_recall_guard: la muestra salió vacía para %', v.index_name USING HINT = '¿La tabla tiene filas con esa columna no nula?'; END IF; INSERT INTO recall_guard.measurements (index_name, k, sample_size, recall, index_bytes) VALUES (v.index_name, p_k, n, round(total / n, 4), v.index_bytes); RETURN round(total / n, 4); END; $$; -- --------------------------------------------------------------------------- -- 5. La línea base y el drift -- --------------------------------------------------------------------------- -- "Este recall es el que acepto." Sin esto, un 0,82 no dice nada; con esto, -- dice que perdiste 15 puntos desde que lo aprobaste. CREATE FUNCTION recall_guard.approve( p_index regclass, p_k int DEFAULT 10, p_sample_size int DEFAULT 30, p_note text DEFAULT NULL ) RETURNS numeric LANGUAGE plpgsql AS $$ DECLARE v_name text; r numeric; BEGIN SELECT index_name INTO v_name FROM recall_guard.vector_indexes WHERE index_oid = p_index; r := recall_guard.measure(p_index, p_k, p_sample_size); INSERT INTO recall_guard.baselines (index_name, k, sample_size, recall, note) VALUES (v_name, p_k, p_sample_size, r, p_note) ON CONFLICT (index_name) DO UPDATE SET k = EXCLUDED.k, sample_size = EXCLUDED.sample_size, recall = EXCLUDED.recall, approved_at = now(), note = EXCLUDED.note; RETURN r; END; $$; -- El chequeo que se agenda: vuelve a medir todo lo aprobado y reporta la caída. -- Devuelve filas en vez de escribir en el log porque un monitor lo consume mejor. CREATE FUNCTION recall_guard.check() RETURNS TABLE ( index_name text, baseline numeric, current numeric, drift numeric, verdict text ) LANGUAGE plpgsql AS $$ DECLARE b record; r numeric; BEGIN FOR b IN SELECT * FROM recall_guard.baselines LOOP BEGIN r := recall_guard.measure(b.index_name::regclass, b.k, b.sample_size); EXCEPTION WHEN OTHERS THEN -- Un índice que ya no se puede medir es una novedad, no un silencio. index_name := b.index_name; baseline := b.recall; current := NULL; drift := NULL; verdict := 'NO SE PUDO MEDIR: ' || SQLERRM; RETURN NEXT; CONTINUE; END; index_name := b.index_name; baseline := b.recall; current := r; drift := round(r - b.recall, 4); verdict := CASE WHEN r >= b.recall - 0.02 THEN 'ok' WHEN r >= b.recall - 0.10 THEN 'degradado' ELSE 'critico' END; RETURN NEXT; END LOOP; END; $$; COMMENT ON FUNCTION recall_guard.check() IS 'Vuelve a medir cada índice aprobado y compara con su línea base. ' 'Pensado para agendarse con pg_cron: la degradación de un índice ANN es gradual ' 'y silenciosa, así que el único momento en que se detecta es cuando alguien mira.';