From October 2021 to April 2022 I was CTO of a venture building gift recommendations. You picked an occasion, picked some tags about the person, and got suggestions. The company stopped before the recommendation layer was built, so what follows is the problem as I found it and what I would do about it now, not a system I shipped.
The constraint that shapes everything: the recipient is never a user. Whoever receives the gift has no account, no history, no past purchases. Collaborative filtering is unavailable by construction, not by lack of data. That is cold start in its hardest form, and it is why the first instinct is to reach for a model.
The instinct was wrong. The hard part was a calendar.
An occasion is not a label
We were going to launch in English with Spain as the larger target market. So the occasion list needed a second region, and I assumed that meant translating it.
It does not. The set is different, the dates are different, and in one case the occasion contains the product category.
- Spain gives presents on 6 January. Día de Reyes is the traditional gift day, not Christmas Eve. The whole December peak moves twelve days, and with it stock, affiliate deals and the marketing calendar.
- Sant Jordi, 23 April, is a book and a rose. The occasion is the category. It is Catalonia rather than all of Spain, so the taxonomy needs a region below the country.
- Mother's Day is a different Sunday. First in Spain, second in Germany, same name. It is also a detalle, a smaller present than a birthday, so the price band moves too.
- The name day has no German equivalent. An entire gift occasion exists in one market and not the other.
None of that is a translation problem. An occasion is a date, a price band and a set of allowed categories, and all three are regional.
What the system actually needed
The input was a set of chosen tags plus an occasion. Structured, not free text. That matters, because a structured input does not need vector search, and building one anyway is how a small team spends its runway.
OCCASIONS = {
# occasion, region date price band allowed categories
("mothers_day", "DE"): ("2nd Sunday in May", (20, 60), {"flowers", "jewellery", "books"}),
("mothers_day", "ES"): ("1st Sunday in May", (10, 30), {"flowers", "sweets", "books"}),
("reyes", "ES"): ("6 January", (20, 80), {"toys", "books", "clothing"}),
("sant_jordi", "ES-CT"): ("23 April", (10, 40), {"books", "flowers"}),
("name_day", "ES"): ("varies by name", (5, 25), {"sweets", "flowers", "small_gifts"}),
}
def candidates(products, chosen_tags, occasion):
date, (low, high), allowed = OCCASIONS[occasion]
for p in products:
if p["category"] not in allowed: # Sant Jordi is books and roses.
continue # Nothing else is a gift that day.
if not low <= p["price"] <= high: # A detalle is not a birthday present.
continue
shared = chosen_tags & p["tags"]
if shared:
yield p, len(shared) / len(chosen_tags | p["tags"])
def popularity_baseline(products, occasion, k=5):
_, _, allowed = OCCASIONS[occasion]
eligible = [p for p in products if p["category"] in allowed]
return sorted(eligible, key=lambda p: -p["sales_30d"])[:k]
Rules, a set intersection and a sort. No model, no embeddings, no vector database. It is explainable, which matters when a user asks why they were shown a book, and it is fast enough that the whole catalogue fits in a loop.
The last function is not a fallback. It is the thing every recommender has to beat, and I will come back to it.
January 2022, and the switch that was the wrong question
Three months into the project, on 25 January 2022, OpenAI shipped an embeddings endpoint. The obvious question was whether to move to it. I have since gone back through the archived pages, and the answer is clearer now than it felt then.
Their own numbers, price from the pricing page archived on 1 March 2022 and accuracy from the announcement:
- Previous state of the art, free: 50.2% average over eleven BEIR search tasks
text-search-ada-001, $0.0080 per 1K tokens: 49.0%text-search-babbage-001, $0.0120: 50.4%text-search-curie-001, $0.0600: 50.9%text-search-davinci-001, $0.6000: 52.8%
The tier you could afford scored below the state of the art it was meant to replace. The tier that beat it cost seventy five times more. On a hundred thousand products at roughly a hundred tokens each, one pass with davinci is about six thousand dollars, against a catalogue that changed daily. For reference, ada-002 arrived in December 2022 at $0.0001, an eightieth of what ada cost that spring.
The price alone settles it. Two other things settle it further.
Text similarity does not solve tag matching. BEIR measures retrieving documents from a text query. That was not the task. Buying a better answer to a question nobody asked is a common way to spend a quarter.
The announcement says nothing about any language other than English. No multilingual claim, no benchmark outside English tasks. That is not proof that Spanish was bad. It is proof that a team targeting Spain had no published basis to expect it to be good, which for a technology decision amounts to the same thing.
What the model could not have supplied was the thing we were missing. The gap was cultural, not semantic. No embedding in 2022 knew that Catalonia gives books on 23 April, because that fact lives in a calendar rather than in the distance between two sentences.
The feasibility study that would have been right
We never ran one, and this is the part I would insist on now.
Label a hundred to three hundred pairs of occasion plus tags by hand, two people independently so you can see how much they agree. Score precision at five and nDCG. Then measure the one thing that decides whether any of it was worth building:
Does it beat popularity? Most bought item in the eligible category, which is the function above. If a recommender cannot beat most-bought, it has produced nothing but latency and a bill. That baseline is where recommendation projects die, and they die quietly, because teams compare their model against no model rather than against the cheap answer. It is a green check that proves nothing, in a different domain.
It is the same argument I keep making about scored agent loops, four years older. A number with nothing to compare it to says nothing at all.
What I would build today
Embeddings now cost close to nothing and a model can normalise a catalogue in one pass, which removes weeks of the work. That changes the second half of the problem and none of the first.
I would still start with the occasion table. I would still measure against popularity before anything else. What I would use a model for is the part it is actually good at: turning messy affiliate product text into clean categories and tags, so the deterministic matcher above has something worth matching on.
And I would write the occasion taxonomy first, before any code, because it is the part that needs a person who knows that the second Sunday in May is the wrong Sunday in Spain.
Next
What is missing is the number. I have the design and the argument, and no measurement against a real catalogue, because there was never a catalogue to measure against once the company stopped. Doing it properly needs an open product dataset and a bilingual labelled set, which is a weekend rather than a paragraph.
If you have built recommendations across more than one country: how did you handle the occasions that exist in one market and not the other? I am curious whether anyone models them as data or whether everyone ends up with a switch statement per region, which is what I would have shipped.
Prices are from OpenAI's pricing page as archived on 1 March 2022 and accuracy figures from their announcement of 25 January 2022. The recommendation layer described here was never built; the company stopped in April 2022.
Building something similar?
I write about setups I actually use. If you're working on something comparable, I'd be curious what your workflow looks like.