<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Search And Recomendations]]></title><description><![CDATA[Search And Recomendations]]></description><link>https://oleber.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 04 Nov 2026 17:33:51 GMT</lastBuildDate><atom:link href="https://oleber.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Cheaper, but not much cheaper — Recommendation slots that stopped eating the margin]]></title><description><![CDATA[Every product page on the shop had a strip of recommended alternatives under the "Add to cart" button. It held twenty products, but only the first five…]]></description><link>https://oleber.hashnode.dev/cheaper-but-not-much-cheaper-recommendation-slots-that-stopped-eating-the-margin</link><guid isPermaLink="true">https://oleber.hashnode.dev/cheaper-but-not-much-cheaper-recommendation-slots-that-stopped-eating-the-margin</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sat, 31 Oct 2026 12:52:21 GMT</pubDate><content:encoded><![CDATA[<p>Every product page on the shop had a strip of recommended alternatives under the "Add to cart" button. It held twenty products, but only the first five were visible without scrolling, and those five did almost all the work.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/a5400a74-3b80-4ed6-8807-581845addabd.png" alt="" style="display:block;margin:0 auto" />

<h2>The cheap side won</h2>
<p>The recommender was asked for the twenty alternatives most likely to lead to a purchase, and the strip showed them in that order. Nothing told it to balance <em>upsells</em>, alternatives that cost more than the product on screen, against <em>downsells</em>, alternatives that cost less.</p>
<p>Its training data had a strong pattern: shoppers <em>look</em> at expensive things and <em>buy</em> cheaper ones. The recommender learned it well. The quickest route to a purchase was a cheaper alternative, so the downsells won. The five visible slots filled with cheaper products, often much cheaper, while the pricier alternatives sat far to the right, where few people scrolled.</p>
<p>Clicks and purchases from the strip went up. Then finance asked why the margin on those orders had gone down. A €120 machine bought from a €400 page is not the same sale as a €380 one, and cheaper products usually carry lower margins. The recommender had maximised purchases, exactly as asked, while pulling revenue out of the shop.</p>
<h2>Two kinds of slot, two different jobs</h2>
<p>The fix started with a question nobody had asked: what are the five visible slots <em>for</em>?</p>
<p><strong>Downsells are reassurance.</strong> A shopper hesitating on a €400 machine is asking "am I overpaying?" A couple of similar machines priced just under it answer that: the price is fair, there's no hidden bargain. Many of those shoppers then buy the original. A <em>much</em> cheaper downsell does the opposite and sends them away with a low-margin item.</p>
<p><strong>Upsells are a choice.</strong> The shopper should see something clearly better at a clearly higher price and decide whether it's worth it. Three upsells at about the same price are one choice shown three times.</p>
<h2>Ask for more, curate the first screen</h2>
<p>The strip now asks the recommender for <strong>thirty</strong> candidates instead of twenty, still ranked by purchase likelihood. A small selection step fills the five visible slots:</p>
<ul>
<li><strong>Two downsells:</strong> of the candidates cheaper than the product, take the <strong>two most expensive</strong>, the ones closest to its own price, not the ones the recommender liked most.</li>
<li><strong>Three upsells:</strong> split the pricier candidates into three price tiers (a small step up, a medium step and a big step) and take the <strong>best-ranked candidate from each</strong>. The shopper always sees a modest upgrade, a real upgrade and a premium option.</li>
</ul>
<p>The other fifteen places, seen only after scrolling, take the remaining candidates <strong>in the recommender's own order</strong>. A shopper who scrolls is already exploring, so there the recommender can show whatever it thinks will sell.</p>
<p>The recommender still decides <em>which</em> products are good alternatives; the selection step decides <em>where on the price range</em> the first five sit.</p>
<h2>What changed</h2>
<p>The new strip ran as a controlled experiment against the old one. Clicks dipped slightly, since the cheapest items, the most clicked, had left the first screen. Purchases from the strip held steady, margin recovered, and more shoppers who opened a downsell went back and bought the original product.</p>
<p>Retraining the model with margin in its objective was the obvious "proper" fix. Asking for more candidates and arranging the first screen by price got there without touching the model.</p>
]]></content:encoded></item><item><title><![CDATA[Six sizes of one Hand Cream filled the Search page — Spreading Search results by title similarity]]></title><description><![CDATA[A beauty and household shop had a catalogue with no structure above the single product. There was no category tree and no notion of a product family: the…]]></description><link>https://oleber.hashnode.dev/six-sizes-of-one-hand-cream-filled-the-search-page-spreading-search-results-by-title-similarity</link><guid isPermaLink="true">https://oleber.hashnode.dev/six-sizes-of-one-hand-cream-filled-the-search-page-spreading-search-results-by-title-similarity</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Thu, 29 Oct 2026 16:36:09 GMT</pubDate><content:encoded><![CDATA[<p>A beauty and household shop had a catalogue with no structure above the single product. There was no category tree and no notion of a product family: the 50 ml and the 100 ml of the same hand cream were two unrelated rows.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/2bdcc15f-12d4-46fa-ac7e-353edee090fe.png" alt="" style="display:block;margin:0 auto" />

<p>Search paid for that. A shopper typing <em>"hand cream"</em> got the bestseller in 30 ml, 50 ml, 75 ml and 100 ml, then its two-pack, then its gift set. Each was a perfect match; together they were one product shown six times. Other brands started on page two, and shoppers who wanted to compare never saw them.</p>
<p>The proper fix was to build product families: have the catalogue team group variants by hand, or train a model to cluster them. Both meant months of work, and every new product would need grouping first.</p>
<p>The fix that shipped never asks what a family is. It only notices when two titles look almost the same.</p>
<p>It starts from edit distance: the number of single-character changes (insertions, deletions, substitutions) needed to turn one title into another. <em>"Shea Hand Cream 50 ml"</em> becomes <em>"Shea Hand Cream 100 ml"</em> with two changes; turning it into another brand's cream takes many more. Dividing by the length of the longer title turns that into a similarity between 0 and 1:</p>
<p>$$\text{sim}(a,b) = 1 - \frac{\text{lev}(a,b)}{\max(|a|,|b|)}$$</p>
<p>where \(\text{lev}(a,b)\) is the edit distance between titles <em>a</em> and <em>b</em>, and \(|a|\) is the length of title $a$.</p>
<p>The first three pages are then rebuilt greedily, one slot at a time:</p>
<ol>
<li>Retrieve everything the normal ranking puts on the first three pages, with its score.</li>
<li>Place the highest-scoring product in the next open slot.</li>
<li>Lower the score of every remaining product by how similar its title is to the one just placed, using the penalty below.</li>
<li>Go back to step 2 with the updated scores until the three pages are full.</li>
</ol>
<p>The penalty in step 3 is:</p>
<p>$$s_d \leftarrow s_d \cdot \bigl(1 - \lambda \cdot \text{sim}(d,p)\bigr)$$</p>
<p>where \(s_d\) is product $d$'s current score, $p$ is the product just placed, and \(\lambda\) sets how hard the push is.</p>
<p>Because the penalties multiply, a product that resembles several already-placed items keeps sinking, while a distinct product barely moves. Variants are not removed; they give their slot to the next distinct product. Nothing leaves the three-page pool, and from page four on the normal ranking is untouched.</p>
<p>"Hand cream" now opened with the bestseller, then five other brands, with the bestseller's 100 ml further down the first page. Before rollout, the team reviewed the rebuilt pages for the most frequent queries with the category managers; after it, they monitored clicks beyond the first rows and immediately rephrased searches, and both moved in the right direction.</p>
]]></content:encoded></item><item><title><![CDATA[You already own this fridge - demoting big items a shopper just bought]]></title><description><![CDATA[A home-appliance retailer continuosly get an odd complaint. Customers who had just bought a fridge kept seeing that same fridge at the top of their…]]></description><link>https://oleber.hashnode.dev/you-already-own-this-fridge-demoting-big-items-a-shopper-just-bought</link><guid isPermaLink="true">https://oleber.hashnode.dev/you-already-own-this-fridge-demoting-big-items-a-shopper-just-bought</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Wed, 28 Oct 2026 09:08:58 GMT</pubDate><content:encoded><![CDATA[<p>A home-appliance retailer continuosly get an odd complaint. Customers who had just bought a fridge kept seeing that same fridge at the top of their searches. Someone typing “kitchen” a week after delivery got their own fridge first, then a slightly cheaper one, then the one they had decided against, and many others. A shopper looking for a kettle saw things they would not buy again for a decade.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/df07269a-0227-4229-904d-4d418341c588.png" alt="" style="display:block;margin:0 auto" />

<p>The cause was personalization. Search boosted products from the categories each shopper had been browsing, and nobody browses fridges more than someone in the middle of buying one: comparing models, rereading the same three product pages. It learned, correctly, that this shopper cared about fridges. What it never learned was that the interest ended the moment the order was placed. Past views kept pushing fridges up, whatever came next.</p>
<p>The obvious fix was to make personalization smarter: feed purchases into the model, retrain it to predict what each shopper wants next, until it learns that buying a fridge ends the need. That meant months of work, a new data pipeline, and choices the merchandising team could not explain. A first quick fix hid purchased items from that shopper’s results entirely. That broke a real case: shoppers searching the exact model name to find spare filters, or to buy a second unit for a holiday flat, now found nothing.</p>
<p>The rule that shipped sat on top of personalization instead of changing it. When a signed-in shopper has bought something from a “durable” category in the last 90 days, two things happen. The personalization boost for that category is switched off, so other fridges stop rising on past browsing. And the bought item itself has its score cut: low enough to leave the first rows, high enough to still appear when the shopper types its exact name. Everything else, including consumables, is untouched.</p>
<p>The list of durable categories was not written by hand. It came from order history: for each category, the share of buyers who bought again in that category within 90 days. Fridges, washing machines, sofas and mattresses sat close to zero. Coffee capsules, printer ink and water filters sat high and were left alone, since showing a past purchase there is useful. Every category below a low cut-off joined the list, refreshed monthly from new orders.</p>
<p>It took a few days to build, since purchase history and categories were already available at search time. After rollout, the team reviewed searches from recent buyers and monitored click-through on the first rows, which rose for that group, while “I already bought this” complaints stopped.</p>
]]></content:encoded></item><item><title><![CDATA[Paid to be first, not paid to be wrong - A relevance floor for sponsored Search results]]></title><description><![CDATA[A classifieds site let sellers pay to promote a listing. A promoted listing appeared in highlighted slots above the regular results of any search it…]]></description><link>https://oleber.hashnode.dev/paid-to-be-first-not-paid-to-be-wrong-a-relevance-floor-for-sponsored-search-results</link><guid isPermaLink="true">https://oleber.hashnode.dev/paid-to-be-first-not-paid-to-be-wrong-a-relevance-floor-for-sponsored-search-results</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 17:23:44 GMT</pubDate><content:encoded><![CDATA[<p>A classifieds site let sellers pay to promote a listing. A promoted listing appeared in highlighted slots above the regular results of any search it matched. Every promotion cost the same, so when several matched, slots were filled at random.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/88ac7efb-9ca8-4638-8ba8-43e22ad7dd76.png" alt="" style="display:block;margin:0 auto" />

