Skip to main content

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:

  • default uses nativeRank, the basic text relevance function
  • text_only uses BM25 with title weighted 2x over description, plus match-features so you can see the raw scores
  • ecommerce combines 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
defaultRunning shoes lightweight mesh (0.157)Trail running shoes (0.150)
text_onlyTrail running shoes (4.81)Running shoes lightweight mesh (4.40)
ecommerceRunning 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.

RankProductRelevanceWhy
1Denim jacket classic~98.9In stock, rating 4.4
2Packable rain jacket~96.9In stock, rating 4.2
3Waterproof hiking jacket~52.9Highest 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:

  1. totalCount is 3: the packable rain jacket, the denim jacket, and the hiking jacket (the overcoat doesn't contain the word "jacket")
  2. Each hit has a matchfeatures object with all 5 features
  3. The rain jacket ranks first, with freshness(updated_at) close to 1.0
  4. 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-features for 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.