Demo

Three pieces of the engine you can move with the mouse. A race between eight shards that share one score cutoff, the engine's own arithmetic for real queries, and a grid showing what happens to a search when you narrow it down.

Overview. The first widget shows machines helping each other skip work that could not have changed the answer, and counts how much they skipped. The second opens up the arithmetic behind a handful of real queries, one clickable step at a time. The third is a slider: drag it to narrow a search down, and watch what that does to an engine that reads everything next to what it does to an engine that only looks at a small pool of candidates.
flowchart LR
        s0["shard 0
scanning"] -->|"my tenth best"| f["coordinator
cutoff = highest published"] s1["shard 1
scanning"] -->|"my tenth best"| f s7["shard 7
scanning"] -->|"my tenth best"| f f -->|"raised cutoff to every shard"| s0 f -->|"raised cutoff to every shard"| s1 f -->|"raised cutoff to every shard"| s7
What the first widget animates: every shard reads its own slice, publishes the score of its own tenth-best answer, and takes the highest of those back.

1. The race to a shared cutoff

Eight machines, one query. Each of them reads its own slice of the corpus and keeps a running list of the best answers it has found. As soon as one of those lists is full, its last entry is a useful piece of information for everybody: the best ten in the whole corpus are the best ten out of everything the machines hold together, so the corpus-wide tenth-best cannot be worse than any single machine's tenth-best. Each machine publishes that number, the coordinator takes the highest one and sends it back down, and every machine starts discarding anything below it. No real winner can be lost that way, because a document below the published cutoff already has ten documents beating it.

Press play. The lane bars are each machine's progress through its own slice. The counter is how many candidates had to be considered for the answer. Switch off the sharing to see what the same query costs when each machine can only use a cutoff it worked out for itself.

candidates considered 209
shared cutoff 0.000
skipped, against no sharing 183
  1. shard 0 27
  2. shard 1 26
  3. shard 2 26
  4. shard 3 26
  5. shard 4 26
  6. shard 5 26
  7. shard 6 26
  8. shard 7 26
The lane motion is a picture of the mechanism. The candidate totals it settles on are measured on the 86.6M-chunk court corpus, eight shards, 40 queries per point, with the totals divided evenly across the lanes for the picture.
results asked forcandidates, sharing onsharing offskipped
10 209 392 47%
100 2,542 4,055 37%
1,000 28,631 41,460 31%
10,000 247,442 646,560 62%

Both columns return the same documents, in the same order, with the same score bits. That equality is what makes the saving worth having. It is not a quality setting to be traded against speed; it is work that could not have changed the answer.

One detail the picture would otherwise hide. On the vector side a machine still reads every part of its slice. The shared cutoff decides what has to be considered, not when a machine can stop reading. A machine could stop early if it could show that everything in the part it has not read yet falls below the cutoff, and the vector scan cannot yet produce that assurance, so for now it reads on unless the query is cancelled. On the keyword side the equivalent assurance does exist. The word lists are stored in blocks, each block carrying the highest score any document in it could reach, so a block whose best case is below the cutoff is skipped without being read: worth up to about seventy times the speed on a word that appears in a large share of the corpus.

2. Real explain trees

Setting explain on a request hands each hit the arithmetic that produced its score. Every tree below came out of a real engine: a build of the current source, two shard nodes and a coordinator over localhost, native analysis, and twelve short documents written for this page. None of this is reformatted arithmetic; the values and the descriptions are what the engine returned.

The twelve documents that were indexed
idtextyeartopic
0search engines rank documents by relevance2019search
1mountain trails reward early risers with quiet views2021outdoors
2coffee beans roast darker as the search for flavor deepens2020coffee
3documents about coffee brewing methods and grind size2018coffee
4relevance ranking blends lexical and dense signals2022search
5the mountain air is thin and the coffee tastes better2017outdoors
6dense vector search retrieves documents by embedding distance2023search
7a slow pour brings coffee flavor forward2016coffee
8trail maps help hikers plan a mountain route2015outdoors
9lexical search matches terms in documents exactly2024search
10espresso machines and coffee grinders share one flavor goal2014coffee
11alpine mountain huts serve strong coffee to hikers2013outdoors

request Query{k=6, explain=true, selection=lexical leaf "lex" text="coffee documents" (whitespace tokenizer, Porter stemmer, normalized stems)}

executed bm25_search

#1 document 3 documents about coffee brewing methods and grind size 1.7540191

matched: lex · signals: lex 1.7540191

#2 document 0 search engines rank documents by relevance 1.1817307

matched: lex · signals: lex 1.1817307

#3 document 9 lexical search matches terms in documents exactly 1.1180447

matched: lex · signals: lex 1.1180447

request Query{k=6, explain=true, selection=composite OR{lexical leaf "lex" text="coffee", dense leaf "vec" native score mode over document 0's own vector} scored by RRF (rrf_k 60)}

executed hybrid_search:global_rank

#1 document 7 a slow pour brings coffee flavor forward 0.032522473

matched: vec, lex · signals: vec 0.79910135 · lex 0.7305024

#2 document 10 espresso machines and coffee grinders share one flavor goal 0.031009614

matched: vec, lex · signals: vec 0.7867265 · lex 0.6594265

#3 document 3 documents about coffee brewing methods and grind size 0.030834915

matched: vec, lex · signals: vec 0.7661852 · lex 0.6931472

request Query{k=6, explain=true, selection=composite AND{CEL filter "recent" year >= 2019, lexical leaf "lex" text="search"}}

executed bm25_search

#1 document 0 search engines rank documents by relevance 1.1817307

matched: lex, recent · signals: lex 1.1817307

#2 document 9 lexical search matches terms in documents exactly 1.1180447

matched: lex, recent · signals: lex 1.1180447

#3 document 6 dense vector search retrieves documents by embedding distance 1.060872

matched: lex, recent · signals: lex 1.060872

request Query{k=6, explain=true, selection=boolean{must=[CEL filter "recent" year >= 2019], should=[lexical leaf "lex" text="search"]}}

executed boolean:bitmap

#1 document 0 search engines rank documents by relevance 1.1817307

matched: recent, lex · signals: lex 1.1817307

#2 document 9 lexical search matches terms in documents exactly 1.1180447

matched: recent, lex · signals: lex 1.1180447

#3 document 6 dense vector search retrieves documents by embedding distance 1.060872

matched: recent, lex · signals: lex 1.060872

Captured from a single-process run of the engine. Click a node to fold or unfold it; the root's value is the score that was served.

A few things to look for. In the two-word keyword tree the root is the sum of one contribution per word, and each contribution is a field weight multiplied by two familiar quantities: how unusual the word is across the corpus, and how often it occurs in this document once the document's length is accounted for. The inputs are printed, so you can see that coffee is reduced to the stem coffe, appears in six of the twelve documents, and is therefore worth less than document, which appears in four. The fused tree shows the other way of combining two rankings: the root is a sum of terms of the form 1 / (60 + rank), one per ranking the document appeared in, with its raw score sitting underneath as the number that fixed its position rather than as something being added up. And in the boolean tree the condition appears under matched but never as a node of its own, because a condition decides membership and contributes no score at all. Two of the results come back with a score of zero, admitted by the condition alone.

The same query, folded

The last capture asks the same keyword query for a page of three results and, in the same request, folds up some arithmetic over the pool of candidates the page was drawn from: a count, a sum, and a mean of the year column, grouped by topic, plus a histogram of year in buckets of ten. This is the response the engine returned.

groupdocumentssum of yearmean year
all pooled 6 12098 2016.3333333333333
coffee 4 8068 2017
outdoors 2 4030 2015

The histogram came back with two buckets: 5 documents from 2010 and 1 from 2020, with 0 values that could not be bucketed. The mean is an exact fraction of exact sums, not a float reduction in whatever order the shards happened to reply, and asking for page two of this query would report the same numbers to the bit, because naming an aggregation fixes the pool.

3. What narrowing a search does

Searches usually come with conditions attached: only this court, only after 1990, only within twenty-five kilometres of a point. Whether that is easy to honour depends on how the engine finds its candidates in the first place. Most vector search engines build a graph of neighbours and walk it, following links towards the query, which is fast and which chooses its candidates before it knows anything about your condition. Then the condition is applied, and whatever survives is what you get. An engine that reads every document instead can apply the condition first and score only what is left.

Here is a corpus of 10,000 documents, one square per 25 of them. Drag the slider to change how much of the corpus the condition allows through. The dark squares are the documents that survive it.

Each square stands for 25 documents. With JavaScript off, read the arithmetic below as the content: what matters is what each of the two designs returns, not the picture.

A scan that reads everything

6,000 documents scored

The condition becomes a list of which rows are still allowed, and the scan takes that list as a mask, so a document the condition removed is never scored. What comes back is the true best ten among the survivors, at every position of the slider.

the exact best ten

A graph walk, filtered afterwards

60 of 100 candidates survive

The walk collects a fixed pool of 100 candidates chosen before the condition was consulted, and then drops the ones the condition excludes. What is left is whatever share of the pool the condition happened to allow.

60 left, 10 needed

The right-hand column is arithmetic about filtering after the fact, not a measurement of any particular engine: a pool of 100 candidates picked before the condition was applied keeps about 100 times the condition's share of them.

The two designs stay close together until the condition gets narrow, and then they part. The scan does the same amount of work at every position of the slider and returns the same exact answer. The filtered-afterwards pool, once the condition is down to a few percent, no longer holds enough surviving documents to fill a page of ten, and the ones it does hold were not chosen for being the best among the survivors, because the condition was not known when they were chosen. The usual repairs are to enlarge the pool until the query is slow, or to push the condition down into the graph walk and give up the assurances the walk was relying on.

In this engine the condition is compiled once on the coordinator into a typed tree of comparisons, resolved on each shard against that shard's own vocabulary, and turned into a list of which rows are still allowed. The vector scan takes that list as a mask, and the keyword side consults the same tree before a document can enter the answer, which is why a query that mixes both and carries a condition describes the set of documents it actually returned.

About these captures. The explain trees and the aggregation came out of a throwaway build of the engine on a single machine: two shard nodes and a coordinator talking over the local loopback, with twelve documents written for this page. The reference fleet was never contacted, and the local engine was stopped once the capture was done. Every request the documentation describes was served, and none of them returned an error.

Related