<p>The trouble was that search matching is loose: a listing qualifies if it shares enough words with the query. A promoted car seat titled <em>“baby car seat, fits most strollers”</em> matched <em>“baby stroller”</em>, and could top that search. The regular results underneath were fine. The first thing a buyer saw was not.</p>
<p>A simple idea came from a review question: would this car seat show up for <em>“baby stroller”</em> if nobody had paid for it? The search engine already answered that on every request. The regular ranking was tuned for years and trusted.</p>
<p>So the rule became: a promoted listing may take a highlighted slot only if it also ranks high enough in the regular results. How high depends on the slot:</p>
<p>$$\text{organic rank limit} = 25 + 10 * \text{paid slot}$$</p>
<p>Paid slots are counted from zero. The top slot requires a listing in the regular top 25, the second the top 35, the third the top 45.</p>
<p>The slots were filled top-down. The top slot was drawn at random among promoted listings within the regular top 25, the second among the remaining ones within the top 35, and so on. A listing too weak for the top slot could still land a lower one. When no promoted listing passed the rule, the rule was dropped for that query, and slots were filled from every match as before. With nothing relevant promoted, a loose match was no worse than before, and sellers still got their visibility.</p>
<p>The limits were deliberately generous. The goal was never to make listings earn their place — then nobody would pay — only to require a plausible answer. Ranking 20th for <em>“baby stroller”</em> means the engine thinks it’s some kind of stroller. Missing the top 45 usually means it shares a word with the query and nothing more.</p>
<p>The car seat disappeared from <em>“baby stroller”</em> and kept showing for <em>“baby car seat”</em>, where it belonged. Reviewed against the most-searched queries, glaring mismatches were gone wherever a relevant alternative existed, promotion sales held steady, and clicks on highlighted slots went up.</p>
]]></content:encoded></item><item><title><![CDATA[Two Search rankings on one page - Comparing ranking changes in days, not weeks]]></title><description><![CDATA[An online grocer’s search team kept coming up with small ranking tweaks, such as giving best-sellers more weight or scoring titles above descriptions. Each…]]></description><link>https://oleber.hashnode.dev/two-search-rankings-on-one-page-comparing-ranking-changes-in-days-not-weeks</link><guid isPermaLink="true">https://oleber.hashnode.dev/two-search-rankings-on-one-page-comparing-ranking-changes-in-days-not-weeks</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 15:18:27 GMT</pubDate><content:encoded><![CDATA[<p>An online grocer’s search team kept coming up with small ranking tweaks, such as giving best-sellers more weight or scoring titles above descriptions. Each one went through an <em>A/B test</em>. Shoppers were split into two groups, one group saw the current ranking and the other saw the new one, and the team compared how often each group bought something.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/f5caa2d2-2ccf-4e6b-9f72-135ba5463bef.png" alt="" style="display:block;margin:0 auto" />

<p>For small tweaks, this was slow. Shoppers differ from each other far more than two similar rankings do, so each test took about four weeks. Eleven tweaks were waiting in line, and the proposed fix was a bigger experiment platform that would take two months to build.</p>
<h2><strong>Let the same shopper judge both</strong></h2>
<p>The team turned to <em>interleaving AB-Test</em>: instead of comparing two groups, it shows both rankings on the same page. For every search, both rankings are computed and a coin flip decides which one picks first. Then the two take turns, each adding its highest-placed product that is not already on the page, tagged with its team.</p>
<p>Take the query <em>“tomato”</em>. The old ranking, A, leads with fresh <em>brandywine</em> and <em>cherry tomatoes</em>. The new ranking, B, favours best-sellers, so it leads with <em>canned chopped tomatoes</em> and a <em>bottle of passata</em>. If B wins the the first coin flip and A wins the second, the page reads: <em>canned tomatoes</em> <em><strong>(B)</strong></em>, <em>brandywine tomatoes</em> <em><strong>(A)</strong></em>, <em>cherry tomatoes</em> <em><strong>(A)</strong></em>, <em>passata</em> <em><strong>(B)</strong></em>.</p>
<p>A shopper who converts the <em>brandywine</em> and <em>cherry tomatoes</em> gives this search to <em>A</em>. A shopper who converts only the <em>passata</em> gives it to <em>B</em>. Searches with equal converts, or with no converts at all, count for neither side. The coin flip keeps it fair, since top slots get more converts.</p>
<h2><strong>Turning wins into an answer</strong></h2>
<p>The headline number is the share of decided searches that B won:</p>
<p>$$preference = \frac{#B\ wins}{#B\ wins + #A\ wins}$$</p>
<p>A preference of 0.5 means no preference. To tell a real preference from luck, imagine the two rankings are equally good. Then every search would be a coin flip, and the preference would wander around 0.5 by an amount called the <em>standard error</em>:</p>
<p>$$\text{standart error} = \frac{0.5}{\sqrt{\text{decided searches}}}$$</p>
<p><strong>The interval</strong> says how precise the result is. About 95% of the time, the true preference lies within two standard errors of the measured one. This range is the <em>95% confidence interval</em>:</p>
<p>$$\text{interval} = \text{preference} \pm 2 \cdot \text{standard error}$$</p>
<p><strong>The p-value</strong> says whether the result could be luck. It is the probability of getting a result at least this far from 0.5 if the two rankings were equally good. Start by measuring the distance from 0.5 in standard errors:</p>
<p>$$\text{distance from even} = \frac{\text{preference} - 0.5}{\text{standard error}}$$</p>
<p>Luck produces a distance of 1 about one time in three (a p-value of 0.32), a distance of 2 about one time in twenty (0.05), and a distance of 3 about three times in a thousand (0.003).</p>
<p>For <em>“tomato”,</em> A won 1,550 of 2,500 decided searches, so B’s preference was 0.38. The standard error was 0.01, which gives an interval of 0.36 to 0.40, 12 standard errors below even. Shoppers wanted fresh tomatoes. Across all queries, B scored 0.54 over 10,000 decided searches: an interval of 0.53 to 0.55 and a distance of 8. B won overall while losing badly on <em>“tomato”</em>, so the team shipped it with a fix for queries where people want the fresh product, not the processed best-seller.</p>
<p>The number of decided searches was fixed before each test started. Checking the p-value daily and stopping on the first good-looking day produces false winners.</p>
<h2><strong>Why it is faster</strong></h2>
<p>In an A/B test, most of the noise comes from differences between the two groups of people. In interleaving, every shopper compares both rankings on the same page. The best-seller tweak, which had needed four weeks, reached a p-value below 0.001 in three days, and the queue of eleven tweaks cleared in under a month. Replayed on six past changes with known A/B results, interleaving agreed with all six. Interleaving now screens the ideas, and only the winners go on to an A/B test.</p>
]]></content:encoded></item><item><title><![CDATA[The second map - catching bad Search results with what people bought together]]></title><description><![CDATA[Related to: A platform running search for a large network of independent hardware store tenants had a usual vector search problem. A query was turned by an…]]></description><link>https://oleber.hashnode.dev/the-second-map-catching-bad-search-results-with-what-people-bought-together</link><guid isPermaLink="true">https://oleber.hashnode.dev/the-second-map-catching-bad-search-results-with-what-people-bought-together</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 14:54:05 GMT</pubDate><content:encoded><![CDATA[<p><strong>Related to:</strong> <a href="https://oleber.hashnode.dev/the-cheap-trick-that-fixed-my-search-retrieval-more-than-any-fancy-embedding-model">https://oleber.hashnode.dev/the-cheap-trick-that-fixed-my-search-retrieval-more-than-any-fancy-embedding-model</a></p>
<p>A platform running search for a large network of independent hardware store tenants had a usual vector search problem. A query was turned by an embedding model into a point in a high-dimensional space, positioned so products with similar meaning sit nearby. <em>“18V cordless drill”</em> and <em>“compact battery-powered drill/driver”</em> landed close, sharing almost no words. The nearest points became the results.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/682c1bf7-61b5-4c06-990f-62635659e811.png" alt="" style="display:block;margin:0 auto" />

<p>This platform ran no category tree at all: no taxonomy to slot products into, no category list to check a result against. The embedding space’s mistakes went straight to the results page - <em>“1/2-inch impact wrench”</em> returning a cordless impact driver, close because both listings leaned on <em>“impact”</em>, <em>“torque”,</em> <em>“cordless”</em> - was typical, even though a mechanic shopping for one rarely wants the other.</p>
<p>What these hardware tenants did have was order history  -  useful not for products bought together, but for the opposite pattern. Feed a model every order as a sequence of products (word2vec way works) and it pulls close items that keep turning up surrounded by the same other items, whether or not they ever share an order. That’s the signature of a substitute: a shopper buying one brand’s 20V drill almost never buys a rival brand’s 20V drill too, yet the two sit in near-identical company  -  the same bits, batteries, and chargers  - across the order history. A second space, <em>P</em>, was built this way, alongside the search space <em>C</em>. Two rival cordless drills sat close in <em>P</em>; an impact driver, filling a different role in a job, did not.</p>
<p>A text query can’t be placed in <em>P</em> - <em>“cordless drill”</em> isn’t a product anyone bought - so it couldn’t re-rank results directly. What it could do was judge the result set as a group: a genuine match is mostly substitutes for one job, and substitutes cluster in <em>P</em>, while a stray result that made the cut in <em>C</em> only by shared adjectives sat apart.</p>
<p>So each candidate in a query’s top results got a score in <em>P</em>: its distance to its <em>N</em> nearest neighbors among the other candidates. A genuine substitute sits shoulder to shoulder with several others; a stray result sits off on its own, and that distance shows it. Scores across the result set were then compared to their own median, using the median absolute deviation (MAD) — how far a typical score strays from the median — as the unit of spread.</p>
<p>Press enter or click to view image in full size</p>
<p>$$\text{keep candidate:}\ dist_p(candidate) \leq Median + 2 \cdot MAD$$</p>
<p>Median and MAD don’t get dragged around by the one or two results that are wildly off, unlike a mean and a fixed cutoff would - so the threshold self-calibrated per query, nothing hand-tuned per tenant. A candidate too new or rarely bought to have a reliable score was left alone.</p>
]]></content:encoded></item><item><title><![CDATA[Trending is a Ratio — What to show before anyone types]]></title><description><![CDATA[A home-and-garden retailer’s app had a small list under the search box. Tap the empty box, and before you typed a letter it offered “Trending now”: sofa…]]></description><link>https://oleber.hashnode.dev/trending-is-a-ratio-what-to-show-before-anyone-types</link><guid isPermaLink="true">https://oleber.hashnode.dev/trending-is-a-ratio-what-to-show-before-anyone-types</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 14:27:05 GMT</pubDate><content:encoded><![CDATA[<p>A home-and-garden retailer’s app had a small list under the search box. Tap the empty box, and before you typed a letter it offered “Trending now”: <em>sofa</em>, <em>rug</em>, <em>bed frame</em>, <em>lamp</em>. A few days later, the same four.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/2cbad293-b8de-4b52-9105-14a484710cb8.png" alt="" style="display:block;margin:0 auto" />

