Lab - Ranking and Facets
In this lab you will add multi-signal rank profiles that combine text relevance with business metrics, use match-features to understand scoring, and build grouping queries for faceted navigation.
Prerequisites
Your Vespa instance should be running with the application package and 20 products from Lab 3.
Add Rank Profiles
Open ecommerce-app/schemas/product.sd. Replace the existing rank-profile default block and add two new profiles. Place all rank profiles after the document-summary short block:
rank-profile default {
first-phase {
expression: nativeRank(title, description)
}
}
rank-profile text_only {
first-phase {
expression: bm25(title) * 2 + bm25(description)
}
match-features {
bm25(title)
bm25(description)
}
}
rank-profile ecommerce {
first-phase {
expression {
bm25(title) * 3 +
bm25(description) +
attribute(rating) * 10 +
if(attribute(in_stock), 50, 0) +
freshness(updated_at) * 20
}
}
match-features {
bm25(title)
bm25(description)
attribute(rating)
attribute(in_stock)
freshness(updated_at)
}
}
Three profiles, each more sophisticated:
defaultuses nativeRank, the basic text relevance functiontext_onlyuses BM25 with title weighted 2x over description, plusmatch-featuresso you can see the raw scoresecommercecombines BM25 text relevance, product rating, an in-stock boost, and a freshness signal. Products that are in stock, highly rated, and recently updated rank higher
Redeploy
vespa deploy --wait 300 ecommerce-app
Compare Rank Profiles
Search for "shoes" with the default profile:
vespa query "select * from product where default contains 'shoes'" "ranking=default"
Now with the text-only BM25 profile:
vespa query "select * from product where default contains 'shoes'" "ranking=text_only"
Two products match: the running shoes and the trail running shoes. The canvas sneakers and leather ankle boots are also in the Shoes category, but the word "shoes" appears in neither their title nor their description, so they don't match a text query for it.
Notice that the order flips between the two profiles. With default (nativeRank), the running shoes come first; with text_only, the trail running shoes come first. Look at the matchfeatures object in each text_only hit to see why: the trail running shoes have a slightly higher bm25(title) because "shoes" makes up a larger part of a shorter title, and title matches count double in this profile.
Now with the e-commerce profile:
vespa query "select * from product where default contains 'shoes'" "ranking=ecommerce"
Notice how the scores are much higher because of the rating and in-stock bonuses. The two products have the same rating (4.7), but the trail running shoes are out of stock (in_stock = false), so they miss the 50-point in-stock boost and drop back to second place.
Here is a summary of what you should see:
| Profile | #1 | #2 |
|---|---|---|
default | Running shoes lightweight mesh (0.157) | Trail running shoes (0.150) |
text_only | Trail running shoes (4.81) | Running shoes lightweight mesh (4.40) |
ecommerce | Running shoes lightweight mesh (102.9) | Trail running shoes (53.4) |
Inspect Match Features
Run a query and look at the matchfeatures field in the JSON response:
vespa query "select * from product where default contains 'jacket'" "ranking=ecommerce"
For each hit, you will see something like:
"matchfeatures": {
"attribute(in_stock)": 0.0,
"attribute(rating)": 4.800000190734863,
"bm25(description)": 1.1359450610051194,
"bm25(title)": 1.2455097942521527,
"freshness(updated_at)": 0.0
}
This tells you exactly why one jacket ranked above another. The three jackets have almost identical BM25 scores, so the other signals decide the order. The denim jacket (rating 4.4) outranks the packable rain jacket (rating 4.2) by roughly (4.4 - 4.2) * 10 = 2 points, which matches the relevance gap you'll see in the response.
| Rank | Product | Relevance | Why |
|---|---|---|---|
| 1 | Denim jacket classic | ~98.9 | In stock, rating 4.4 |
| 2 | Packable rain jacket | ~96.9 | In stock, rating 4.2 |
| 3 | Waterproof hiking jacket | ~52.9 | Highest rating (4.8), but out of stock |
The hiking jacket is still returned. The in-stock signal only affects ranking; it doesn't filter anything out.
The freshness(updated_at) is 0.0 for every product. The updated_at timestamps in the dataset are from early 2024, and by default freshness decays linearly to 0 over about three months, so all products count as stale.
Update a Ranking Signal
All products are stale, so the freshness signal in the ecommerce profile has no effect yet. Change that with a partial update that marks the packable rain jacket as just updated. This command writes the current time (in seconds) into an update file:
echo "{\"fields\": {\"updated_at\": {\"assign\": $(date +%s)}}}" > update-fresh.json
vespa document update id:product:product::14 update-fresh.json
Run the jacket query again:
vespa query "select * from product where default contains 'jacket'" "ranking=ecommerce"
The rain jacket has moved from second to first place, at ~116.9. Its freshness(updated_at) is now ~1.0, which adds ~20 points and puts it ahead of the denim jacket (~98.9).
You didn't redeploy or re-feed anything. Rank expressions are evaluated at query time, so changing an attribute value changes the ranking immediately.
Add Grouping Queries
Grouping gives you faceted navigation. Try these queries:
Category facets
Grouping expressions are part of the YQL statement. They come after the where clause, separated by a pipe (|). Because the grouping expression contains spaces and parentheses, pass the whole statement with an explicit yql= prefix and wrap it in single quotes:
vespa query 'yql=select * from product where true | all(group(category) order(-count()) each(output(count())))' \
"ranking=ecommerce"
This shows how many products exist in each category. You should see counts like Tops: 4, Bottoms: 4, Shoes: 4, Outerwear: 4, Accessories: 4. The grouping result is returned next to the regular hits, as a group:root hit that contains a grouplist for each grouping expression:
{
"id": "grouplist:category",
"label": "category",
"children": [
{ "value": "Accessories", "fields": { "count()": 4 } },
{ "value": "Outerwear", "fields": { "count()": 4 } },
{ "value": "Shoes", "fields": { "count()": 4 } },
{ "value": "Bottoms", "fields": { "count()": 4 } },
{ "value": "Tops", "fields": { "count()": 4 } }
]
}
Brand facets
vespa query 'yql=select * from product where true | all(group(brand) order(-count()) each(output(count())))' \
"ranking=ecommerce"
Price range buckets
vespa query 'yql=select * from product where true | all(group(price / 50) each(output(count())))' \
"ranking=ecommerce"
This groups products into $50 price buckets. A product at $79 goes into bucket 1 (79/50 = 1), a product at $189 goes into bucket 3.
Average rating by category
vespa query 'yql=select * from product where true | all(group(category) each(output(avg(attribute(rating)))))' \
"ranking=ecommerce"
Multiple facets in one query
vespa query 'yql=select * from product where category contains "Shoes" | all(group(brand) each(output(count()))) | all(group(color) each(output(count())))' \
"ranking=ecommerce"
This returns both brand and color facets for the category Shoes in a single request: SpeedStep (2), StreetWalk (1), and TrailPeak (1) for brand, and one product each in Black, Brown, Orange, and White for color.
Checkpoint
Run this query:
vespa query "select * from product where default contains 'jacket'" "ranking=ecommerce" "hits=3"
Verify that:
totalCountis 3: the packable rain jacket, the denim jacket, and the hiking jacket (the overcoat doesn't contain the word "jacket")- Each hit has a
matchfeaturesobject with all 5 features - The rain jacket ranks first, with
freshness(updated_at)close to 1.0 - The hiking jacket ranks last, with
attribute(in_stock)at 0, even though it has the highest rating
What You Built
Your application now has:
- Three rank profiles: basic nativeRank, BM25 text-only, and a multi-signal e-commerce profile
match-featuresfor ranking transparency and debugging- Grouping queries for category, brand, and price range facets
In the next lab, you will add vector search with embeddings and build a hybrid retrieval pipeline.