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

@ -0,0 +1,65 @@
"""richer full-text search document (title + keywords + search terms + description)
Revision ID: 0032_search_document
Revises: 0031_title_fts
Create Date: 2026-06-30
Broadens the feed's full-text search from the title alone to a weighted search document, so the
local search behaves more like YouTube's — finding videos by the uploader's own keywords or by
the search query that surfaced them, not only by words in the title:
- `keywords` the creator's snippet.tags (already fetched with the snippet we request, so
zero extra quota), stored space-joined.
- `search_terms` distinct live-search queries that surfaced the video (across all users),
appended by the search route. Folds YouTube's relevance judgement into local
search: a video YT returned for a query becomes findable by it locally.
These plus a truncated description feed a STORED generated `search_vector` column, weighted
title (A) > keywords+search_terms (B) > description (C) so ts_rank ranks title hits highest. A
plain GIN index on the column guarantees the planner uses it (no expression/param matching). The
title-only expression index from 0031 is dropped (superseded). Existing rows get title +
description indexed immediately; keywords/search_terms fill in as videos are (re-)enriched and
searched. Uses the `unaccent_simple` config from 0031 (accent-insensitive).
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0032_search_document"
down_revision: Union[str, None] = "0031_title_fts"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_SEARCH_VECTOR_EXPR = (
"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')"
)
def upgrade() -> None:
op.add_column("videos", sa.Column("keywords", sa.Text(), nullable=True))
op.add_column("videos", sa.Column("search_terms", sa.Text(), nullable=True))
# Replace the title-only index with a generated weighted search_vector + its GIN index.
op.execute("DROP INDEX IF EXISTS ix_videos_title_fts")
op.execute(
f"ALTER TABLE videos ADD COLUMN search_vector tsvector "
f"GENERATED ALWAYS AS ({_SEARCH_VECTOR_EXPR}) STORED"
)
op.execute(
"CREATE INDEX ix_videos_search_vector ON videos USING gin (search_vector)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_videos_search_vector")
op.drop_column("videos", "search_vector")
op.drop_column("videos", "search_terms")
op.drop_column("videos", "keywords")
# Recreate the title-only FTS index from 0031.
op.execute(
"CREATE INDEX ix_videos_title_fts ON videos "
"USING gin (to_tsvector('public.unaccent_simple', coalesce(title, '')))"
)

View file

@ -3,6 +3,7 @@ from datetime import date, datetime
from sqlalchemy import ( from sqlalchemy import (
BigInteger, BigInteger,
Boolean, Boolean,
Computed,
Date, Date,
DateTime, DateTime,
Float, Float,
@ -14,6 +15,7 @@ from sqlalchemy import (
UniqueConstraint, UniqueConstraint,
func, func,
) )
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base from app.db import Base
@ -237,6 +239,14 @@ class Video(Base, TimestampMixin):
topic_categories: Mapped[list | None] = mapped_column(JSON) topic_categories: Mapped[list | None] = mapped_column(JSON)
default_language: Mapped[str | None] = mapped_column(String(16)) default_language: Mapped[str | None] = mapped_column(String(16))
detected_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( is_short: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false", index=True 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)) 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") channel: Mapped["Channel"] = relationship(back_populates="videos")

View file

@ -221,22 +221,19 @@ def _filtered_query(
published_before, datetime.min.time(), tzinfo=timezone.utc published_before, datetime.min.time(), tzinfo=timezone.utc
) + timedelta(days=1) ) + timedelta(days=1)
query = query.where(Video.published_at < end) query = query.where(Video.published_at < end)
# Full-text relevance search on the title: YouTube-like "fuzzy" matching (word-order- # Full-text relevance search over the weighted search document (title > creator keywords +
# independent, multi-word AND, prefix on the word being typed) + accent-insensitive via # the queries that surfaced the video > description; see Video.search_vector). YouTube-like
# the unaccent_simple config. The channel name still matches as a substring so channel # "fuzzy" matching: word-order-independent, multi-word AND, prefix on the word being typed,
# searches work. `rank_expr` (ts_rank) drives the optional "relevance" sort. The # accent-insensitive (unaccent_simple). The channel name still matches as a substring so
# to_tsvector expression must match the GIN index (migration 0031) verbatim to be used. # channel searches work. `rank_expr` (weight-aware ts_rank) drives the "relevance" sort.
rank_expr = None rank_expr = None
if q: if q:
ts_str = _to_tsquery_str(q) ts_str = _to_tsquery_str(q)
if ts_str: if ts_str:
tsq = func.to_tsquery("public.unaccent_simple", ts_str) tsq = func.to_tsquery("public.unaccent_simple", ts_str)
title_vec = func.to_tsvector(
"public.unaccent_simple", func.coalesce(Video.title, "")
)
query = query.where( query = query.where(
or_( or_(
title_vec.op("@@")(tsq), Video.search_vector.op("@@")(tsq),
func.unaccent(Channel.title).ilike(func.unaccent(f"%{q}%")), func.unaccent(Channel.title).ilike(func.unaccent(f"%{q}%")),
) )
) )
@ -244,7 +241,7 @@ def _filtered_query(
# EXACTLY — a raw float4 vs the float8 cursor value mismatches on round-trip and # EXACTLY — a raw float4 vs the float8 cursor value mismatches on round-trip and
# breaks paging (the same page repeats). 1e6 granularity is ample; ties break on # breaks paging (the same page repeats). 1e6 granularity is ample; ties break on
# published_at then id. # published_at then id.
rank_expr = cast(func.ts_rank(title_vec, tsq) * 1000000, BigInteger) rank_expr = cast(func.ts_rank(Video.search_vector, tsq) * 1000000, BigInteger)
else: else:
# No usable tokens (e.g. only punctuation): fall back to a plain substring match. # No usable tokens (e.g. only punctuation): fall back to a plain substring match.
like = func.unaccent(f"%{q}%") like = func.unaccent(f"%{q}%")

View file

@ -16,7 +16,7 @@ from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import and_, select from sqlalchemy import and_, func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session, aliased from sqlalchemy.orm import Session, aliased
@ -223,6 +223,20 @@ def search_youtube(
.values([{"user_id": user.id, "video_id": vid} for vid in ordered]) .values([{"user_id": user.id, "video_id": vid} for vid in ordered])
.on_conflict_do_nothing(index_elements=["user_id", "video_id"]) .on_conflict_do_nothing(index_elements=["user_id", "video_id"])
) )
# 7) Fold the query into each result's search_terms (shared across users) so local
# full-text search inherits YouTube's relevance — a video YT returned for this query
# becomes findable by it even when its title doesn't contain the words. Skip rows
# that already include the term; the generated search_vector updates automatically.
db.execute(
update(Video)
.where(Video.id.in_(ordered))
.where(func.coalesce(Video.search_terms, "").notilike(f"%{term}%"))
.values(
search_terms=func.trim(
func.coalesce(Video.search_terms, "").op("||")(" ").op("||")(term)
)
)
)
db.commit() db.commit()
return { return {

View file

@ -234,6 +234,9 @@ def apply_video_details(video: Video, item: dict) -> None:
video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None video.view_count = int(stats["viewCount"]) if stats.get("viewCount") else None
video.like_count = int(stats["likeCount"]) if stats.get("likeCount") else None video.like_count = int(stats["likeCount"]) if stats.get("likeCount") else None
video.category_id = int(snippet["categoryId"]) if snippet.get("categoryId") else None video.category_id = int(snippet["categoryId"]) if snippet.get("categoryId") else None
# Creator keyword tags (free with the snippet we already fetch) → search document.
tags = snippet.get("tags")
video.keywords = " ".join(tags) if tags else None
video.topic_categories = topics.get("topicCategories") video.topic_categories = topics.get("topicCategories")
video.default_language = snippet.get("defaultLanguage") or snippet.get( video.default_language = snippet.get("defaultLanguage") or snippet.get(
"defaultAudioLanguage" "defaultAudioLanguage"