Vespa gets filed next to Qdrant, Weaviate, and pgvector, and the filing is misleading. Those systems are vector stores that learned to filter; Vespa is a search engine that treats tensors as a first-class type and treats ranking as an explicitly staged computation. Two consequences follow, and both are arithmetic rather than taste. First, the scoring math runs inside the content node, on data that never crosses a network boundary. Second, ranking is split into phases with different candidate counts, so the expensive model only ever sees a few documents. This piece works those numbers — the cost equation, how to size the rerank count, where a cross-encoder can actually afford to live, and what the whole query costs end to end.

A search engine with vectors, not a vector store with filters

Most vector databases start from one primitive — approximate nearest neighbour over a dense index — and add filtering, text matching, and reranking around the edges. Vespa starts from a general matching engine: a query is a tree of operands, and a nearest-neighbour operand sits in that tree next to a term match, a range predicate, and a boolean filter, all evaluated by the same matcher over the same document set.

That single design decision is why the math in this article looks different from the math in the rest of this cluster. There is no ‘vector search, then rerank in a separate service’ pipeline to price, because the ranking expression, the machine-learned model, and the raw tensors all live in the same process as the postings. The costs that dominate are therefore not network hops but per-document CPU multiplied by candidate count — and Vespa gives you explicit knobs to control both factors independently, which is the whole point of what follows.

Advertisement

The tensor data model: indexed, mapped, and mixed

A Vespa field can be a tensor with a declared type, and the type tells you the memory bill exactly. An indexed dimension is dense with a fixed size: tensor<float>(x[384]) is a plain 384-vector costing 384 × 4 = 1536 bytes. A mapped dimension is sparse and keyed by label: tensor<float>(token{}) stores only the entries you populate, which is how a learned sparse representation fits the same type system.

The interesting case is mixed: tensor<float>(chunk{}, x[384]) holds one embedding per chunk of a document in a single field. Twenty chunks costs 20 × 384 × 4 ≈ 30.7 kB per document, and declaring the cell type bfloat16 halves that to 15.4 kB with a precision loss far below the noise floor of the embedding itself. The point is that a document is not forced to be one vector, so you do not have to explode it into twenty rows and deduplicate afterwards.

Ranking expressions: the math runs where the data is

Tensors would be storage trivia if you could not compute on them in place. Vespa’s ranking expressions are a small tensor language built on two operations: join, which multiplies tensors cell-wise over shared dimensions, and reduce, which collapses a dimension by sum, max, or average. A dot product is a join followed by a sum-reduce; a max-over-chunks score is a nested reduce.

first-phase:  bm25(text) + 8 * closeness(field, emb)
second-phase: reduce(reduce(query(q) * attribute(chunks),
                            sum, x), max, chunk)

The inner reduce scores every chunk against the query vector; the outer one keeps the best chunk. Doing this in the engine is not a convenience. Shipping the raw tensors to an external reranker would move 1000 × 30.7 kB ≈ 31 MB per query across the network, which at any realistic query rate is the whole cost of the system. Computing on resident memory makes it a few milliseconds.

Multi-phase ranking: the cost equation

Every ranking design obeys the same equation. Let N be the candidates a query matches on one content node, R the rerank-count handed to the second phase, and G the count handed to the global phase after merging. With per-document costs c1 < c2 < c3:

T ≈ N·c1 + R·c2   (per content node)
      + G·c3        (once, in the container)

Take 20M documents over 8 content nodes, a query matching N = 100,000 per node, a cheap first phase at c1 = 0.4 µs and a model at c2 = 20 µs. Single-phase scoring costs 100,000 × 20 µs = 2.0 s per node. Two-phase with R = 200 costs 40 ms + 4 ms = 44 ms — roughly 45× cheaper for a ranking that differs only in the tail. The asymmetry to hold onto: rerank-count is per node, so 8 nodes rerank 1600 documents cluster-wide while each pays for only 200.

Sizing the rerank count

Cost in R is linear. Benefit is not. The gain from a larger R is the probability that the document the second phase would rank first actually survived the first phase, and that probability saturates: doubling from 50 to 100 usually moves NDCG@10 visibly, doubling from 400 to 800 usually does not move it at all. So the sizing method is empirical and cheap — measure NDCG@10 at R = 50, 100, 200, 400, 800 and stop at the knee.

