<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Pushpendra Yadav</title>
    <description>The latest articles on DEV Community by Pushpendra Yadav (@pushpendra2011).</description>
    <link>https://dev.to/pushpendra2011</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4182491%2F4ee13f37-a727-4120-aceb-9e645454a072.png</url>
      <title>DEV Community: Pushpendra Yadav</title>
      <link>https://dev.to/pushpendra2011</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/pushpendra2011"/>
    <language>en</language>
    <item>
      <title>I took down my own search 🔥</title>
      <dc:creator>Pushpendra Yadav</dc:creator>
      <pubDate>Fri, 16 Oct 2026 18:47:13 +0000</pubDate>
      <link>https://dev.to/pushpendra2011/i-took-down-my-own-search-oii</link>
      <guid>https://dev.to/pushpendra2011/i-took-down-my-own-search-oii</guid>
      <description>&lt;p&gt;&lt;a href="https://becauseiliked.com/?utm_source=devto&amp;amp;utm_campaign=part3" rel="noopener noreferrer"&gt;&lt;strong&gt;Because I liked&lt;/strong&gt;&lt;/a&gt; asks what you loved &lt;em&gt;and why&lt;/em&gt;, then finds films, shows, books and games that match the why. &lt;a href="https://dev.to/pushpendra2011/i-built-a-recommender-that-asks-why-you-loved-something-heres-what-broke-1ma4"&gt;Part 1&lt;/a&gt; was how it works; &lt;a href="https://dev.to/pushpendra2011/i-measured-my-ai-search-the-numbers-were-humbling-4lkc"&gt;part 2&lt;/a&gt; was measuring it.&lt;/p&gt;

&lt;p&gt;This one is shorter on cleverness and longer on mistakes. In one week I broke search for real users, found the cause was an experiment I'd forgotten was still running, taught it that "animated" doesn't always mean cartoon, and added 1,810 films for 38 cents.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔥 Lesson 1: background jobs share your database with your users
&lt;/h2&gt;

&lt;p&gt;Every title page shows "More like this": its 12 nearest neighbours, precomputed into a table so a page view is one indexed read instead of a vector search. After adding a batch of films, I started a job to recompute those lists for all 14,000 titles. Six titles at a time, each one a vector search.&lt;/p&gt;

&lt;p&gt;Then I tried the site:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Search is having trouble right now. Please try again.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The vector search behind every query was hitting Postgres's statement timeout; with retries, a search took 26 seconds to fail. The database is small, and my batch job was hammering it with the same kind of query users were waiting on. Stopping the job brought search back.&lt;/p&gt;

&lt;p&gt;Two things went wrong at once, and the second one hid behind the first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The job could stall forever.&lt;/strong&gt; Before the outage, the job had already gone quiet: the process was alive, but nothing had been written for six minutes. One request to the database had never come back, and nothing told it to give up. Every call now gets a deadline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// a hung request otherwise stalls the whole run&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It couldn't resume.&lt;/strong&gt; A full recompute rewrote everything, so killing it meant starting over. It now takes a &lt;code&gt;--before &amp;lt;time&amp;gt;&lt;/code&gt; flag and skips titles already done since then, plus &lt;code&gt;--concurrency&lt;/code&gt; so it can run gently. The rest runs overnight, when nobody's searching.&lt;/p&gt;

&lt;h2&gt;
  
  
  👻 Lesson 2: your experiment is in the same index as production
&lt;/h2&gt;

&lt;p&gt;Even with the job stopped, "films only" searches took over 5 seconds. Unfiltered ones took 140 ms. The difference was in the table.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://dev.to/pushpendra2011/i-measured-my-ai-search-the-numbers-were-humbling-4lkc"&gt;part 2&lt;/a&gt; I described giving every film and show a second vector, describing only its lead character, to fix searches like &lt;em&gt;"the main character is a sarcastic stoic"&lt;/em&gt;. It didn't pass the test set, so I parked it. The vectors stayed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vectors in &lt;code&gt;item_embeddings&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Search (&lt;code&gt;v3&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;13,826&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parked experiment (&lt;code&gt;lead-v1&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;7,971&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One HNSW index covered both. A filtered search walks the graph until it has collected enough rows that pass every filter (right version, right medium), and over a third of what it walked past could never match. Books, about 6% of the catalog, were worse.&lt;/p&gt;

&lt;p&gt;The fix was to delete the parked vectors. The lines themselves are still stored on each title, so re-embedding them later costs about a cent. When the experiment comes back, its vectors go in a table and index of their own. Filtered searches dropped to 0.9–3.8 seconds cold, and about 0.3 seconds warm.&lt;/p&gt;