<p>The list counted the last three days of searches and showed the top eight. But the most <em>searched</em> queries are not the most <em>trending</em> ones. During a July heatwave, searches for <em>“fan”</em> jumped eightfold and still sat in fourteenth place, behind <em>“sofa”</em>. Raw counts reward size. A trend is about change.</p>
<p>The first proposal was a <em>forecasting model</em>: a statistical model predicting each query’s expected demand and flagging anything well above it. For tens of thousands of queries, that was a quarter of work for a list of eight words. The real question was simpler: <em>is this query in more demand than usual?</em> “Usual” could be the query’s own recent past.</p>
<h2><strong>Step 1: count purchases, not searches</strong></h2>
<p>Searches alone were noisy. When a celebrity’s garden made the news, searches for <em>“garden statue”</em> jumped tenfold, but almost nobody bought one. So the count became <em>converting searches</em>: searches after which the shopper bought something from those results in the same visit.</p>
<h2><strong>Step 2: compare the last three days with usual</strong></h2>
<p>For each query, count its converting searches in the last three days (“recent”) and in the four weeks before. “Usual” is what a typical three days looked like in those four weeks:</p>
<p>$$baseline = \frac{\text{converting searches in the previous 28 days}}{28} \cdot 3$$</p>
<p>The trend compares recent demand with that:</p>
<p>$$trend=\frac{recent+k}{baseline+k}$$</p>
<p>Three days is short enough to catch a heatwave while it is still happening, and long enough for mid-sized queries to collect a useful number of purchases. Four weeks keeps one odd day from bending what counts as normal. The smoothing constant k = 10 stops a query going from zero purchases to one from scoring infinitely high; it now scores 1.1.</p>
<h2><strong>Step 3: rank by size and change together</strong></h2>
<p>The trend alone favours small queries: “hammock stand” going from 3 purchases to 25 beats “sun lounger” going from 60 to 160. Volume alone brings back the sofas. So the ranking multiplies the two:</p>
<p>$$score = recent \cdot trend$$</p>
<p>Three days in July, counting converting searches:</p>
<img src="https://miro.medium.com/v2/resize:fit:1400/1*ngbgBTYZ9gB279D7elHb3g.png" alt="" width="700" height="370" />

<p>The heatwave rose to the top and the news story sank. But <em>“sofa”</em> still edges past <em>“sun lounger”</em> on volume alone, so two guard rails sit around the score:</p>
<ul>
<li>At least 20 converting searches in the three days, so a handful of buyers cannot fake a trend.</li>
<li>A trend of at least 2, which keeps <em>“sofa”</em> and <em>“rug”</em> out however large they are.</li>
</ul>
<p>Out-of-stock products cannot convert, so they never appear as dead ends.</p>
<h2><strong>The outcome</strong></h2>
<p>The list started to move. July brought <em>fan</em>, <em>paddling pool</em> and <em>sun lounger</em>. The team checked the list against weekly sales during the first month and monitored taps on it after rollout. Shoppers used it far more once it stopped repeating itself.</p>
]]></content:encoded></item><item><title><![CDATA[Search Autocomplete can point to a category, not just finish your words]]></title><description><![CDATA[Related to: Search autocomplete is just counting, not predicting Type “cat” into a pet retailer’s search box, and the vast majority of people typing it…]]></description><link>https://oleber.hashnode.dev/search-autocomplete-can-point-to-a-category-not-just-finish-your-words</link><guid isPermaLink="true">https://oleber.hashnode.dev/search-autocomplete-can-point-to-a-category-not-just-finish-your-words</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 13:49:54 GMT</pubDate><content:encoded><![CDATA[<p><em><strong>Related to:</strong></em> <a href="https://oleber.hashnode.dev/search-autocomplete-is-just-counting-not-predicting">Search autocomplete is just counting, not predicting</a></p>
<p>Type <em>“cat”</em> into a pet retailer’s search box, and the vast majority of people typing it want ads exactly on one category: <em>“Animals &gt; Cat”.</em> A handful buy cat food instead, a smaller handful buy a leash or a scratching post, and a few land somewhere that fits none of the above — <em>“Animals &gt; Food”,</em> <em>“Animals &gt; Accessories”,</em> and <em>“Animals &gt; Others”,</em> four siblings under one parent with wildly uneven popularity.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/7be4b7eb-be98-4a8e-9937-cc42da1e6b1f.png" alt="" style="display:block;margin:0 auto" />

<p>The fix was to tag the suggestion with a category chip - <em>"cat" → "Animals &gt; Cat”,</em> ideally with a second chip for whichever runner-up deserved one. The question was how to pick both automatically.</p>
<p>The instinct was a lookup: find every past purchase that followed the query <em>“cat”</em>, check which category it landed in, and count. A trie can hold the query completions, indexed under <em>“&lt;query&gt;&lt;separator&gt;&lt;category id&gt;”</em> key and the counts as values. Walking to the prefix <em>"cat&lt;separator&gt;"</em>, <em>“category id”</em> gave the raw count for each one in a single categories.</p>
<p>But a purchase in <em>“Animals &gt; Cat”</em> is also a purchase in its parent, <em>“Animals”</em> - and so is one in <em>“Animals &gt; Food”</em>, <em>“Animals &gt; Accessories”</em> or <em>“Animals &gt; Others”</em>. Every purchase votes for its own category and the shared ancestor above it, so “Animals,” collecting votes from all four, always ranked up the biggest raw count, even though <em>“Animals &gt; Cat”</em> alone held the overwhelming majority at level 2.</p>
<p>The fix leaned on depth. Every category has a level — steps down from the root. <em>“Animals”</em> is level 1; the other four are level 2. A specific category makes a far stronger claim than the root above it, so the score needed to reward specificity instead of raw volume:</p>
<p>$$S(c) = N(c) \cdot x^{level(c)}$$</p>
<ul>
<li>N(c) is the purchase count for category c</li>
<li><em>x</em> is a constant greater than 1, tuned against real query logs until the picks matched what a merchandiser would call obvious.</li>
</ul>
<p>With <em>“Animals &gt; Cat”</em> already holding most of the votes, it won easily once the boost stopped <em>“Animals”</em> from winning on aggregate volume alone. Purchase count was the simplest choice; summing revenue or margin instead would weight a category by what it earned, not how often it sold - useful where a few high-value sales should outrank a flood of cheap ones.</p>
<p>The second chip was trickier. Naively, <em>“Animals”</em> still carried <em>“Animals &gt; Cat”</em> purchases baked in alongside <em>“Animals &gt; Food”</em>, <em>“Animals &gt; Accessories”</em>, and <em>“Animals &gt; Others“</em> - so it won second place easily too, riding the same votes that had just crowned <em>“Animals &gt; Cat”</em> the winner. That runner-up said nothing new. The fix was to make the winner’s votes stop counting once spent. After picking <em>“Animals &gt; Cat”</em>, its rolled-up count was subtracted back out of <em>“Animals”</em> before scoring anything else:</p>
<p>$$N'(a) = N(a)-N(w)\ \text{for every ancestor a of winner w}$$</p>
<p>Recomputing after that put <em>“Animals”</em> back into the running on equal footing, holding only what <em>“Animals &gt; Food”</em>, <em>“Animals &gt; Accessories”</em>, and <em>“Animals &gt; Others”</em> had contributed between them — and this time it won second place fairly. None of those three had enough conversions alone to beat the other two combined, so the honest runner-up was <em>“Animals”</em> itself, standing in for “something else in the pet aisle.” Only past that did <em>“Animals &gt; Food”</em>, <em>“Animals &gt; Accessories”</em>, and <em>“Animals &gt; Others”</em> show up individually, ranked by whatever share of the votes each had earned.</p>
]]></content:encoded></item><item><title><![CDATA[The 10K queries that are half your Search traffic: precomputing and enriching them overnight]]></title><description><![CDATA[Every evening, search at an online fashion retailer got slow. A results page that took about 120 milliseconds during the day took close to a second for the…]]></description><link>https://oleber.hashnode.dev/the-10k-queries-that-are-half-your-search-traffic-precomputing-and-enriching-them-overnight</link><guid isPermaLink="true">https://oleber.hashnode.dev/the-10k-queries-that-are-half-your-search-traffic-precomputing-and-enriching-them-overnight</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 11:52:19 GMT</pubDate><content:encoded><![CDATA[<p>Every evening, search at an online fashion retailer got slow. A results page that took about 120 milliseconds during the day took close to a second for the slowest 5% of searches between 7 and 10 p.m., and conversion in those hours showed it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/b474051e-72f6-44d7-9f2d-4e80e8499192.png" alt="" style="display:block;margin:0 auto" />

