fix: add ILIKE fallback for substring matches in search (#452)

trigram similarity alone misses cases where a short query
(e.g. "real") is a substring of a word in a long title.

added OR condition with ILIKE to catch exact substring matches
while preserving fuzzy matching behavior for typos.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 47e27bcc 325c739a

Changed files
+18 -9
backend
src
backend
api
+18 -9
backend/src/backend/api/search.py
··· 4 4 5 5 from fastapi import APIRouter, Depends, Query 6 6 from pydantic import BaseModel 7 - from sqlalchemy import func, select 7 + from sqlalchemy import func, or_, select 8 8 from sqlalchemy.ext.asyncio import AsyncSession 9 9 10 10 from backend._internal.atproto.handles import search_handles ··· 142 142 async def _search_tracks( 143 143 db: AsyncSession, query: str, limit: int 144 144 ) -> list[TrackSearchResult]: 145 - """search tracks by title using trigram similarity.""" 145 + """search tracks by title using trigram similarity + substring matching.""" 146 146 # use pg_trgm similarity function for fuzzy matching 147 147 similarity = func.similarity(Track.title, query) 148 + # also match substrings (e.g. "real" in "really") 149 + substring_match = Track.title.ilike(f"%{query}%") 148 150 149 151 stmt = ( 150 152 select(Track, Artist, similarity.label("relevance")) 151 153 .join(Artist, Track.artist_did == Artist.did) 152 - .where(similarity > 0.1) # minimum similarity threshold 154 + .where(or_(similarity > 0.1, substring_match)) 153 155 .order_by(similarity.desc()) 154 156 .limit(limit) 155 157 ) ··· 173 175 async def _search_artists( 174 176 db: AsyncSession, query: str, limit: int 175 177 ) -> list[ArtistSearchResult]: 176 - """search artists by handle and display_name using trigram similarity.""" 178 + """search artists by handle and display_name using trigram similarity + substring.""" 177 179 # combine similarity scores from handle and display_name (take max) 178 180 handle_sim = func.similarity(Artist.handle, query) 179 181 name_sim = func.similarity(Artist.display_name, query) 180 182 combined_sim = func.greatest(handle_sim, name_sim) 183 + # also match substrings 184 + substring_match = or_( 185 + Artist.handle.ilike(f"%{query}%"), 186 + Artist.display_name.ilike(f"%{query}%"), 187 + ) 181 188 182 189 stmt = ( 183 190 select(Artist, combined_sim.label("relevance")) 184 - .where(combined_sim > 0.1) 191 + .where(or_(combined_sim > 0.1, substring_match)) 185 192 .order_by(combined_sim.desc()) 186 193 .limit(limit) 187 194 ) ··· 204 211 async def _search_albums( 205 212 db: AsyncSession, query: str, limit: int 206 213 ) -> list[AlbumSearchResult]: 207 - """search albums by title using trigram similarity.""" 214 + """search albums by title using trigram similarity + substring.""" 208 215 similarity = func.similarity(Album.title, query) 216 + substring_match = Album.title.ilike(f"%{query}%") 209 217 210 218 stmt = ( 211 219 select(Album, Artist, similarity.label("relevance")) 212 220 .join(Artist, Album.artist_did == Artist.did) 213 - .where(similarity > 0.1) 221 + .where(or_(similarity > 0.1, substring_match)) 214 222 .order_by(similarity.desc()) 215 223 .limit(limit) 216 224 ) ··· 235 243 async def _search_tags( 236 244 db: AsyncSession, query: str, limit: int 237 245 ) -> list[TagSearchResult]: 238 - """search tags by name using trigram similarity.""" 246 + """search tags by name using trigram similarity + substring.""" 239 247 similarity = func.similarity(Tag.name, query) 248 + substring_match = Tag.name.ilike(f"%{query}%") 240 249 241 250 # count tracks per tag 242 251 track_count_subq = ( ··· 252 261 similarity.label("relevance"), 253 262 ) 254 263 .outerjoin(track_count_subq, Tag.id == track_count_subq.c.tag_id) 255 - .where(similarity > 0.1) 264 + .where(or_(similarity > 0.1, substring_match)) 256 265 .order_by(similarity.desc()) 257 266 .limit(limit) 258 267 )