<?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>TanStack Redact: Ship Less React Without Touching Your React Code</title>
      <dc:creator>Pushpendra Yadav</dc:creator>
      <pubDate>Fri, 30 Oct 2026 15:07:25 +0000</pubDate>
      <link>https://dev.to/pushpendra2011/tanstack-redact-ship-less-react-without-touching-your-react-code-eed</link>
      <guid>https://dev.to/pushpendra2011/tanstack-redact-ship-less-react-without-touching-your-react-code-eed</guid>
      <description>&lt;p&gt;I've been doing frontend for about twelve years, and a big part of that has been fighting bundle size. Code splitting. Lazy routes. Swapping heavy date libraries for lighter ones. Hunting down that one dependency that pulls in all of lodash. A Webpack 4 to 5 migration done with codemods because doing it by hand across a big codebase wasn't realistic.&lt;/p&gt;

&lt;p&gt;But there was always one chunk of the bundle nobody touched: React and React DOM themselves. You just accepted that cost. It was the floor.&lt;/p&gt;

&lt;p&gt;TanStack Redact just lowered the floor. And the reason I think it matters isn't the clever engineering behind it (though there's plenty of that). It's how &lt;em&gt;useful&lt;/em&gt; it is on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Redact actually is
&lt;/h2&gt;

&lt;p&gt;Redact is a smaller, React-compatible runtime for the browser. You add one plugin to your build config, and your existing React imports, components, hooks and third-party libraries run on Redact in the browser instead of React DOM.&lt;/p&gt;

&lt;p&gt;That's the whole pitch. No new API to learn. No &lt;code&gt;import { h } from 'somewhere-else'&lt;/code&gt;. No rewriting components. Your app code stays exactly the same.&lt;/p&gt;

&lt;p&gt;It started as Tanner Linsley's experiment on his personal site and tanstack.com, where it's been running in production since April 2026. It stuck, other people started using it in production too, and on October 26 it shipped as &lt;strong&gt;0.2.0 with stable integrations for TanStack Start, Vite, Vinext and Next.js&lt;/strong&gt; (both App Router and Pages Router).&lt;/p&gt;

&lt;h2&gt;
  
  
  The usefulness is the headline
&lt;/h2&gt;

&lt;p&gt;Most performance wins come with a migration cost. Move to a new framework, adopt a new rendering model, rewrite your state layer, retrain the team. The win is real, but you pay for it in weeks.&lt;/p&gt;

&lt;p&gt;Redact flips that. Here's a plain Vite + React app:&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;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;react&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vitejs/plugin-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redact&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tanstack/redact/vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;redact&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;And TanStack Start:&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;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;tanstackStart&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tanstack/react-start/plugin/vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;react&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vitejs/plugin-react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redact&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@tanstack/redact/vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;ssr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;noExternal&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="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;tanstackStart&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;redact&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;For Next.js there's a &lt;code&gt;withRedact&lt;/code&gt; wrapper from &lt;code&gt;@tanstack/redact/next&lt;/code&gt; (the docs have the exact setup).&lt;/p&gt;

&lt;p&gt;That's it. Install, add a line, build, measure.&lt;/p&gt;