<p>The obvious fix was hardware. The team added machines to the search cluster, the group of servers that run the search engine. That doubled the hosting bill and cut the slowdown by about a third.</p>
<h2><strong>Looking at what people actually typed</strong></h2>
<p>Before the next upgrade, an engineer counted a typical evening’s queries. First they were normalized (lowercased, extra spaces trimmed) so <em>“Black Jeans “</em> and <em>“black jeans”</em> counted as one search, then sorted by frequency. The top 10.000 queries accounted for nearly 50% of the traffic.</p>
<h2><strong>Answer the common questions in advance</strong></h2>
<p>A nightly job takes the 10,000 most common queries from the past month, runs each through the normal search engine, and stores the top 250 product IDs in a cache: a fast store where looking up a query is as cheap as reading a dictionary entry.</p>
<p>At search time:</p>
<ol>
<li>Normalize the query.</li>
<li>If it’s in the cache, take the stored product IDs.</li>
<li>Otherwise, send it to the search engine as before.</li>
</ol>
<p>One detail mattered most: stock. A list built at 3 a.m. can show a dress that sold out at noon, so stock is never cached. A live check keeps product only if the product is on stock. Checking stock for 250 known IDs is far cheaper than a full search, and leaves plenty to fill a page.</p>
<p>Searches with filters or a non-default sort skip the cache.</p>
<h2><strong>Time to think</strong></h2>
<p>The night also offered the Applied Scientists something the live path never had: time. A live search gets 10’s milliseconds; the nightly job gets hours. A heavier reranker, which re-scores the top results with a slower and more careful model, took about two seconds per query, far too slow for live traffic. Overnight it was affordable: <em>10,000×2sec≈5.6 hours</em>. The busiest queries got rankings the live engine could never afford.</p>
<h2><strong>The result</strong></h2>
<p>The next evening, about half of all searches never reached the cluster. Peak load fell to daytime levels, and the slowest 5% of searches came back in about 150 milliseconds. The extra machines were removed; the cache and nightly job cost a fraction of them.</p>
]]></content:encoded></item><item><title><![CDATA[The weekly hour that beat a Search relevance model - reading the top queries that get no clicks]]></title><description><![CDATA[A home and garden retailer’s search team had spent a quarter building a relevance model: a system that predicts how well each query’s results satisfy…]]></description><link>https://oleber.hashnode.dev/the-weekly-hour-that-beat-a-search-relevance-model-reading-the-top-queries-that-get-no-clicks</link><guid isPermaLink="true">https://oleber.hashnode.dev/the-weekly-hour-that-beat-a-search-relevance-model-reading-the-top-queries-that-get-no-clicks</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 11:45:42 GMT</pubDate><content:encoded><![CDATA[<p>A home and garden retailer’s search team had spent a quarter building a relevance model: a system that predicts how well each query’s results satisfy shoppers, backed by a dashboard of quality metrics.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/444e0f68-3d5a-48d6-81d6-870b39b256a0.png" alt="" style="display:block;margin:0 auto" />

<p>Meanwhile <em>“garden hose”</em>, one of the busiest spring queries, quietly stopped selling. The model’s scores looked fine. Three weeks later, a revenue report caught it. Typing the query revealed the cause in seconds: the results were connectors, nozzles and reels, with no actual hoses. A category migration had left a filter pointing at the old category. The ranking was working perfectly on the wrong products.</p>
<p>A relevance model judges whether the order is right. It assumes the right products are there to be ordered. The signal that did catch the problem was simpler: thousands of searches, almost no clicks.</p>
<h2><strong>Finding the queries that matter</strong></h2>
<p>The obvious report sorts queries by click-through rate, the share of searches that end in a click:</p>
<p>$$CTR(q)=\frac{clicks(q)}{searches(q)}$$</p>
<p>Sorted from the lowest CTR, it filled up with odd queries searched three times a week. Sorted by volume, it showed healthy popular queries. The version that worked estimates the clicks each query lost compared with a typical query:</p>
<p>$$lost(q)=searches(q) \cdot max(0, topqCTR - CTR(q))$$</p>
<p>Here topqCTR is the median CTR across queries with meaningful traffic. A rare query loses little, while a busy query that is slightly off loses a lot.</p>
<h2><strong>The hour</strong></h2>
<p>Every Monday, a search analyst and a merchandiser take the top 50 queries by lost clicks. They run each search live and file the cause into one of four buckets:</p>
<ul>
<li><strong>Broken filter or rule:</strong> the wrong products are eligible, like the hoses.</li>
<li><strong>Bad data:</strong> missing images, a €0 price, broken titles.</li>
<li><strong>Missing product:</strong> shoppers want something the catalog doesn’t carry.</li>
<li><strong>Ranking:</strong> the right products are there, in the wrong order.</li>
</ul>
<p>Each finding gets an owner, and the next week’s report confirms the fix: the query’s CTR recovers or it drops off the list.</p>
<p>Over the first quarter, ranking was the smallest bucket. “Dishwasher” had a supplier feed without images. A new phone was heavily searched two weeks before it was stocked. “Black Friday” had no landing page. None of these was something a relevance model could fix. The model project continued, now picking up its new cases from the “ranking” bucket.</p>
<h2><strong>Where it falls short</strong></h2>
<ul>
<li>It only sees busy queries. Rare searches each lose too few clicks to make the top 50, even when together they add up, and a model still has a role there. General solutions for the top queries can help in rare queries also.</li>
<li>Low clicks aren’t always failure either: “opening hours” is answered on the results page, so those queries need a “known and fine” list.</li>
<li>It also catches problems after shoppers have hit them, within a week instead of three, but still after.</li>
<li>It only works while the hour happens.</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[One row per Search request - the nightly table that replaced a hundred fragile joins]]></title><description><![CDATA[A merchandiser at an online home goods retailer asked what sounded like a simple question: “When people search for pillow, how often do they end up buying…]]></description><link>https://oleber.hashnode.dev/one-row-per-search-request-the-nightly-table-that-replaced-a-hundred-fragile-joins</link><guid isPermaLink="true">https://oleber.hashnode.dev/one-row-per-search-request-the-nightly-table-that-replaced-a-hundred-fragile-joins</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 11:34:40 GMT</pubDate><content:encoded><![CDATA[<p>A merchandiser at an online home goods retailer asked what sounded like a simple question: “When people search for <em>pillow</em>, how often do they end up buying one?”</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/92b4253f-0721-49d7-9698-278987d854e9.png" alt="" style="display:block;margin:0 auto" />

