diff --git a/alembic/versions/20260727_0027_scenes_title_trgm.py b/alembic/versions/20260727_0027_scenes_title_trgm.py new file mode 100644 index 0000000..fd41ddc --- /dev/null +++ b/alembic/versions/20260727_0027_scenes_title_trgm.py @@ -0,0 +1,46 @@ +"""indeks trigramowy na scenes.title + +Revision ID: 0027_scenes_title_trgm +Revises: 0026_scene_performer_source +Create Date: 2026-07-27 + +Bez tego każde szukanie słowa w tytułach to sekwencyjny skan 3,46 mln wierszy. Blokowało +to ranking kandydatów do przeglądu over-attribution +(`scripts/review_performer_attributions.py`): metryka „ile scen ma tę nazwę w tytule, ale +NIE jest do niej przypisanych" wymaga jednego skanu NA KANDYDATA, a kandydatów jest ~800. +Przy skanie sekwencyjnym to godziny obciążania bazy — próba została ubita po 10 minutach. + +GIN + `gin_trgm_ops` obsługuje `LIKE`, `ILIKE` i regexy (`~`, `~*`), a pg_trgm normalizuje +wielkość liter sam, więc indeks na surowym `title` wystarcza. + +**CONCURRENTLY i `autocommit_block`**: zwykłe `CREATE INDEX` bierze lock blokujący zapisy +na `scenes` na cały czas budowy (kilka minut przy tej wielkości), czyli zatrzymałoby +ingest. `CONCURRENTLY` nie może biec w transakcji, a Alembic domyślnie owija migrację w +transakcję — stąd `autocommit_block()`. + +Uwaga operacyjna: nieudane `CREATE INDEX CONCURRENTLY` zostawia indeks w stanie INVALID. +Sprawdzenie: `SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;` — taki +trzeba dropnąć ręcznie i powtórzyć. +""" +from collections.abc import Sequence + +from alembic import op + +revision: str = "0027_scenes_title_trgm" +down_revision: str | None = "0026_scene_performer_source" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + with op.get_context().autocommit_block(): + op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm") + op.execute( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_scenes_title_trgm " + "ON scenes USING gin (title gin_trgm_ops)" + ) + + +def downgrade() -> None: + with op.get_context().autocommit_block(): + op.execute("DROP INDEX CONCURRENTLY IF EXISTS ix_scenes_title_trgm") diff --git a/scripts/review_performer_attributions.py b/scripts/review_performer_attributions.py index 294b3b8..7b73d77 100644 --- a/scripts/review_performer_attributions.py +++ b/scripts/review_performer_attributions.py @@ -37,11 +37,13 @@ niewiarygodne i trzeba je przeczytać. Rapture przy 2,5 okazała się realna (zo sceny), „Precious" przy 3,1 okazała się wyłącznie przymiotnikiem („precious stepdaughter", „her ass is precious") i poszła w całości. -Świadomie NIE ma tu trybu rankingu wszystkich kandydatów: bez indeksu trigramowego na -`scenes.title` to skan 3,4 mln tytułów RAZY ~800 kandydatów, czyli godziny obciążania -bazy. Dla jednej osoby ten sam skan trwa sekundy. +`--candidates` daje ranking, kogo przejrzeć. Działa dzięki indeksowi trigramowemu +(migracja 0027) — przedtem pojedyncze szukanie słowa w tytułach było skanem 3,46 mln +wierszy, więc ranking ~800 kandydatów zajmował godziny i został ubity. Teraz jedno +zapytanie to ~26 ms. Użycie (kontener worker): + python -m scripts.review_performer_attributions --candidates python -m scripts.review_performer_attributions "Rapture" python -m scripts.review_performer_attributions "Rapture" --detach-file /tmp/zle.txt python -m scripts.review_performer_attributions "Rapture" --detach-file /tmp/zle.txt --yes @@ -79,6 +81,28 @@ WHERE s.title ~* ('\\m' || :name || '\\M') WHERE sp.scene_id = s.id AND sp.performer_id = :pid) """ +# UWAGA: `ORDER BY 3::numeric / scen` NIE działa — po rzutowaniu Postgres traktuje +# trójkę jako literał, nie numer kolumny, i sortuje po 3/scen (czyli po liczbie scen +# rosnąco). Wyrażenie trzeba powtórzyć jawnie, stąd osobne CTE. +_CANDIDATES_SQL = """ +WITH kand AS ( + SELECT pf.id, pf.canonical_name, + (SELECT count(*) FROM scene_performers sp WHERE sp.performer_id = pf.id) AS scen + FROM performers pf + WHERE pf.canonical_name !~ ' ' AND length(pf.canonical_name) >= 4 +), z AS ( + SELECT k.canonical_name, k.scen, + (SELECT count(*) FROM scenes s + WHERE s.title ~* ('\\m' || k.canonical_name || '\\M') + AND NOT EXISTS (SELECT 1 FROM scene_performers sp + WHERE sp.scene_id = s.id AND sp.performer_id = k.id)) AS obce + FROM kand k WHERE k.scen >= :min_scenes +) +SELECT canonical_name, scen, obce FROM z +ORDER BY obce::numeric / scen DESC +LIMIT :limit +""" + def _find_performer(session, name: str): row = session.execute( @@ -94,6 +118,9 @@ def _find_performer(session, name: str): def main() -> None: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("performer", nargs="?", help="canonical_name performera") + ap.add_argument("--candidates", action="store_true", help="ranking: kogo przejrzeć") + ap.add_argument("--min-scenes", type=int, default=60) + ap.add_argument("--limit", type=int, default=25) ap.add_argument("--detach-file", help="plik z prefiksami tytułów do odpięcia") ap.add_argument("--yes", action="store_true", help="wykonaj odpięcie (bez tego dry-run)") ap.add_argument( @@ -103,8 +130,21 @@ def main() -> None: ) args = ap.parse_args() + if args.candidates: + with session_scope() as s: + rows = s.execute( + text(_CANDIDATES_SQL), + {"min_scenes": args.min_scenes, "limit": args.limit}, + ).fetchall() + print("nazwa sceny obce wskaźnik") + for nazwa, scen, obce in rows: + print(f" {nazwa[:20]:22} {scen:5} {obce:7} {obce/scen:7.1f}") + print("\nWskaźnik = obce sceny ze słowem w tytule / przypisania tej osoby.") + print("Wysoki = nazwa jest zwykłym słowem; NIE znaczy, że osoba jest fikcyjna.") + return + if not args.performer: - print("podaj canonical_name performera") + print("podaj canonical_name performera albo --candidates") sys.exit(1) prefixes: list[str] = []