This document explains the six recommendation strategies in recommender-service.
For the evaluation results, see EVALUATION.md.
For the sources, see REFERENCES.md.
All strategies use the same pipeline. Only step 4 changes between strategies.
graphql-gatewayaskssearch-servicefor a wide pool of up to 200 candidates.search-servicecombines vector search and BM25 keyword search with Reciprocal Rank Fusion (RRF).- The gateway removes products that have no ratings. It keeps the 50 candidates with the highest scores.
recommender-serviceapplies the selected strategy to these 50 candidates.- The gateway returns the first
topKresults.
A strategy can re-order the candidates. Some strategies can also add products that search did not find.
| Strategy | Signal | Adds products | Source class |
|---|---|---|---|
| None | Search score only | No | PassthroughStrategy |
| Popularity | Search score and clickstream popularity | Yes, up to 2 | PopularityBoostStrategy |
| Collaborative filtering | Item-to-item co-occurrence | Yes, up to 2 | CollaborativeFilteringStrategy |
| Bandit exploration | Category-level Thompson sampling | No | BanditExploreStrategy |
| Neural ranking | Trained XGBoost ranking model | No | NeuralRankingStrategy |
| Diverse popularity | Popularity, then MMR diversity | Yes, through Popularity | DiversityAwareStrategy |
The strategies read from the clickstream through ClickstreamRepositoryPort.
- Popularity score. Each event adds a weight to its product: view 0.2, click 0.5, add to cart 0.8, purchase 1.0.
- Interactions. Clicks, add-to-cart events, and purchases are interactions. Views are not interactions, because each results page shows many products.
- Item similarity. Two products are similar when users interact with both in the same session. The similarity is the co-occurrence count divided by
sqrt(sessions with A × sessions with B). This is cosine similarity over session interaction sets. - User profile. For each user, the system records the products and the top-level categories of their interactions.
This strategy returns the candidates without change. Use it as the control group when you compare the other strategies.
This strategy combines search relevance with popularity.
- It scales the search scores and the popularity scores of the candidates to the range 0 to 1 (min-max).
- It calculates
0.6 × search score + 0.4 × popularity scorefor each candidate. - It sorts the candidates by this value.
- It adds up to 2 popular products from the full catalog that are not in the candidates.
This strategy promotes products that are similar to the products that the user interacted with.
- For each candidate, it finds the highest similarity to the user's history and to the current session.
- It adds
4.0 × history similarity + 10.0 × session similarityto the candidate score. The session has a 2.5 times higher weight, because recent behavior shows current intent. - It uses the highest similarity, not the average. One strong match is a good reason to recommend a product.
- It adds up to 2 related products that are not in the candidates.
- Cold start. A user with no history and no session gets the Popularity strategy. This is a documented fallback, not a silent pass-through.
This strategy is a contextual multi-armed bandit. It changes which categories appear first, so users see more varied results.
- Arms. Each top-level product category (for example, "Furniture") is one arm. This keeps the number of arms small.
- Priors. At startup, the strategy calculates a Beta(α, β) prior for each arm from historical engagement of the 500 most popular products.
- Context. The requesting user's most frequent category gets a higher α for that request.
- Selection. For each request, the strategy takes a random sample from each arm's Beta distribution (Thompson sampling). The arm with the highest sample comes first.
- Calibration. One category can take a maximum of 60% of the first 5 positions. The remaining products of that category move lower in the list. They are not removed.
- Order in an arm. In each category, products keep their original order.
Limit. The priors do not change during operation. The project has no live traffic to give rewards.
This strategy scores each candidate with a pairwise learning-to-rank model.
- Model. XGBoost with the
rank:ndcgobjective, exported to ONNX. Java runs it with ONNX Runtime. - Training. The model trains on the clickstream in Python. See training/TRAINING.md.
- Features. The model uses 7 features. The numbers start at 0, the same as the feature array in the code.
| # | Feature | Calculation |
|---|---|---|
| 0 | Category match | 1 if the product is in the user's top category, else 0 |
| 1 | Base score | Sigmoid of the search score |
| 2 | Popularity | log(1 + popularity score) |
| 3 | Co-occurrence | log(1 + co-occurrence with the user's products) |
| 4 | Average rating | Average rating divided by 5 |
| 5 | Review count | log(1 + rating count) |
| 6 | Session category overlap | Part of the session's products in the same category |
FeatureParityTest makes sure that the Java features and the Python training features are identical. A difference between them is a common cause of errors in production ranking models.
Limit. No feature measures how well a product matches the query. EVALUATION.md shows the effect of this limit.
This strategy is a decorator. It takes the output of the Popularity strategy and re-orders it for diversity. It can wrap any strategy.
It uses Maximal Marginal Relevance (MMR). At each position, it selects the product with the highest value of:
0.7 × relevance − 0.3 × highest similarity to the products already selected
The similarity between two products is the higher value of these two signals:
- Category similarity. 0.5 if the products have the same top-level category. 1.0 if they also have the same product class.
- Embedding similarity. The rank of one product in the other product's list of nearest neighbors in Pinecone.
The strategy uses the higher value because a product can have no embedding in the index. In that case, the category signal still applies.
Performance. The strategy sends one neighbor query for each candidate, not one for each pair. It runs these queries in parallel on 5 threads. docs/PROJECT_STATE.md records the measured latency and the reason for 5 threads.
- Add the new name to each of these enums. The names must be identical, because the services map them by name.
proto/recommender_service.proto(Strategy)recommender-service:domain/Strategy.javagraphql-gateway:domain/RecommenderStrategy.javaandgraphql/schema.graphqlsweb/src/graphql/types.ts(RecommenderStrategyandSTRATEGIES)
- Write a class that implements
RecommendationStrategy. - Add the class to the map in
domain/strategy/Strategies.java. - Add a display name in
eval/ResultsMarkdownWriter.java. The compiler shows an error until you do this. - Add a badge label in
web/src/utils/humanizeSource.ts. - Write unit tests with
FakeClickstreamRepository.
The offline evaluation includes the new strategy automatically.