<p>Answering it took an analyst one day. Everything the app recorded went into a single events table, the kind a product analytics tool like PostHog or Google Analytics produces: one row per event, with an event name, a user, a session, a timestamp and a bag of properties. A search, the click on a result, the basket addition and the order were four different rows, sometimes hours apart, among millions of page views and button taps. Linking an order back to a search meant joining that table to itself over and over, digging item IDs out of the properties, lining events up by time, then making a call on the awkward cases. If a shopper searched <em>“pillow”</em>, then <em>“memory foam pillow”</em>, and bought from the second results page, which search gets the credit? What if the purchase came three hours later?</p>
<p>A month later, a second analyst made different calls and got a different number. Neither was wrong, but they couldn’t both be reported.</p>
<p>Every analysis was deciding attribution, which search gets credit for which action, from scratch. The fix was to decide it once, write the answer down, and let everything else read from that.</p>
<h2>The table</h2>
<p>The search attribution table is built from that same events table once a day, overnight, when the previous day’s orders have mostly come in. It has one row for every search request, with fields like:</p>
<ul>
<li><code>request_id</code>, <code>user_id</code>, <code>session_id</code>, <code>search_ts</code></li>
<li><code>query</code> and <code>filters</code>, exactly as the shopper sent them</li>
<li><code>results</code>: the list of items shown</li>
<li><code>max_level</code>: the furthest the shopper got with any of those results</li>
</ul>
<p>Each entry in <code>results</code> holds the <code>item_id</code>, its <code>position</code> on the page, its category at three depths (<code>category_l1</code> like <em>Bedding</em>, <code>category_l2</code> like <em>Pillows</em>, <code>category_l3</code> like <em>Memory Foam Pillows</em>), a <code>level</code>, and the <code>features</code>: the numbers the ranking system used to order it at the moment of the search.</p>
<p>The <code>level</code> records what happened to that item after this search:</p>
<ul>
<li>0: shown, no interaction</li>
<li>1: clicked</li>
<li>2: added to basket</li>
<li>3: purchased</li>
</ul>
<p>Other businesses use different steps, but higher always means further down the funnel. The request’s <code>max_level</code> is just the best outcome on the page:</p>
<p>$$max_level = \max_{i \in results} level_i$$</p>
<p>The nightly job holds the rule that used to live in each analyst’s head. A simple version credits an action on an item to the most recent search in the same session that showed it. The exact rule matters less than having exactly one.</p>
<h2>The questions become short</h2>
<p>How well does each query do? A search “gets a click” if anything on the page got a click, so the funnel rates for a query <em>q</em> are:</p>
<p>$$rate_k(q)=\frac{# searches\ of\ q\ with\ max_level \geq k}{#searches\ of\ q}$$</p>
<pre><code class="language-sql">SELECT 
  query,
  COUNT(*) AS searches,
  AVG(CASE WHEN max_level &gt;= 1 THEN 1.0 ELSE 0 END) AS click_rate,
  AVG(CASE WHEN max_level &gt;= 2 THEN 1.0 ELSE 0 END) AS basket_rate,
  AVG(CASE WHEN max_level &gt;= 3 THEN 1.0 ELSE 0 END) AS purchase_rate
FROM search_attribution
WHERE search_date &gt;= CURRENT_DATE - INTERVAL '30' DAY
GROUP BY query
HAVING COUNT(*) &gt;= 100
ORDER BY searches DESC;
</code></pre>
<p>The merchandiser’s one-day question became one row of this output.</p>
<p>How much does position matter? Expanding the results list into one row per item makes it possible to ask how often each slot gets clicked, whatever is in it:</p>
<pre><code class="language-sql">SELECT 
  r.position,
  AVG(CASE WHEN r.level &gt;= 1 THEN 1.0 ELSE 0 END) AS ctr
FROM 
  search_attribution s
  CROSS JOIN UNNEST(s.results) AS r
WHERE s.search_date &gt;= CURRENT_DATE - INTERVAL '30' DAY
GROUP BY r.position
ORDER BY r.position;
</code></pre>
<p>(The syntax for expanding a list varies by database: UNNEST, LATERAL VIEW explode, and so on. The idea doesn't.)</p>
<p>Which categories do people actually want for a query? This is the probability that a search for q leads to an interaction with at least one item in category c:</p>
<p>$$P(c|q)=\frac{# searches\ of\ q\ with\ an\ interaction\ in\ c}{#searches\ of\ q}$$</p>
<pre><code class="language-sql">WITH 
  hits AS (
    SELECT
      s.query, 
      r.category_l2,
      COUNT(DISTINCT s.request_id) AS cnt
    FROM 
      search_attribution s
      CROSS JOIN UNNEST(s.results) AS r
    WHERE 
      s.search_date &gt;= CURRENT_DATE - INTERVAL '30' DAY
      AND s.max_level &gt;= 1
      AND r.level &gt;= 1
    GROUP BY ALL
  ),

  totals AS (
    SELECT 
      query, 
      SUM(cnt) AS total_cnt
    FROM hits
    GROUP BY query
  )

SELECT 
  h.query, 
  h.category_l2,
  cnt / total_cnt AS p_interaction
FROM 
  hits h
  JOIN totals t ON t.query = h.query
WHERE t.total_cnt &gt;= 100
ORDER BY h.query, p_interaction DESC;
</code></pre>
<p>For <em>“pillow”</em>, <em>Pillows</em> came out far ahead, with <em>Outdoor Cushions</em> a distant second. That’s exactly what a query-to-category classifier (a model that guesses which category a query belongs to) is trained to predict. For busy queries it can simply be counted, and used to boost the categories shoppers actually engage with.</p>
<h2>The side effect nobody planned for</h2>
<p>Each result stores its features, as they were at search time, next to what the shopper did. That makes the table a ready-made training set for a ranking model, with features that match what the live system saw.</p>
<h2>The limits</h2>
<p>Buying on a phone after searching on a laptop, or coming back the next day, falls outside a session-based rule. Purchases after the nightly cut-off are missed until a later rebuild, so the latest day always looks a little worse than it was. And it’s a day behind by design: it answers “what happened yesterday”, not “what’s breaking right now”.</p>
]]></content:encoded></item><item><title><![CDATA[Synonyms in Search are a maintenance nightmare - here’s the shortcut that mostly works]]></title><description><![CDATA[I inherited a file called synonyms.txt. Four thousand two hundred lines, no owner, no change-log worth reading. It was a simple text file where each line…]]></description><link>https://oleber.hashnode.dev/synonyms-in-search-are-a-maintenance-nightmare-here-s-the-shortcut-that-mostly-works</link><guid isPermaLink="true">https://oleber.hashnode.dev/synonyms-in-search-are-a-maintenance-nightmare-here-s-the-shortcut-that-mostly-works</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 10:57:24 GMT</pubDate><content:encoded><![CDATA[<p>I inherited a file called <code>synonyms.txt</code>. Four thousand two hundred lines, no owner, no change-log worth reading. It was a simple text file where each line was a group of words the search engine should treat as interchangeable — <code>couch, sofa, settee</code> on one line, <code>tv, television</code> on another. Reasonable idea. Except nobody had touched it in eight months, and the business hadn't stood still that long.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/411180cf-a655-4ceb-afc1-b0c41c7cee62.png" alt="" style="display:block;margin:0 auto" />

<p>The CEO queue told the story better than any dashboard. <em>“Searched ‘fridge,’ got zero results. Typed ‘refrigerator’ instead, worked fine”.</em> A new product line launched with a slang name the catalog didn’t use anywhere, and searches for it just died. Every fix was the same: find the file, add a line, get it reviewed, redeploy. For a one-word problem, that’s an absurd amount of ceremony - and the list only ever grew, because nobody wanted to be the one who deleted an old line and broke something.</p>
<p>What changed my mind was a pattern in the session logs - the record of what one visitor searched, in order, during one visit. People search <em>“fridge”,</em> see nothing worth clicking, and ten seconds later, same session, search <em>“refrigerator”</em> and click something. They’d already solved my problem for me.</p>
<p>So instead of waiting for a human to notice, I started mining for that pattern: pairs of searches, same session, where the first got no clicks and the second - worded differently - got one. Frequency does the filtering. One person typing <em>“fridge”</em> then <em>“refrigerator”</em> is a coincidence. A few people doing it in a month is a synonym.</p>
<p>Except it’s noisier than that sounds. A month of mining produces thousands of candidates, and many aren’t synonyms: typos (<em>“resturant”</em> → <em>“restaurant”</em>), plurals, unrelated searches that landed in the same session, and sneaky near-misses where the second query narrows the first rather than restating it — <em>“sofa”</em> then <em>“sofa bed”</em> isn’t the same intent, it’s a more specific one, and treating it as a synonym would quietly break search for everyone.</p>
<p>Before anything gets near a human — or an AI — I run two cheap filters. First, repetition: drop any pair that didn’t recur at least a handful of times, since a one-off is noise, not a pattern. Second, edit similarity — how many single-character changes it takes to turn one string into the other. <em>“resturant”</em> and <em>“restaurant”</em> are one typo apart; <em>“fridge”</em> and <em>“refrigerator”</em> aren’t close by that measure at all. Requiring low edit similarity strips out most typos and plurals before they cost a single review cycle.</p>
<p>What survives is noisy in the interesting way - real reformulations, including intent-narrowing ones like <em>“sofa bed”</em> that no string-distance check catches. That batch goes to an LLM: pair, frequency, a few example queries, one narrow yes/no question - same intent, or different? Not inventing synonyms, just judging ones behavior already nominated. I still spot-check a sample before anything ships, because it’s caught waving through a few of those near-misses too.</p>
<p>It’s not a complete fix. Brand-new terminology has no reformulation history yet, so this catches nothing until people have already been frustrated by it once. It only sees users who retry - the ones who search once, get nothing, and leave never show up in a log pair at all. For anything new or high-stakes, I still keep a thin manual layer around. But for the long, unglamorous tail of synonyms, this replaced a dictionary that decayed the moment I stopped staring at it with one that keeps itself roughly current on its own.</p>
]]></content:encoded></item><item><title><![CDATA[Fixing misspelled Search queries using working queries]]></title><description><![CDATA[Every e-commerce search box eventually meets the same enemy: typos. A user types “runing shoes” or “bluetoth speaker”, and if your system can’t recover…]]></description><link>https://oleber.hashnode.dev/fixing-misspelled-search-queries-using-working-queries</link><guid isPermaLink="true">https://oleber.hashnode.dev/fixing-misspelled-search-queries-using-working-queries</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 10:46:41 GMT</pubDate><content:encoded><![CDATA[<p>Every e-commerce search box eventually meets the same enemy: typos. A user types <em>“runing shoes”</em> or <em>“bluetoth speaker”,</em> and if your system can’t recover, that’s a lost sale. Most teams reach for one of a few standard fixes - each with real limitations.</p>
<h2><strong>Why the Usual Fixes Fall Short</strong></h2>
<p><strong>Fuzzy matching against the catalog.</strong> The fuzziness parameter lets queries match product titles within an edit distance of 1 or 2. It’s easy to enable and catches obvious slips. But it optimises against the wrong corpus. Product titles are written by merchandisers, not shoppers — full of brand names, model numbers, and marketing phrasing that customers rarely type. <em>“Sweter”</em> may resolve to <em>“sweater”</em> or <em>“sweeter”</em>, won’t reliably resolve to the right item just because some listing happens to contain a nearby string; the catalog wasn’t built to model how people search, so fuzzy matches against it are essentially guesses dressed up as corrections.</p>
<p><strong>The built-in suggester (e.g., phrase or term suggesters).</strong> These generate candidate corrections from the indexed terms themselves - meaning they inherit the same catalog-language bias, plus they tend to suggest whatever term is most frequent in the index, not whatever term shoppers actually intended.</p>
<p><strong>Generic spelling dictionaries.</strong> Off-the-shelf dictionaries (aspell-style corpora, general English word lists) know that <em>“recieve”</em> should be <em>“receive”,</em> but they have no concept of product vocabulary, brand names, or category-specific slang. They’ll <em>“correct”</em> a legitimate product term into an unrelated dictionary word, or fail silently on domain-specific misspellings entirely.</p>
<p>The common thread: all three approaches try to infer intent from data that doesn’t represent the customer’s language. What’s missing is a model of how people actually search — which is sitting in your query logs the whole time.</p>
<h2><strong>Correct against queries, not products</strong></h2>
<p>A more reliable approach flips the source of truth from the catalog to real search behavior:</p>
<ol>
<li><strong>Select top-converting queries from logs.</strong> Pull queries that reliably lead to clicks, add-to-carts, or purchases — this is your “known good” query vocabulary.</li>
<li><strong>Index those queries as keyword.</strong> Store them in a lightweight side index, separate from the product catalog, preserving exact strings.</li>
<li><strong>On low or zero results, search this query index for low edit-distance matches</strong> and re-run the user’s search using the corrected query instead of the catalog.</li>
</ol>
<p>The precision gain comes from matching against the <em>right</em> vocabulary.</p>
<h3><strong>A few examples</strong></h3>
<p>The german typo <em>“wane”</em> is matching <em>“wand”</em> (wall) instead of the likely more frequent <em>“wanne”</em> (tub):</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/fb1d3aa3-9fb9-4240-9b75-094623a33799.png" alt="" style="display:block;margin:0 auto" />

<p>But Germans are not alone, consider some english ambiguous cases:</p>
<ul>
<li><em>“sweter”</em> is edit-distance 1 from both <strong>sweater</strong> and <strong>sweeter</strong>.</li>
<li><em>“blendr”</em> is edit-distance 1 from both <strong>blender</strong> and <strong>blander</strong>.</li>
<li><em>“toastr”</em> is edit-distance 1 from both <strong>toaster</strong> and <strong>toastier</strong>.</li>
</ul>
<p>A catalog or dictionary has no way to break these ties — both candidates are equally plausible strings. But when you match against top-converting queries, the ambiguity mostly resolves itself: <em>“sweater”</em> appears constantly as a converting search term, while <em>“sweeter”</em> essentially never does, because nobody searches an e-commerce site for the word <em>“sweeter”</em> on its own. The frequency and conversion signal embedded in real query logs acts as a prior that pure string-distance math can’t provide.</p>
<p>This also means the correction candidate pool shrinks to queries people actually type with purchase intent, so a low-frequency edit-distance neighbor doesn’t get promoted over the obviously correct, converting query. The system isn’t just “spelling aware” — it’s shaped by demand.</p>
<p>On cases where no similar popular query is found, a catalog fallback can help.</p>
]]></content:encoded></item><item><title><![CDATA[Fading out of stock from Search, instead of yanking It]]></title><description><![CDATA[Related to: Boosting Search by stock, Racing the clock on seasonal stock - A deadline-aware boost that knew where to stop A home goods retailer’s search…]]></description><link>https://oleber.hashnode.dev/fading-out-of-stock-from-search-instead-of-yanking-it</link><guid isPermaLink="true">https://oleber.hashnode.dev/fading-out-of-stock-from-search-instead-of-yanking-it</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 10:33:24 GMT</pubDate><content:encoded><![CDATA[<p><strong>Related to:</strong> <a href="https://oleber.hashnode.dev/boosting-search-by-stock">Boosting Search by stock</a>, <a href="https://oleber.hashnode.dev/racing-the-clock-on-seasonal-stock-a-deadline-aware-boost-that-knew-where-to-stop">Racing the clock on seasonal stock - A deadline-aware boost that knew where to stop</a></p>
<p>A home goods retailer’s search team got the same complaint from two different directions in the same week. A shopper wrote in asking why a rug she’d bookmarked, out-of-stock since Tuesday, had simply disappeared from search — she’d wanted to sign up for a restock alert, and now the listing was gone, as if it had never existed. Two days later, a merchandiser flagged the opposite problem: a lamp that had been sold out for six weeks was still turning up on the first page for <em>“floor lamp”</em>, pushing aside things shoppers could actually buy.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/8f4c74b7-3c65-411b-914c-302683debdd1.png" alt="" style="display:block;margin:0 auto" />