Two corrections to the naive reading. First, because R is per node, adding nodes widens the global candidate pool for free: R = 100 on 8 nodes already reranks 800 documents. Second, a useful floor is 10–20× the number of results you actually display, because the second phase can only reorder what it is given — too small an R caps quality silently, with no error and no log line, while too large an R is a pure latency tax on a flat curve.

Advertisement

Where a model can afford to run

Vespa will evaluate a gradient-boosted tree ensemble or an ONNX model inside the ranking expression, so the real question is which tier can pay for it. A GBDT with 500 trees of depth 6 is about 3000 branch evaluations per document, memory-latency bound at roughly 5 µs. At R = 200 that is 1 ms per node — comfortably a second-phase model.

A cross-encoder is a different universe. Take 6 layers, d = 384, T = 256 tokens. Non-embedding parameters are 12d^2 per layer = 1.77M, so 10.6M total; the token embedding table is a lookup, not a matmul, and does not count. Forward cost is 2 × 10.6e6 × 256 ≈ 5.4 GFLOP, plus attention at 4T^2 d per layer ≈ 0.6 GFLOP — call it 6 GFLOP per document. At 50 GFLOP/s per core that is 120 ms for one document. This is why it belongs in the global phase, over tens of documents, in the stateless tier you can scale separately.

The ANN operand and its per-node target

nearestNeighbor(emb, q) carries a targetHits annotation and behaves like any other operand: AND it with a filter, OR it with a weak-AND text query, and the matcher handles the combination. Because the filter is inside the query tree rather than applied afterwards, the graph search can consult it while walking and skip documents that cannot qualify; when the filter is selective enough that a graph walk would wander uselessly, the engine falls back to an exact scan of the surviving set, which is cheap precisely because that set is small. (The general recall arithmetic of pre- versus post-filtering is covered in the vector-database and Qdrant articles in this series.)

Two things trip people up. targetHits is per content node, so 100 across 8 nodes is up to 800 candidates, and the engine may raise it further under a restrictive filter. And the ANN result is an input to ranking, not the ranking: closeness() is one term in a first-phase expression that also sees BM25 and freshness.

Fan-out, coverage, and bounding N

A Vespa cluster is two tiers: stateless containers that parse queries and run the global phase, and stateful content nodes that hold the data. The container dispatches to every node in a group, each returns its own top-R, and the container merges. Query latency is therefore the maximum over the fan-out, not the mean — with a per-node p99 of 40 ms and a fan-out of 8, the chance all eight land fast is 0.99^8 ≈ 0.92, so a node-level p99 shows up as roughly a query-level p92.

The safety valves attack the two terms of the cost equation directly. A soft timeout returns partial results with an explicit coverage percentage instead of an error, so a slow node degrades the answer rather than breaking it. And match-phase degradation caps N by ordering on a quality attribute such as popularity: if N · c1 is 40 ms, capping matches at 20,000 makes it 8 ms, at the cost of dropping unpopular documents from consideration.

Real-time writes, and the budget end to end

Updating a plain attribute is an in-place write measured in microseconds, with no document reindex — which is why click counts and stock levels can be ranking features in Vespa without a batch pipeline. Inserting a vector into an HNSW graph is not cheap in the same way: it costs on the order of efConstruction × M distance evaluations across O(log N) levels, call it 0.5 ms. At 2000 vector writes per second per node that is a full core burned permanently — about 6% of a 16-core node, taken from the same CPU serving queries. Real-time indexing is affordable; it is not free, and it must be in the capacity plan.

Adding it up for one query: 1 ms parse, 1 ms dispatch, 3 ms ANN, 8 ms capped first phase, 1 ms GBDT second phase, 4 ms merge and summary fetch — 18 ms. Add a cross-encoder over 20 documents — 120 ms of core time each, spread across 16 cores — and it becomes roughly 170 ms, of which 90% is the cross-encoder. That ratio is the design.

Vespa is a matching engine with first-class tensors, so the nearest-neighbour operand, the filter, and the ranking model all live in one process and the cost you optimize is candidates × per-document CPU, not network hops. The whole discipline is the equation N·c1 + R·c2 + G·c3: bound N with match-phase degradation, size R at the knee of the NDCG curve while remembering it is per node, and keep G tiny because a 6-layer cross-encoder is roughly 6 GFLOP and 120 ms of CPU per document. Cheap linear features and tree ensembles belong on the content node; transformers belong in the global phase on the stateless tier you can scale on its own.