fix(api): szybkie szukanie po tytule dla popularnych i krotkich fraz
Some checks are pending
Backend tests / test (push) Waiting to run
Some checks are pending
Backend tests / test (push) Waiting to run
Planer szacowal kazda fraze na 427 trafien (histogram 100 probek, dolny limit), wiec
zawsze szedl indeksem trigramow i sortowal wszystkie trafienia: "vo" 44 s (dwie
litery nie maja trigramu, indeks oddaje 4,3 mln wierszy), "mis" 6,5 s, "free" 1,9 s
(GOON-73/75).
- 0046: histogram title_normalized 1000 probek, szacunki bliskie prawdy.
- list_scenes: przy q i sorcie po dacie najpierw przejscie po indeksie sortu z
limitem 300 ms (bez bitmap, w SAVEPOINT wycofywanym zawsze), gdy probka tytulow
z pg_stats mowi, ze wystarczy przejsc do 30 tys. scen; inaczej zwykly plan.
Frazy bez trigramu zawsze przejsciem (limit 3 s, potem pusto).
115 prawdziwych wyszukiwan z logow: wyniki identyczne, najgorszy 44 s -> 0,8 s,
srednie frazy dotykaja 3-7x mniej stron ("bad" 122 tys. -> 16 tys.).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7accd4692f
commit
45ee7a81e1
3 changed files with 228 additions and 9 deletions
36
alembic/versions/20260922_0046_scenes_title_stats.py
Normal file
36
alembic/versions/20260922_0046_scenes_title_stats.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Wiekszy histogram statystyk dla scenes.title_normalized (szukanie po tytule).
|
||||||
|
|
||||||
|
Postgres szacuje `ILIKE '%fraza%'` z histogramu kolumny. Przy domyslnych 100 probkach
|
||||||
|
popularne frazy nie trafialy w zadna i szacunek spadal na dolny limit 0,01% (427 scen),
|
||||||
|
wiec planista zawsze szedl przez indeks trigramow i potem sortowal wszystkie trafienia:
|
||||||
|
"vo" 44 s (dwie litery nie maja trigramu, indeks oddaje 4,3 mln wierszy), "mis" 6,5 s,
|
||||||
|
"free" 1,9 s (Sentry GOON-73/75, 2026-09-22). Z 1000 probkami szacunki sa bliskie
|
||||||
|
prawdy i dla popularnych fraz wygrywa przejscie po ix_scenes_created_at_desc do 51.
|
||||||
|
trafienia: 9 ms, 15 ms, 60 ms. Rzadkie frazy dalej ida przez trigramy.
|
||||||
|
|
||||||
|
ANALYZE samej kolumny czyta probke 300 tys. wierszy, kilkanascie sekund.
|
||||||
|
|
||||||
|
Revision ID: 0046_scenes_title_stats
|
||||||
|
Revises: 0045_autovacuum_big_tables
|
||||||
|
Create Date: 2026-09-22
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0046_scenes_title_stats"
|
||||||
|
down_revision: str | None = "0045_autovacuum_big_tables"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("SET LOCAL lock_timeout = '5s'")
|
||||||
|
op.execute("ALTER TABLE scenes ALTER COLUMN title_normalized SET STATISTICS 1000")
|
||||||
|
op.execute("ANALYZE scenes (title_normalized)")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("SET LOCAL lock_timeout = '5s'")
|
||||||
|
op.execute("ALTER TABLE scenes ALTER COLUMN title_normalized SET STATISTICS -1")
|
||||||
|
op.execute("ANALYZE scenes (title_normalized)")
|
||||||
|
|
@ -20,10 +20,12 @@ from sqlalchemy import (
|
||||||
func,
|
func,
|
||||||
literal_column,
|
literal_column,
|
||||||
select,
|
select,
|
||||||
|
text,
|
||||||
true,
|
true,
|
||||||
union,
|
union,
|
||||||
)
|
)
|
||||||
from sqlalchemy.exc import IntegrityError
|
from psycopg.errors import QueryCanceled
|
||||||
|
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||||
from sqlalchemy.orm import Session, aliased
|
from sqlalchemy.orm import Session, aliased
|
||||||
|
|
||||||
from app.auth import require_api_key
|
from app.auth import require_api_key
|
||||||
|
|
@ -208,6 +210,115 @@ def _blacklists_empty(session: Session, device_id: str) -> bool:
|
||||||
return not has_any
|
return not has_any
|
||||||
|
|
||||||
|
|
||||||
|
# Szukanie po tytule (GOON-73/75, pomiary 2026-09-22). Sa dwa dobre plany i planer nie
|
||||||
|
# umie wybrac miedzy nimi, bo nie wie, ile tytulow pasuje:
|
||||||
|
# - przejscie po indeksie sortu od najnowszych do 51. trafienia: popularne i srednie frazy
|
||||||
|
# ("vo", "bad", "veronica") 10-110 ms, rzadkie ("jackerman") ponad 20 s, bo idzie przez
|
||||||
|
# caly katalog;
|
||||||
|
# - indeks trigramow, potem sort wszystkich trafien: rzadkie 30-500 ms, popularne sekundy
|
||||||
|
# ("mis" 6,5 s), a dwuliterowe ("vo") 44 s, bo bez trigramu indeks oddaje 4,3 mln wierszy.
|
||||||
|
# Dlatego najpierw przejscie z limitem czasu; nie zdazylo, to fraza jest rzadka i zwykly
|
||||||
|
# plan (trigramy) jest wlasciwy. Wiekszy histogram tytulow (migracja 0046) poprawil
|
||||||
|
# szacunki, ale srednie frazy dalej szly trigramami (0,2-1,3 s zamiast 10-110 ms).
|
||||||
|
# Zeby rzadkie frazy (nazwiska) i dalekie strony nie placily limitu na probe, szacujemy z
|
||||||
|
# probki tytulow ze statystyk Postgresa (1000 wartosci, ta sama, z ktorej planer liczy
|
||||||
|
# szacunek), ile scen trzeba przejsc: (offset + strona) / udzial trafien w probce. Na 115
|
||||||
|
# prawdziwych wyszukiwaniach przejscie wygrywalo zawsze do ~30 tys., wyzej i przy
|
||||||
|
# zawezeniu (tag, obsada, studio, dlugie minimum) zwykle nie miescilo sie w limicie.
|
||||||
|
_SEARCH_WALK_BUDGET_MS = 300
|
||||||
|
_SEARCH_WALK_MAX_SCAN = 30_000
|
||||||
|
# Fraza bez ciagu 3 liter/cyfr nie ma trigramu, wiec awaryjnego planu nie ma. Dluzszy
|
||||||
|
# limit, a po nim pusta lista: to zwykle stan w trakcie pisania ("q", "xz").
|
||||||
|
_SEARCH_WALK_BUDGET_NO_TRGM_MS = 3000
|
||||||
|
_TRGM_RUN_RE = re.compile(r"[^\W_]{3}")
|
||||||
|
_TITLE_SAMPLE_TTL = 3600.0
|
||||||
|
_TITLE_SAMPLE: dict = {"ts": 0.0, "vals": ()}
|
||||||
|
|
||||||
|
|
||||||
|
def _title_sample(session: Session) -> tuple[str, ...]:
|
||||||
|
"""Histogram + najczestsze wartosci `scenes.title_normalized` z pg_stats (cache 1 h)."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
now = _time.monotonic()
|
||||||
|
if _TITLE_SAMPLE["vals"] and now - _TITLE_SAMPLE["ts"] < _TITLE_SAMPLE_TTL:
|
||||||
|
return _TITLE_SAMPLE["vals"]
|
||||||
|
row = session.execute(
|
||||||
|
text(
|
||||||
|
"SELECT histogram_bounds::text::text[], most_common_vals::text::text[] "
|
||||||
|
"FROM pg_stats WHERE schemaname = 'public' AND tablename = 'scenes' "
|
||||||
|
"AND attname = 'title_normalized'"
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
vals = tuple((row[0] or []) + (row[1] or [])) if row else ()
|
||||||
|
_TITLE_SAMPLE.update(ts=now, vals=vals)
|
||||||
|
return vals
|
||||||
|
|
||||||
|
|
||||||
|
def _search_walk_ids(session: Session, ordered, off: int, want: int, budget_ms: int):
|
||||||
|
"""Id strony z `ordered` bez skanow bitmapowych i z limitem czasu; None po limicie.
|
||||||
|
|
||||||
|
Bez bitmap indeks trigramow (GIN) odpada, wiec planer idzie po indeksie sortu i konczy
|
||||||
|
na LIMIT. SAVEPOINT wycofujemy zawsze: SET LOCAL przezylby jego zwolnienie i reszta
|
||||||
|
requestu (obsada, tagi, zrodla) szlaby bez bitmap i z limitem czasu. Nic nie tracimy,
|
||||||
|
to sam odczyt, a id sa juz w Pythonie.
|
||||||
|
"""
|
||||||
|
sp = session.begin_nested()
|
||||||
|
try:
|
||||||
|
session.execute(
|
||||||
|
select(
|
||||||
|
func.set_config("enable_bitmapscan", "off", True),
|
||||||
|
func.set_config("statement_timeout", str(budget_ms), True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(
|
||||||
|
session.execute(
|
||||||
|
ordered.with_only_columns(Scene.id)
|
||||||
|
.offset(literal_column(str(off)))
|
||||||
|
.limit(literal_column(str(want)))
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
|
except OperationalError as e:
|
||||||
|
if isinstance(e.orig, QueryCanceled):
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
sp.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
def _search_walk_budget(q: str, off: int, want: int, narrowed: bool, sample) -> int | None:
|
||||||
|
"""Limit czasu (ms) na przejscie od najnowszych albo None, gdy od razu zwykly plan.
|
||||||
|
|
||||||
|
`sample` to funkcja zwracajaca probke tytulow, wolana tylko gdy potrzebna.
|
||||||
|
"""
|
||||||
|
if not _TRGM_RUN_RE.search(q):
|
||||||
|
return _SEARCH_WALK_BUDGET_NO_TRGM_MS
|
||||||
|
if narrowed:
|
||||||
|
return None
|
||||||
|
vals = sample()
|
||||||
|
needle = q.lower()
|
||||||
|
hits = sum(needle in v for v in vals)
|
||||||
|
if not hits or (off + want) * len(vals) / hits > _SEARCH_WALK_MAX_SCAN:
|
||||||
|
return None
|
||||||
|
return _SEARCH_WALK_BUDGET_MS
|
||||||
|
|
||||||
|
|
||||||
|
def _search_walk_rows(
|
||||||
|
session: Session, ordered, q: str, off: int, want: int, narrowed: bool
|
||||||
|
) -> list[Scene] | None:
|
||||||
|
"""Strona wynikow szukania przejsciem od najnowszych; None = uzyj zwyklego planu."""
|
||||||
|
budget = _search_walk_budget(q, off, want, narrowed, lambda: _title_sample(session))
|
||||||
|
if budget is None:
|
||||||
|
return None
|
||||||
|
ids = _search_walk_ids(session, ordered, off, want, budget)
|
||||||
|
if ids is None:
|
||||||
|
# Bez trigramu zwykly plan czyta caly indeks (44 s), wiec lepiej pusto.
|
||||||
|
return None if budget == _SEARCH_WALK_BUDGET_MS else []
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
by_id = {s.id: s for s in session.execute(select(Scene).where(Scene.id.in_(ids))).scalars()}
|
||||||
|
return [by_id[i] for i in ids if i in by_id]
|
||||||
|
|
||||||
|
|
||||||
def _split_csv(raw: str | None) -> list[str]:
|
def _split_csv(raw: str | None) -> list[str]:
|
||||||
if not raw:
|
if not raw:
|
||||||
return []
|
return []
|
||||||
|
|
@ -840,15 +951,35 @@ def list_scenes(
|
||||||
rows = [by_id[i] for i in page_ids if i in by_id]
|
rows = [by_id[i] for i in page_ids if i in by_id]
|
||||||
has_more = page < len(feed_pages) or len(cand) >= cand_n
|
has_more = page < len(feed_pages) or len(cand) >= cand_n
|
||||||
else:
|
else:
|
||||||
rows = (
|
rows = studio_rows
|
||||||
studio_rows
|
if rows is None and q and (
|
||||||
if studio_rows is not None
|
sort == "created_at" or (sort == "release_date" and tag_sort_id is None)
|
||||||
else session.execute(
|
):
|
||||||
ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want)))
|
rows = _search_walk_rows(
|
||||||
|
session,
|
||||||
|
ordered,
|
||||||
|
q,
|
||||||
|
_off,
|
||||||
|
_want,
|
||||||
|
narrowed=bool(
|
||||||
|
tag_slug_list
|
||||||
|
or perf_id_strings
|
||||||
|
or studio_slug_list
|
||||||
|
or origin
|
||||||
|
or min_quality_p is not None
|
||||||
|
or released_within_days is not None
|
||||||
|
or max_duration_sec is not None
|
||||||
|
or (min_duration_sec or 0) > 600
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if rows is None:
|
||||||
|
rows = (
|
||||||
|
session.execute(
|
||||||
|
ordered.offset(literal_column(str(_off))).limit(literal_column(str(_want)))
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
)
|
)
|
||||||
.scalars()
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
has_more = len(rows) > per_page
|
has_more = len(rows) > per_page
|
||||||
rows = rows[:per_page]
|
rows = rows[:per_page]
|
||||||
|
|
||||||
|
|
|
||||||
52
tests/test_search_walk.py
Normal file
52
tests/test_search_walk.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
from app.api.scenes import (
|
||||||
|
_SEARCH_WALK_BUDGET_MS,
|
||||||
|
_SEARCH_WALK_BUDGET_NO_TRGM_MS,
|
||||||
|
_search_walk_budget,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 1000 tytulow jak histogram z pg_stats: 20 z "anal", 1 z "hailey rose", reszta obojetna.
|
||||||
|
SAMPLE = ["anal scene %d" % i for i in range(20)] + ["hailey rose pov"] + [
|
||||||
|
"other title %d" % i for i in range(979)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _budget(q, off=0, want=51, narrowed=False):
|
||||||
|
return _search_walk_budget(q, off, want, narrowed, lambda: SAMPLE)
|
||||||
|
|
||||||
|
|
||||||
|
def test_common_phrase_walks():
|
||||||
|
# 20/1000 = 2%: na strone 1 trzeba przejsc ~2,5 tys. scen
|
||||||
|
assert _budget("anal") == _SEARCH_WALK_BUDGET_MS
|
||||||
|
|
||||||
|
|
||||||
|
def test_phrase_absent_from_sample_goes_to_trigrams():
|
||||||
|
assert _budget("jackerman") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_hit_phrase_goes_to_trigrams():
|
||||||
|
# 1/1000 = 0,1%: ~51 tys. scen do przejscia, powyzej progu
|
||||||
|
assert _budget("hailey rose") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_page_of_common_phrase_goes_to_trigrams():
|
||||||
|
# strona 13 "anal": (600 + 51) / 2% = ~32 tys.
|
||||||
|
assert _budget("anal", off=600) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_narrowed_search_goes_to_trigrams():
|
||||||
|
assert _budget("anal", narrowed=True) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_phrase_always_walks():
|
||||||
|
# bez trigramu zwykly plan czyta caly indeks; tez przy zawezeniu i poza probka
|
||||||
|
for q in ("vo", "q", "xz", " a "):
|
||||||
|
assert _budget(q, narrowed=True) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
|
||||||
|
assert _budget("zq", off=5000) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_not_read_when_not_needed():
|
||||||
|
def boom():
|
||||||
|
raise AssertionError("probka niepotrzebna")
|
||||||
|
|
||||||
|
assert _search_walk_budget("vo", 0, 51, False, boom) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
|
||||||
|
assert _search_walk_budget("anal", 0, 51, True, boom) is None
|
||||||
Loading…
Add table
Reference in a new issue