fix(api): szukanie w serwisie i sortowanie po studiu z rzadka fraza
Some checks are pending
Backend tests / test (push) Waiting to run
Some checks are pending
Backend tests / test (push) Waiting to run
- Szukanie w jednym serwisie do 60 tys. scen zaczyna od jego scen (scenes.id = ANY),
gdy fraza jest popularna albo bez trigramu: "sel" w hqporner 3,4 s -> 0,1 s, "brat"
w fullmoviesxxx 2,9 s -> 0,15 s. Rzadkie frazy dalej trigramami (GOON-75).
- Poprawka regresji z c08abf6: fraza bez trigramu przy zawezeniu po 3 s oddawala
pusta liste ("ai" w fullmoviesxxx: 0 wynikow). Teraz od serwisu albo limit 10 s.
- sort=studio z rzadka fraza: JOIN ze studiami i sort samych trafien zamiast
przejscia po 33 tys. studiow ("sunny leone" 928 s -> 0,26 s, "savanna" 20 s -> 0,17 s).
Porownanie ze starym kodem na 100 wyszukiwaniach w serwisach i 12 z sortem po studiu:
wyniki identyczne poza naprawionymi pustymi listami.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
0da36d5c1e
commit
3349700f72
2 changed files with 119 additions and 4 deletions
|
|
@ -14,6 +14,8 @@ from pydantic import BaseModel
|
|||
from sqlalchemy import (
|
||||
Date,
|
||||
and_,
|
||||
any_,
|
||||
bindparam,
|
||||
distinct,
|
||||
exists,
|
||||
false,
|
||||
|
|
@ -25,6 +27,8 @@ from sqlalchemy import (
|
|||
union,
|
||||
)
|
||||
from psycopg.errors import QueryCanceled
|
||||
from sqlalchemy.dialects.postgresql import ARRAY
|
||||
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
|
|
@ -228,8 +232,25 @@ def _blacklists_empty(session: Session, device_id: str) -> bool:
|
|||
_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").
|
||||
# limit, a po nim pusta lista: to zwykle stan w trakcie pisania ("q", "xz"). Przy
|
||||
# zawezeniu (tag itp.) trafienia sa rzadsze, wiec limit wiekszy, zeby nie oddac pustej
|
||||
# listy tam, gdzie wyniki sa.
|
||||
_SEARCH_WALK_BUDGET_NO_TRGM_MS = 3000
|
||||
_SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS = 10_000
|
||||
# Szukanie w jednym serwisie (ekran serwisu w apce, GOON-75): dla serwisu do tylu scen
|
||||
# zaczynamy od jego scen (id z playback_sources, potem scenes.id = ANY), zamiast od
|
||||
# calego katalogu. Czas zalezy od wielkosci serwisu, nie od frazy, a z trigramem
|
||||
# Postgres laczy oba indeksy (BitmapAnd). Pomiar 2026-09-22: "sel" w hqporner 3,4 s ->
|
||||
# 0,11 s, "brat" w fullmoviesxxx 2,9 s -> 0,15 s, "ai" 8,6 s -> 2,2 s. W duzym serwisie
|
||||
# (sxyprn 108 tys., xvideos 444 tys.) przejscie od najnowszych trafia szybko, a samo
|
||||
# pobranie i przeslanie tablicy id kosztuje 0,5-0,9 s (sxyprn "sislovesme" 0,12 -> 1,65 s).
|
||||
_ORIGIN_DRIVEN_MAX_SCENES = 60_000
|
||||
# Rzadka fraza (nazwisko): mniej niz tyle trafien w probce tytulow, czyli ponizej ~0,4%
|
||||
# katalogu. Jej sceny najszybciej znajduja trigramy, bo jest ich malo. W serwisie
|
||||
# (0,02-0,36 s) sciezka od serwisu placilaby tylko staly narzut tablicy id (~0,5 s),
|
||||
# a przy sort=studio przejscie po studiach szuka trafien w kazdym z 33 tys. studiow
|
||||
# ("sunny leone" 15 min), podczas gdy trigramy + sort trafien to 0,37 s.
|
||||
_RARE_PHRASE_HITS = 4
|
||||
_TRGM_RUN_RE = re.compile(r"[^\W_]{3}")
|
||||
_TITLE_SAMPLE_TTL = 3600.0
|
||||
_TITLE_SAMPLE: dict = {"ts": 0.0, "vals": ()}
|
||||
|
|
@ -291,7 +312,7 @@ def _search_walk_budget(q: str, off: int, want: int, narrowed: bool, sample) ->
|
|||
`sample` to funkcja zwracajaca probke tytulow, wolana tylko gdy potrzebna.
|
||||
"""
|
||||
if not _TRGM_RUN_RE.search(q):
|
||||
return _SEARCH_WALK_BUDGET_NO_TRGM_MS
|
||||
return _SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS if narrowed else _SEARCH_WALK_BUDGET_NO_TRGM_MS
|
||||
if narrowed:
|
||||
return None
|
||||
vals = sample()
|
||||
|
|
@ -319,6 +340,44 @@ def _search_walk_rows(
|
|||
return [by_id[i] for i in ids if i in by_id]
|
||||
|
||||
|
||||
def _rare_title_phrase(q: str, sample) -> bool:
|
||||
"""Fraza z trigramem, ktora w probce tytulow trafia rzadko (patrz _RARE_PHRASE_HITS).
|
||||
|
||||
`sample` to funkcja zwracajaca probke tytulow, wolana tylko gdy potrzebna.
|
||||
"""
|
||||
if not _TRGM_RUN_RE.search(q):
|
||||
return False
|
||||
needle = q.lower()
|
||||
return sum(needle in v for v in sample()) < _RARE_PHRASE_HITS
|
||||
|
||||
|
||||
def _origin_driven_worth(q: str, sample) -> bool:
|
||||
"""Czy szukanie w serwisie zaczac od jego scen: fraza bez trigramu albo popularna."""
|
||||
return not _rare_title_phrase(q, sample)
|
||||
|
||||
|
||||
def _origin_scene_ids(session: Session, origin: str) -> list[uuid.UUID] | None:
|
||||
"""Id scen z zywym zrodlem serwisu albo None, gdy serwis jest za duzy na te sciezke.
|
||||
|
||||
Wielkosc bierzemy z source_stats (liczone co 6 h), zeby dla xvideos nie ciagnac
|
||||
setek tysiecy id tylko po to, zeby je wyrzucic.
|
||||
"""
|
||||
from app.models.source_stats import SourceStats
|
||||
|
||||
size = session.execute(select(SourceStats.scenes).where(SourceStats.origin == origin)).scalar()
|
||||
if size is not None and size > _ORIGIN_DRIVEN_MAX_SCENES:
|
||||
return None
|
||||
ids = list(
|
||||
session.execute(
|
||||
select(PlaybackSource.scene_id)
|
||||
.where(PlaybackSource.origin == origin, PlaybackSource.dead_at.is_(None))
|
||||
.distinct()
|
||||
.limit(_ORIGIN_DRIVEN_MAX_SCENES + 1)
|
||||
).scalars()
|
||||
)
|
||||
return ids if len(ids) <= _ORIGIN_DRIVEN_MAX_SCENES else None
|
||||
|
||||
|
||||
def _split_csv(raw: str | None) -> list[str]:
|
||||
if not raw:
|
||||
return []
|
||||
|
|
@ -850,6 +909,18 @@ def list_scenes(
|
|||
ordered = base.order_by(
|
||||
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
|
||||
)
|
||||
elif sort == "studio" and q and _rare_title_phrase(q, lambda: _title_sample(session)):
|
||||
# Rzadka fraza: trafien jest malo, wiec zwykly JOIN ze studiami i sort samych
|
||||
# trafien (trigramy) zamiast przejscia po studiach, ktore szukaloby ich w kazdym
|
||||
# studiu z osobna ("sunny leone" 15 min -> 0,37 s, "savannah" 20 s -> 0,88 s).
|
||||
# Ten sam porzadek: studia po nazwie, w studiu najswiezsze, bez studia na koncu.
|
||||
sst = aliased(Studio, name="sort_studio")
|
||||
ordered = base.outerjoin(sst, sst.id == Scene.studio_id).order_by(
|
||||
sst.name_normalized.asc().nullslast(),
|
||||
_effective_date_desc(),
|
||||
Scene.created_at.desc(),
|
||||
Scene.id.desc(),
|
||||
)
|
||||
elif sort == "studio":
|
||||
# Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze. Fetch idzie
|
||||
# osobna sciezka (LATERAL po studiach), bo jeden SELECT z LEFT JOIN nie da sie tu
|
||||
|
|
@ -952,7 +1023,21 @@ def list_scenes(
|
|||
has_more = page < len(feed_pages) or len(cand) >= cand_n
|
||||
else:
|
||||
rows = studio_rows
|
||||
if rows is None and q and (
|
||||
origin_ids = None
|
||||
if (
|
||||
rows is None
|
||||
and q
|
||||
and origin
|
||||
and sort in ("created_at", "release_date")
|
||||
and _origin_driven_worth(q, lambda: _title_sample(session))
|
||||
):
|
||||
origin_ids = _origin_scene_ids(session, origin)
|
||||
if origin_ids is not None:
|
||||
ordered = ordered.where(
|
||||
Scene.id
|
||||
== any_(bindparam("origin_scene_ids", origin_ids, type_=ARRAY(PG_UUID(as_uuid=True))))
|
||||
)
|
||||
if rows is None and origin_ids is None and q and (
|
||||
sort == "created_at" or (sort == "release_date" and tag_sort_id is None)
|
||||
):
|
||||
rows = _search_walk_rows(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
from app.api.scenes import (
|
||||
_SEARCH_WALK_BUDGET_MS,
|
||||
_SEARCH_WALK_BUDGET_NO_TRGM_MS,
|
||||
_SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS,
|
||||
_origin_driven_worth,
|
||||
_rare_title_phrase,
|
||||
_search_walk_budget,
|
||||
)
|
||||
|
||||
|
|
@ -40,13 +43,40 @@ def test_narrowed_search_goes_to_trigrams():
|
|||
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(q) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
|
||||
assert _budget("zq", off=5000) == _SEARCH_WALK_BUDGET_NO_TRGM_MS
|
||||
|
||||
|
||||
def test_short_phrase_narrowed_gets_longer_budget():
|
||||
# z tagiem trafienia sa rzadsze; po 3 s oddawalismy pusta liste mimo wynikow
|
||||
assert _budget("ai", narrowed=True) == _SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_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("vo", 0, 51, True, boom) == _SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS
|
||||
assert _search_walk_budget("anal", 0, 51, True, boom) is None
|
||||
|
||||
|
||||
def test_origin_driven_for_common_and_short_phrases():
|
||||
# "anal" 20/1000 w probce: popularna, w malym serwisie zaczynamy od jego scen
|
||||
assert _origin_driven_worth("anal", lambda: SAMPLE)
|
||||
# bez trigramu zawsze od serwisu (inaczej przejscie po calym katalogu)
|
||||
assert _origin_driven_worth("ai", lambda: SAMPLE)
|
||||
|
||||
|
||||
def test_origin_driven_skipped_for_rare_phrases():
|
||||
# nazwisko: trigramy znajda kilka scen szybciej niz tablica id serwisu
|
||||
assert not _origin_driven_worth("hailey rose", lambda: SAMPLE)
|
||||
assert not _origin_driven_worth("jackerman", lambda: SAMPLE)
|
||||
|
||||
|
||||
def test_rare_phrase_detection():
|
||||
assert _rare_title_phrase("hailey rose", lambda: SAMPLE)
|
||||
assert _rare_title_phrase("sunny leone", lambda: SAMPLE)
|
||||
assert not _rare_title_phrase("anal", lambda: SAMPLE)
|
||||
# bez trigramu nie jest "rzadka": trigramy i tak by jej nie znalazly
|
||||
assert not _rare_title_phrase("ai", lambda: SAMPLE)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue