<?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[Building OneFindMe]]></title><description><![CDATA[Building OneFindMe]]></description><link>https://ohadfarkash.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Building OneFindMe</title><link>https://ohadfarkash.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 30 Aug 2026 23:37:34 GMT</lastBuildDate><atom:link href="https://ohadfarkash.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I Asked AI to Build a Shopping Basket Under a Hard Budget — Here's What Broke]]></title><description><![CDATA[I run OneFindMe, an AI product-search front end for a large marketplace. The last thing I shipped was a feature that sounds trivial and isn't: type a need and a hard budget — "useful things for a big ]]></description><link>https://ohadfarkash.hashnode.dev/i-asked-ai-to-build-a-shopping-basket-under-a-hard-budget-here-s-what-broke</link><guid isPermaLink="true">https://ohadfarkash.hashnode.dev/i-asked-ai-to-build-a-shopping-basket-under-a-hard-budget-here-s-what-broke</guid><category><![CDATA[AI]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[algorithms]]></category><category><![CDATA[programming]]></category><dc:creator><![CDATA[Ohad Farkash]]></dc:creator><pubDate>Fri, 28 Aug 2026 15:21:55 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a8aba7f49cc85279e0d6f00/420a3813-3596-4951-9634-0084964ec4c4.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I run <a href="https://onefindme.com/en/">OneFindMe</a>, an AI product-search front end for a large marketplace. The last thing I shipped was a feature that sounds trivial and isn't: type a need and a <em>hard</em> budget — "useful things for a big dog, 80 total" — and get back a real basket of real products that never goes a cent over.</p>
<p>The word doing the work is <em>real</em>. The model is not allowed to invent a single product, price, rating or shipping figure. Everything with a number attached comes from the marketplace API. The model only gets to do the one thing it's actually good at: understand what a human meant. Drawing that line is where all the interesting failures lived.</p>
<p>Here's what broke.</p>
<h2>What the AI is actually allowed to do</h2>
<p>When someone types "useful things for a big dog, 80," the model does not return products. It returns a <em>plan</em>:</p>
<pre><code class="language-json">{
  "domain": ["dog", "pet", "כלב"],
  "roles": [
    { "he": "Chew toy",  "kw": "dog chew toy" },
    { "he": "Leash",     "kw": "large dog leash" },
    { "he": "Brush",     "kw": "pet hair brush" },
    { "he": "Bowl",      "kw": "dog food bowl" }
  ]
}
</code></pre>
<p>That's it. A set of complementary <em>roles</em> that together serve the need, each with a search keyword. No prices, no products, no ratings — the model never sees a catalogue. Every role keyword then goes to the real search endpoint, in parallel, and comes back with real listings: price, image, rating, order count, affiliate link. The AI understood the intent; the marketplace supplied the facts.</p>
<p>This split is the whole design. The moment you let a language model emit a price or a product name, you've built a very confident fiction generator. Keep it on the intent side of the wall and it's genuinely useful.</p>
<h2>The data that simply does not exist: shipping</h2>
<p>The feature has a switch: <em>does the budget include shipping?</em> Honoring it turned out to be impossible in the obvious way, because <strong>the affiliate API does not return a shipping cost.</strong> It returns an item price and a delivery time in <em>days</em> — never a freight figure. There is no endpoint that gives you "this item ships to that country for X."</p>
<p>I could have had the model estimate shipping. That's exactly the invention I'd banned. So the honest version:</p>
<ul>
<li><p><strong>"Include shipping"</strong> → filter the search to free-shipping items only. Now shipping is a <em>verified</em> zero, not a guess, and the budget math stays true.</p>
</li>
<li><p><strong>"Products only"</strong> → ignore shipping entirely and say so.</p>
</li>
</ul>
<p>Currency was a smaller version of the same lesson: the marketplace converts prices server-side when you pass a target currency, so there is no live FX call to reconcile per item — you just work in one currency the whole way through. Coupons exist in the payload but change without notice, so they're surfaced as a caveat, never subtracted from the total. If a number can't be trusted, it doesn't get to move the budget.</p>
<h2>Choosing items without going over: yes, it's Knapsack</h2>
<p>Once each role has a pool of real candidates, picking a combination that maximizes value without exceeding the budget is the <a href="https://en.wikipedia.org/wiki/Knapsack_problem">0/1 Knapsack problem</a> wearing a shopping hat.</p>
<p>I didn't reach for a full dynamic-programming solution, for three reasons: the item count is small (a handful of roles, ~20 candidates each), the whole thing runs inside a request budget of a few seconds, and there's a constraint textbook knapsack doesn't have — <em>diversity</em>. Two chew toys is not a good dog basket even if the numbers are optimal.</p>
<p>So it's a greedy build with a fill pass:</p>
<pre><code class="language-js">// 1. base: cheapest viable item per role, so every role is covered
for (const role of rolesByCheapest) {
  const pick = role.candidates.find(c =&gt; spend + c.price &lt;= budget);
  if (pick) { basket.push(pick); spend += pick.price; }
}

