One catalog, twenty-one Vespa features:
how an anonymous-shopper experience runs on a single schema.
3.17M Amazon products (US + CA, 2023 vintage), six personas, in-container embedder, cookie sessions, ♥-Like partial-updates, four homepage carousels — every surface is a different rank profile or grouping query, no microservices required.
The setup
Corpus: two asaniczka Amazon dumps from 2023 — the original US 1.4M plus a 2.2M Canadian sibling. Dedup on ASIN (US wins) yields ~3.36M unique rows; after the quality filter (valid category, non-zero price, image URL, reasonable title length) we're at 3,168,572 indexable products. The CA dump uses categoryName as a string with `&` stripped to whitespace; we restore the canonical name, then extend the 248-category vocabulary with 198 CA-only categories for a total of 446 categories. Most fields are real — title, price, ratings, reviews, image URL, and crucially boughtInLastMonth on 18% of rows. Brand, warehouse, stock are synthetic, generated deterministically by the feeder and documented in feeder/SYNTHETIC.md.
Embedder: snowflake-arctic-embed-xs (384-d, CLS-pooled), configured inservices.xml as an in-container hugging-face-embedder. Vespa handles both indexing-time embedding of title and query-time embedding of the user's search string — there is no Python encoder service.
<component id="arctic" type="hugging-face-embedder">
<transformer-model url=".../snowflake-arctic-embed-xs/onnx/model.onnx"/>
<tokenizer-model url=".../snowflake-arctic-embed-xs/tokenizer.json"/>
<pooling-strategy>cls</pooling-strategy>
<normalize>true</normalize>
</component>Schema (excerpt): the embedding is materialised by the indexing pipeline; the persona arrives as a query tensor at search time.
field title_emb type tensor<float>(x[384]) {
indexing: input title | embed arctic | attribute | index
attribute { distance-metric: prenormalized-angular }
index { hnsw { ... } }
}
field category_signals type tensor<float>(cat{}) { indexing: summary | attribute }
rank-profile personalized {
inputs {
query(q_emb) tensor<float>(x[384])
query(persona) tensor<float>(cat{})
query(w_personal) double
query(w_popularity) double
query(w_rating) double
query(w_clearance) double
}
function content_score() { expression: 0.5 * bm25(title) + 0.5 * closeness(field, title_emb) }
function persona_affinity() { expression: sum(query(persona) * attribute(category_signals)) }
function pop_signal() { expression: log10(attribute(popularity) + 1) }
...
global-phase {
rerank-count: 200
expression {
normalize_linear(content_score)
+ query(w_personal) * normalize_linear(persona_affinity)
+ query(w_popularity) * normalize_linear(pop_signal)
+ query(w_rating) * normalize_linear(rating_signal)
+ query(w_clearance) * normalize_linear(clearance_signal)
}
}
}Tensor framework as universal representation
The persona is a tensor<float>(cat{}) mapped tensor — sparse, keyed by category name, with positive and negative weights. Each product carries a parallel category_signals tensor with a 1.0 cell for each ancestor category. The dot product sum(query(persona) * attribute(category_signals)) yields the per-product affinity, and Vespa evaluates it across millions of docs per query.
A persona “Gadget lover” literally looks like this in personas.json:
{
"Cell Phones & Accessories": 0.9,
"Headphones, Earbuds & Accessories": 0.9,
"Wearable Technology": 0.6,
"Beading & Jewelry Making": -0.3,
"Doll Accessories": -0.4
}The negatives matter. They're what makes switching personas visually obvious — toys drop off the gadget page; jewellery drops off the parent page. Try the persona switcher in the top-right of the header to see this live.
Real-time contextual adaptation
Every weight in the global-phase blend is a query() input. The α / β / γ sliders on /search push the new values with each request; no app redeploy, no model retrain. Same goes for the persona itself — when you switch from gadget to home_cook, the next query carries the new persona tensor as a query input and ranking reshuffles immediately.
Behavioural signal integration
Anonymous shoppers get a pes_session_id cookie on first visit. The backend maintains one user_session document per cookie. Every /events/view,/events/like, and /cart/add call updates the session's persona tensor via the partial-update API — per-cell accumulation, not read-modify-write.
# When the user views a product in category "Headphones":
curl -X PUT "http://localhost:8080/document/v1/user_session/user_session/docid/<sid>" \
-H "Content-Type: application/json" -d '
{
"fields": {
"persona": {
"modify": {
"operation": "add",
"create": true,
"cells": {"Headphones, Earbuds & Accessories": 0.1}
}
},
"last_seen": {"assign": 1717100000}
}
}'That's the entire “feature store + signal capture + real-time activation” flow that would normally span a feature service, a Kafka topic, a streaming aggregator, and a cache. Here it's one PATCH. The next query reads the freshly-updated cell.
Per-product counters work the same way: ♥-Like calls a partial-update on the product's likes + popularity fields. Search results pick up the change on the next request — try it on any tile.
Phased ranking
The personalized profile runs in three phases:
- First-phase (every candidate):
content_score, a cheap BM25 + cosine blend. - Second-phase (top 1000): +
persona_affinity. Keeps the heavy dot product off the long tail. - Global-phase (top 200): the live-tunable blend with normalization.
Putting the persona dot in second-phase lets the global-phase normalize_lineardenominator stay informative — normalising across the top 200 keeps the slider sensitivity high, where the same operation across the full retrieval set would flatten it.
Recommendations beyond search
The homepage runs four parallel queries, each a different rank profile:
- For you →
for_youprofile, persona × category_signals dot product. - Trending now →
trendingprofile, popularity-led with persona nudge in second-phase. - Recently viewed → IDs from the session's
recent_viewsarray, fetched in one batchedby_idsquery. - Discover something new →
randomrank profile, fresh on every refresh.
On the product page, Similar items uses the similar profile with the source product's embedding as query(seed_emb). Frequently bought with this uses popularity-weighted same-category retrieval — honest about the heuristic (no fake cooccurrence graph).
What this would normally cost in microservices
A typical commerce stack splits this work across services:
- A search engine for lexical retrieval
- A vector database for semantic retrieval
- A feature store for popularity/likes/views
- A personalisation service maintaining user vectors
- An A/B experimentation layer for rank-profile toggling
- A recommendation service for “similar items” / “frequently bought”
Vespa replaces all six with one schema, one query language, one ranking framework, and a partial-update API on the same document store. The article's claim that you avoid “expensive data shuffling across distributed services” isn't marketing — it's a structural property of the architecture.
v2 additions: ML rerank, ship-from preference
Three extensions land on top of v1 without touching the homepage UX — each one is a schema or rank-profile change that flows through to existing surfaces:
v2.1 — LightGBM intrinsic-quality rerank
The hand-tuned blend in personalized (popularity + rating + clearance) is a fine starting point but a single linear sum can't capture interactions like “high-stars-and-cheap beats high-stars-alone”. So a sister profile, personalized_ml, swaps that doc-side sum for a learned function: a LightGBM regressor trained on log1p(bought_last_month) over the seven intrinsic features (stars, log-reviews, discount, has-discount, log-price, is-bestseller).
Vespa has a first-class lightgbm() rank feature that reads the model's native JSON dump — no ONNX conversion step — and runs on content nodes inside the rank pipeline (no data shipping to stateless), so it's cheaper than ONNX for tree models. The feature names in the JSON resolve to named functions in the rank profile:
rank-profile personalized_ml inherits personalized {
function stars() { expression: attribute(stars) }
function reviews_log() { expression: log10(attribute(reviews) + 1) }
function discount_fraction() { expression: attribute(discount_fraction) }
function has_discount() { expression: if (attribute(has_discount), 1, 0) }
function price_log() { expression: log10(attribute(price) + 1) }
function is_bestseller() { expression: if (attribute(is_bestseller), 1, 0) }
function ml_score() { expression: lightgbm("ranker.json") }
second-phase {
rerank-count: 1000
expression: content_score + 0.3 * persona_affinity + 0.2 * ml_score
}
global-phase {
rerank-count: 200
expression {
normalize_linear(content_score)
+ query(w_personal) * normalize_linear(persona_affinity)
+ query(w_popularity) * normalize_linear(ml_score) # replaces pop_signal
+ query(w_rating) * normalize_linear(rating_signal)
+ query(w_clearance) * normalize_linear(clearance_signal)
+ query(w_geo) * normalize_linear(geo_affinity)
}
}
}The classic/ML toggle on /search is an A/B switch at the ranking.profile query parameter. Same retrieval, same persona, different intrinsic-quality function — clean comparison without redeploys. Training is in feeder/train_ranker.py; the artifact ships at vespa-app/models/ranker.json (~2 MB, 100 boost rounds).
v2.3 — Ship-from preference as a mapped tensor
Six synthetic fulfilment centres exist on every product (deterministic from a hash of the ASIN). In v1 the warehouse was only a facet string; v2.3 adds a parallel tensor<float>(wh{}) with a single 1.0 cell at the doc's warehouse — same trick as category_signals. The shopper's pick on /me writes a matching tensor to their session, and the rank expression dot-products them:
# product.sd
field warehouse_signal type tensor<float>(wh{}) { indexing: summary | attribute }
# rank-profile personalized
function geo_affinity() { expression: sum(query(geo_pref) * attribute(warehouse_signal)) }
global-phase {
expression {
...
+ query(w_geo) * normalize_linear(geo_affinity)
}
}When the session has no geo_pref, geo_affinity evaluates to zero and the term washes out — cold-start is unaffected. The default w_geo only turns on once the shopper sets a preference. This is the GeoSearch story without the geoLocation operator: a mapped tensor is enough when distance to a discrete set of warehouses is what matters, not haversine miles.
v2.4 — Binary quantisation at 10M-scale (article-only)
The HNSW index on title_emb is the dominant memory cost. At 3.17M docs we're paying ~5 GB for the float32 vectors and another ~13 GB for the graph; at 10M docs that extrapolates to ~16 GB resident per content node before any other attributes.
Vespa supports binarize + pack_bits in the indexing pipeline, giving each embedding a parallel 48-byte binary version (384 bits ÷ 8) — about a 16× shrink. The pattern, already shipped in the reverse-image-search demo:
field title_emb_binary type tensor<int8>(x[48]) {
indexing: input title | embed arctic | binarize | pack_bits | attribute | index
attribute { distance-metric: hamming }
index { hnsw { max-links-per-node: 16, neighbors-to-explore-at-insert: 200 } }
}
rank-profile binary_first inherits personalized {
first-phase { expression: closeness(field, title_emb_binary) } # cheap hamming
second-phase { rerank-count: 1000
expression: closeness(field, title_emb) } # exact float on top-K
}Net cost: ~75 MB per million docs for the binary HNSW vs ~1.5 GB float32, exact float rerank on the top-1000 to recover ~99% of recall. We don't enable it in this demo — 3.17M fits the prod cluster but is the natural threshold where binary-first retrieval starts paying off — the pattern is one indexing-pipeline change away when the corpus continues to grow.
Coverage matrix
21 Vespa e-commerce features called out in the demo plan. End of v1 demos 14 live; end of v2 demos 17 live.
| # | Feature | Tier | Where |
|---|---|---|---|
| 1 | ANN vector search (HNSW) | v1 | /product Similar Items; closeness reranks /search |
| 2 | In-container hugging-face-embedder | v1 | index + query |
| 3 | Mapped + dense tensors as universal repr | v1 | persona, category_signals, embedding |
| 4 | Personalisation via query() tensors | v1 | every surface |
| 5 | Phased ranking: first / second / global | v1 | personalized profile |
| 6 | normalize_linear blending | v1 | global-phase |
| 7 | Mutable attributes (partial-update) | v1 | ♥-Like, view, stock |
| 8 | Behavioural signal integration (real-time) | v1 | cookie → session → tensor |
| 9 | Grouping & faceting | v1 | /search sidebar |
| 10 | random rank profile | v1 | Discover carousel |
| 11 | Multiple doc types in one app | v1 | product + user_session |
| 12 | Lexical retrieval + semantic rerank (BM25 + closeness) | v1 | /search; nearestNeighbor powers Similar Items |
| 13 | A/B testing via rank-profile switch | v1 | /search ML toggle |
| 14 | Cart recommendations | v1 | /cart page |
| 15 | LightGBM intrinsic-quality rerank | v2.1 | /search — personalized_ml profile |
| 16 | Ship-from preference (mapped-tensor geo) | v2.3 | /me — geo_affinity |
| 17 | Binary quantisation | article-only | see v2.4 + RIS |
| 18 | Multi-vector / ColBERT late interaction | v3 | not yet |
| 19 | Streaming search / batch indexing | article only | methodology |
| 20 | Diversity / dedup reranking | v1 | carousel design |
Methodology + caveats
- Corpus vintage: 2023. Some product URLs may now 404 on Amazon; the embedded images are still served from Amazon's CDN.
- What's synthetic: warehouse (hash mod 6), stock (seeded RNG). Documented in
feeder/SYNTHETIC.md.boughtInLastMonthis real. - What's stubbed: binary quantisation (v2.4) is article-only; multi-vector / ColBERT late interaction (v3) is not implemented. v2.1, v2.2, v2.3 are live.
- Throughput: in-container embedder + 3.17M docs on a 2-node t3-class content cluster. Embedding is the bottleneck on feed; query latency is unaffected since the embedder runs in the container nodes which scale independently.
- Privacy: the cookie holds a UUID; no PII is stored. Clearing your history on /me deletes the user_session doc.
Vespa Cloud · snowflake-arctic-embed-xs · asaniczka Amazon Products 2023 · Source blog post