<p>Both complaints traced back to the same rule. The search index treated stock as a switch: in stock, the item ranked normally; out of stock, it dropped out of results the moment inventory hit zero. That handled the obvious case - nobody wants a page full of things they can’t buy - but it was too blunt for what shoppers and the business both needed. An item that sold out an hour ago is usually still worth showing: it might restock by the weekend, and someone browsing <em>“floor lamp”</em> today may well still be shopping next week. An item that’s been gone for two months is a different story. Same rule, wildly different situations.</p>
<p>The team’s first instinct was to patch the threshold: keep out-of-stock items visible for exactly three days, then drop them. It fixed the rug case and, for a while, looked like enough. But three days is just as arbitrary as zero. A popular item worth featuring for a week got cut off early; a niche item nobody would look for again lingered exactly as long as the bestseller next to it. The threshold didn’t know the difference between <em>“temporarily gone”</em> and <em>“gone for good”</em> - it only knew how to count to three.</p>
<p>Someone on the team then floated training a model: feed in category, past restock frequency, how fast the item used to sell, and let it learn how long an out-of-stock listing should stay visible before it’s more clutter than help. It was shelved almost as fast as it was proposed. Restock timing is noisy - a supplier delay or a shipping snag can throw off any pattern the model had learned - and nobody wanted to explain to a merchandiser why the model kept a slow-moving lamp visible for eleven days but pulled a fast-selling one after four, especially when the honest answer would be “the training data said so.”</p>
<p>What shipped instead borrowed an idea from how audio fades out instead of cutting off: rather than a hard cutoff, out-of-stock items lose a share of their search score every day they stay unavailable, following a smooth curve instead of a step function. The formula reused the same score the item already had in stock, and applied a decay factor based on how many days it had been out:</p>
<p>$$score_{dacayed}=score_{in_stock} \cdot 0.5^{\frac{days_out_of_stock}{half_life}}$$</p>
<p>With “half-life”, the number of days it takes for the score to drop by half. With a half-life of five days, an item that sold out yesterday still ranks close to where it did in stock; by day five it’s ranked at half its normal strength; by day fifteen, an eighth. Nothing vanishes on a fixed date. A rug gone for a day barely moves. A lamp gone for two months has quietly decayed to a score so low it never surfaces on a normal search, without ever being explicitly removed - so a shopper who searches by exact name, or has it bookmarked, can still find it and sign up for a restock alert.</p>
<p>The half-life itself didn’t need to be one number for the whole catalog. Fast-moving categories like phone accessories, which restock quickly or get replaced by a near-identical model, used a shorter half-life; slower categories like furniture, where a “back in three weeks” is common and worth waiting for, used a longer one. Picking those numbers per category was still a judgment call, made by people who knew the category, not a model — but it was one knob per category instead of one for the whole catalog, and easy enough to explain in a sentence: “rugs fade out over three weeks, phone cases over four days.”</p>
]]></content:encoded></item><item><title><![CDATA[The leash that kept Vector Search on topic]]></title><description><![CDATA[A marketplace’s lexical search - matching the actual words in a query against the actual words in a listing - missed an entire class of searches. “Warm…]]></description><link>https://oleber.hashnode.dev/the-leash-that-kept-vector-search-on-topic</link><guid isPermaLink="true">https://oleber.hashnode.dev/the-leash-that-kept-vector-search-on-topic</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 10:09:12 GMT</pubDate><content:encoded><![CDATA[<p>A marketplace’s lexical search - matching the actual words in a query against the actual words in a listing - missed an entire class of searches. <em>“Warm jacket for winter hiking”</em> returned nothing for an <em>“insulated shell parka”,</em> even though any shopper could see the results could be the same. The fix was vector search: an embedding model placing queries and products as points in a high-dimensional space, so <em>“warm jacket”</em> and <em>“insulated shell”</em> sit near each other without sharing a word. For every query, the seventy nearest product points were merged into the lexical results.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/b6c85ffb-d8d2-4993-a5b7-be3bd1b9eeaa.png" alt="" style="display:block;margin:0 auto" />

<p>Some queries started showing no sense results. A search for <em>“apple watch band”</em> started returning a dog collars - no shared word anywhere between the two, a leap lexical search would never make. The embedding model was giving poor results some times.</p>
<p>The instinct was to make the embeddings smarter - retrain, bake category signals into the vectors. That was slow to iterate on and hard to fully debug: a bad result meant a retrain-and-hope cycle, not a cheap lever to pull.</p>
<p>The fix that shipped sat in front of vector search instead of inside it. The catalog already had a taxonomy - a broad category tree, and a more specific L2 subcategory beneath it. If a query’s plausible L2 categories could be guessed cheaply, the seventy vector results could be filtered down to the ones that matched, with the embeddings themselves untouched.</p>
<p>Most traffic didn’t need a model for that. A small number of distinct queries accounted for the bulk of searches, and each already had years of purchase history. A nightly SQL job counted, per exact query string, which L2 categories its purchases had landed in, and kept the ones above a minimum share — no training, just a lookup table.</p>
<p>That table only covered queries it had seen enough of. Typos, unusual phrasing, brand-new searches had no row to check. For those, a small multi-label classifier trained with FastText — light enough to run on every search — predicted plausible categories from the words themselves. It wasn’t asked to figure out exactly what the shopper meant, only which categories a match couldn’t possibly belong to: a coarse net for nonsense, not a fine one for intent.</p>
<p>The catch was how strict that net was set. Too strict, and it rejected a query’s own correct category along with the insane ones, whenever odd phrasing left the model unconfident. Too loose, and it let through some of the very noise it existed to catch.</p>
]]></content:encoded></item><item><title><![CDATA[Racing the clock on seasonal stock - A deadline-aware boost that knew where to stop]]></title><description><![CDATA[Related to: Boosting Search by stock, Fading out of stock from Search, instead of yanking It Two months before Christmas, a Category Manager flagged a…]]></description><link>https://oleber.hashnode.dev/racing-the-clock-on-seasonal-stock-a-deadline-aware-boost-that-knew-where-to-stop</link><guid isPermaLink="true">https://oleber.hashnode.dev/racing-the-clock-on-seasonal-stock-a-deadline-aware-boost-that-knew-where-to-stop</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 09:54:36 GMT</pubDate><content:encoded><![CDATA[<p><strong>Related to:</strong> <a href="https://oleber.hashnode.dev/boosting-search-by-stock">Boosting Search by stock</a>, <a href="https://oleber.hashnode.dev/preview/6addd5c530dff6e7290f39dc">Fading out of stock from Search, instead of yanking It</a></p>
<p>Two months before Christmas, a Category Manager flagged a familiar worry: a run of seasonal products  - ornaments, advent calendars, holiday sweaters -  had to sell through by December 24. After that date, the same stock would sit in a warehouse for eleven months, worth a fraction of its current price. The catalog held thousands of items, but only about 5% of them carried this deadline. Everything else was business as usual. The lever here was search ranking: nudge these items higher while there was still time to move them, without disturbing anything else.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/f354792d-e40e-4df5-918e-e940f32ce4f4.png" alt="" style="display:block;margin:0 auto" />