// 2. fill: add new-role items (variety first), then upgrade to pricier/better
//    picks, until only a few units of budget remain
while (budget - spend &gt; SLACK &amp;&amp; basket.length &lt; MAX) {
  const add = bestAffordableNewRole(spend);      // prefer an unused role
  const up  = bestUpgradeThatUsesBudget(spend);  // else spend up on a better item
  if (!apply(add, up)) break;
}
</code></pre>
<p>The base pass guarantees coverage. The fill pass is what makes the basket actually <em>feel</em> like the budget you asked for — which brings me to the bug that embarrassed me most.</p>
<h2>The basket that spent 36 of a 200 budget</h2>
<p>Early on, a "cosmetics basket, 200" came back at 36. Technically valid — every item real, under budget, nothing invented. Practically useless. A customer asking for a 200 basket and getting 36 worth of stuff feels short-changed, not thrifty.</p>
<p>The cause was the value function. "Quality first" scored items by rating and sales, which has no opinion about <em>using the budget</em>. It happily picked one cheap, well-rated item per role and stopped. The fix was two-part: scale the number of roles with the budget (a 200 cosmetics basket wants 6–9 item types, not 3), and add the fill loop above, which explicitly targets a near-full budget. Cosmetics at 200 now lands at ~199. Never over — that constraint is absolute — but close enough that the number you typed is the number you get.</p>
<h2>The bug that made it look like a scam</h2>
<p>The one that actually scared me: a <strong>nail-polish basket returned a women's coat</strong> for 80. Nothing about a coat belongs in a nail order.</p>
<p>Root cause was a relevance shortcut. Each role carried a "must contain" keyword to filter noise, and the role <em>top coat</em> had contributed <code>coat</code>. A listing titled "Women Suede Coat" matched <code>coat</code> and sailed through. A generic word from one role had opened the door to a completely different category.</p>
<p>The fix was to stop filtering per role and filter per <em>basket</em>. The planner now returns a <code>domain</code> — a few need-specific stems, in every language the title might be in — and <strong>every</strong> item, whatever its role, must contain one of them:</p>
<pre><code class="language-js">const domain = ["nail", "polish", "manicure", "ציפורנ"]; // for "nail polish"
const relevant = p =&gt;
  domain.some(stem =&gt; (p.title + " " + p.titleLocal).toLowerCase().includes(stem));
