feat(feed): broaden search to a weighted document (title + keywords + queries + description)

Makes the local search behave more like YouTube's — finding videos by the
uploader's own keywords or the query that surfaced them, not only words in the
title. A DB-generated, weighted search_vector (migration 0032) replaces the
title-only FTS index:
- keywords: the creator's snippet.tags (free — already in the snippet we fetch),
  stored on enrich.
- search_terms: distinct live-search queries that surfaced the video (across all
  users), appended by the search route — folds YouTube's relevance into local
  search (a video YT returned for a query becomes findable by it even without a
  title match), the user's own idea.
- description (truncated) for broad recall on the existing catalog.
Weighted title(A) > keywords+queries(B) > description(C) so ts_rank keeps title
hits on top. A plain GIN index on the generated column guarantees index use (no
expression/param matching). Verified on localdev: recall 146->213 for one query;
7 'eurovision' hits via the document but not the title; index scan confirmed.
This commit is contained in:
npeter83 2026-06-30 02:00:38 +02:00
parent 20bcdf5ecb
commit 4395afc210
5 changed files with 115 additions and 11 deletions

View file

@ -3,6 +3,7 @@ from datetime import date, datetime
from sqlalchemy import (
BigInteger,
Boolean,
Computed,
Date,
DateTime,
Float,
@ -14,6 +15,7 @@ from sqlalchemy import (
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
@ -237,6 +239,14 @@ class Video(Base, TimestampMixin):
topic_categories: Mapped[list | None] = mapped_column(JSON)
default_language: Mapped[str | None] = mapped_column(String(16))
detected_language: Mapped[str | None] = mapped_column(String(16))
# Creator-supplied keyword tags (snippet.tags joined by spaces) — fetched for free with the
# snippet we already request. Indexed into search_vector so the feed search matches the
# uploader's own keywords, not just words in the title.
keywords: Mapped[str | None] = mapped_column(Text)
# Accumulated distinct live-search queries that surfaced this video (across all users) —
# folds YouTube's own relevance judgement into the local search: a video YouTube returned
# for "ti amo magyarul" becomes findable by that query locally even if its title lacks it.
search_terms: Mapped[str | None] = mapped_column(Text)
is_short: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True
)
@ -272,6 +282,21 @@ class Video(Base, TimestampMixin):
)
unavailable_reason: Mapped[str | None] = mapped_column(String(24))
# Weighted full-text search document (DB-generated, never written by the app): title (A) >
# creator keywords + search queries (B) > truncated description (C). ts_rank honours the
# weights so title matches outrank description matches. Backed by a GIN index (migration
# 0032). The accent-insensitive `unaccent_simple` config comes from migration 0031.
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
"setweight(to_tsvector('public.unaccent_simple', coalesce(title, '')), 'A') || "
"setweight(to_tsvector('public.unaccent_simple', coalesce(keywords, '') || ' ' || "
"coalesce(search_terms, '')), 'B') || "
"setweight(to_tsvector('public.unaccent_simple', left(coalesce(description, ''), 1000)), 'C')",
persisted=True,
),
)
channel: Mapped["Channel"] = relationship(back_populates="videos")