<p>The starting point was a number the search team already had on hand from an earlier stock-based ranking project: expected_days_of_stock, how many days current inventory would last at the item’s average sales pace.</p>
<p>$$expected_days_of_stock=\frac{total_stock}{average_saily_sales}$$</p>
<p>​That number had previously been used to reward comfortable runway - items unlikely to run out soon got a small lift. Here the need was the opposite: flag items that wouldn’t sell through in time. Dividing expected_days_of_stock by the shopping days remaining until the deadline gave a risk ratio.</p>
<p>$$risk_ratio=\frac{expected_days_of_stock}{shopping_days_left}$$</p>
<p>The first attempt at shopping_days_left simply counted the calendar days to December 24. That overstated how much time was actually left: an order only helps if it arrives in time to go under the tree, and delivery itself takes days the customer doesn’t get to spend shopping. shopping_days_left had to be the days until the deadline minus the item’s expected delivery time, not the raw calendar gap.</p>
<p>$$shopping_days_left=days_until_deadline - expected_delivery_days$$</p>
<p>A slow-shipping item - bulky, or fulfilled from a distant warehouse - effectively ran out of runway earlier than a fast-shipping one with identical stock and sales pace, and the ranking needed to reflect that instead of treating every item as if it could arrive overnight.</p>
<p>A risk_ratio &gt; 1 meant the item was on pace to still have stock left after its last realistic ship date — exactly the case that needed a push.</p>
<p>The first draft applied risk_ratio catalog-wide. That was wrong on its face: for 95% of the catalog there was no December 24 deadline at all, so shopping_days_left had no real meaning, and the formula would have produced boosts (or penalties) for items that were never part of the problem. The fix wasn’t a tweak to the math — it was a scope flag, computed upstream, that zeroed the boost for anything outside the seasonal set before the formula ever ran. The formula itself was never trusted to stay neutral on its own; scope was enforced separately.</p>
<p>For the 5% actually in scope, a plain linear boost above risk_ratio = 1 behaved badly at both ends. Just past the threshold, the boost was too small to matter - an item barely over the line got barely more visibility, when it needed a push soon. Far past the threshold, a straight line kept climbing without bound, letting a handful of badly overstocked items dominate results regardless of relevance. What was needed was a curve that responded quickly near the threshold and then leveled off - the same shape as a cup of coffee cooling toward room temperature, fast at first, then flattening out. That’s a half-life decay, capped by a constant K that sets the maximum possible boost:</p>
<p>$$boost=K\cdot(1-2^{- \frac{risk_ratio-1}{half_life}})$$</p>
<p>At risk_ratio = 1, the exponent is zero and boost = 0 - no bonus yet. As risk_ratio climbs past 1, boost rises quickly at first and then approaches K without ever quite reaching it. half_life is the amount by which risk_ratio must exceed 1 for the item to earn exactly half of K - the same halving-distance idea as a physical half-life, just running toward a ceiling instead of toward zero.</p>
<p>As shopping_days_left shrank on the way to December 24, half_life shrank with it in lockstep, so the same amount of excess risk saturated the boost faster automatically as the deadline approached - no manual retuning, and no separate parameter to tune per item or category.</p>
<p>The boost was folded into ranking multiplicatively, not additively:</p>
<p>$$final_score=base_relevance \cdot (1+boost)$$</p>
<p>This kept the risk boost a nudge, not an override - a seasonal item with weak relevance to the query still couldn’t outrank a strong match on a competing item; it only closed the gap between comparably relevant results.</p>
<p>The one place this needed a hard rule, not just a formula, was the deadline’s final approach. As shopping_days_left approaches zero, the dynamically shrinking half-life shrinks with it, and the boost curve stops looking like a smooth ramp and starts looking like a step function - full boost or none, with almost no room in between. Shopping_days_left was floored at 1 to keep the curve from degenerating entirely, but that’s a patch on the edge case, not a fix for it: the closer to the deadline, the less gracefully this technique behaves, which is worth knowing before leaning on it in the final days of a sale.</p>
]]></content:encoded></item><item><title><![CDATA[Search autocomplete is just counting, not predicting]]></title><description><![CDATA[Related to: Search Autocomplete can point to a category, not just finish your words An online retailer’s search box had a problem that only showed up in…]]></description><link>https://oleber.hashnode.dev/search-autocomplete-is-just-counting-not-predicting</link><guid isPermaLink="true">https://oleber.hashnode.dev/search-autocomplete-is-just-counting-not-predicting</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 09:29:35 GMT</pubDate><content:encoded><![CDATA[<p><em><strong>Related to:</strong></em> <a href="https://oleber.hashnode.dev/search-autocomplete-can-point-to-a-category-not-just-finish-your-words">Search Autocomplete can point to a category, not just finish your words</a></p>
<p>An online retailer’s search box had a problem that only showed up in demos nobody meant to give. Type <em>“kids bi”,</em> and the dropdown confidently suggested <em>“kids bicycle helmet flame retardant”</em>. Nothing on the site matched. The suggestion wasn’t wrong exactly — grammatical, on-topic, plausible — it was just invented.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/35f304b3-cfcc-406e-9e36-d6a6a47ce8f0.png" alt="" style="display:block;margin:0 auto" />

<p>The autocomplete ran on a sequence-to-sequence model, the architecture family used for machine translation, trained to predict how a partial query continues. Fit on historical query logs, this seemed reasonable: generalize to prefixes it hadn’t seen verbatim. In practice, “generalize” and “invent” were the same behavior wearing different clothes — the model returned things that sounded like what people had searched for, not things they actually had. Nobody could tell from a single suggestion; it took a customer hitting zero results to notice.</p>
<p>The fix was almost insultingly simple: a trie. A trie is a tree where each path from the root spells out a prefix, and any node can be marked as the end of something real — a standard first-year data structure, usually taught for spell-checkers.</p>
<p>The trie was built from the query log, restricted to phrases that had actually led to a purchase, each carrying a weight for how often it converted. Typing <em>“kids bi”</em> walked the trie to that prefix and returned the completions with the highest weight underneath it. If nobody had ever bought anything down the <em>“flame retardant”</em> branch, no such suggestion could exist. The structure can’t hallucinate; it only returns paths that were put there.</p>
<p>The weighting mattered as much as the trie itself. An earlier version weighted by raw query frequency, surfacing typos ahead of the correct spelling, since misspellings compound quietly. Weighting by conversions meant a completion had to be popular with people who actually finished the task — and it was easy to defend internally, since anyone could read the trie and see why a suggestion appeared, no vectors or attention weights to interpret. Debugging became a lookup, not an investigation.</p>
<p>The catch: a trie only knows what’s already happened. It offers nothing for a genuinely new prefix until purchase history accumulates. The model’s worst flaw, inventing nonsense, was also its only advantage: it could say something about a prefix it had never seen. The trie says nothing at all, which is safer, but cold-start queries still need a fallback.</p>
]]></content:encoded></item><item><title><![CDATA[Boosting Search by stock]]></title><description><![CDATA[Related to: Racing the clock on seasonal stock - A deadline-aware boost that knew where to stop, Fading out of stock from Search, instead of yanking It A…]]></description><link>https://oleber.hashnode.dev/boosting-search-by-stock</link><guid isPermaLink="true">https://oleber.hashnode.dev/boosting-search-by-stock</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 09:12:00 GMT</pubDate><content:encoded><![CDATA[<p><strong>Related to:</strong> <a href="https://oleber.hashnode.dev/racing-the-clock-on-seasonal-stock-a-deadline-aware-boost-that-knew-where-to-stop">Racing the clock on seasonal stock - A deadline-aware boost that knew where to stop</a>, <a href="https://oleber.hashnode.dev/preview/6addd5c530dff6e7290f39dc">Fading out of stock from Search, instead of yanking It</a></p>
<p>A popular sofa had five units left in the warehouse. By any normal accounting, that’s “in stock” — the listing page said so, the relevance model gave it full weight, and it sat near the top of search results for “grey sofa.” What the ranking didn’t know was that this particular sofa was selling fifteen units a day. Five units of inventory against that pace is closer to “gone by tonight” than “available.”</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/f9db7d11-5c4d-494e-83f2-16c5c591722f.png" alt="" style="display:block;margin:0 auto" />

<p>The first instinct was to work with the number already sitting in the product database: total units on hand. Penalize anything below some threshold, say fewer than five in stock. It didn’t hold up. Five units of that fast-selling sofa was basically already gone. Five units of a slow-moving accent chair that sells twice a week was weeks of comfortable buffer. Same number, opposite urgency — raw unit count says nothing about how fast those units disappear, and any threshold drawn on that number is really a guess about a category’s typical pace, dressed up as a fact about one item.</p>
<p>What the ranking actually needed wasn’t a count of units. It was a measure of time: given how fast an item is currently selling, how many days would its current stock last? Call that expected_days_of_stock. A sofa with five units and heavy daily demand might have less than a day of runway left, even while the stock count alone still reads "available." An accent chair with the same five units and light demand might have weeks. Once stock is expressed in days instead of units, it becomes directly comparable across an entire catalog, regardless of how fast or slow any individual category normally moves.</p>
<p>With that number available per item, the boost itself is a single ratio, folded into the existing score:</p>
<p>$$final_score = base_relevance \cdot boost$$</p>
<p>$$boost = 1 + \frac{K \cdot expected_days_of_stock}{expected_days_of_stock + half_life}$$</p>
<p>At <em>expected_days_of_stock = 0</em> — no runway left — the fraction is zero and <em>boost = 1</em>: no bonus, the item ranks on relevance alone. As <em>expected_days_of_stock</em> grows, the fraction climbs toward 1 and boost climbs toward 1 + K: a comfortably-stocked item gets up to a K-sized lift over an otherwise identical item that's about to run out. K sets the ceiling on how much stock can matter relative to relevance — a small K means stock only breaks close ties; a larger one lets it move items further. Framed as a boost for abundance rather than a penalty for scarcity, an item never gets punished below its relevance score for being low on stock; it simply stops earning the bonus that better-stocked competitors get.</p>
<p>half_life is what keeps that curve from being a step function. It's the number of expected days of stock at which an item has earned exactly half the maximum boost — the point where the fraction equals 0.5. Below it, gains come fast: going from half a day of runway to two days moves the needle a lot. Above it, gains taper off — going from thirty days to sixty barely changes the boost, because thirty days was already comfortably safe. One knob, tuned per category to whatever "comfortably stocked" means for how fast that category typically sells.</p>
<p>Computing expected_days_of_stock itself comes down to dividing stock by a sales rate:</p>
<p>$$expected_days_of_stock = \frac{total_stock}{a \cdot M + (1 - a) \cdot N}, 0 &lt; a &lt; 1$$</p>
<p>M is the item's own average daily sales — the most direct signal, when there's enough history behind it. N is the average daily sales among the category's top sellers, standing in for "how fast an item like this typically moves." a weights how much to trust the item's own number against that category benchmark. A brand-new listing has no real M to speak of — zero or one lucky sale tells you almost nothing — so leaning on N avoids treating an unproven item as either doomed or immortal. An item with months of steady sales history has an M worth trusting on its own.</p>
<p>That argues for a itself moving with how much history an item actually has. A simple version: count the number of days in the last 30 that the item was available for sale at all, and scale a by that fraction —</p>
<p>$$a = \frac{days_available_last_30}{30}$$</p>
<p>A listing available for all 30 days gets a = 1: its own sales history fully determines its rate, no category blending needed. A listing available for 3 of the last 30 days gets a = 0.1: almost entirely deferring to the category benchmark, since three days of data barely qualifies as a track record yet. As the item accumulates more selling days, a rises on its own, and the formula gradually shifts its trust from the category's average to the item's own proven pace — no separate rule needed for "new" versus "established" items, just the same fraction recalculated as history accumulates.</p>
<p>It’s still an estimate, not a guarantee. M and N are both historical averages, and a sudden spike — a listing that goes viral overnight — can burn through stock far faster than its recent history predicts, so the formula reacts a beat behind reality rather than ahead of it. half_life is a good feature for category responsible to manage. K is still tuned by hand but it can be learned. A formula built from sales counts and stock counts the system already tracked did more to keep search results buyable than the demand-forecasting model that would have been the "proper" way to solve it.</p>
]]></content:encoded></item><item><title><![CDATA[A two-line formula for freshness on Search that didn’t bury the old stuff]]></title><description><![CDATA[New listings in the refurbished electronics category kept disappearing. A seller would post a like-new ThinkPad at 9 a.m., and by lunchtime it had sunk to…]]></description><link>https://oleber.hashnode.dev/a-two-line-formula-for-freshness-on-search-that-didn-t-bury-the-old-stuff</link><guid isPermaLink="true">https://oleber.hashnode.dev/a-two-line-formula-for-freshness-on-search-that-didn-t-bury-the-old-stuff</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 08:26:13 GMT</pubDate><content:encoded><![CDATA[<p>New listings in the refurbished electronics category kept disappearing. A seller would post a like-new ThinkPad at 9 a.m., and by lunchtime it had sunk to page three, buried under a stack of used cables and generic phone cases that happened to be older and, apparently, more “relevant” by the system’s math. Search wasn’t broken exactly — it just had no concept of new.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/f6ab4717-8f49-40be-a1fa-4bcdb029cd5b.png" alt="" style="display:block;margin:0 auto" />