&lt;p&gt;Think about what this means in a real team:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No refactor PR.&lt;/strong&gt; Your diff is a config file and a &lt;code&gt;package.json&lt;/code&gt; line. Easy to review, easy to reason about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback is trivial.&lt;/strong&gt; Remove the plugin and you're back on React. That makes it a low-risk experiment instead of a bet-the-quarter decision.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your ecosystem comes with you.&lt;/strong&gt; Component libraries, form libraries, query libraries — they keep importing &lt;code&gt;react&lt;/code&gt; and just get Redact in the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nobody has to learn anything.&lt;/strong&gt; New hires still write React. Your docs, your Storybook, your patterns all stay valid.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've led teams where the right performance move was blocked purely because it meant touching hundreds of components. A tool that gives you the win without that cost is the kind of thing that actually gets adopted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;The TanStack team built the same small app with React and with Redact in each framework, then added up the gzip size of all emitted client JS and CSS:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;React&lt;/th&gt;
&lt;th&gt;Redact&lt;/th&gt;
&lt;th&gt;Saved&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TanStack Start&lt;/td&gt;
&lt;td&gt;109.3 KB&lt;/td&gt;
&lt;td&gt;62.3 KB&lt;/td&gt;
&lt;td&gt;47.0 KB (43.0%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vite&lt;/td&gt;
&lt;td&gt;68.3 KB&lt;/td&gt;
&lt;td&gt;20.1 KB&lt;/td&gt;
&lt;td&gt;48.1 KB (70.5%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vinext&lt;/td&gt;
&lt;td&gt;138.1 KB&lt;/td&gt;
&lt;td&gt;98.8 KB&lt;/td&gt;
&lt;td&gt;39.3 KB (28.5%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;175.6 KB&lt;/td&gt;
&lt;td&gt;139.9 KB&lt;/td&gt;
&lt;td&gt;35.7 KB (20.4%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those are the &lt;strong&gt;default&lt;/strong&gt; builds. If you turn off optional features your app doesn't use, Start drops to 59.8 KB and a client-only Vite app goes down to 12.6 KB.&lt;/p&gt;

&lt;p&gt;The team is upfront that these are whole-build totals for a small sample app, so your percentages will vary. The benchmark source is public if you want to reproduce it. I appreciate that honesty — it's rare in launch posts.&lt;/p&gt;

&lt;p&gt;Why do 35–48 KB of gzip matter? Because that's JavaScript every user downloads, parses and executes before your app becomes interactive. On a fast laptop on fiber, sure, you won't notice. On a mid-range Android phone on a patchy mobile connection — which is a huge share of real users in markets like India — that's a noticeable chunk of your load budget, and it's coming out of the one dependency you thought you couldn't shrink.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's not a toy React
&lt;/h2&gt;

&lt;p&gt;My first worry with any "React but smaller" project is: what breaks? A counter demo working proves nothing. Real apps lean on hydration, Suspense, transitions, forms, error boundaries, and a pile of framework internals.&lt;/p&gt;

&lt;p&gt;Redact supports JSX, hooks, context, refs, &lt;code&gt;memo&lt;/code&gt;, &lt;code&gt;lazy&lt;/code&gt;, portals, error boundaries, streaming SSR and hydration. Pending transitions, form actions and optimistic state are covered too.&lt;/p&gt;

&lt;p&gt;What gave me more confidence is how they test it. The compatibility suite runs real browser journeys in &lt;strong&gt;Chromium, Firefox and WebKit&lt;/strong&gt;: retained layouts, back/forward navigation, redirects, canceled navigation, Server Actions, pending forms, optimistic updates, uploads, streamed Suspense, and Fast Refresh that keeps state across edits. That's the stuff that actually breaks in production.&lt;/p&gt;

&lt;p&gt;And you don't lose your tooling: React DevTools still works for inspection, editing and commit profiling in development.&lt;/p&gt;

&lt;h2&gt;
  
  
  Server Components stay with React
&lt;/h2&gt;

&lt;p&gt;This is a really smart design call. With Next.js and Vinext, Redact doesn't try to replace everything. React still does server rendering and Server Components. The framework still owns routing, caching and Server Action transport. Redact picks up in the browser, hydrates the HTML, and runs your client components.&lt;/p&gt;

&lt;p&gt;So you're not swapping out your framework's server story. You're only swapping the part that ships to your users' devices. That keeps the blast radius small, and it's exactly where the bytes matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go smaller if you want to
&lt;/h2&gt;

&lt;p&gt;For Start and Vite you can tune what's included:&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;// Start: keep hydration and router features, drop what you don't use&lt;/span&gt;
&lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;features&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;activity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;fragmentRefs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;portal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="c1"&gt;// Vite: a client-only app with no optional features&lt;/span&gt;
&lt;span class="nf"&gt;redact&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;preset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nano&lt;/span&gt;&lt;span class="dl"&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 default is a sensible "full" preset, and the docs recommend starting there and only disabling features after checking what your app (and its dependencies) actually use. Next.js and Vinext use the full runtime so framework features keep working.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Why not just use Preact?"
&lt;/h2&gt;

&lt;p&gt;Fair question, and the Redact team answers it directly: Preact is smaller. But Redact keeps more of the modern React behavior your app may depend on, like pending transitions, deferred results and form actions. If you're on a modern React stack and you don't want to discover compatibility gaps one bug report at a time, that matters more than the last few kilobytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest tradeoff
&lt;/h2&gt;

&lt;p&gt;Redact renders &lt;strong&gt;synchronously&lt;/strong&gt;. No time slicing, no interruptible renders. Suspended transitions, pending forms and optimistic state still work, but a genuinely heavy render can block input while it runs.&lt;/p&gt;

&lt;p&gt;For a lot of apps, that's fine. Marketing and content sites, docs, ecommerce storefronts, typical CRUD dashboards, embedded widgets — most of their renders are small, and the bundle savings are pure win.&lt;/p&gt;

&lt;p&gt;Where I'd be careful: screens that render huge trees in one go, or code that relies on &lt;code&gt;useTransition&lt;/code&gt; to keep typing responsive during an expensive update. The TanStack team says the same thing: try it against your busiest screens first. Their compatibility guide spells out the boundaries clearly.&lt;/p&gt;

&lt;p&gt;That's not a knock on Redact. It's a clear, documented choice, and it's the reason the runtime can be this small.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I'm a fan
&lt;/h2&gt;

&lt;p&gt;What I like most is the attitude behind it. This isn't "React is bad, switch to X." Tanner is explicit that he still believes in React and wants it to keep growing. Redact is just another runtime option inside the React ecosystem for people who want to keep their components and ship less.&lt;/p&gt;

&lt;p&gt;That's the kind of tool I want more of: respects the code you already have, gives you a real win, tells you the tradeoff upfront, and lets you walk back with one line if it doesn't fit.&lt;/p&gt;

&lt;p&gt;If you have a React app on Vite, TanStack Start or Next.js, the cheapest experiment you'll run this month is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;code&gt;pnpm add @tanstack/redact&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;redact()&lt;/code&gt; (or &lt;code&gt;withRedact&lt;/code&gt;) to your config&lt;/li&gt;
&lt;li&gt;Build, compare your bundle, click through your heaviest screens&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Worst case you learn something about your app. Best case you delete tens of kilobytes from every page load without changing a single component.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Links&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Launch post: &lt;a href="https://tanstack.com/blog/introducing-tanstack-redact" rel="noopener noreferrer"&gt;https://tanstack.com/blog/introducing-tanstack-redact&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Docs: &lt;a href="https://tanstack.com/redact/latest" rel="noopener noreferrer"&gt;https://tanstack.com/redact/latest&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Compatibility guide: &lt;a href="https://tanstack.com/redact/latest/docs/compatibility" rel="noopener noreferrer"&gt;https://tanstack.com/redact/latest/docs/compatibility&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/TanStack/redact" rel="noopener noreferrer"&gt;https://github.com/TanStack/redact&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
      <category>performance</category>
    </item>
    <item>
      <title>Google's crawler ran up my AI bill</title>
      <dc:creator>Pushpendra Yadav</dc:creator>
      <pubDate>Tue, 27 Oct 2026 19:19:04 +0000</pubDate>
      <link>https://dev.to/pushpendra2011/googles-crawler-ran-up-my-ai-bill-42c3</link>
      <guid>https://dev.to/pushpendra2011/googles-crawler-ran-up-my-ai-bill-42c3</guid>
      <description>&lt;p&gt;&lt;a href="https://becauseiliked.com/?utm_source=devto&amp;amp;utm_campaign=part6" 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 about money. Last week Anthropic charged my card $10, then $10 again, on days when almost nobody was using the site. My analytics showed a handful of searches. My model bill showed thousands.&lt;/p&gt;

&lt;p&gt;Daily Haiku cost had been cents to about $2 a day. Then &lt;strong&gt;~$7 on Oct 22 and ~$14 on Oct 23&lt;/strong&gt;: two-thirds of the month's $34 in two days. Nobody had come. Something had.&lt;/p&gt;

&lt;p&gt;The short version: &lt;strong&gt;the URLs that cost money were my own fault&lt;/strong&gt;, and four of the five fixes only half worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧾 How a search URL costs money
&lt;/h2&gt;

&lt;p&gt;A search on the site is a few calls to Claude Haiku: one to understand the query (what medium, what era, what the person actually loved), one to write a line on why each top pick fits, one for picks in other media. Results are cached, so a search someone already ran is free. A &lt;em&gt;new&lt;/em&gt; search is not.&lt;/p&gt;

&lt;p&gt;Every title page also has "why chips": one-tap reasons like &lt;em&gt;"shows like Breaking Bad: a decent man slowly turning bad"&lt;/em&gt;. They were plain links:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/?q=shows+like+breaking+bad%3A+a+decent+man+slowly+turning+bad"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now do the maths. About 13,850 title pages, several chips each, every chip a distinct search URL that nobody had run yet. I had built tens of thousands of uncached, paid, crawlable links, and listed every title page in my sitemap so crawlers would find them faster.&lt;/p&gt;

&lt;p&gt;Crawlers did exactly what crawlers do. Each link they followed was a few Haiku calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  🕷️ Fix 1: robots.txt is a request, not a wall
&lt;/h2&gt;

&lt;p&gt;Google Search Console's crawl stats showed Googlebot fetching search URLs by the thousand. My search pages already carried &lt;code&gt;noindex&lt;/code&gt;, and that doesn't help here: Google has to fetch the page to &lt;em&gt;see&lt;/em&gt; the &lt;code&gt;noindex&lt;/code&gt;, and the fetch is the part that costs money.&lt;/p&gt;

&lt;p&gt;So search URLs went into robots.txt:&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;// src/app/robots.ts&lt;/span&gt;
&lt;span class="nx"&gt;disallow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="c1"&gt;// Each new search is a few uncached Haiku calls, and the title pages'&lt;/span&gt;
  &lt;span class="c1"&gt;// why chips link to tens of thousands of them. They carry noindex&lt;/span&gt;
  &lt;span class="c1"&gt;// anyway, so nothing is lost in search.&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/*?q=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Googlebot respects it. The bill didn't go to zero, because not everything that crawls is Googlebot.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧱 Fix 2: firewalls stop the honest bots
&lt;/h2&gt;

&lt;p&gt;What was left came in two kinds: a crawler on AWS IP addresses, and later something on rotating home IPs that ignored robots.txt entirely.&lt;/p&gt;

&lt;p&gt;I added Vercel firewall rules (by user agent, and by AS number for the AWS traffic), then turned on Bot Protection, which challenges anything that doesn't behave like a browser. Each step cut the traffic. None got it to zero.&lt;/p&gt;

&lt;p&gt;That's the limit of identifying bots by &lt;em&gt;what they are&lt;/em&gt;. A scraper on rotating home IPs looks exactly like your users, because it's using the same kind of connection your users have.&lt;/p&gt;

&lt;p&gt;(I also decided not to block bots in general. Search engines bring visitors, and link previews on X, Slack and WhatsApp come from bots too. Your &lt;code&gt;/api/og&lt;/code&gt; route has to stay reachable or every shared link turns into a bare URL.)&lt;/p&gt;

&lt;h2&gt;
  
  
  🧯 Fix 3: a cost ceiling has to fail closed
&lt;/h2&gt;

&lt;p&gt;Next, a ceiling: a site-wide daily cap on search-path model calls, counted in Redis.&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;// src/lib/daily-cap.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;takeSearchCall&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`cap:search-llm:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;used&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&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="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;incr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;never&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;timeout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;used&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;span class="k"&gt;void&lt;/span&gt; &lt;span class="nx"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DailyCapReached&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;counter unavailable&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;DAILY_CALLS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;DailyCapReached&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cap reached&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;Every model call on the search path takes from this allowance first. When it throws, the caller falls back: plain text search instead of understood search, posters without the "why it fits" line.&lt;/p&gt;

&lt;p&gt;The interesting decision is in the &lt;code&gt;catch&lt;/code&gt;. My per-IP rate limiter &lt;strong&gt;fails open&lt;/strong&gt;: if Redis is down, let people search, because a broken rate limiter shouldn't take the site down. The cap &lt;strong&gt;fails closed&lt;/strong&gt;: if the counter can't be read, skip the model call. A cost ceiling that disappears whenever Redis has a bad day isn't a ceiling.&lt;/p&gt;

&lt;p&gt;And then Redis had a bad day. On launch day, my Upstash free tier hit its monthly command limit. Part of the reason was my own debugging: I'd scanned tens of thousands of cache keys while investigating all this. The cap did exactly what I designed it to do. It failed closed, and every search quietly fell back to plain text matching. Someone searched for something like &lt;em&gt;The Sixth Sense&lt;/em&gt; and &lt;em&gt;The Sixth Sense&lt;/em&gt; wasn't in the results.&lt;/p&gt;

&lt;p&gt;Two lessons in one:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fail closed is right for a cost ceiling, but it's only safe if you watch it.&lt;/strong&gt; A fallback that's quiet enough to be a good user experience is also quiet enough to miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A cap can't tell a person from a bot.&lt;/strong&gt; On a heavy scraper day, the bots spend the budget and the real users get the fallback. The cap limits the damage. It doesn't fix the cause.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  🎟️ Fix 4: spend only on a person's action
&lt;/h2&gt;

&lt;p&gt;The fix that actually worked came from changing the question. Not "who is this request from?", which I'd already lost, but &lt;strong&gt;"did a person just do something?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A request may ask the model for something new only if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it's from a signed-in user (an account is a person), or&lt;/li&gt;
&lt;li&gt;it carries a valid &lt;strong&gt;search pass&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Otherwise the page is &lt;strong&gt;cache-only&lt;/strong&gt;: anything already written is shown in full, and nothing new is asked of the model.&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;// src/lib/search-pass.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;modelCallsAllowed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signedIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next-router-prefetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signedIn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;validPass&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PASS_COOKIE&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nx"&gt;value&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;Router prefetches never spend money, pass or not. Next.js prefetches links in view, and a prefetch isn't a person.&lt;/p&gt;

&lt;p&gt;The pass is a cookie: an expiry time and an HMAC signature over it, HttpOnly, valid for 30 minutes.&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;newPass&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;passKey&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;PASS_SECONDS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;exp&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="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exp&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validPass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;passKey&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;exp&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+$/&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;exp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;want&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;got&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;want&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="nx"&gt;got&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;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;want&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;got&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;No database, no session table. The signature means a client can't forge a pass or extend its expiry. &lt;code&gt;timingSafeEqual&lt;/code&gt; means it can't guess one byte by byte either.&lt;/p&gt;

&lt;p&gt;Only a Server Action sets the cookie, and the page calls it only on a person's action: submitting the search box, tapping a why chip, or the first tap or key press of a visit. The chips stopped being links:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
  &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;ensureSearchPass&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To a person, nothing changed: tap a chip, get a search. To anything that only follows links, the searches no longer exist.&lt;/p&gt;

&lt;p&gt;A bot that loads a search URL directly now gets a "Ready when you are" page with a &lt;strong&gt;Show picks&lt;/strong&gt; button, and it costs nothing. Searches that are already cached still render in full, so the pages search engines index lose nothing.&lt;/p&gt;

&lt;p&gt;Is it a fortress? No. Someone could write a scraper for this site that calls the Server Action first. But that raises the bar from "fetch a URL" to "drive the page like a person", and the daily cap is still there as a backstop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The result:&lt;/strong&gt; it shipped on Oct 24. Oct 25 cost cents. I'm not counting the days after, because that's when Redis was out of commands and the cap was blocking everything, so they prove nothing. I'll update this post with a clean week.&lt;/p&gt;

&lt;h2&gt;
  
  
  📋 If you're putting an LLM behind a URL
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Any GET that calls a model is a cost endpoint.&lt;/strong&gt; If anything links to it, assume something will crawl it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make links to expensive pages buttons&lt;/strong&gt;, or gate them behind a gesture. Crawlers follow &lt;code&gt;href&lt;/code&gt;s. They don't click.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Separate "can read answers" from "can create new ones".&lt;/strong&gt; Cache-only mode gave me both: full pages for search engines, no spend for bots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiters fail open, cost ceilings fail closed.&lt;/strong&gt; And alert on the one that fails closed, because it will be quiet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your diagnostics cost money too.&lt;/strong&gt; Scanning cache keys used up a real slice of my Redis free tier.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The irony of this post: everything that went wrong here, I designed myself, including the clever chip links that made the product better for people and a feast for crawlers.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The site is &lt;a href="https://becauseiliked.com/?utm_source=devto&amp;amp;utm_campaign=part6" rel="noopener noreferrer"&gt;Because I liked&lt;/a&gt;: describe what you loved and why, and get films, shows, books and games matched on the why. Try a search. You're a person, so this one's allowed to cost me money 🙂&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>security</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>AI Made Everything Look the Same. That’s the Frontend’s Best News in a Decade.</title>
      <dc:creator>Pushpendra Yadav</dc:creator>
      <pubDate>Wed, 21 Oct 2026 09:34:23 +0000</pubDate>
      <link>https://dev.to/pushpendra2011/ai-made-everything-look-the-same-thats-the-frontends-best-news-in-a-decade-5e6m</link>
      <guid>https://dev.to/pushpendra2011/ai-made-everything-look-the-same-thats-the-frontends-best-news-in-a-decade-5e6m</guid>
      <description>&lt;h2&gt;
  
  
  You can spot it in two seconds
&lt;/h2&gt;

&lt;p&gt;Open any landing page launched in the last year and give yourself two seconds. You already know whether a person made it.&lt;/p&gt;

&lt;p&gt;The gradient hero. The three feature cards in a row, each with a tiny icon in a tinted circle. Everything rounded exactly the same amount. A "Get started" button, a "See how it works" button next to it in ghost style. Inter, or whatever Inter is wearing this month. A purple that fades into a blue. Scroll and there's a logo wall of companies that have never heard of the product.&lt;/p&gt;

&lt;p&gt;None of this is wrong. That's the uncomfortable part. Every one of those choices is defensible on its own. Together they produce a page that could belong to any of ten thousand companies, and your brain files it under "nobody was here" before you've read a word.&lt;/p&gt;

&lt;p&gt;People started calling this AI slop somewhere around 2024 and it stuck, because it names a feeling we all had and couldn't explain. Same thing happened with AI images a year earlier. The hands got fixed. The skin got fixed. And still, you can tell.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everyone got the order wrong
&lt;/h2&gt;

&lt;p&gt;When the image models landed, the joke was that designers were first in line. Then Copilot and Cursor and the rest showed up and the joke moved to frontend. Backend was supposed to be safe. Distributed systems, consistency guarantees, all that hard stuff. The real engineering.&lt;/p&gt;

&lt;p&gt;I've spent twelve years on the frontend side of that line and I think it's backwards.&lt;/p&gt;

&lt;p&gt;Here is the claim, and the rest of this post is me trying to earn it: the more AI makes everything look the same, the more the interface becomes the only part of a product that can still be different. Backend converging on sameness is a feature. Frontend converging on sameness is a funeral.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backend is the easier target, and nobody minds
&lt;/h2&gt;

&lt;p&gt;Think about what makes a task easy for a model. It wants a well-documented problem, a known good shape for the answer, and a way to check whether it got it right.&lt;/p&gt;

&lt;p&gt;Backend has all three.&lt;/p&gt;

&lt;p&gt;The patterns are written down. Not just written down, written down by the people who scaled them. Every big tech engineering blog is a free textbook on queues, caches, sharding, idempotency, retries. The reasoning behind each decision is public. There's a word for the ideal backend and it is "boring". Boring is a compliment. Nobody opens a status page and thinks "I wish this Postgres felt more personal".&lt;/p&gt;

&lt;p&gt;Correctness is checkable. Tests pass or they don't. p99 is under the budget or it isn't. The contract is the OpenAPI spec and the model can read it.&lt;/p&gt;

&lt;p&gt;And the user never sees it. This is the big one. If your API is shaped like Stripe's and your queue is shaped like everyone's queue, your customer will never know and would not care if they did. Sameness costs you nothing here. It is literally best practice.&lt;/p&gt;

&lt;p&gt;Someone will say "try vibe-coding a consensus protocol". Fair. The hard five percent of backend stays hard. It also lives in maybe fifty companies on earth. The median backend is a CRUD app with a job runner and a cache, and that shape has been solved so many times the model has seen it from every angle.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I saw when I built the pipeline myself
&lt;/h2&gt;

&lt;p&gt;I'm not arguing from the sidelines. I built a Figma-to-code pipeline on top of MCP. Designer finishes a frame, the pipeline reads it, maps it onto our existing component library, and hands back a working React screen that uses our buttons and our inputs and our spacing tokens instead of inventing new ones.&lt;/p&gt;

&lt;p&gt;It worked. Better than I expected. Settings pages, forms, tables, modals, the whole middle of the app came out usable on the first pass most of the time. The kind of screen a mid-level engineer would have spent a day and a half on became a twenty-minute review.&lt;/p&gt;

&lt;p&gt;Here's what it never did well, and I watched it try for months.&lt;/p&gt;

&lt;p&gt;It could not decide what was worth being different. Hand it a frame where the designer had done something slightly odd on purpose, a card that breaks the grid, a transition that lingers a beat longer than it should, and it would quietly regularise it. Snap the card back to the grid. Use the default easing. Not because it was wrong, because the odd thing didn't match anything in the library and the safest move was to pick the nearest known shape.&lt;/p&gt;

&lt;p&gt;That is the exact behaviour that produces slop. Not errors. Safety. A thousand locally reasonable choices that add up to a page with no fingerprints on it.&lt;/p&gt;

&lt;p&gt;The human in the loop wasn't there to fix bugs. The human was there to say "no, leave that weird, it's the point".&lt;/p&gt;

&lt;h2&gt;
  
  
  Taste has no spec
&lt;/h2&gt;

&lt;p&gt;Now run the backend checklist against the frontend.&lt;/p&gt;

&lt;p&gt;Well-documented problem? Sort of. There are design systems and WCAG and a hundred "best practices" posts. But none of them tell you what your product should feel like. There is no engineering blog that explains why Linear feels like Linear.&lt;/p&gt;

&lt;p&gt;Known good shape? That's the trap. There is a known good shape. It's the one from the first section. Following it is how you end up looking like everyone.&lt;/p&gt;

&lt;p&gt;A way to check? A user looks at the screen for two hundred milliseconds and decides whether they trust you. There's no test for that. There's no p99 for "this looks like somebody cared".&lt;/p&gt;

&lt;p&gt;And this is the part that holds up even as the models get better. Being distinctive is relative. The moment a style is learnable and common, it stops being distinctive by definition. Whatever the model converges on becomes the new default, and the new default is exactly what people learn to see through. The target moves every time it's hit. A model that nails today's taste is producing tomorrow's tell.&lt;/p&gt;

&lt;p&gt;Meanwhile features have become table stakes. Anyone can clone your feature list over a weekend now. Nobody is choosing you for having a dashboard. What's left to choose on is how it feels to use the thing, and that is the surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Imperfect is not the same as bad
&lt;/h2&gt;

&lt;p&gt;I keep hearing "people want human imperfection in UI now" and I want to be careful with it, because it's half right and the wrong half is dangerous.&lt;/p&gt;

&lt;p&gt;Nobody wants a misaligned button. Nobody misses the 2009 web.&lt;/p&gt;

&lt;p&gt;What people respond to is intention. A choice that a template would never make, made on purpose, and then committed to. Linear's keyboard-first everything. Arc's sidebar that refused to look like a browser. Raycast's little animations that nobody asked for. These are not imperfect products. They're extremely polished. They're just polished in a direction nobody else picked.&lt;/p&gt;

&lt;p&gt;The tell for slop isn't ugliness. It's the absence of a decision. You look at a page and can't find a single spot where someone chose the harder option because they liked it better.&lt;/p&gt;

&lt;p&gt;So if you're a frontend person reading this and feeling relieved, don't get the wrong idea. The job isn't to add quirks. The job is to have opinions and ship them. That was always the job. It's just that for a while, shipping fast could cover for not having any. It can't anymore, because fast is free now.&lt;/p&gt;

&lt;h2&gt;
  
  
  "The future has no UI"
&lt;/h2&gt;

&lt;p&gt;There's a camp that says all of this is moot because the interface is going away. You'll talk to an agent, it'll call the APIs, work gets done, no screens. Who needs a frontend when you can just ask.&lt;/p&gt;

&lt;p&gt;I'd buy it if I knew what I wanted every time I opened an app. I don't. Most people don't.&lt;/p&gt;

&lt;p&gt;Think about how you actually use the big consumer products. You open Spotify without a song in mind. You open YouTube without a query. You open a shopping site to see what's there. You scroll Instagram to be shown things. Half the money in consumer software comes from people who arrived with no intent and left having found something. That's discovery, and discovery is a browsing problem.&lt;/p&gt;

&lt;p&gt;Agents are great at retrieval. "Book me the 7am flight." "Find the invoice from March." Clear ask, clear answer, no screen needed, fine. But a chat box is a terrible place to not know what you want. A blank input with a blinking cursor is the least helpful interface ever designed for someone who hasn't decided yet. A good grid of options beats it every time.&lt;/p&gt;

&lt;p&gt;So yes, a chunk of the interface will disappear, the chunk that was only ever a form in front of an API. That chunk was already the boring part. What's left is the part where someone has to be shown something they didn't ask for and be glad about it. That part gets more important, not less.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the value goes
&lt;/h2&gt;

&lt;p&gt;When building something is free, the value moves to whatever isn't. I count three things: distribution, trust, and the way the product feels in your hands. Two of those three live in the interface. A page that looks generated now reads as "we didn't care enough to be here", and that's a trust problem before it's a design problem.&lt;/p&gt;

&lt;p&gt;I'll put a prediction down so you have something to argue with. Within two years, "must not look AI-generated" shows up as a line item in design briefs, written down, the way "must be mobile responsive" did ten years ago. And somewhere right now, someone is building a product whose entire edge is that it looks like a person made it. I'd bet on them.&lt;/p&gt;

&lt;p&gt;Backend engineers, you're fine. Boring was always the goal, and you're about to get very good at it very cheaply.&lt;/p&gt;

&lt;p&gt;Frontend people, the bar just moved. Not down. Up, and sideways, to a place the models can't follow because the moment they get there it stops being the place.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>frontend</category>
      <category>discuss</category>
    </item>
    <item>
      <title>My semantic search recommended Sharktopus to Jaws fans</title>
      <dc:creator>Pushpendra Yadav</dc:creator>
      <pubDate>Tue, 20 Oct 2026 12:58:45 +0000</pubDate>
      <link>https://dev.to/pushpendra2011/my-semantic-search-recommended-sharktopus-to-jaws-fans-1lig</link>
      <guid>https://dev.to/pushpendra2011/my-semantic-search-recommended-sharktopus-to-jaws-fans-1lig</guid>
      <description>&lt;p&gt;I run a recommendation site. You type something you loved and why, and it finds films, shows, books and games that match. Under the hood it's the standard 2026 stack: an LLM reads the query, an embedding model turns it into a vector, Postgres with pgvector finds the nearest titles.&lt;/p&gt;

&lt;p&gt;Last week I opened the page for &lt;em&gt;Jaws&lt;/em&gt;. Under "More films like Jaws":&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Sand Sharks. Toxic Shark. Sharktopus.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nobody who loved &lt;em&gt;Jaws&lt;/em&gt; wants &lt;em&gt;Sharktopus&lt;/em&gt;. Well, almost nobody.&lt;/p&gt;

&lt;p&gt;The embeddings weren't broken. They were doing exactly what embeddings do, which isn't quite what the word "semantic" promises. Here are five ways my search matched words instead of meaning, how each one showed up, and what fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The name is in the vector
&lt;/h2&gt;

&lt;p&gt;&lt;em&gt;Hollow Knight&lt;/em&gt; is a hard, lonely, beautiful game about exploring a ruined kingdom. Its nearest neighbours included &lt;em&gt;Knights in the Nightmare&lt;/em&gt; and &lt;em&gt;The Knight Witch&lt;/em&gt;. &lt;em&gt;Kal Ho Naa Ho&lt;/em&gt;, a Bollywood weepie, sat next to &lt;em&gt;Kuch Naa Kaho&lt;/em&gt; and &lt;em&gt;Kaho Naa… Pyaar Hai&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;None of those are alike. They share &lt;strong&gt;words in the title&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I did this to myself. Every item is embedded from a text that starts with its name:&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;// so that queries naming a work ("like Arrival") land near it&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;year&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;item&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="s2"&gt;. &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line made sense at the time, and it does help a query that names a title. But a title is a few heavily weighted tokens at the very start of the text, and two works that share them get pulled together no matter what the rest says.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix I shipped&lt;/strong&gt; (the root fix is re-embedding without titles, which means recomputing everything): when a search starts from a title, demote results whose only tie to it is a shared word.&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="nx"&gt;titleWords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&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;{L}&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="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;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;w&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;4&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;SMALL_WORDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/s$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;// "Knights" → "knight"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;echoes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;titleWords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;anchorWords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="nx"&gt;results&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;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;echoes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;results&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;echoes&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Demote, don't drop: sometimes a shared word is a real connection. The four-letter minimum is a deliberate trade-off. It catches "knight", but not "naa", because a rule loose enough to catch "naa" would also catch "man" and "war".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check yours:&lt;/strong&gt; take ten items and look at their nearest neighbours. If the neighbours share words in the name more often than they should, your names are in your vectors.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The nearest neighbour of anything is its sequel
&lt;/h2&gt;

&lt;p&gt;"Movies like Jaws" returned &lt;em&gt;Jaws 2&lt;/em&gt;, &lt;em&gt;Jaws 3-D&lt;/em&gt; and &lt;em&gt;Jaws: The Revenge&lt;/em&gt; in the top five. "Games like Hollow Knight" returned its own DLC.&lt;/p&gt;

&lt;p&gt;Of course it did. A sequel shares the setting, the tone, the characters, half the description and the name. It's the most similar thing in the catalog, and the least useful recommendation: anyone asking for "movies like Jaws" already knows Jaws 2 exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; a blunt franchise key (the first two meaningful words of the title, minus subtitle and trailing number), so that a search starting from a title leaves out the title's own franchise and keeps one result per franchise:&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;Jaws: The Revenge&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// "jaws"&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 Witcher 3: Wild Hunt - Hearts of Stone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// "witcher"&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;Jaws 3-D&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// "jaws 3"  ← my first version. The "-D" broke the number rule.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is real. The test I added after finding it is two lines long. Write the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The LLM rewrote the user's intent away
&lt;/h2&gt;

&lt;p&gt;This one took me the longest to see, because the results looked fine.&lt;/p&gt;

&lt;p&gt;Search &lt;strong&gt;"movies like Interstellar"&lt;/strong&gt; and you get 2001, Ad Astra, Gravity, Arrival. Good.&lt;/p&gt;

&lt;p&gt;Search &lt;strong&gt;"movies like Interstellar: the ache of leaving children behind"&lt;/strong&gt; and you get… 2001, Ad Astra, Gravity, Arrival. The same list, in nearly the same order. The part the user actually cared about changed nothing.&lt;/p&gt;

&lt;p&gt;My pipeline has an LLM step that rewrites the query into a richer description before embedding it. Here's what it produced:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"Vast, awe-filled space drama with a melancholy ache about leaving children behind; sweeping, emotionally weighted, a father's sacrifice… set against cosmic scale, somber and contemplative with a big orchestral score."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The user wrote eight words of why. The rewrite buried them in forty words describing &lt;em&gt;Interstellar&lt;/em&gt;. Then my code blended the query vector 50/50 with Interstellar's own vector on top. The why was drowned twice.&lt;/p&gt;

&lt;p&gt;My first instinct was the wrong one: turn down the blend. At 25% and at 15%, the results barely moved. I was tuning a knob that wasn't the cause.&lt;/p&gt;

&lt;p&gt;My second instinct was also wrong: fix the prompt ("when a title is named, describe only the why"). The Interstellar results improved, and my eval score &lt;strong&gt;fell from 37% to 34%&lt;/strong&gt;. A prompt change shifts how the model reads &lt;em&gt;every&lt;/em&gt; query, including the 28 that never named a title.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix that worked&lt;/strong&gt; was in code, not the prompt. When a query names a title &lt;em&gt;and&lt;/em&gt; says why, embed the why in the user's own words and let the title's vector supply the rest:&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="nx"&gt;why&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;whyBeyondTitles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;references&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;// "Movies like Interstellar: the ache…" → "the ache of leaving children behind"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;why&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;embedQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;why&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;embedQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;understoodText&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nx"&gt;queryEmbedding&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;blend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;titleVector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the same search leads with &lt;em&gt;Leave the World Behind&lt;/em&gt;, &lt;em&gt;Children of Men&lt;/em&gt; and &lt;em&gt;E.T.&lt;/em&gt; And every eval case scored exactly what it scored before, because a query that doesn't name a title never reaches this code path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; an LLM rewrite step is a lossy step. Print what it produces for your hardest queries, not your typical ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The average said fine. The cases said otherwise.
&lt;/h2&gt;

&lt;p&gt;My database was too small to keep the vectors in memory, so I tested shrinking them. OpenAI's &lt;code&gt;text-embedding-3-small&lt;/code&gt; is trained so you can keep only the first N dimensions, and 768 instead of 1,536 halves the size. Half precision (2 bytes a number instead of 4) halves it too.&lt;/p&gt;

&lt;p&gt;My eval (28 real-style queries, recall@12) said:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&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;Full vectors&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;768 dimensions, half precision&lt;/td&gt;
&lt;td&gt;35%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three points. Probably noise, right?&lt;/p&gt;

&lt;p&gt;Then I read the individual cases. The post-apocalyptic story game search lost &lt;strong&gt;both&lt;/strong&gt; Last of Us games. The political fantasy search went from 75% to 25%, losing House of the Dragon and The Witcher. Those aren't reshuffles near the cutoff. Those are &lt;em&gt;the answers&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Half precision at full length (&lt;code&gt;halfvec(1536)&lt;/code&gt; in pgvector), by contrast, changed &lt;strong&gt;zero&lt;/strong&gt; of 28 cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The lesson:&lt;/strong&gt; report "up / down / flat" per case next to the mean, and read the downs. My eval script now prints this on every run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vs last run on the same 28 cases: 38% → 35% · 4 up, 5 down, 19 flat
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A mean can hide one disaster behind three small wins. A list of the five worse cases can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. "Nearest" isn't "good"
&lt;/h2&gt;

&lt;p&gt;I wanted a row under each search: &lt;em&gt;"the same feeling, in other media"&lt;/em&gt;. Searched for a TV show? Here's the book and the game that give you the same thing. Nobody else does this, and it's a great demo.&lt;/p&gt;

&lt;p&gt;Searched &lt;strong&gt;"shows like Breaking Bad: a decent man slowly turning bad"&lt;/strong&gt;. Nearest film: &lt;strong&gt;&lt;em&gt;Breakdown&lt;/em&gt;&lt;/strong&gt;. Nearest game: &lt;strong&gt;&lt;em&gt;Downfall&lt;/em&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A shared syllable and a vibe of things going badly. Across media it gets worse, because books, films and games are described in different vocabularies, so the nearest item from another medium is often the one that shares a word with the query rather than a meaning.&lt;/p&gt;

&lt;p&gt;I tried a similarity floor: only show an item if it's at least X% as close as the main results. At 88%, the junk got through. At 90%, the junk still got through, and good picks started dropping out. Similarity scores across domains just aren't calibrated against each other.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; retrieval proposes, an LLM disposes. The three nearest items from each medium go to a small model with one job, and it's explicitly allowed to say no:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Keep only candidates that genuinely give the user the same thing they
asked for... Sharing a word, a setting or a genre is not enough. Be
strict: keeping none is fine, and better than keeping a stretch.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking Bad now gets &lt;em&gt;The Iceman&lt;/em&gt; ("a seemingly ordinary family man living a double life") and &lt;em&gt;Darkly Dreaming Dexter&lt;/em&gt; ("a respectable professional hiding a dark nature"). A courtroom drama gets &lt;em&gt;Phoenix Wright: Ace Attorney&lt;/em&gt;. A heist film gets &lt;em&gt;Six of Crows&lt;/em&gt; and &lt;em&gt;Hitman 3&lt;/em&gt;. It costs about $0.0002 a search, and when nothing fits, the row just doesn't render.&lt;/p&gt;

&lt;p&gt;The line that matters is "keeping none is fine". Without it, the model fills every slot, because you asked for slots.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;All five failures look the same from the outside: results that are &lt;em&gt;close&lt;/em&gt; and &lt;em&gt;wrong&lt;/em&gt;. And all five came from the same assumption, that vector similarity measures the thing you care about. It measures overlap in the text you embedded. Sometimes that's meaning. Often it's names, sequels, the LLM's paraphrase, or vocabulary.&lt;/p&gt;

&lt;p&gt;What actually caught these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Looking at neighbours by hand&lt;/strong&gt;, for the items you know best. Every one of these I found by looking at a page, not at a metric.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small eval with per-case diffs&lt;/strong&gt;, run on every change. Mine is 30 queries. It doesn't need to be big. It needs to be read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Printing the intermediate text.&lt;/strong&gt; The rewrite step was the bug, and it was invisible until I printed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Letting a model say "none".&lt;/strong&gt; Retrieval is good at candidates and bad at refusing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And keep your sense of humour: it really did recommend Sharktopus.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The site is &lt;a href="https://becauseiliked.com/?utm_source=devto&amp;amp;utm_campaign=sharktopus" rel="noopener noreferrer"&gt;Because I liked&lt;/a&gt;: describe what you loved and why, and get films, shows, books and games matched on the why. Try a title with a reason, "movies like Jaws: the dread of what's under the water", and tell me what it gets wrong. That's how I find these.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>machinelearning</category>
      <category>programming</category>
    </item>
    <item>
      <title>My database was too small for my search</title>
      <dc:creator>Pushpendra Yadav</dc:creator>
      <pubDate>Sun, 18 Oct 2026 17:01:40 +0000</pubDate>
      <link>https://dev.to/pushpendra2011/my-database-was-too-small-for-my-search-4ngg</link>
      <guid>https://dev.to/pushpendra2011/my-database-was-too-small-for-my-search-4ngg</guid>
      <description>&lt;p&gt;&lt;a href="https://becauseiliked.com/?utm_source=devto&amp;amp;utm_campaign=part4" 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. Under the hood it's OpenAI embeddings in Postgres with pgvector, on Supabase's paid instance. (&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; is 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; is how I measure it. This one stands on its own.)&lt;/p&gt;

&lt;p&gt;For a week, real searches kept failing with &lt;em&gt;"Search is having trouble right now."&lt;/em&gt; This is how I found out why, the three fixes that didn't work, and the one that did, at no cost and with no loss in quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  🐢 The symptom
&lt;/h2&gt;

&lt;p&gt;The catalog has 13,826 titles, each with a 1,536-dimension embedding. A search is one SQL function call that finds the nearest vectors, optionally filtered ("films only", "books only").&lt;/p&gt;

&lt;p&gt;Same four searches, run back to back against production:&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;Run 1&lt;/th&gt;
&lt;th&gt;Run 2&lt;/th&gt;
&lt;th&gt;Run 3&lt;/th&gt;
&lt;th&gt;Run 4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Films only&lt;/td&gt;
&lt;td&gt;3.7 s&lt;/td&gt;
&lt;td&gt;2.2 s&lt;/td&gt;
&lt;td&gt;1.4 s&lt;/td&gt;
&lt;td&gt;5.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Books only&lt;/td&gt;
&lt;td&gt;1.2 s&lt;/td&gt;
&lt;td&gt;1.1 s&lt;/td&gt;
&lt;td&gt;4.9 s&lt;/td&gt;
&lt;td&gt;1.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All media&lt;/td&gt;
&lt;td&gt;2.3 s&lt;/td&gt;
&lt;td&gt;0.6 s&lt;/td&gt;
&lt;td&gt;0.4 s&lt;/td&gt;
&lt;td&gt;1.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Not consistently slow. &lt;em&gt;Erratic.&lt;/em&gt; And the anonymous role's statement timeout is about 8 seconds, so the bad runs became errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  ❌ Fix 1: rebuild the index
&lt;/h2&gt;

&lt;p&gt;The HNSW index was 192 MB. The instance's &lt;code&gt;shared_buffers&lt;/code&gt; (Postgres's own cache) is 224 MB. After I deleted a batch of experimental vectors, the index was carrying a lot of dead weight, so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;reindex&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;concurrently&lt;/span&gt; &lt;span class="n"&gt;item_embeddings_vector_idx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;192 MB → 108 MB. Searches were still slow.&lt;/p&gt;

&lt;p&gt;That's when I re-read my own search function and remembered something: &lt;strong&gt;filtered searches don't use the index at all.&lt;/strong&gt; Months ago, a filtered HNSW search ("books only" when the nearest neighbours are all films) walked thousands of rejects and timed out, so filtered calls switched to an exact scan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;match_medium&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;perform&lt;/span&gt; &lt;span class="n"&gt;set_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'enable_indexscan'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'off'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 14k rows an exact scan is a few million multiply-adds, which is nothing. The CPU wasn't the problem. Reading the rows was.&lt;/p&gt;

&lt;h2&gt;
  
  
  ❌ Fix 2: move the vectors out of TOAST
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;vector(1536)&lt;/code&gt; is about 6 KB. Postgres moves any value over about 2 KB out of the row into a side table (TOAST), so every row of my exact scan was a separate lookup into that side table. And it was bloated:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Relation&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;item_embeddings&lt;/code&gt; (the rows)&lt;/td&gt;
&lt;td&gt;1.9 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;its TOAST table&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;171 MB&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;171 MB for about 85 MB of live vectors, because deleted rows leave space behind that autovacuum marks reusable but never gives back.&lt;/p&gt;

&lt;p&gt;A 6 KB vector still fits in an 8 KB page, so you can tell Postgres to keep it inline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;item_embeddings&lt;/span&gt; &lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt; &lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;vacuum&lt;/span&gt; &lt;span class="k"&gt;full&lt;/span&gt; &lt;span class="n"&gt;item_embeddings&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;-- rewrites the table so existing rows move&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Supabase gotcha: run those as &lt;strong&gt;two separate executions&lt;/strong&gt;. The SQL editor wraps a multi-statement run in a transaction, and you get &lt;code&gt;VACUUM cannot run inside a transaction block&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Result: rows 108 MB, TOAST 0 bytes. A clean sequential scan. Searches: &lt;strong&gt;still erratic.&lt;/strong&gt; Films-only timed out twice in a row.&lt;/p&gt;

&lt;h2&gt;
  
  
  ❌ Fix 3: keep it warm
&lt;/h2&gt;

&lt;p&gt;If the data falls out of cache, read it back in on a schedule: &lt;code&gt;pg_prewarm&lt;/code&gt; every five minutes from &lt;code&gt;pg_cron&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This made things worse, or at best did nothing. Here's the measurement that explained everything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Read&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fetch 1 row&lt;/td&gt;
&lt;td&gt;75–230 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Walk 9,000 vector rows, first time&lt;/td&gt;
&lt;td&gt;3.6 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Same walk, immediately after&lt;/td&gt;
&lt;td&gt;0.14 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The database is fast when the data is in memory, and &lt;strong&gt;the data doesn't stay in memory.&lt;/strong&gt; Search needs 108 MB of rows plus a 108 MB index, about 216 MB, on a machine where Postgres, the auth service, the API layer and the OS all share a small amount of RAM. Pages get evicted between searches. Prewarming a working set that doesn't fit just re-reads it from a throttled disk every five minutes, competing with the searches it was meant to help. I turned it off.&lt;/p&gt;

&lt;p&gt;The honest fix is a bigger machine. I wanted to try the free one first: &lt;strong&gt;a smaller working set.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🧪 Two ways to shrink a vector
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fewer dimensions.&lt;/strong&gt; &lt;code&gt;text-embedding-3-small&lt;/code&gt; is trained so the leading dimensions carry most of the meaning; OpenAI's &lt;code&gt;dimensions&lt;/code&gt; parameter is literally truncation. And cosine distance ignores length, so you don't even need to re-normalise. Keep the first 768 at half precision: &lt;strong&gt;~1.5 KB per vector, ~50 MB all in.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lower precision.&lt;/strong&gt; pgvector's &lt;code&gt;halfvec&lt;/code&gt; stores each number in 2 bytes instead of 4. Same 1,536 dimensions: &lt;strong&gt;~3 KB per vector, ~110 MB all in.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Neither needs a single new API call. Both are a cast in SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;subvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embedding&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;768&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;halfvec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;-- fewer dimensions&lt;/span&gt;
&lt;span class="n"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;halfvec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="c1"&gt;-- lower precision&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I didn't want to guess which one loses quality, so I ran my eval set on both. It has 28 real-style searches, each with titles that should come back, scored by recall@12 against the same pipeline the site runs.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Mean recall@12&lt;/th&gt;
&lt;th&gt;vs full vectors&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Full (&lt;code&gt;vector(1536)&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;216 MB&lt;/td&gt;
&lt;td&gt;38%&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;768 dims, half precision&lt;/td&gt;
&lt;td&gt;~50 MB&lt;/td&gt;
&lt;td&gt;35%&lt;/td&gt;
&lt;td&gt;4 up, 5 down&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1536 dims, half precision&lt;/td&gt;
&lt;td&gt;~110 MB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;38%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0 up, 0 down, 28 flat&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 768 version looked fine on average and wasn't fine when I read the cases. &lt;em&gt;"A post-apocalyptic story game"&lt;/em&gt; lost &lt;strong&gt;both&lt;/strong&gt; Last of Us games. &lt;em&gt;"Political fantasy with dragons"&lt;/em&gt; went from 75% to 25%, dropping House of the Dragon and The Witcher. Those aren't reshuffles; they're the answers.&lt;/p&gt;

&lt;p&gt;Half precision changed nothing on all 28 cases. Same hits, same ranks.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔧 Shipping it without a migration
&lt;/h2&gt;

&lt;p&gt;I didn't want to touch the table the embedding scripts write to. So the half-precision copy is a separate table, kept in sync by a trigger:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;item_vectors&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;item_id&lt;/span&gt;           &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;embedding_version&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;embedding&lt;/span&gt;         &lt;span class="n"&gt;halfvec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;embedding_version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- 3 KB is still over the TOAST threshold: inline from the start&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;item_vectors&lt;/span&gt; &lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt; &lt;span class="n"&gt;plain&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="n"&gt;item_embeddings_sync_vector&lt;/span&gt;
  &lt;span class="k"&gt;after&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="n"&gt;embedding&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;item_embeddings&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;each&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;sync_item_vector&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The search function is a copy of the old one with the same signature, reading the new table and casting the query vector the same way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;declare&lt;/span&gt;
  &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="n"&gt;halfvec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;query_embedding&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;halfvec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app picks the function by name, with an environment variable override, so rolling back is one setting in Vercel, not a deploy:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MATCH_ITEMS_FN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MATCH_ITEMS_FN&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;match_items_short&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The offline job that precomputes "More like this" lists still uses the full vectors. It runs at night and can afford the bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  📉 Result
&lt;/h2&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;Before (warm)&lt;/th&gt;
&lt;th&gt;After, cold&lt;/th&gt;
&lt;th&gt;After, warm&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Films only&lt;/td&gt;
&lt;td&gt;1.4–5.6 s, some timeouts&lt;/td&gt;
&lt;td&gt;5.3 s&lt;/td&gt;
&lt;td&gt;0.3–0.5 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Books only&lt;/td&gt;
&lt;td&gt;1.0–4.9 s&lt;/td&gt;
&lt;td&gt;0.9 s&lt;/td&gt;
&lt;td&gt;0.1–0.4 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TV only&lt;/td&gt;
&lt;td&gt;1.0–4.8 s&lt;/td&gt;
&lt;td&gt;1.8 s&lt;/td&gt;
&lt;td&gt;0.1–0.2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cost: $0, plus a few cents of eval runs. The first search after a long quiet spell is still slow, which is what a small machine does. But it's under the timeout now, and the retry path covers the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  📎 While I was in there: Vercel's free tier
&lt;/h2&gt;

&lt;p&gt;The same week, Vercel's dashboard said I'd gone past two Hobby-plan limits. The one that mattered was &lt;strong&gt;ISR writes: 218K of 200K.&lt;/strong&gt; Every regeneration of a cached page counts, and I had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;14,000 title pages regenerating &lt;strong&gt;daily&lt;/strong&gt;, and crawlers visiting all of them;&lt;/li&gt;
&lt;li&gt;a deploy after nearly every commit, each one throwing the cached pages away;&lt;/li&gt;
&lt;li&gt;a "where to watch" lookup cached per title per day, called for every pick in every search.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fixes: title pages regenerate weekly (they only change when "More like this" is recomputed), the watch-provider cache went to three days, AI training crawlers are disallowed in &lt;code&gt;robots.txt&lt;/code&gt; (search crawlers stay allowed: they send people), and I now deploy from a staging branch in batches.&lt;/p&gt;

&lt;h2&gt;
  
  
  🔍 And: the slow users weren't the ones I tested for
&lt;/h2&gt;

&lt;p&gt;Lighthouse simulates a mid-range phone, and put the home page's LCP at 3.4 s. Real phones agreed: PostHog's Web Vitals said 3.4 s at p75. Real &lt;em&gt;desktops&lt;/em&gt; said &lt;strong&gt;10.3 s&lt;/strong&gt;, mostly Chrome on Windows. I'd never have looked there. The sample is small (about 170 page loads), but a blank page for that long is a lost visitor, whatever the sample size.&lt;/p&gt;

&lt;p&gt;Two things were in the way:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The landing page waited for a database read before sending anything.&lt;/strong&gt; It's rendered per request, and it fetched the curated poster shelf (the same for everyone) before streaming a byte. On a slow-database day, the heading and the search box waited too. Now the shelf is cached for a day and streamed behind its own &lt;code&gt;Suspense&lt;/code&gt; boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Too much JavaScript before the page could settle.&lt;/strong&gt; PostHog (about 100 KB) loaded with the page; now it loads when the browser goes idle, and events sent before that are queued, not dropped. supabase-js (about 60 KB) loaded on every title page just to ask &lt;em&gt;"is anyone signed in?"&lt;/em&gt;. Signed-out visitors have no session cookie, so now they skip the download entirely.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Initial JavaScript, uncompressed: &lt;strong&gt;title page 1,381 → 850 KB, home 1,623 → 1,314 KB.&lt;/strong&gt; I'll report the real-user numbers when there's a week of them, not before.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧭 What I'd tell myself a month ago
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure the read, not the query.&lt;/strong&gt; "1 row in 100 ms, 9,000 rows in 3.6 s cold and 0.14 s warm" told me more than any &lt;code&gt;EXPLAIN&lt;/code&gt; did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prewarming only helps if the working set fits.&lt;/strong&gt; If it doesn't, it's a cron job that thrashes the cache on a schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shrinking embeddings is a quality decision, so treat it like one.&lt;/strong&gt; Truncating dimensions looked harmless on the mean and dropped the right answer on two searches. Half precision didn't move a single case. I'd never have known without an eval set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lab scores aren't your users.&lt;/strong&gt; Lighthouse matched my phone visitors exactly and said nothing about the desktop ones, who were three times slower.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  🙏 Try 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=part4" 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;"a post-apocalyptic story game where the relationship matters more than the combat"&lt;/em&gt;, the search the 768-dimension version got wrong. If you run pgvector on a small instance, I'd love to hear what your working set looks like, and whether &lt;code&gt;halfvec&lt;/code&gt; held up on your eval.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>postgres</category>
    </item>
    <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>