&lt;p&gt;The lesson is boring and I keep relearning it: &lt;strong&gt;an experiment is only parked once it can't touch the production path.&lt;/strong&gt; Keeping its data in the shared table wasn't parking it.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎨 Lesson 3: "an animated girl" isn't a cartoon
&lt;/h2&gt;

&lt;p&gt;Search for &lt;em&gt;"an animated film that makes adults cry"&lt;/em&gt; and the results included &lt;em&gt;Close&lt;/em&gt;, &lt;em&gt;Wonder&lt;/em&gt; and &lt;em&gt;Bridge to Terabithia&lt;/em&gt;. All good films, all live-action. "Animated" was just one more word in the mood being matched, and the mood ("makes adults cry") is what matters most.&lt;/p&gt;

&lt;p&gt;The catalog already knows: TMDB tags films and shows with an Animation genre. So the model that reads each search got a new field, &lt;code&gt;format&lt;/code&gt;, which search turns into a hard filter.&lt;/p&gt;

&lt;p&gt;Then the obvious problem. My first prompt's rule was "animated → animated films only". I tested it on six phrasings and it got three wrong:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Search&lt;/th&gt;
&lt;th&gt;First prompt&lt;/th&gt;
&lt;th&gt;Right answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;An animated movie that can make a grown man cry&lt;/td&gt;
&lt;td&gt;animated&lt;/td&gt;
&lt;td&gt;animated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A chirpy young animated girl living her life to the fullest&lt;/td&gt;
&lt;td&gt;animated&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;lively&lt;/em&gt;, no filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A film about an animated, talkative old man&lt;/td&gt;
&lt;td&gt;animated&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;lively&lt;/em&gt;, no filter&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;I loved Up because the old man slowly opens up&lt;/td&gt;
&lt;td&gt;animated&lt;/td&gt;
&lt;td&gt;no filter: describing a loved film, not asking&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix was to say what the word is attached to: "animated" describing the &lt;strong&gt;work&lt;/strong&gt; (film, series, anime, cartoon) is a format; describing a &lt;strong&gt;person&lt;/strong&gt; means lively and stays part of the mood. The tightened prompt got all nine phrasings in the next round right, including "more animated films like Up" (yes) and "a cartoonish, over-the-top comedy" (no: that's a style).&lt;/p&gt;

&lt;p&gt;When the filter is off, the word still shapes the search, so "a chirpy animated girl" still leans animated without shutting out live-action. When the meaning is unclear, that's the right default.&lt;/p&gt;

&lt;h2&gt;
  
  
  🦈 Lesson 4: your catalog is biased toward whatever you seeded it from
&lt;/h2&gt;

&lt;p&gt;I've been answering recommendation threads on Reddit, and one asked for &lt;em&gt;lighthearted horror with a retro vibe, like Fright Night and The Lost Boys&lt;/em&gt;. The crowd's answers were good. Then I checked them against my catalog: &lt;strong&gt;9 of the 16 suggestions I checked weren't in it&lt;/strong&gt;, including the top-voted one, &lt;em&gt;The Frighteners&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The catalog was seeded from TMDB's "popular" lists, which skew to what's popular &lt;em&gt;now&lt;/em&gt;. So I measured the gap against TMDB's full list, sorted by vote count:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;TMDB films with…&lt;/th&gt;
&lt;th&gt;Missing from my catalog&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2,000+ votes&lt;/td&gt;
&lt;td&gt;2 of 2,837&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000+ votes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,831 of 5,021&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Nearly every big film was there; over a third of the next tier wasn't. That's where cult and retro films live: &lt;em&gt;The Frighteners&lt;/em&gt;, &lt;em&gt;Slither&lt;/em&gt;, &lt;em&gt;Arachnophobia&lt;/em&gt;, &lt;em&gt;Trick 'r Treat&lt;/em&gt;, &lt;em&gt;Bonnie and Clyde&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;I added 24 first and read their descriptions before running the rest, the habit from part 2. The full run, through Anthropic's Batch API:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Seed 1,810 films (TMDB)&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Describe them&lt;/td&gt;
&lt;td&gt;$0.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embed them&lt;/td&gt;
&lt;td&gt;$0.009&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lead-character lines&lt;/td&gt;
&lt;td&gt;$0.05&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≈ $0.38&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;32 films still fail the description quality check, mostly by slipping into "a film I know well…". They stay out of search rather than in with a bad description.&lt;/p&gt;

&lt;h2&gt;
  
  
  🖼️ Lesson 5: small things that make a page feel finished
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Colour before the image.&lt;/strong&gt; Grids of grey loading tiles look broken on a slow connection, which is most connections in India. Each poster and still now carries its average colour, worked out by shrinking a thumbnail to a single pixel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;thumbnail&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fill&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toBuffer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;resolveWithObject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;// → '#81b1d2' for Up's sky&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That ran across 13,850 titles for nothing but bandwidth. Then I found my own bug: a poster that took over 2.5 seconds swapped its colour for a designed fallback cover, so on exactly the slow connections this was for, people saw the fallback. Now the colour stays until the image arrives, and the fallback is only for images that fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Not looking like a film database.&lt;/strong&gt; A visitor clicked a poster on the home page and left two seconds later. People clicked "12,000+ titles, browse it all" and took the product for a film database. That link is gone. So is "Browse" in the footer, and "← The library" on title pages is now "← Back to search". The product is the question, &lt;em&gt;why did you love it?&lt;/em&gt;, so the page now leads with the question.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔎 What about Google?
&lt;/h2&gt;

&lt;p&gt;Part 2 promised the answer to whether the "why each pick fits" text on title pages gets them indexed. Honest update: &lt;strong&gt;too early to tell.&lt;/strong&gt; Search Console still shows 2 pages indexed and 8 not, and that report runs days behind. A brand-new domain gets crawled cautiously until other sites link to it. I'll report when there's data, not before.&lt;/p&gt;

&lt;h2&gt;
  
  
  🙏 Try it, break it
&lt;/h2&gt;

&lt;p&gt;Free, no sign-up to search, no ads: &lt;a href="https://becauseiliked.com/?utm_source=devto&amp;amp;utm_campaign=part3" rel="noopener noreferrer"&gt;&lt;strong&gt;becauseiliked.com&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try &lt;em&gt;"an animated movie that can make a grown man cry"&lt;/em&gt;, then &lt;em&gt;"a chirpy animated girl living her life to the fullest"&lt;/em&gt;, and tell me if it read them right. And if a film you love is missing, put it in the comments: the catalog has a cult-classic gap, and you're better at finding it than any charts or trends.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
      <category>postgres</category>
    </item>
    <item>
      <title>I measured my AI search. The numbers were humbling 📏</title>
      <dc:creator>Pushpendra Yadav</dc:creator>
      <pubDate>Thu, 15 Oct 2026 18:17:46 +0000</pubDate>
      <link>https://dev.to/pushpendra2011/i-measured-my-ai-search-the-numbers-were-humbling-4lkc</link>
      <guid>https://dev.to/pushpendra2011/i-measured-my-ai-search-the-numbers-were-humbling-4lkc</guid>
      <description>&lt;p&gt;In &lt;a href="https://dev.to/pushpendra2011/i-built-a-recommender-that-asks-why-you-loved-something-heres-what-broke-1ma4"&gt;part 1&lt;/a&gt; I built &lt;a href="https://becauseiliked.com/?utm_source=devto&amp;amp;utm_campaign=part2" rel="noopener noreferrer"&gt;&lt;strong&gt;Because I liked&lt;/strong&gt;&lt;/a&gt;: you describe something you loved &lt;strong&gt;and why&lt;/strong&gt;, and it finds films, shows, books and games that match the &lt;em&gt;why&lt;/em&gt;, then explains each pick.&lt;/p&gt;

&lt;p&gt;Part 1 ended with "the misses are the most useful thing you can send me". People sent them, across several platforms. This post is about what I did with them: building a test set, a fix that worked, a fix that &lt;em&gt;should&lt;/em&gt; have worked and didn't, and what real visitors typed that I never planned for.&lt;/p&gt;

&lt;h2&gt;
  
  
  📏 Lesson 1: stop judging search by looking at it
&lt;/h2&gt;

&lt;p&gt;For weeks my process was: change something, type three searches I liked, squint at the results, ship. Every change "looked better". Some of them weren't.&lt;/p&gt;

&lt;p&gt;So I wrote a test set. Each case is a search (some from real visitors, some written to mirror them) plus 3–5 titles a well-read friend would name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hard-but-fair"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"a hard but fair game where dying teaches you something"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Dark Souls"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hollow Knight"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Celeste"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Sekiro: Shadows Die Twice"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Hades"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The score is &lt;strong&gt;recall@12&lt;/strong&gt;: what fraction of the expected titles appear in the first 12 results. It's a blunt instrument. Five titles can't capture "good taste", and a great pick that isn't on the list scores zero. But it's the same blunt instrument every time, and that's the point.&lt;/p&gt;

&lt;p&gt;The first honest number: &lt;strong&gt;28%&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Mean recall@12&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Baseline&lt;/td&gt;
&lt;td&gt;28%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Named works become anchors ("like Breaking Bad, but funnier")&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Eras become filters ("from the 90s")&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Acclaim as a tiebreaker&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  🏆 Lesson 2: similarity can't tell a classic from its clone
&lt;/h2&gt;

&lt;p&gt;Here's what "a hard but fair game where dying teaches you something" returned at baseline: &lt;em&gt;White Knuckle, Causality, I Wanna Maker, Levelhead, NetHack, Celeste…&lt;/em&gt; Every one of them is hard, and plenty are fair. &lt;em&gt;Celeste&lt;/em&gt; at #6 was the only title a person would actually name, and Dark Souls wasn't in the top 12 at all.&lt;/p&gt;

&lt;p&gt;To an embedding, an obscure precision platformer and Dark Souls are close neighbours. They're &lt;em&gt;described&lt;/em&gt; the same way. Only one of them is the one people mean.&lt;/p&gt;

&lt;p&gt;The fix is to let audience opinion break ties. For each candidate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;similarity&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;ACCLAIM_WEIGHT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;acclaim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;medium&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;votes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// acclaim is 0–1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;acclaim&lt;/code&gt; combines the audience rating with how many people rated it, so a 9/10 with 40 votes doesn't beat a 9/10 with 40,000. It only re-sorts the top 50 by fit, so a famous title that &lt;em&gt;doesn't&lt;/em&gt; fit can't sneak in.&lt;/p&gt;

&lt;p&gt;The interesting part is the weight. I swept it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;th&gt;Recall@12&lt;/th&gt;
&lt;th&gt;What happened&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.03&lt;/td&gt;
&lt;td&gt;31%&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.06&lt;/td&gt;
&lt;td&gt;36%&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;0.1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dark Souls #1 and Hollow Knight #6 for "hard but fair"; quiet films keep their quiet picks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.15&lt;/td&gt;
&lt;td&gt;42%&lt;/td&gt;
&lt;td&gt;
&lt;em&gt;Her&lt;/em&gt; jumps to #2 for "a quiet film about ordinary rooms"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;0.15 scored higher, and I didn't take it. &lt;em&gt;Her&lt;/em&gt; is a fine film, but it isn't a quiet film about ordinary rooms. It's a famous film that's &lt;em&gt;near&lt;/em&gt; one. That's the failure the whole app exists to avoid: fame over fit. The test set can't see that distinction, so I made the call by reading the results.&lt;/p&gt;

&lt;p&gt;Not every case improved across these changes, either. "Time loop" dropped from 40% to 20%, and "90s romcom" went to zero. A mean that goes up can hide cases that go down, so the eval prints a delta per case, and I read all of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎭 Lesson 3: the fix that worked offline and failed the test
&lt;/h2&gt;

&lt;p&gt;A visitor from Reddit searched:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"An analytical drama where the main character is a sarcastic stoic who struggles with his own emotions."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's &lt;em&gt;House&lt;/em&gt;. Or &lt;em&gt;Sherlock&lt;/em&gt;. Neither appeared. In a sample of 155 titles, &lt;em&gt;House&lt;/em&gt; ranked &lt;strong&gt;153rd&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The reason is a variant of part 1's lesson. Each title has one description, and a description of &lt;em&gt;House&lt;/em&gt; talks about medicine, mysteries, a hospital, a team. The lead's personality is one sentence among many, and one sentence barely moves a vector.&lt;/p&gt;

&lt;p&gt;So I tried giving every film and show a second fingerprint that is &lt;em&gt;only&lt;/em&gt; about the lead:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The lead is a brilliant, sharp-tongued diagnostician who treats patients as puzzles and colleagues with open contempt, while privately fighting chronic pain and an addiction to painkillers."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Offline, before spending anything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Description only&lt;/th&gt;
&lt;th&gt;Appending the lead line to it&lt;/th&gt;
&lt;th&gt;Lead line as its own vector&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lie to Me&lt;/td&gt;
&lt;td&gt;#145&lt;/td&gt;
&lt;td&gt;#136&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monk&lt;/td&gt;
&lt;td&gt;#151&lt;/td&gt;
&lt;td&gt;#147&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#18&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sherlock&lt;/td&gt;
&lt;td&gt;#154&lt;/td&gt;
&lt;td&gt;#154&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#33&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;House&lt;/td&gt;
&lt;td&gt;#153&lt;/td&gt;
&lt;td&gt;#153&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;#80&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Appending the line to the description did nothing, for the same reason as before: it gets averaged away. As its own vector it worked. Writing 7,971 lead lines through Anthropic's Batch API cost &lt;strong&gt;$0.22&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In the real pipeline, the model that reads the query writes a matching "The lead is…" sentence, embeds it, searches the lead vectors, and fuses that ranking with the normal one using reciprocal rank fusion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// each list votes 1/(k + rank); titles in both lists add up&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;add&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;RRF_K&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;rank&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I ran the test set. &lt;strong&gt;The case still scored 0%.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The results were &lt;em&gt;better&lt;/em&gt;: Scorpion, Death Note, The Imitation Game, Good Will Hunting, all reasonable answers to that request. But not the five titles on the list. Digging in, the lead vector turned out to be very sensitive to wording. The sentence the model wrote put &lt;em&gt;Sherlock&lt;/em&gt; at &lt;strong&gt;#35&lt;/strong&gt;; a sentence I wrote by hand put it at &lt;strong&gt;#2&lt;/strong&gt;. Tuning the prompt until one test case passes is how you overfit a test set of 28.&lt;/p&gt;

&lt;p&gt;So it's parked. The 7,971 lines and their vectors are in the database, ready for a better idea, maybe several phrasings per query with the best match kept. I'm not editing the test case to say "Scorpion counts". Moving the goalposts after missing is exactly what a test set is supposed to stop you doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  📋 Lesson 4: your users search in shapes you never designed for
&lt;/h2&gt;

&lt;p&gt;My mental model of a search was a sentence with a &lt;em&gt;why&lt;/em&gt; in it. Here's an actual search from this week:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Dark, Severance, Black Mirror, Love, Death &amp;amp; Robots, MR. Robot, Lost, 1899, From, Alice in Borderlands, Squid game,"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No why at all, just a list. The results were decent (Silo first, which they clicked), but &lt;strong&gt;#3 was 1899 and #7 was FROM&lt;/strong&gt;. I'd handed back two of their own shows.&lt;/p&gt;

&lt;p&gt;The filter that drops titles you named skipped one-word titles on purpose, so that "dark thrillers" doesn't remove the show &lt;em&gt;Dark&lt;/em&gt;. A comma-separated list of capitalised entries is a different thing, so now that counts too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// "Dark, Severance, 1899, From" is a list; "dark, moody, slow thrillers" isn't&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;titled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[\p&lt;/span&gt;&lt;span class="sr"&gt;{Lu}&lt;/span&gt;&lt;span class="se"&gt;\p&lt;/span&gt;&lt;span class="sr"&gt;{N}&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;titled&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.66&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;entries&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bigger question was product, not code. A list of titles can only be matched on the titles themselves, which is exactly the genre-shelf matching this app exists to avoid. Refusing the search would be worse, though. So the results stay, and above them the page now asks:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;These match the shows themselves.&lt;/strong&gt; Tell us what you love about them, and we'll match that instead.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The input comes with sentence starters (&lt;em&gt;The mood&lt;/em&gt;, &lt;em&gt;A character&lt;/em&gt;, &lt;em&gt;A moment&lt;/em&gt;, &lt;em&gt;How it felt&lt;/em&gt;) that drop "because the mood was…" into the box for them to finish. Whatever they write becomes the why the next search matches on.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎮 Lesson 5: "More like GTA V" was GTA V, six times
&lt;/h2&gt;

&lt;p&gt;Every title page lists its nearest neighbours. For games, the catalog has every edition: Special, Enhanced, Story Mode, DLC. They're all described almost identically, so they're each other's nearest neighbours. All six of GTA V's top picks were GTA V: the base game twice, then the Special, Enhanced, Premium Online and Story Mode editions.&lt;/p&gt;

&lt;p&gt;The fix is a deliberately blunt franchise key: the first two meaningful words of the title, once the subtitle and any trailing number are stripped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;franchiseKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Grand Theft Auto V: Special Edition&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// "grand theft"&lt;/span&gt;
&lt;span class="nf"&gt;franchiseKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Grand Theft Auto: San Andreas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;// "grand theft"&lt;/span&gt;
&lt;span class="nf"&gt;franchiseKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The Lord of the Rings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;// "lord rings"&lt;/span&gt;
&lt;span class="nf"&gt;franchiseKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Lord of War&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;// "lord war": stays separate&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At most one title per franchise per list. I recomputed all 12,016 lists. That cost nothing, since it's database work only. Skyrim's page now suggests Enderal, The Witcher 3 and Dragon Age instead of a shelf of its own editions.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔎 Lesson 6: Google doesn't care about your embeddings
&lt;/h2&gt;

&lt;p&gt;I finally set up Google Search Console. Its first report: six of my title pages were &lt;strong&gt;"Crawled – currently not indexed"&lt;/strong&gt;. Google had read them and decided they weren't worth keeping.&lt;/p&gt;

&lt;p&gt;It's hard to argue. A title page's synopsis comes from TMDB (or IGDB, for games), so it's the same blurb as on IMDb and a thousand other sites. The part only I have is the &lt;em&gt;why&lt;/em&gt;, and the pages didn't really say it.&lt;/p&gt;

&lt;p&gt;So the most popular title pages now open their "More like…" section with what people love about the title, and why each pick fits. The first draft was bad in an instructive way. My catalog descriptions are heavy on look and sound (part 1's "golden hour" work), so the model described &lt;em&gt;Breaking Bad&lt;/em&gt; as "sun-bleached desert light set against fluorescent-lit rooms". "Sun-bleached" appeared 13 times across 20 drafts. Nobody searching "shows like Breaking Bad" means the lighting.&lt;/p&gt;

&lt;p&gt;The second draft fed in the synopsis, themes and the lead lines from Lesson 3, and asked for story and character first:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;People love Breaking Bad for its slow corruption of a decent, proud man, the tension between his family life and his criminal double life, and the dread that he will not stop before he destroys everyone around him.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Americans&lt;/strong&gt;: like Walter, a controlled person hides a second life from his family, and the strain between loyalty and deception drives the story.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It cost &lt;strong&gt;$0.009 for 20 pages&lt;/strong&gt;. There was one factual slip in 16 pages: it gave &lt;em&gt;Fear the Walking Dead&lt;/em&gt; a "protective father", when the lead is a mother. That slip, plus wanting proof the copy helps, is why it's on 25 pages so far, not 12,000. Those six rejected pages got the new copy, and I asked Google to look again. Whether it changes Google's mind is part 3.&lt;/p&gt;

&lt;p&gt;While I was in there, I fixed the URLs. Title pages were &lt;code&gt;/browse/3bd40852-97cc-4767-8584-388236de35cc&lt;/code&gt;. They're now &lt;code&gt;/browse/forza-horizon-5-3bd40852&lt;/code&gt;. The trailing 8 hex characters are the start of the item's UUID, and the lookup is a range query, so it still uses the primary key index:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ids are uuid-typed, so "starts with" is a range&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-0000-0000-0000-000000000000`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lte&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-ffff-ffff-ffff-ffffffffffff`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Old links 308 to the new ones. The best time to change your URLs is before Google has indexed them, and for me that was this week.&lt;/p&gt;

&lt;h2&gt;
  
  
  💸 Lesson 7: cheap per call is not cheap per mistake
&lt;/h2&gt;

&lt;p&gt;Part 1 said re-describing the catalog cost about $2.30. True. What it didn't say: when you're iterating, you run it again. My bill showed that $2.30 more than once in two days. It's not a lot of money, but it's the wrong habit, and it scales with the catalog. The rules now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sample first.&lt;/strong&gt; 20 titles, read every one, then decide. Most of the fixes in this post started that way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State the cost before running anything catalog-wide&lt;/strong&gt;, and put a hard cost cap in the script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use the Batch API&lt;/strong&gt; for anything that isn't interactive: half price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefer database work.&lt;/strong&gt; The franchise fix touched 12,016 rows for $0.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What things cost now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Lead lines for 7,971 films and shows: &lt;strong&gt;$0.22&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Page copy: about &lt;strong&gt;$0.0005 per page&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;A full test-set run: a few cents&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🙏 Try it, break it
&lt;/h2&gt;

&lt;p&gt;It's free, no sign-up needed to search, no ads: &lt;a href="https://becauseiliked.com/?utm_source=devto&amp;amp;utm_campaign=part2" rel="noopener noreferrer"&gt;&lt;strong&gt;becauseiliked.com&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Try a search &lt;em&gt;with&lt;/em&gt; a why, then one without. And if you have a search it gets wrong, especially a "the main character is…" one, put it in the comments. It goes straight into the test set.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I built a recommender that asks *why* you loved something — here's what broke 🍿 Pt. 1</title>
      <dc:creator>Pushpendra Yadav</dc:creator>
      <pubDate>Tue, 13 Oct 2026 16:33:41 +0000</pubDate>
      <link>https://dev.to/pushpendra2011/i-built-a-recommender-that-asks-why-you-loved-something-heres-what-broke-1ma4</link>
      <guid>https://dev.to/pushpendra2011/i-built-a-recommender-that-asks-why-you-loved-something-heres-what-broke-1ma4</guid>
      <description>&lt;p&gt;Every recommendation engine I've used asks the wrong question. "Because you watched &lt;em&gt;Interstellar&lt;/em&gt;" gets you more space epics. But maybe the space was never the point. Maybe it was the loneliness, and an ending that hit your heart instead of your brain.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://becauseiliked.com/?utm_source=devto" rel="noopener noreferrer"&gt;&lt;strong&gt;Because I liked&lt;/strong&gt;&lt;/a&gt;: you describe something you loved &lt;strong&gt;and why&lt;/strong&gt;, in your own words, and it finds films, shows, books and games that match the &lt;em&gt;why&lt;/em&gt;. Every pick explains how it connects to what you said.&lt;/p&gt;

&lt;p&gt;Type &lt;em&gt;"a lonely, beautiful sci-fi where the twist is emotional, not clever"&lt;/em&gt; and you get &lt;em&gt;Moon&lt;/em&gt;, &lt;em&gt;Her&lt;/em&gt;, &lt;em&gt;Contact&lt;/em&gt;, &lt;em&gt;The Midnight Sky&lt;/em&gt;, and &lt;em&gt;Finch&lt;/em&gt;, a gentle film about a dying man building a robot to look after his dog. No space operas, no heist-in-orbit thrillers. It matched the feeling, not the genre shelf.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/4qzqV96AK9s" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;It's a solo project. This post covers how it works, and the parts that broke in instructive ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧱 The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; (App Router) on &lt;strong&gt;Vercel&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt;: Postgres + &lt;strong&gt;pgvector&lt;/strong&gt; for similarity search&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt; &lt;code&gt;text-embedding-3-small&lt;/code&gt; for vectors&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claude Haiku&lt;/strong&gt; for reading queries, describing titles and writing explanations&lt;/li&gt;
&lt;li&gt;About &lt;strong&gt;12,000 titles&lt;/strong&gt; from TMDB (films, TV), IGDB (games) and Open Library (books)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🧠 How a search works
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read the request.&lt;/strong&gt; An LLM splits what you typed into the part that describes a &lt;em&gt;feeling&lt;/em&gt; and the parts that are &lt;em&gt;constraints&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed the feeling&lt;/strong&gt; and find the nearest titles in pgvector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply the constraints&lt;/strong&gt; the data can actually check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explain the top picks&lt;/strong&gt; against your own words.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 1 exists because of the first big lesson.&lt;/p&gt;

&lt;h2&gt;
  
  
  💥 Lesson 1: embeddings encode topic, not instruction
&lt;/h2&gt;

&lt;p&gt;Early on, "feel-good beach-town shows, dubbed in Hindi" returned… Indian TV. Not beach towns. The words "Hindi" and "India" dominated the vector, and the thing the person actually wanted (sun, sea, warmth) ranked below 300.&lt;/p&gt;

&lt;p&gt;An embedding doesn't know "dubbed in Hindi" is a filter. It just sees a topic. So now a model reads the request first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// what gets embedded vs what becomes a filter&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;texture&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;warm, sunny, low-stakes shows set in a beach town&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;intent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;audio&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nx"&gt;medium&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tv&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only &lt;code&gt;texture&lt;/code&gt; goes into the vector. Language, age suitability, availability and named people become filters or boosts, each checked against real data. And when we &lt;em&gt;can't&lt;/em&gt; check something (dubs on a specific streaming service, say), the app says so instead of pretending.&lt;/p&gt;

&lt;p&gt;Dubs are the honest gap. No catalog I can access says which shows are dubbed in Hindi on which service, so the app doesn't pretend. It says it can't check dubs yet, and offers titles &lt;em&gt;originally made&lt;/em&gt; in Hindi instead, as a clearly labelled alternative rather than a quiet swap.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎭 Lesson 2: names in a vector pull in look-alikes
&lt;/h2&gt;

&lt;p&gt;Someone asked for comedies "which also star Kevin James" and got comedies… without Kevin James. The name was in the vector, and the vector found films that &lt;em&gt;felt&lt;/em&gt; similar.&lt;/p&gt;

&lt;p&gt;The fix was the same pattern again: pull the person out of the query, look them up in the stored cast and crew credits, and put every title crediting them first, ranked by how well each one fits the &lt;em&gt;why&lt;/em&gt;. Searching "directed by David Fincher" now leads with all eleven Fincher films we have.&lt;/p&gt;

&lt;h2&gt;
  
  
  ⚡ Lesson 3: my title pages took 10 seconds (it was geography)
&lt;/h2&gt;

&lt;p&gt;Each title has its own page, which is the bulk of the SEO surface. They were taking 1–10 seconds. Three causes, three fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Distance.&lt;/strong&gt; My Vercel functions ran in Washington DC (the default) while the database sits in Mumbai. Every query crossed the planet, about five times per page. Pinning functions to Mumbai with a one-line &lt;code&gt;vercel.json&lt;/code&gt; cut the base cost from about 1.2 s to about 50 ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live vector search on every view.&lt;/strong&gt; "More like this" ran a similarity search per page view. I precomputed the 12 nearest neighbours for every title into a table instead, which turned it into one indexed read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-visitor rendering.&lt;/strong&gt; The page checked who was signed in, so it couldn't be cached. I moved the signed-in bits to the client, and now the page is cached at the edge.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Result: &lt;strong&gt;about 0.1 s&lt;/strong&gt; for a cached title page, down from up to 10 s.&lt;/p&gt;

&lt;h2&gt;
  
  
  👀 Lesson 4: your first real user will find your blind spot
&lt;/h2&gt;

&lt;p&gt;I launched quietly and watched (anonymous, cookieless) analytics. One of the first real visitors typed something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"a quiet film where ordinary rooms, window light and careful framing make everyday life feel interesting. Gentle mood, little violence."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's a beautiful, very &lt;em&gt;film person&lt;/em&gt; request. Two of the results were serial-killer thrillers. The visitor refined once and left.&lt;/p&gt;

&lt;p&gt;The catalog had &lt;em&gt;Paterson&lt;/em&gt; and &lt;em&gt;Perfect Days&lt;/em&gt;, close to perfect answers, and neither surfaced. Why? My per-title descriptions (written by an LLM, then embedded) talked about story and character arcs, and mentioned the &lt;em&gt;look&lt;/em&gt; only in passing. People don't search for plots. &lt;strong&gt;They mood-watch.&lt;/strong&gt; They ask for how something looks, sounds and feels to sit with.&lt;/p&gt;

&lt;p&gt;So I rewrote the description prompt to lead with the experience. Here's &lt;em&gt;Paterson&lt;/em&gt;, before and after:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; "…The look is spare and patient…"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt; "Soft, unhurried daylight through kitchen windows and bus glass, with static camera setups that let ordinary rooms and street corners sit in frame… Intensity is very low: no violence, no gore."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every title now states its &lt;strong&gt;aesthetic&lt;/strong&gt; (light, palette, framing, sound), its &lt;strong&gt;vibe&lt;/strong&gt; and the occasions it suits ("a rainy Sunday"), and its &lt;strong&gt;intensity&lt;/strong&gt; in plain words, so "little violence" finally has something to match against.&lt;/p&gt;

&lt;p&gt;Re-describing all 12,000 titles through Anthropic's Batch API cost &lt;strong&gt;about $2.30&lt;/strong&gt;. A quality gate rejected descriptions that slipped into the first person ("a film I know well…") and retried them.&lt;/p&gt;

&lt;p&gt;The same search now leads with &lt;strong&gt;&lt;em&gt;Paterson&lt;/em&gt;&lt;/strong&gt;, followed by quiet, carefully framed films like &lt;em&gt;The Quiet Girl&lt;/em&gt;, &lt;em&gt;Close&lt;/em&gt; and &lt;em&gt;Umberto D.&lt;/em&gt;, and the three thrillers are gone. One real visitor, one honest miss, one catalog-wide fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔍 What's next: making "golden hour" findable
&lt;/h2&gt;

&lt;p&gt;A single paragraph per title averages detail away. Ask for "a film where most scenes are shot in golden hour" and that detail is maybe 2% of a title's description, and 2% barely moves a vector. Next up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real keywords&lt;/strong&gt; from TMDB and IGDB ("small town", "rain", "road trip"): facts the model can't be trusted to remember&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured facets&lt;/strong&gt; (visual motifs, intensity scores, runtime), so some asks become filters instead of hopes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid search:&lt;/strong&gt; vectors for the vibe, facets for the specifics&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  💸 What it costs to run
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Picks for one search: about &lt;strong&gt;$0.002&lt;/strong&gt; in LLM usage&lt;/li&gt;
&lt;li&gt;Re-describing the whole catalog: about &lt;strong&gt;$2.30&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Embedding it: about &lt;strong&gt;$0.10&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cheap enough to iterate on every time a real query exposes a gap, which is the whole game.&lt;/p&gt;

&lt;h2&gt;
  
  
  🙏 Try it, break it
&lt;/h2&gt;

&lt;p&gt;It's free, with no sign-up needed to search, and no ads. &lt;a href="https://becauseiliked.com/?utm_source=devto" rel="noopener noreferrer"&gt;&lt;strong&gt;becauseiliked.com&lt;/strong&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Describe something you genuinely loved and &lt;em&gt;why&lt;/em&gt;, then tell me in the comments whether the picks got the why or just the genre. The misses are the most useful thing you can send me.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
