PETPET · Recommendation system design
Inside PETPET’s recommendation system: from events to product rankings
A recommendation feed is not just a model call. It is a chain: capture useful behavior, turn it into evidence, find candidates, balance relevance with shopping rules, and still check that each product is safe to show.
October 30, 2026 · 9 min read
There isn’t one recommendation algorithm
PETPET has two related questions to answer. “What should this person see?” starts from a user’s history. “What is similar to this product?” starts from an item and looks for neighbors. They can share interaction data and ranking signals, but they do not have to use the same candidate source or the same serving path.
The personalized feed is the clearest example of the design. It used to do too much of its selection and ranking while handling the app request. I moved the repeatable work into background jobs, then made the API read a prepared ordering and hydrate only the requested page. The rest of this article follows that data from the first tap to the final card.
Tracking is the input layer, not the recommendation engine
A product view, a submitted search, a click on a search result, and an add-to-cart action tell us different things. They first pass through the event pipeline: the API validates the event, Pub/Sub separates the app request from downstream processing, and a worker stores the event idempotently. If publishing cannot complete, the existing recovery path can persist the event for later processing.
Recommendation jobs read the persisted history; they do not train a model inside the tracking request. That boundary matters. A shopper should not wait for feature building or model scoring just because they opened a product. It also gives the system one durable place from which different jobs can rebuild features.
Before an event can teach the model anything, its user and product need stable identities. The worker resolves product references such as database IDs, product IDs, variants, SKUs, and product-group IDs to a canonical product key. For user behavior, a known account ID is preferred; anonymous or session aliases are joined only when the available history makes that mapping unambiguous.
That normalization prevents a quiet but damaging problem: the same product appearing as several unrelated items, or one anonymous session being attached to the wrong account. The model can only learn from the relationships the data layer gives it.
An action has strength, and that strength fades
These are implicit signals, not ratings. A shopper never said “I like this product 4 out of 5”; they did something that gives us a clue. PETPET’s feature builder gives stronger actions more weight, then reduces older evidence with exponential decay:
effective weight = base action weight × 0.5(age / half-life)
In the worker’s interaction features, a product view starts at 1.0, a search-result click at 2.5, an add to cart at 4.0, and a purchase at 6.0. Views and search clicks use a 14-day half-life, cart actions 30 days, and purchases 90 days. A half-life is the point where that signal has faded to half its starting strength—not an expiry date.
Paid invoice lines contribute purchase evidence too. Canceled and refunded invoices are excluded, and returned quantity is subtracted from the line. This is important for commerce: an abandoned cart is useful intent, but a completed and retained purchase is a stronger signal.
Search has its own path through the features. A submitted query updates the user’s query profile; it is not treated as if the user viewed every product in the catalog. A click from the result page can inform both the query profile and the user–product interaction. The worker later matches that profile against product text with word and character n-gram TF-IDF features, so a query can contribute even when there is no direct product click.
From a sparse history to candidate products
Once interactions are normalized and weighted, the worker builds a sparse user-by-product matrix. It is sparse because a person interacts with only a tiny fraction of the catalog. The ALS model from the implicit library factorizes that history into compact user and item representations. It alternates between fitting user factors and product factors; a user–product score comes from how well those latent vectors align. In plain language, it learns patterns such as “people whose histories look like this often continue with products like those,” without needing a hand-written rule for every pair.
The same trained model can produce a user’s collaborative candidates and item-to-item neighbors. The worker also has a co-occurrence path: products appearing in the same user histories can become related candidates. These approaches capture different evidence; one looks for latent similarity across many interactions, the other uses direct shared behavior.
Collaborative filtering has a familiar weakness: a brand-new product or a user with no history has little behavior to learn from. So the candidate pool is not built from ALS alone. Depending on the surface, the system can also bring in search affinity, recent sales, discounts, best-seller signals, followed brands, and eligible sale campaigns. Guest or segment rankings provide a useful starting point when a personal profile is thin.
Related-product recommendations are a separate case. The prepared item-similarity model can supply neighbors; the older request-time path can also use Pinecone vector similarity when it needs more candidates, then fall back to same-category or popular products. That is a candidate-retrieval fallback, not the same thing as the ALS user profile. Product text and behavior are complementary tools, not one blended “AI score” that magically knows everything.
The final order is a hybrid, versioned ranking
Candidate sources do not naturally share a scale. An ALS score, a discount percentage, and a sales count are not directly comparable. The reranker calculates components such as collaborative relevance, co-occurrence, recent sales, discount, query match, followed brand, sale push, lifetime sales, and best-seller strength. It normalizes each component across the current candidate set, then combines them with a versioned weighted policy.
final score(product) = Σ active weight(signal) × normalized signal(product)
The worker loads a validated, versioned ranking policy, and different surfaces can use different weight profiles. The scoring structure stays understandable while the business balance can evolve. The result is a hybrid recommender: machine-learned candidates plus explicit, inspectable commerce rules.
There are also a few practical ordering rules after scoring. A product bought very recently is pushed down, not banned forever—pet food and litter are replenishable. Variants that belong to the same product group are collapsed so one feed does not show the same item in several sizes. A configurable brand cap helps the first part of the list avoid becoming a wall of one brand.
Before a new version becomes active, the worker validates the output. The API reads a version pointer rather than a half-written build, and the previous usable version can remain available as a fallback. That makes “rebuild recommendations” a publish step with a safety boundary, not a batch of writes that users may observe mid-flight.
Recent intent gets a lighter, faster-moving layer
A full collaborative model is good at patterns accumulated over time, but it should not need to retrain every time someone searches for a new kind of pet food. The code also contains a separate nearline worker that reads recent events and builds a short-lived per-user overlay. It uses an hours-scale decay, so a recent search or product view can matter now without permanently rewriting the slower-changing base ranking.
That overlay can combine the products a person recently touched, similar items around those products, search affinity, category intent, followed brands, and the current segment prior. A source watermark with an overlap helps the worker pick up delayed events; the resulting overlay is tied to the base, segment, and catalog versions it was built against, and it expires. In the serving path that uses it, the overlay is merged ahead of the base personalized list and duplicate products are removed. It is a small patch over a prepared ranking—not a second ALS training run and not an extra model call in the app request.
This is also why tracking needs to be reliable but not synchronous. Event persistence feeds both the longer-horizon model and the recent-intent job. The two consumers can interpret the same action at different time scales: a click can be a modest piece of long-term evidence and a strong, short-lived hint about what the person wants to see next.
The API serves a page, then checks reality
At request time, the API selects the appropriate user ranking and fills gaps from an eligible segment ranking. It takes the requested page, batch-loads current product cards, and rechecks the facts that may have changed since the worker ran: visibility, availability, and the current catalog representation. If some candidates no longer qualify, the serving layer can continue into the ranked list rather than treating a stale candidate as a valid response.
I chose to cache and version the ordering separately from the full product card for the same reason. A ranking can be reused; price, stock, and visibility have a shorter shelf life. The cached list says what is worth considering. The live catalog decides what can actually be shown now.
In the production logs I checked, the personalized recommendation endpoint moved from roughly 700 ms to around 300 ms after the read-model redesign. Those are approximate observations from the live request path, not a lab benchmark. The architectural change was moving repeated selection and ranking out of the request while keeping a bounded, live hydration step.
What this design does—and what it does not
Implicit feedback is helpful, but it is not ground truth. A view may be curiosity; a purchase may be a gift; and silence does not prove dislike. New users and products still need a sensible fallback. That is why the model is one signal source inside a larger ranking system, not an oracle that gets the last word.
The most important design choice was separating three clocks: durable event capture, slower model/read-model refresh, and live product checks at response time. Each has different freshness needs. Tracking should not block the shopper, model building should not happen on the API thread, and product availability should not rely on yesterday’s snapshot.
If you want the surrounding pieces, I also wrote about how shopping events are persisted and why ranking moved out of the API request. For the ALS background, the Hu–Koren–Volinsky paper on implicit feedback and the implicit model documentation are good starting points.