perf(scenes): indeks trigramowy na title + ranking kandydatow do przegladu

Kazde szukanie slowa w tytulach bylo sekwencyjnym skanem 3,46 mln wierszy. Blokowalo
to ranking over-attribution: metryka "ile scen ma te nazwe w tytule, ale NIE jest do
niej przypisanych" wymaga jednego skanu NA KANDYDATA, a kandydatow jest ~800. Proba
zostala ubita po 10 minutach.

GIN + gin_trgm_ops obsluguje LIKE, ILIKE i regexy, a pg_trgm sam normalizuje wielkosc
liter, wiec indeks na surowym title wystarcza. Efekt: 26 ms zamiast sekund na
zapytanie, ranking 800 kandydatow w 48 sekund zamiast godzin. Indeks 315 MB.

Zbudowany CONCURRENTLY w autocommit_block: zwykle CREATE INDEX trzymaloby lock
blokujacy zapisy na scenes przez cala budowe i zatrzymalo ingest.

Ranking od razu pokazuje dwie rozne rzeczy. Czyste smieci ("Pornhub" 105.9, "Monster"
92, "Pretty" 63, "Nice" 42) oraz SAME NAZWISKA (Devine, Starr, Dior, Cruz, West,
Knight, Johnson, James) - wpisy powstale z rozbicia imienia i nazwiska, ktore potem
lapia kazda scene z tym nazwiskiem, nalezaca do zupelnie innej osoby.

Usuniete przy okazji: "Pornhub" (108 scen, 0 z kanonu, wszystkie to "full video on
pornhub") i "Precious" (119 scen, 0 z kanonu, sam przymiotnik).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
goon-foss 2026-07-27 15:59:52 +02:00
parent 470d70c11e
commit ef8c255e1f
2 changed files with 90 additions and 4 deletions

View file

@ -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 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")

View file

@ -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] = []