perf(feed): relevance ranks the newest 2000 matches, the rest follow newest first

A broad word matched most of the library and ts_rank read every match:
"a" took 4.4 s a page on prod. The window's start is read once (0.1 s) and
rides the cursor, so a video ingested mid-scroll cannot repeat a row; the
page is read as its two halves, each on an index (80 ms + 5 ms).
This commit is contained in:
2026-10-03 00:28:29 +02:00
parent f8f22fc985
commit e0e28c006c
3 changed files with 204 additions and 16 deletions
+62 -6
View File
@@ -12,7 +12,7 @@ import binascii
import json import json
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
from sqlalchemy import BigInteger, Select, and_, cast, false, func, or_, select from sqlalchemy import BigInteger, Select, and_, case, cast, false, func, or_, select
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
from app.collation import collated from app.collation import collated
@@ -265,7 +265,8 @@ def filtered_query(
# the queries that surfaced the video > description; see Video.search_vector). YouTube-like # the queries that surfaced the video > description; see Video.search_vector). YouTube-like
# "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed, # "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed,
# accent-insensitive (unaccent_simple). The channel name still matches as a substring so # accent-insensitive (unaccent_simple). The channel name still matches as a substring so
# channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort. # channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort, over the
# newest RANK_WINDOW matches only (see there).
rank_expr = None rank_expr = None
if q: if q:
# Resolve the channels whose NAME matches up front (there are only a few hundred channels, so # Resolve the channels whose NAME matches up front (there are only a few hundred channels, so
@@ -543,6 +544,54 @@ def sort_keys(sort: str, seed: int, rank_expr=None, lang: str | None = None) ->
return table.get(sort) or table["newest"] return table.get(sort) or table["newest"]
# Relevance ranks only the newest RANK_WINDOW matches (E31): ts_rank reads every match's tsvector, and
# a broad word matches most of the library ("a" ranked 193k rows in 6.8 s on prod, against 1.1 s to just
# find them). The rows past the window keep rank 0, so they follow the ranked ones newest first; a word
# that broad has no meaningful relevance order anyway, and a specific one fits the window whole.
RANK_WINDOW = 2000
def rank_window_start(db: Session, query: Select) -> datetime | None:
"""The published_at the ranked window starts at: the RANK_WINDOW-th newest match, None when fewer
match (rank them all). The newest-first walk stops after RANK_WINDOW matches (40 ms for "a")."""
return db.scalar(
query.with_only_columns(Video.published_at)
.order_by(Video.published_at.desc())
.offset(RANK_WINDOW - 1)
.limit(1)
)
def windowed_rank(rank_expr, since: datetime | None):
"""`rank_expr` inside the window, 0 past it. CASE evaluates ts_rank only for the rows inside."""
if since is None:
return rank_expr
return case((Video.published_at >= since, rank_expr), else_=0)
def windowed_relevance_rows(
db: Session, query: Select, keys: list[dict], window: datetime, cursor_id: str | None, n: int
) -> list:
"""Up to `n` rows of the windowed relevance order, read as its two halves: the window ranked, then
the rest newest first (every window row sorts before every row past it). As one ORDER BY the
planner computes the CASE for every match and sorts them all (2.7 s for "a" on prod); split, each
half rides an index (80 ms + 5 ms). With a cursor, `keys` carry its values (keyset_predicate)."""
pub_key = keys[1] # relevance: [rank, published_at]
rows: list = []
if cursor_id is None or pub_key["value"] >= window:
inside = query.where(Video.published_at >= window).order_by(*order_by(keys))
if cursor_id is not None:
inside = inside.where(keyset_predicate(keys, cursor_id))
rows = list(db.execute(inside.limit(n)).all())
if len(rows) < n:
newest = [pub_key] # past the window every rank is 0: the order is newest first
rest = query.where(Video.published_at < window).order_by(*order_by(newest))
if cursor_id is not None:
rest = rest.where(keyset_predicate(newest, cursor_id))
rows += db.execute(rest.limit(n - len(rows))).all()
return rows
def order_by(keys: list[dict]): def order_by(keys: list[dict]):
cols = [] cols = []
for k in keys: for k in keys:
@@ -589,16 +638,22 @@ def keyset_predicate(keys: list[dict], cursor_id: str):
return rec(0) return rec(0)
def encode_cursor(keys: list[dict], row) -> str: def encode_cursor(keys: list[dict], row, window: datetime | None = None) -> str:
"""`window` is the relevance window's start: the cursor carries it, so every page ranks against the
window page one did. Recomputed per page, a video ingested mid-scroll would push the window's oldest
row to rank 0, behind the cursor, and the reader would see it twice."""
vals = [] vals = []
for i, k in enumerate(keys): for i, k in enumerate(keys):
v = getattr(row, f"_k{i}") v = getattr(row, f"_k{i}")
vals.append(v.isoformat() if (v is not None and k["kind"] == "dt") else v) vals.append(v.isoformat() if (v is not None and k["kind"] == "dt") else v)
payload = json.dumps({"k": vals, "id": row.id}) body: dict = {"k": vals, "id": row.id}
if window is not None:
body["w"] = window.isoformat()
payload = json.dumps(body)
return base64.urlsafe_b64encode(payload.encode()).decode() return base64.urlsafe_b64encode(payload.encode()).decode()
def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]: def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str, datetime | None]:
try: try:
payload = json.loads(base64.urlsafe_b64decode(cursor.encode())) payload = json.loads(base64.urlsafe_b64decode(cursor.encode()))
raw = payload["k"] raw = payload["k"]
@@ -608,7 +663,8 @@ def decode_cursor(keys: list[dict], cursor: str) -> tuple[list, str]:
datetime.fromisoformat(v) if (v is not None and k["kind"] == "dt") else v datetime.fromisoformat(v) if (v is not None and k["kind"] == "dt") else v
for k, v in zip(keys, raw) for k, v in zip(keys, raw)
] ]
return vals, payload["id"] window = payload.get("w")
return vals, payload["id"], datetime.fromisoformat(window) if window else None
except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError) as exc: except (binascii.Error, json.JSONDecodeError, KeyError, ValueError, TypeError) as exc:
# Framework-free: the route handler translates this into an HTTP 400 so the # Framework-free: the route handler translates this into an HTTP 400 so the
# read-model stays web-layer-agnostic (see routes/feed.get_feed). # read-model stays web-layer-agnostic (see routes/feed.get_feed).
+28 -10
View File
@@ -19,8 +19,11 @@ from app.feedquery import (
filtered_query, filtered_query,
keyset_predicate, keyset_predicate,
order_by, order_by,
rank_window_start,
serialize, serialize,
sort_keys, sort_keys,
windowed_rank,
windowed_relevance_rows,
) )
from app.models import ( from app.models import (
POSITION_MAX_S, POSITION_MAX_S,
@@ -109,30 +112,45 @@ def get_feed(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, rank_expr = filtered_query(db, user, **params) query, rank_expr = filtered_query(db, user, **params)
keys = sort_keys(sort, seed, rank_expr, lang_for_user(user)) lang = lang_for_user(user)
keys = sort_keys(sort, seed, rank_expr, lang)
# Expose each key column on the row so we can build the next cursor from the last item.
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
query = query.order_by(*order_by(keys))
vals, cursor_id, window = None, None, None
if cursor: if cursor:
# The read-model raises a plain ValueError on a malformed cursor; translate it to the # The read-model raises a plain ValueError on a malformed cursor; translate it to the
# HTTP 400 here so feedquery stays web-framework-free. # HTTP 400 here so feedquery stays web-framework-free.
try: try:
vals, cursor_id = decode_cursor(keys, cursor) vals, cursor_id, window = decode_cursor(keys, cursor)
except ValueError: except ValueError:
# `from None`: the ValueError is an expected bad-input signal, not an error to chain. # `from None`: the ValueError is an expected bad-input signal, not an error to chain.
raise refused(400, "invalid_cursor") from None raise refused(400, "invalid_cursor") from None
ranked = sort == "relevance" and rank_expr is not None
if ranked and not cursor:
window = rank_window_start(db, query)
if ranked and window is not None:
keys = sort_keys(sort, seed, windowed_rank(rank_expr, window), lang)
# Expose each key column on the row so we can build the next cursor from the last item.
query = query.add_columns(*[k["expr"].label(f"_k{i}") for i, k in enumerate(keys)])
if vals is not None:
for k, v in zip(keys, vals): for k, v in zip(keys, vals):
k["value"] = v k["value"] = v
query = query.where(keyset_predicate(keys, cursor_id)) if ranked and window is not None:
rows = windowed_relevance_rows(db, query, keys, window, cursor_id, limit + 1)
rows = db.execute(query.limit(limit + 1)).all() else:
query = query.order_by(*order_by(keys))
if cursor_id is not None:
query = query.where(keyset_predicate(keys, cursor_id))
rows = db.execute(query.limit(limit + 1)).all()
has_more = len(rows) > limit has_more = len(rows) > limit
page = rows[:limit] page = rows[:limit]
return { return {
"items": [serialize(r) for r in page], "items": [serialize(r) for r in page],
"next_cursor": encode_cursor(keys, page[-1]) if (has_more and page) else None, "next_cursor": (
encode_cursor(keys, page[-1], window if ranked else None)
if (has_more and page)
else None
),
"has_more": has_more, "has_more": has_more,
"limit": limit, "limit": limit,
} }
+114
View File
@@ -0,0 +1,114 @@
"""Relevance ranks only the newest RANK_WINDOW matches (E31), DB-lane.
A broad word ("a") matched most of the prod library and ts_rank read every match's tsvector: 6.8 s a page.
The ranking now covers the newest RANK_WINDOW matches and the rest follow newest first. The window's start
rides the cursor, so a video ingested mid-scroll cannot push a row behind the cursor and repeat it.
"""
from datetime import datetime, timezone
import pytest
from app import feedquery
from app.models import Channel, Subscription, User, Video
def _video(vid: str, day: int, title: str) -> Video:
return Video(
id=vid,
channel_id="UCw",
published_at=datetime(2024, 1, day, tzinfo=timezone.utc),
title=title,
is_short=False,
live_status="none",
via_search=False,
via_explore=False,
)
def _seed(db) -> User:
db.add(Channel(id="UCw", title="Window"))
user = User(email="window@example.com", email_verified=True)
db.add(user)
db.flush()
db.add(Subscription(user_id=user.id, channel_id="UCw", priority=0, hidden=False))
# m1 is the oldest and the strongest match, m4 the strongest of the newest three; the rest tie on one
# title "foo" each (ts_rank counts occurrences, it does not normalise by length).
db.add_all(
[
_video("m1", 1, "foo foo foo foo foo"),
_video("m2", 2, "foo and some other words here"),
_video("m3", 3, "foo plus some filler words"),
_video("m4", 4, "foo foo foo"),
_video("m5", 5, "foo with a longer title"),
_video("m6", 6, "foo with yet another title"),
]
)
db.commit()
return user
def _ids(client, cursor=None, limit=50):
url = f"/api/feed?q=foo&sort=relevance&limit={limit}"
if cursor:
url += f"&cursor={cursor}"
resp = client.get(url)
assert resp.status_code == 200, resp.text
body = resp.json()
return [it["id"] for it in body["items"]], body["next_cursor"]
def test_a_specific_word_ranks_every_match(db, as_user):
client = as_user(_seed(db))
ids, _ = _ids(client)
assert ids[:2] == ["m1", "m4"], ids
def test_a_broad_word_ranks_the_newest_window_and_the_rest_follow_newest_first(
db, as_user, monkeypatch
):
monkeypatch.setattr(feedquery, "RANK_WINDOW", 3)
client = as_user(_seed(db))
ids, _ = _ids(client)
assert ids[0] == "m4", ids # the strongest of the newest three
assert sorted(ids[:3]) == ["m4", "m5", "m6"], ids
assert ids[3:] == ["m3", "m2", "m1"], ids # past the window: newest first, m1 not ranked
def test_paging_keeps_the_window_page_one_ranked_against(db, as_user, monkeypatch):
monkeypatch.setattr(feedquery, "RANK_WINDOW", 3)
client = as_user(_seed(db))
full, _ = _ids(client)
first, cursor = _ids(client, limit=2)
assert first == full[:2]
# A newer match lands mid-scroll. Ranked against a recomputed window, m4 (shown on page one) would
# fall out of it to rank 0, sort behind the cursor and come back on page two.
db.add(_video("m7", 7, "foo arriving while the reader scrolls"))
db.commit()
walked = list(first)
while cursor:
page, cursor = _ids(client, cursor, limit=2)
walked.extend(page)
assert len(walked) == len(set(walked)), walked
assert set(full) <= set(walked), walked
@pytest.mark.parametrize(
("window", "limit"),
# 3: the page is read as its two halves (the window ranked, the rest newest first), and pages of 2
# and 3 cross the window's edge mid-page and exactly at it. 2000: one ranked query.
[(3, 2), (3, 3), (2000, 2)],
)
def test_cursor_walk_matches_the_unpaged_order(db, as_user, monkeypatch, window, limit):
monkeypatch.setattr(feedquery, "RANK_WINDOW", window)
client = as_user(_seed(db))
full, _ = _ids(client)
if window == 3:
assert full == ["m4", "m6", "m5", "m3", "m2", "m1"]
walked, cursor = _ids(client, limit=limit)
while cursor:
page, cursor = _ids(client, cursor, limit=limit)
walked.extend(page)
assert walked == full