<p>The first attempt at a fix was the obvious one: a flat freshness multiplier. Anything listed today got a 3x boost. A month old, the multiplier faded to 1x, no boost at all. Simple, cheap, easy to explain to anyone on the team.</p>
<p>It broke almost immediately. A shopper searching for a specific model number — a rare, exact match, the kind of listing that should win every time — got outranked by a brand-new listing that barely matched the query at all. The multiplier didn’t care how strong the underlying match was; it just multiplied whatever score walked in the door. A weak match times 3 could easily beat a strong match times 1. Freshness wasn’t adding a nudge, it was overriding relevance entirely.</p>
<p>The real fix needed two properties the flat multiplier didn’t have: a decay that felt natural instead of a cliff, and a ceiling that kept freshness from ever overpowering a genuinely better match.</p>
<p>The decay came from a half-life curve, the same shape used to describe radioactive decay or a cooling cup of coffee:</p>
<p>$$\text{freshness(age)} = 0.5 ^ {\frac{\text{age}}{\text{half life}}}$$</p>
<p>A listing at exactly one half-life old is worth half its original freshness; two half-lives, a quarter; and so on. No arbitrary cutoff, just a smooth slope down.</p>
<p>The ceiling came from capping how much that freshness score could move the needle:</p>
<p>$$\text{final_score} = \text{base_relevance} \cdot (1 + \text{max_boost} \cdot \text{freshness(age)})$$</p>
<p>With max_boost set at 0.15, a brand-new listing can picks a 15% bump. That's enough to pull a comparable new listing ahead of a stale one, but nowhere near enough to let a weak match leapfrog the exact model-number hit. Relevance stays the senior partner; freshness just gets a vote, not a veto.</p>
<p>Two objections came up before this shipped. First: doesn’t a decay curve still let a rotating cast of new arrivals crowd out listings that have proven themselves over time? It does, if freshness is the only signal in play. The fix was to blend the freshness boost with a click-based performance score, so a listing that’s been converting well for weeks keeps its standing on merit, while new listings only get their temporary lift on top of that, not instead of it.</p>
<p>Second: is calendar time even the right way to measure “new”? A listing posted at 2 a.m. and one posted at peak traffic aren’t actually the same age in any way that matters — what matters is whether the item has had a fair shot at being seen. That reframed the whole formula around exposure instead of the clock:</p>
<p>$$\text{freshness(impressions)} = 0.5 ^ {\frac{\text{impressions}}{\text{half life impressions)}}}$$</p>
<p>Same shape as before, just decaying by impression count rather than elapsed hours — half_life_impressions is the number of views after which a listing's freshness has faded to half its starting value. An unlucky listing with low traffic no longer gets penalized for time it never got to prove itself in.</p>
<p>None of this makes freshness a solved problem. The half-life and the cap are still tuned by hand, and what counts as “enough” impressions before a listing ages out of its boost is a judgment call, not a law of nature. But it beats the alternative — a multiplier that couldn’t tell a rare exact match from noise.</p>
]]></content:encoded></item><item><title><![CDATA[Taming German compound words in Search (without breaking everything else)]]></title><description><![CDATA[German is a compounding language. Instead of “table lamp”, you get Tischlampe. This is elegant for speakers, but a nightmare for search: a query for Lampe…]]></description><link>https://oleber.hashnode.dev/taming-german-compound-words-in-search-without-breaking-everything-else</link><guid isPermaLink="true">https://oleber.hashnode.dev/taming-german-compound-words-in-search-without-breaking-everything-else</guid><dc:creator><![CDATA[marcos rebelo]]></dc:creator><pubDate>Sun, 25 Oct 2026 07:51:58 GMT</pubDate><content:encoded><![CDATA[<p>German is a compounding language. Instead of <em>“table lamp”,</em> you get <em>Tischlampe</em>. This is elegant for speakers, but a nightmare for search: a query for <em>Lampe</em> should match <em>Tischlampe</em>, and it can’t if the index treats it as one atomic word.</p>
<h2>The Standard Fix: Decompounders</h2>
<p>The usual approach in OpenSearch is a <code>dictionary_decompounder</code>. You feed the analyzer a word list, and at index time it checks whether any word on that list is a valid subword of the compound. <em>Tischlampe</em> splits into <em>Tisch</em> and <em>Lampe</em>, both get indexed, and a query for either one matches.</p>
<p>This works until the word list produces nonsense splits. The classic case: <em>britisch</em> (british),  <em>fantastisch (fantastic)</em> and <em>asiatisch (Asian)</em> — neither has anything to do with furniture, but if <em>Tisch</em> is on the word list, the decompounder strips <em>“-tisch”</em> off both and injects a spurious <em>Tisch</em> token into unrelated documents. Now <em>“Tisch”</em> returns furniture, adjectives, and Asian restaurants.</p>
<h2>The Other Direction: Merging Words Back Into Compounds</h2>
<p>Decompounding solves one half of the problem: a single word matching a compound. The opposite failure happens too — <em>“tisch lampe”</em> should also match a document containing <em>tischlampe</em>. The fix is a synonym contraction rule, mapping a multi-word input to several output tokens:</p>
<pre><code class="language-plaintext">tisch lampe =&gt; tischlampe, tisch, lampe
</code></pre>
<p>Read left to right: the phrase expands to the compound token plus each original word, preserving recall even if only one word appears in the index.</p>
<h2>Picking Candidates Before You Even Call an LLM</h2>
<p>Before any rule gets generated, you need a candidate list — and most of the noise can be filtered out here, cheaply, without an LLM.</p>
<p>Product data. Mine the most frequent words in product titles and attributes, keep only those above a minimum length.</p>
<p>Converting search queries. Product-data frequency tells you what’s in your catalog; query-log frequency tells you what customers type. Check top converting queries for compounds your index can’t decompose.</p>
<h2>Using an LLM to Build the Rules</h2>
<p>Prompt the model with the compound word (batched) and ask it to return the components. LLM writes rules like:</p>
<pre><code class="language-plaintext">tischuhr =&gt; tisch, uhr
</code></pre>
<p>That decomposition alone isn’t the index-time rule. A conversion step — plain code — turns it into the merged form: the parts space-separated on the left, the compound plus parts as output:</p>
<pre><code class="language-plaintext">tisch uhr =&gt; tischuhr, tisch, uhr 
</code></pre>
<p>Batched across candidates, this gives you:</p>
<pre><code class="language-plaintext">
tisch uhr =&gt; tischuhr, tisch, uhr 
boden lampe =&gt; bodenlampe, boden, lampe
nacht tisch =&gt; nachttisch, nacht, tisch
</code></pre>
<p>This works for genuine compounds, but the LLM over-generates for the same reason a naive lookup table does — it’s pattern-matching on morphology, not taxonomy. At this stage every rule keeps both parts unconditionally, since pass one can’t know tisch isn’t the right category for a table clock.</p>
<h2>Second Pass: Generalize, Then Validate</h2>
<p>Pass two takes the compound and checks each fallback token against the category tree, dropping whatever doesn’t belong:</p>
<pre><code class="language-plaintext">tischuhr =&gt; tischuhr, uhr 
bodenlampe =&gt; bodenlampe, lampe 
nachttisch =&gt; nachttisch, tisch
</code></pre>
<p><em>Tischuhr</em> keeps <em>uhr</em>, drops <em>tisch</em> — it’s a clock, not a table. <em>Bodenlampe</em> drops <em>boden</em> likewise. <em>Nachttisch</em> keeps <em>tisch</em>, since a nightstand genuinely is a table. Only survivors make it into the live dictionary.</p>
<h2>Doing It All at Index Time</h2>
<p>When content is written once and read many times — an article, a product description, any user-generated text — both passes belong at index time. Do the work once, at write time, and every search stays a plain <code>match</code>.</p>
<p>Pass two is a single-token input, the safe case for a synonym filter: put it in the index analyzer, and <em>Tischlampe</em> tokenizes as <code>[tischlampe, lampe]</code> at write time — <code>tisch</code> never enters the stream. This replaces the naive <code>dictionary_decompounder</code> outright, using the validated dictionary instead of a flat word list.</p>
<p>Pass one works the same way: if a writer types <em>“tisch lampe”,</em> indexing it injects <code>tischlampe</code>, <code>tisch</code>, and <code>lampe</code> into that document's token stream at write time — every future query for any of the three already matches.</p>
<p>With both passes at index time, the query side is trivial:</p>
<ul>
<li>lampe → matches the lampe token.</li>
<li>tischlampe → matches the tischlampe token.</li>
<li>tisch lampe (however the customer split it) → matches on tokens already present.</li>
</ul>
<p><code>synonym</code> vs. <code>synonym_graph</code>. The legacy <code>synonym</code> filter never produces a token graph, so it drops straight into an index analyzer — but it mishandles position increments when input/output token counts differ, exactly the case here. Plain <code>match</code> is usually fine; phrase queries and highlighting can come out wrong. <code>synonym_graph</code> represents the relationship correctly, but Lucene's inverted index can only store a linear position sequence, so a graph output must be flattened first:</p>
<pre><code class="language-plaintext">synonym_graph → flatten_graph
</code></pre>
<p>This collapses the graph into a position-safe stream. The trade-off: it fixes matching but not exact phrase fidelity — if the field also needs phrase queries or highlighting, keep a second, lightly-analyzed field for that.</p>
<h2>Why This Matters: The furniture Example</h2>
<img src="https://cdn.hashnode.com/uploads/covers/6adb0eb04ea2adcc65bb4a44/6e8b7fe8-1f92-4d5a-a29d-fba0b11edbee.png" alt="" style="display:block;margin:0 auto" />

<p>You can see the naive version of this failure live on this furniture retailer. Search for <em>“tisch”</em> (table) and, alongside actual tables, the results include two <em>Tischlampe</em> (table lamp) and one <em>Tischuhr</em> (table clock) on the top six results. Both got indexed as Tisch because they contain the string, not because a customer looking for a table wants a clock or a lamp.</p>
<p>It’s a small bug, but it’s exactly the kind of relevance issue that erodes trust in a search bar — and exactly the kind of thing a taxonomy-aware LLM pass is well suited to catch before it ships.</p>
]]></content:encoded></item></channel></rss>