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 (
|
from sqlalchemy import (
|
||||||
Date,
|
Date,
|
||||||
and_,
|
and_,
|
||||||
|
any_,
|
||||||
|
bindparam,
|
||||||
distinct,
|
distinct,
|
||||||
exists,
|
exists,
|
||||||
false,
|
false,
|
||||||
|
|
@ -25,6 +27,8 @@ from sqlalchemy import (
|
||||||
union,
|
union,
|
||||||
)
|
)
|
||||||
from psycopg.errors import QueryCanceled
|
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.exc import IntegrityError, OperationalError
|
||||||
from sqlalchemy.orm import Session, aliased
|
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_BUDGET_MS = 300
|
||||||
_SEARCH_WALK_MAX_SCAN = 30_000
|
_SEARCH_WALK_MAX_SCAN = 30_000
|
||||||
# Fraza bez ciagu 3 liter/cyfr nie ma trigramu, wiec awaryjnego planu nie ma. Dluzszy
|
# 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_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}")
|
_TRGM_RUN_RE = re.compile(r"[^\W_]{3}")
|
||||||
_TITLE_SAMPLE_TTL = 3600.0
|
_TITLE_SAMPLE_TTL = 3600.0
|
||||||
_TITLE_SAMPLE: dict = {"ts": 0.0, "vals": ()}
|
_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.
|
`sample` to funkcja zwracajaca probke tytulow, wolana tylko gdy potrzebna.
|
||||||
"""
|
"""
|
||||||
if not _TRGM_RUN_RE.search(q):
|
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:
|
if narrowed:
|
||||||
return None
|
return None
|
||||||
vals = sample()
|
vals = sample()
|
||||||
|
|
@ -319,6 +340,44 @@ def _search_walk_rows(
|
||||||
return [by_id[i] for i in ids if i in by_id]
|
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]:
|
def _split_csv(raw: str | None) -> list[str]:
|
||||||
if not raw:
|
if not raw:
|
||||||
return []
|
return []
|
||||||
|
|
@ -850,6 +909,18 @@ def list_scenes(
|
||||||
ordered = base.order_by(
|
ordered = base.order_by(
|
||||||
Scene.title_normalized.asc(), Scene.created_at.desc(), Scene.id.desc()
|
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":
|
elif sort == "studio":
|
||||||
# Sceny bez studio na końcu; w obrębie studio, najświeższe pierwsze. Fetch idzie
|
# 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
|
# 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
|
has_more = page < len(feed_pages) or len(cand) >= cand_n
|
||||||
else:
|
else:
|
||||||
rows = studio_rows
|
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)
|
sort == "created_at" or (sort == "release_date" and tag_sort_id is None)
|
||||||
):
|
):
|
||||||
rows = _search_walk_rows(
|
rows = _search_walk_rows(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
from app.api.scenes import (
|
from app.api.scenes import (
|
||||||
_SEARCH_WALK_BUDGET_MS,
|
_SEARCH_WALK_BUDGET_MS,
|
||||||
_SEARCH_WALK_BUDGET_NO_TRGM_MS,
|
_SEARCH_WALK_BUDGET_NO_TRGM_MS,
|
||||||
|
_SEARCH_WALK_BUDGET_NO_TRGM_NARROWED_MS,
|
||||||
|
_origin_driven_worth,
|
||||||
|
_rare_title_phrase,
|
||||||
_search_walk_budget,
|
_search_walk_budget,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -40,13 +43,40 @@ def test_narrowed_search_goes_to_trigrams():
|
||||||
def test_short_phrase_always_walks():
|
def test_short_phrase_always_walks():
|
||||||
# bez trigramu zwykly plan czyta caly indeks; tez przy zawezeniu i poza probka
|
# bez trigramu zwykly plan czyta caly indeks; tez przy zawezeniu i poza probka
|
||||||
for q in ("vo", "q", "xz", " a "):
|
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
|
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 test_sample_not_read_when_not_needed():
|
||||||
def boom():
|
def boom():
|
||||||
raise AssertionError("probka niepotrzebna")
|
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, 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
|
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