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 sceny), Precious" przy 3,1 okazała się wyłącznie przymiotnikiem („precious
stepdaughter", „her ass is precious") i poszła w całości. 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 `--candidates` daje ranking, kogo przejrzeć. Działa dzięki indeksowi trigramowemu
`scenes.title` to skan 3,4 mln tytułów RAZY ~800 kandydatów, czyli godziny obciążania (migracja 0027) przedtem pojedyncze szukanie słowa w tytułach było skanem 3,46 mln
bazy. Dla jednej osoby ten sam skan trwa sekundy. wierszy, więc ranking ~800 kandydatów zajmował godziny i został ubity. Teraz jedno
zapytanie to ~26 ms.
Użycie (kontener worker): 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"
python -m scripts.review_performer_attributions "Rapture" --detach-file /tmp/zle.txt 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 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) 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): def _find_performer(session, name: str):
row = session.execute( row = session.execute(
@ -94,6 +118,9 @@ def _find_performer(session, name: str):
def main() -> None: def main() -> None:
ap = argparse.ArgumentParser(description=__doc__) ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("performer", nargs="?", help="canonical_name performera") 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("--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("--yes", action="store_true", help="wykonaj odpięcie (bez tego dry-run)")
ap.add_argument( ap.add_argument(
@ -103,8 +130,21 @@ def main() -> None:
) )
args = ap.parse_args() 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: if not args.performer:
print("podaj canonical_name performera") print("podaj canonical_name performera albo --candidates")
sys.exit(1) sys.exit(1)
prefixes: list[str] = [] prefixes: list[str] = []