</code></pre>
<p>A coat contains none of <code>nail / polish / manicure</code>, so it's gone — regardless of which role's keyword it happened to match. Precision beat recall here on purpose: in a basket, one off-topic item reads as "this thing is broken," and I'd rather drop a borderline product than ship the coat.</p>
<h2>Speed beat correctness. Again.</h2>
<p>The first working version took 10–16 seconds cold: one planning call to the model, then N marketplace searches. Users don't wait 15 seconds for a basket; they leave.</p>
<p>Two things fixed it. The searches were already cached, so the second time anyone builds a similar basket the role searches are warm. And the <em>plan</em> — the roles for a given need and budget band — is cacheable too, and priority-independent, so I cache it and skip the model entirely on a repeat. A brand-new query is still ~10s (N cold marketplace round-trips are the floor), but a repeat is <strong>0.4s</strong>. As the cache warms across users, more baskets land in the fast path. Same lesson I keep relearning: a correct answer that arrives too late is a wrong answer.</p>
<h2>What happens when a price changes after you build the basket</h2>
<p>It will. Prices and stock on a live marketplace move by the hour. The basket you show is a snapshot, and pretending otherwise is the same sin as inventing a shipping figure. So the total is computed server-side from the freshest search at build time, the items carry the marketplace's own "prices may change" caveat, and the buy links go straight to the live listing where the real, current price is authoritative. The basket is a <em>starting point that respects your budget</em>, not a locked quote — and it says so.</p>
<h2>The pattern underneath all of it</h2>
<p>Every one of these fixes is the same move: <strong>let the model interpret, never let it assert.</strong> It's brilliant at turning "stuff for a big dog, 80" into search terms and domain anchors. It's a liability the instant it emits a price. Keep the language work and the truth work on opposite sides of a hard wall, and the failures stop being "the AI hallucinated" and start being ordinary, fixable engineering — a missing field, a too-greedy heuristic, a generic keyword that matched the wrong thing.</p>
<p>The budget basket that came out of it is live on OneFindMe if you want to see the shape of it. But the interesting part was never the demo. It was the wall.</p>
]]></content:encoded></item><item><title><![CDATA[I localized my search engine into Arabic for three Gulf markets. Two of them don't exist.]]></title><description><![CDATA[Three months of Search Console data, and what it said about the market I never planned for
Three months ago I finished translating my product search engine into Arabic and pointed it at Saudi Arabia, ]]></description><link>https://ohadfarkash.hashnode.dev/i-localized-my-search-engine-into-arabic-for-three-gulf-markets-two-of-them-don-t-exist</link><guid isPermaLink="true">https://ohadfarkash.hashnode.dev/i-localized-my-search-engine-into-arabic-for-three-gulf-markets-two-of-them-don-t-exist</guid><dc:creator><![CDATA[Ohad Farkash]]></dc:creator><pubDate>Sun, 23 Aug 2026 09:34:18 GMT</pubDate><content:encoded><![CDATA[<h2>Three months of Search Console data, and what it said about the market I never planned for</h2>
<p>Three months ago I finished translating my product search engine into Arabic and pointed it at Saudi Arabia, the UAE and Egypt — the three biggest e-commerce markets in the region. New pages, localized tools, currency defaults, shipping guides, the whole thing.</p>
<p>Last week I finally pulled the Search Console data for all three countries.</p>
<p><strong>574 impressions. Zero clicks.</strong></p>
<p>That number is easy to read as "SEO takes time," shrug, and keep going. But the breakdown underneath it said something much more specific, and it changed what I'm working on. Here's what was in it.</p>
<h2>Two of the three markets had no Arabic demand at all</h2>
<p>I exported queries per country, three months, filtered one country at a time. This is the part I should have done <em>before</em> building anything.</p>
<p><strong>Egypt: 56 queries. Not one of them was about Egypt.</strong></p>
<p>Fourteen were in Arabic. Fifteen were in Hebrew — "how much is 120 dollars," "900 dollars to shekels" — my Israeli users appearing under an Egyptian country filter through carrier routing and VPNs. Hebrew actually out-impressioned Arabic there, 24 to 17.</p>
<p>But the detail that settled it was inside the Arabic queries themselves. Eleven of the fourteen asked about the <strong>Saudi riyal</strong>. Two asked about the <strong>shekel</strong>.</p>
<p><strong>Zero asked about the Egyptian pound.</strong></p>
<p>Whoever those Arabic searchers are, they aren't shopping in Egyptian currency. Building an Egypt page for them would have been building for a country none of them were pricing in.</p>
<p><strong>UAE: 110 queries. Five were in Arabic.</strong></p>
<p>Ninety-six were English. And they weren't shopping queries at all — they were fitness supplement queries. "muscle building supplements." "best supplements for muscle gain." "ideal weight for height."</p>
<p>Zero Arabic queries in the UAE ranked anywhere near the first three pages.</p>
<p><strong>Saudi Arabia was the only market with genuine Arabic demand</strong> — most of the volume, real Arabic phrasing, actual intent. One market out of three.</p>
<p>I had built country pages for all three. Two of them were serving an audience the data says isn't there.</p>
<h2>The content I never planned beat the content I did</h2>
<p>Here's the part that stung.</p>
<p>Buried in the same export: a set of English fitness pages I'd written months earlier as filler — an ideal-weight calculator, a protein calculator, a supplements guide. Three hundred words each. Nobody's priority.</p>
<p><strong>They pulled 207 impressions from the Gulf. More than every Arabic page combined.</strong></p>
<p><code>/en/ideal-weight/</code> alone got 133 impressions across the three countries. My carefully localized Arabic converter — the page I'd spent the most time on — got 154 in Saudi Arabia and 3 in the UAE.</p>
<p>I spent two months pushing in one direction while demand quietly grew in another.</p>
<h2>The mistake that made it worse: I researched keywords from the wrong country</h2>
<p>Before the data arrived, I tried to research Arabic keywords the cheap way — Google's autocomplete API, with the country parameter set to Saudi Arabia, then the UAE, then Egypt.</p>
<p>All three returned identical results. I nearly wrote that up as "Arabic phrasing doesn't vary regionally."</p>
<p>Then I ran a control query — something that <em>has</em> to differ by country, like "best bank." Same results for all three. And the results were about Israeli banks and Jerusalem municipal services.</p>
<p>The country parameter was being ignored. Every "Saudi" result was actually geolocated to my own IP, in Israel. <strong>I had never asked Saudi Arabia anything.</strong></p>
<p>If you're doing international keyword research from your desk: verify with a control query that <em>must</em> differ by market. Three identical result sets look like "no regional difference" and are usually "the question was never asked."</p>
<h2>Autocomplete suggests. Search Console measures.</h2>
<p>I'd built my Arabic currency page around the phrasing autocomplete kept surfacing — a colloquial way of asking "how much is the dollar in riyals."</p>
<p>Real Search Console data, filtered to Saudi Arabia:</p>
<table>
<thead>
<tr>
<th>phrasing</th>
<th>impressions</th>
<th>on my page?</th>
</tr>
</thead>
<tbody><tr>
<td><code>تحويل عملات دولار الى ريال</code></td>
<td>12</td>
<td>no</td>
</tr>
<tr>
<td><code>تحويل عملات دولار</code></td>
<td>11</td>
<td>no</td>
</tr>
<tr>
<td><code>تحويل عمله الدولار</code></td>
<td>9</td>
<td>no</td>
</tr>
<tr>
<td><code>الدولار كم ريال سعودي اليوم</code></td>
<td><strong>4</strong></td>
<td><strong>yes</strong></td>
</tr>
</tbody></table>
<p>Fifty-nine impressions on wording that appeared nowhere on the page. Four on the wording I'd built the entire page around.</p>
<p>Same meaning. Different words. Google understood the topic well enough to show the page, then ranked pages that used the actual searched words above it.</p>
<h2>Position 55 is the number that actually explains the zero</h2>
<p>Average position across all three countries: <strong>55</strong>. That's page six.</p>
<p>Zero clicks at page six isn't a conversion problem or a copy problem. It's the expected outcome. Nobody scrolls there.</p>
<p>That reframed the whole thing for me. I'd been treating this as "the pages need optimizing." They don't — they need to not be competing with xe.com and Google's own currency widget for a query that already has a perfect answer built into the search results page.</p>
<p>Out of 97 Arabic queries, exactly four sit at position 30 or better, and they carry nine impressions between them. One of those four is interesting: <em>"is AliExpress trustworthy"</em> at position 23, with obvious buying intent — someone asking that is about to purchase and wants one last reassurance.</p>
<p>That one query is worth more attention than the other 96 combined, and it took a per-country export to see it.</p>
<h2>What I'd tell myself three months ago</h2>
<p><strong>Export the country data before you build the country page.</strong> Not after. A three-month export takes five minutes and would have saved me two pages and a content cluster.</p>
<p><strong>Check what language the queries are actually in.</strong> "Traffic from Egypt" and "Egyptian demand" turned out to be completely different things.</p>
<p><strong>Let the unplanned winner win.</strong> When content you wrote as filler outperforms content you planned by 2×, that's the market telling you where it is. Arguing with it is expensive.</p>
<p><strong>Average position tells you whether you're in a fixable situation.</strong> Position 20 means work. Position 55 means you picked a fight with an incumbent, and no amount of on-page tuning closes that gap.</p>
<p>I'm still building — the trust query is a cheap, sensible bet, and the fitness cluster is getting real attention now precisely because I didn't choose it. But I'm building where the data points, not where the plan pointed.</p>
<hr />
<p><em>I build</em> <a href="https://onefindme.com/en/"><em>OneFindMe</em></a><em>, an AI product search engine that finds AliExpress products by text or image, surfaces similar items and cheaper alternatives, in 12 languages. Happy to share the raw exports if anyone's doing something similar in these markets.</em></p>
]]></content:encoded></item></channel></rss>